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`.
|
||||
|
||||
|
|
@ -11,9 +31,9 @@ The launch page uses:
|
|||
|
||||
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.
|
||||
- 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="Pragma" content="no-cache" />
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<section id="gameShell" class="game-shell" data-build="v28.21" aria-label="Game screen">
|
||||
<div class="build-stamp" aria-label="loaded build">v28.21 VERIFIED</div>
|
||||
<section id="gameShell" class="game-shell" data-build="v29.00" aria-label="Game screen">
|
||||
<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>
|
||||
|
||||
<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 class="panel-head">
|
||||
<div>
|
||||
<h1>CHICK SORTER <span>v28.21</span></h1>
|
||||
<h1>CHICK SORTER <span>v29.00</span></h1>
|
||||
</div>
|
||||
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
|
||||
</div>
|
||||
|
|
@ -50,13 +50,20 @@
|
|||
<div class="large-tools">
|
||||
<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="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="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="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="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="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="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>
|
||||
|
|
@ -110,6 +117,6 @@
|
|||
</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>
|
||||
</html>
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "zunda-shiwake",
|
||||
"version": "28.2.1",
|
||||
"version": "29.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zunda-shiwake",
|
||||
"version": "28.2.1",
|
||||
"version": "29.0.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "zunda-shiwake",
|
||||
"version": "28.2.1",
|
||||
"version": "29.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"build": "npm run clean && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game.js",
|
||||
"test": "node test_v28_21.mjs"
|
||||
"build": "npm run clean && node scripts/build.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": {
|
||||
"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.
|
||||
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
|
||||
export const BALANCE = {
|
||||
version: 'v28.21 BOOST DRAW HOTFIX',
|
||||
version: 'v29.00',
|
||||
time: {
|
||||
daySeconds: 60,
|
||||
farmShutdownGraceSeconds: 10
|
||||
|
|
@ -84,7 +84,46 @@ export const BALANCE = {
|
|||
conveyor: {
|
||||
baseSpeed: 52,
|
||||
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: {
|
||||
explosionHorizontalVelocityMultiplier: 0.82
|
||||
|
|
@ -406,7 +445,11 @@ export const BALANCE = {
|
|||
autoScanner: 850,
|
||||
mixer: 775,
|
||||
trash: 950,
|
||||
sausageMaker: 825
|
||||
sausageMaker: 825,
|
||||
starterGenerator: 1000,
|
||||
biomassGenerator: 900,
|
||||
coalGenerator: 900,
|
||||
inboundTruck: 850
|
||||
},
|
||||
processingDelayMaxSeconds: {
|
||||
mixer: 1.20,
|
||||
|
|
@ -427,6 +470,18 @@ export const BALANCE = {
|
|||
id: 'boostConveyor', type: 'conveyor', name: 'Boost Conveyor', shortName: 'BOOST BELT', price: 75,
|
||||
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: {
|
||||
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
|
||||
buildable: true, upgradeable: true, maxLevel: 4
|
||||
|
|
@ -448,13 +503,29 @@ export const BALANCE = {
|
|||
buildable: true, upgradeable: true, body: { w: 180, h: 100 }
|
||||
},
|
||||
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 }
|
||||
},
|
||||
sausageMaker: {
|
||||
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'
|
||||
},
|
||||
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: {
|
||||
id: 'maintenanceRoom', type: 'maintenanceRoom', name: 'Maintenance Room', shortName: 'REPAIR ROOM', price: 220,
|
||||
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 FACILITY_DEFS = BALANCE.facilities;
|
||||
|
||||
export const BUILD_TOOL_IDS = ['conveyor', 'boostConveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck', 'sausageMaker', 'maintenanceRoom'];
|
||||
export const EDGE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||
export const GRID_FACILITY_IDS = ['sausageMaker'];
|
||||
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', 'inboundTruck'];
|
||||
export const GRID_FACILITY_IDS = ['sausageMaker', 'starterGenerator', 'biomassGenerator', 'coalGenerator'];
|
||||
export const MACHINE_FACILITY_IDS = [...EDGE_FACILITY_IDS, ...GRID_FACILITY_IDS];
|
||||
export const REQUIRED_RECEIVER_FACILITY_IDS = ['mixer', 'trash', 'truck'];
|
||||
export const INCOME_FACILITY_IDS = ['mixer', 'truck', 'sausageMaker'];
|
||||
|
|
|
|||
|
|
@ -108,8 +108,9 @@ function facilityKind(facility) { return facility?.baseId || facility?.id || '';
|
|||
|
||||
export function syncGridFacilityGeometry(facility) {
|
||||
if (!facility?.gridPlaced) return facility;
|
||||
facility.wCells = Math.max(1, facility.wCells || 3);
|
||||
facility.hCells = Math.max(1, facility.hCells || 3);
|
||||
const def = FACILITY_DEFS[facilityKind(facility)] || {};
|
||||
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.y = GRID.y + facility.row * 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.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;
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +198,7 @@ export function createFacility(game, id, p, price) {
|
|||
price: price || spec.price,
|
||||
builtSession: game.buildSession
|
||||
};
|
||||
if (id === 'inboundTruck') f.inboundOrder = { coalPerDay: 0, zundaMochiPerDay: 0 };
|
||||
return layoutFacilityOnEdge(f, entry, side);
|
||||
}
|
||||
|
||||
|
|
@ -204,13 +210,14 @@ export function createGridFacility(game, id, col, row, price) {
|
|||
name: def?.name || id,
|
||||
baseId: id,
|
||||
col, row,
|
||||
wCells: id === 'sausageMaker' ? 3 : 1,
|
||||
hCells: id === 'sausageMaker' ? 3 : 1,
|
||||
wCells: def?.footprint?.cols || (id === 'sausageMaker' ? 3 : 1),
|
||||
hCells: def?.footprint?.rows || (id === 'sausageMaker' ? 3 : 1),
|
||||
gridPlaced: true,
|
||||
level: 1,
|
||||
price: price || def?.price || 0,
|
||||
builtSession: game.buildSession
|
||||
};
|
||||
if (id === 'biomassGenerator' || id === 'coalGenerator') f.fuelPulses = [];
|
||||
return syncGridFacilityGeometry(f);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,30 @@ export function newTurnStats() {
|
|||
explosions: 0,
|
||||
eventResult: null,
|
||||
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,
|
||||
eventFailed: 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(),
|
||||
blockedCells: new Set(),
|
||||
buildTool: null,
|
||||
footbridgeOrientation: 'vertical',
|
||||
buildSession: 0,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
|
|
@ -130,8 +169,15 @@ export function createGame() {
|
|||
floatingTexts: [],
|
||||
shake: { time: 0, strength: 0 },
|
||||
truckCargo: [],
|
||||
shippedCargo: [],
|
||||
conveyorTiles: new Set(),
|
||||
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: [],
|
||||
scanners: [],
|
||||
facilities: {},
|
||||
|
|
@ -316,9 +362,73 @@ function protectedInitialCells(game) {
|
|||
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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const cells = INITIAL_CONVEYOR_BACKBONE.map(([col, row]) => ({ col, row }));
|
||||
for (const { col, row } of cells) addConveyorTile(game, col, row);
|
||||
|
|
@ -347,11 +457,14 @@ export function resetLayout(game) {
|
|||
}
|
||||
game.conveyorTiles.clear();
|
||||
game.conveyorMeta.clear();
|
||||
game.powerWires = new Map();
|
||||
game.footbridges = new Map();
|
||||
game.maintenanceRooms = [];
|
||||
game.repairmen = [];
|
||||
game.blockedCells = new Set();
|
||||
installInitialConveyorBackbone(game);
|
||||
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.eggFarms = INITIAL_EGG_FARMS.map(farm => {
|
||||
const nextSpawn = nextSpawnDelay(farm);
|
||||
|
|
@ -363,10 +476,16 @@ export function resetLayout(game) {
|
|||
// scanner body/ports, farm output, and machine receiver. This guarantees that
|
||||
// the starter line is always continuous from Egg Farm -> S1 -> S2 -> exits even
|
||||
// when roughly 3% of the initial map is blocked.
|
||||
createStarterPowerLayout(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] });
|
||||
scrubBlockedCellsFromInitialBackbone(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.factoryGraph = null;
|
||||
game.factoryGraphCommittedVersion = -1;
|
||||
|
|
|
|||
|
|
@ -32,8 +32,11 @@ export const TEXT = {
|
|||
routeLabels: {
|
||||
mixer: 'MIXER',
|
||||
truck: 'TRUCK',
|
||||
inboundTruck: 'INBOUND',
|
||||
trash: 'WASTE',
|
||||
sausageMaker: 'SAUSAGE',
|
||||
biomassGenerator: 'BIOMASS',
|
||||
coalGenerator: 'COAL GEN',
|
||||
scanner: 'NEXT',
|
||||
'scanner-role-1': 'NEXT',
|
||||
input: 'IN'
|
||||
|
|
@ -71,7 +74,13 @@ export function buildToolFlavorText(id) {
|
|||
manualScanner: 'A manual checkpoint. The operator is the algorithm.',
|
||||
mixer: 'Makes meat when OUT is clear; otherwise falls back to direct sale.',
|
||||
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.',
|
||||
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 { EVENT_TARGETS, createEventOffer, rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.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 ctx = canvas.getContext('2d');
|
||||
|
|
@ -42,7 +44,7 @@ const ui = {
|
|||
},
|
||||
buttons: {
|
||||
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 = {
|
||||
conveyor: 'conveyor',
|
||||
boostConveyor: 'boostConveyor',
|
||||
powerWire: 'powerWire',
|
||||
insulatedPowerWire: 'insulatedPowerWire',
|
||||
footbridge: 'footbridge',
|
||||
eggFarm: 'eggFarm',
|
||||
autoScanner: 'autoScanner',
|
||||
manualScanner: 'manualScanner',
|
||||
mixer: 'mixer',
|
||||
trash: 'trash',
|
||||
truck: 'truck',
|
||||
inboundTruck: 'inboundTruck',
|
||||
sausageMaker: 'sausageMaker',
|
||||
biomassGenerator: 'biomassGenerator',
|
||||
coalGenerator: 'coalGenerator',
|
||||
maintenanceRoom: 'maintenanceRoom'
|
||||
};
|
||||
for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) {
|
||||
|
|
@ -285,6 +293,10 @@ function startNextTurn() {
|
|||
}
|
||||
ensureMaintenanceState(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);
|
||||
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);
|
||||
|
|
@ -303,15 +315,17 @@ function startNextTurn() {
|
|||
game.floatingTexts = [];
|
||||
game.shake = { time: 0, strength: 0 };
|
||||
game.truckCargo = [];
|
||||
game.shippedCargo = [];
|
||||
resetCameraToFactoryStart();
|
||||
game.stats = newTurnStats();
|
||||
startInboundOrders(game);
|
||||
game.stats.fairiesTribute = game.lastStartFees?.fairiesTribute || 0;
|
||||
if (game.stats.fairiesTribute > 0) {
|
||||
game.stats.penalty += game.stats.fairiesTribute;
|
||||
game.stats.profit -= game.stats.fairiesTribute;
|
||||
}
|
||||
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.selected = null;
|
||||
game.groupDrag = null;
|
||||
|
|
@ -325,6 +339,11 @@ function startNextTurn() {
|
|||
|
||||
function completeTurn() {
|
||||
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 activeEvent = game.eventActive ? { ...game.eventActive } : null;
|
||||
const eventResult = resolveEvent(game);
|
||||
|
|
@ -356,6 +375,7 @@ function completeTurn() {
|
|||
function enterBuildPhase() {
|
||||
game.phase = 'build';
|
||||
game.truckCargo = [];
|
||||
game.shippedCargo = [];
|
||||
game.chicks = [];
|
||||
game.shutdownTimeLeft = 0;
|
||||
game.cleanupTimer = null;
|
||||
|
|
@ -415,6 +435,7 @@ function update(timestamp) {
|
|||
game.lastTimestamp = timestamp;
|
||||
game.frameId = (game.frameId || 0) + 1;
|
||||
chicks.beginFrame?.(game.frameId);
|
||||
updatePowerSystem(game, dt);
|
||||
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
|
||||
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
|
||||
chicks.updateCongestion(game.frameId);
|
||||
|
|
@ -591,6 +612,7 @@ function clickSelect(event) {
|
|||
const obj = build.selectedObject();
|
||||
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(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() {
|
||||
|
|
@ -807,7 +829,7 @@ document.addEventListener('pointerdown', event => {
|
|||
}, 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.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.zeroTimer?.addEventListener('click', debugZeroTimer);
|
||||
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 === '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) {
|
||||
event.preventDefault();
|
||||
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 { wearRatio, conveyorSpeedFactorForKey } from '../systems/maintenance.js';
|
||||
import { facilityBodyPortPoint } from '../core/entities.js';
|
||||
import { powerDetailsFor, currentGeneratorOutputV } from '../systems/power.js';
|
||||
|
||||
// Optional art overlay hook.
|
||||
// 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) {
|
||||
ctx.__gameRef = game;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawBackground(ctx, canvas, game);
|
||||
|
|
@ -79,6 +81,8 @@ export function drawAll(ctx, canvas, game, helpers) {
|
|||
drawGrid(ctx, game);
|
||||
drawExpansionOffers(ctx, game);
|
||||
drawConveyors(ctx, game);
|
||||
drawPowerWires(ctx, game);
|
||||
drawFootbridges(ctx, game);
|
||||
drawScannerConnectors(ctx, game);
|
||||
drawMaintenanceRooms(ctx, game);
|
||||
drawFacilities(ctx, game);
|
||||
|
|
@ -565,6 +569,54 @@ function drawConveyors(ctx, game) {
|
|||
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) {
|
||||
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);
|
||||
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);
|
||||
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.beginPath(); ctx.moveTo(f.x + 12, f.y + f.h - 35); ctx.bezierCurveTo(f.x + 42, f.y + f.h - 52, f.x + 60, f.y + f.h - 20, f.x + 88, f.y + f.h - 38); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function nullSafeGame(ctx) {
|
||||
return ctx.__gameRef || {};
|
||||
}
|
||||
|
||||
function drawFacilityTexture(ctx, image, f, maxCells = 1.45) {
|
||||
if (!image?.loaded) return false;
|
||||
// Keep external-machine sprites at roughly the same pixel density as a grid object.
|
||||
|
|
@ -1122,12 +1185,53 @@ function drawFacilities(ctx, game) {
|
|||
else if (kind === 'trash') drawTrash(ctx, game, f);
|
||||
else if (kind === 'truck') drawTruck(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 || {})) {
|
||||
drawFacilityReceiver(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) {
|
||||
const p = facilityOutputCell(game, f);
|
||||
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);
|
||||
drawFacilityDetails(ctx, t, THEME.truckPink);
|
||||
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.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + 14, t.y + 34);
|
||||
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);
|
||||
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 === 'coal') drawCoalItem(ctx, chick.x, y, chick.radius);
|
||||
else if (chick.sex === 'zundaMochi') drawZundaItem(ctx, chick.x, y, chick.radius);
|
||||
else {
|
||||
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);
|
||||
|
|
@ -1367,6 +1473,31 @@ function drawSausageItem(ctx, x, y, radius = 10) {
|
|||
}
|
||||
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) {
|
||||
ctx.save();
|
||||
const bodyColor = sex === 'male' ? THEME.male : THEME.female;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { record } from './history.js';
|
|||
import { floating, eraseEffect } from './effects.js';
|
||||
import { createSelectionSystem } from './selectionSystem.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 }) {
|
||||
function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
|
||||
|
|
@ -42,6 +44,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
game.blockedCells = shiftRowIndexedSet(game.blockedCells, rowDelta);
|
||||
game.conveyorTiles = shiftRowIndexedSet(game.conveyorTiles, 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 scanner of game.scanners || []) scanner.row += rowDelta;
|
||||
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.conveyorTiles = shiftColIndexedSet(game.conveyorTiles, 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 scanner of game.scanners || []) scanner.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) {
|
||||
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)) {
|
||||
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 output = { col: col + 3, row: row + 1 };
|
||||
if (!pointInGrid(input.col, input.row) || !pointInGrid(output.col, output.row)) return true;
|
||||
if (isBlockedCell(input.col, input.row) || isBlockedCell(output.col, output.row)) return true;
|
||||
for (const port of [input, output]) {
|
||||
const output = { col: col + (draft.wCells || 1), row: row + 1 };
|
||||
const biomassPorts = [
|
||||
input,
|
||||
{ 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);
|
||||
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;
|
||||
|
|
@ -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 === '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 === '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 === 'maintenanceRoom') return (game.maintenanceRooms || []).find(r => r.id === game.selected.id) || 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 === '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 === '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 === 'maintenanceRoom') return `${equipmentName('maintenanceRoom')} #${obj.id}`;
|
||||
return 'Equipment';
|
||||
|
|
@ -286,6 +302,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
|
||||
function equipmentPrice(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);
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +325,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
|
||||
function lastProtectedSaleReason(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') {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!cell) return fail(TEXT.fail.outOfGrid);
|
||||
const { col, row } = cell;
|
||||
|
|
@ -652,6 +715,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
refreshRoutingAfterEdit(game);
|
||||
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 (facilityPortAt(col, row) && !['conveyor', 'boostConveyor'].includes(game.buildTool)) return fail('Machine port must remain open for a conveyor.');
|
||||
if (game.buildTool === 'maintenanceRoom') {
|
||||
|
|
@ -670,7 +739,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
|
||||
function buildAtPoint(p) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -712,6 +781,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
room.price = cost;
|
||||
game.maintenanceRooms = game.maintenanceRooms || [];
|
||||
game.maintenanceRooms.push(room);
|
||||
markPowerDirty(game);
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
game.selected = { type: 'maintenanceRoom', id: room.id };
|
||||
const center = roomCenter(room);
|
||||
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);
|
||||
game.facilities[storageId] = f;
|
||||
refreshRoutingAfterEdit(game);
|
||||
markPowerDirty(game);
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
game.selected = { type: 'facility', id: storageId };
|
||||
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);
|
||||
game.facilities[storageId] = f;
|
||||
refreshRoutingAfterEdit(game);
|
||||
markPowerDirty(game);
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
game.selected = { type: 'facility', id: storageId };
|
||||
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);
|
||||
if (saleReason) return fail(saleReason);
|
||||
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 center = cellCenter(c.col, c.row);
|
||||
refundOrSell(hit, center.x, center.y);
|
||||
|
|
@ -780,34 +872,38 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
game.conveyorMeta.delete(hit.oldKey);
|
||||
eraseEffect(game, center.x, center.y);
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
if (hit.type === 'eggFarm') {
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
} else if (hit.type === 'eggFarm') {
|
||||
const c = cellCenter(hit.ref.col, hit.ref.row);
|
||||
refundOrSell(hit, c.x, c.y);
|
||||
game.eggFarms = game.eggFarms.filter(f => f.id !== hit.ref.id);
|
||||
eraseEffect(game, c.x, c.y);
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
if (hit.type === 'scanner') {
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
} else if (hit.type === 'scanner') {
|
||||
const c = scannerCenter(hit.ref);
|
||||
refundOrSell(hit, c.x, c.y);
|
||||
game.scanners = game.scanners.filter(s => s.id !== hit.ref.id);
|
||||
eraseEffect(game, c.x, c.y);
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
if (hit.type === 'facility') {
|
||||
markPowerDirty(game);
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
} else if (hit.type === 'facility') {
|
||||
const f = hit.ref;
|
||||
refundOrSell(hit, f.x + f.w / 2, f.y + f.h / 2);
|
||||
delete game.facilities[f.id];
|
||||
eraseEffect(game, f.x + f.w / 2, f.y + f.h / 2);
|
||||
refreshRoutingAfterEdit(game);
|
||||
}
|
||||
if (hit.type === 'maintenanceRoom') {
|
||||
markPowerDirty(game);
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
} else if (hit.type === 'maintenanceRoom') {
|
||||
const room = hit.ref;
|
||||
const center = roomCenter(room);
|
||||
refundOrSell(hit, center.x, center.y);
|
||||
game.maintenanceRooms = (game.maintenanceRooms || []).filter(r => r.id !== room.id);
|
||||
eraseEffect(game, center.x, center.y);
|
||||
markPowerDirty(game);
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
}
|
||||
game.selected = null;
|
||||
game.multiSelected = [];
|
||||
|
|
@ -832,6 +928,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
const room = maintenanceRoomAt(game, cell.col, cell.row);
|
||||
if (room) return { type: 'maintenanceRoom', ref: room };
|
||||
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 } };
|
||||
return null;
|
||||
}
|
||||
|
|
@ -846,6 +944,24 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
const obj = selectedObject();
|
||||
if (!obj) return;
|
||||
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 === 'scanner') eraseAtPoint(scannerCenter(obj));
|
||||
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');
|
||||
}
|
||||
|
||||
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) {
|
||||
const own = [
|
||||
{ side: 'left', binding: scanner.keys?.left },
|
||||
|
|
@ -1055,6 +1212,18 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
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 });
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1117,6 +1286,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
|
|||
lines.push(`Level: ${obj.level}/4`);
|
||||
lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`);
|
||||
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') {
|
||||
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
|
||||
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(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`);
|
||||
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') {
|
||||
const meta = game.conveyorMeta.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(`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') {
|
||||
lines.push(`Price: ${yen(equipmentPrice({ type: 'maintenanceRoom', ref: obj }))}`);
|
||||
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'maintenanceRoom', ref: obj }, game).amount)}`);
|
||||
lines.push('Size: 2×2 grid cells');
|
||||
lines.push('Dispatch: 1 repairman during each production day');
|
||||
const power = powerDetailsFor(game, obj);
|
||||
if (power) appendPowerLines(lines, power);
|
||||
} else if (obj.type === 'facility') {
|
||||
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)}`); }
|
||||
|
|
@ -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'}`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!obj || game.phase !== 'build') return '';
|
||||
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,
|
||||
cycleSingleConveyorDirection,
|
||||
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey, branchSelectableExitDirsForKey,
|
||||
removeSelected, switchScannerRole, showManualScannerMenu,
|
||||
removeSelected, switchScannerRole, showManualScannerMenu, showInboundTruckMenu,
|
||||
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
|
||||
isEquipmentCell, equipmentHitBoxes,
|
||||
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 { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.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 }) {
|
||||
function frameCache() {
|
||||
|
|
@ -80,13 +83,18 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
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) {
|
||||
const farmCenter = cellCenter(farm.col, farm.row);
|
||||
const maxOutputs = 1 + extraEggOutletCount(game, farm);
|
||||
const starts = farmOutputStarts(farm, maxOutputs);
|
||||
const starts = farmOutputStarts(farm, maxOutputs).slice(0, 1);
|
||||
if (!starts.length) {
|
||||
suppressUnconnectedFarmSpawn(farm, farmCenter);
|
||||
return true;
|
||||
|
|
@ -102,6 +110,13 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
continue;
|
||||
}
|
||||
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);
|
||||
game.chicks.push(chick);
|
||||
markChicksChanged();
|
||||
|
|
@ -140,6 +155,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
function updateRunning(dt, { closeFarmShutters, completeTurn }) {
|
||||
updateRepairman(game, dt);
|
||||
updateProcessingCooldowns(game, dt);
|
||||
updateInboundTrucks(game, dt, markChicksChanged);
|
||||
flushFacilityOutputs();
|
||||
if (game.cleanupTimer !== null) {
|
||||
game.cleanupTimer = Math.max(0, game.cleanupTimer - dt);
|
||||
|
|
@ -157,6 +173,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
if (producing && game.timeLeft > 0) {
|
||||
for (const farm of game.eggFarms) {
|
||||
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;
|
||||
if (farm.nextSpawn <= 0) {
|
||||
const spawned = spawnChick(farm);
|
||||
|
|
@ -170,6 +187,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
chick.bob += dt * 7;
|
||||
if (chick.stoppedTimer > 0) chick.stoppedTimer = Math.max(0, chick.stoppedTimer - dt);
|
||||
if (chick.stage === 'queued') continue;
|
||||
if (consumeAdjacentZunda(chick, i)) continue;
|
||||
updateRoutedChick(chick, i, 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
return f?.baseId || f?.id || '';
|
||||
}
|
||||
|
||||
function dirExitsToFacility(facility, dirName) {
|
||||
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')
|
||||
|| (facility.side === 'right' && dirName === 'right')
|
||||
|| (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 };
|
||||
}
|
||||
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);
|
||||
if (facilityPair && dirExitsToFacility(facilityPair[1], dirName)) {
|
||||
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 === 'trash') resolveTrash(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');
|
||||
return;
|
||||
}
|
||||
|
|
@ -387,6 +426,19 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
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) {
|
||||
const cell = pointToCell(p.x, p.y);
|
||||
if (!cell) return null;
|
||||
|
|
@ -484,9 +536,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
|
||||
function updateScannerQueues(dt) {
|
||||
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);
|
||||
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;
|
||||
const id = scanner.queue[0];
|
||||
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;
|
||||
const scanner = scannerById(game, chick.scannerId);
|
||||
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);
|
||||
if (!startCell) {
|
||||
if (!auto && scanner.kind === 'manual') resetManualCombo(scanner);
|
||||
|
|
@ -636,11 +695,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
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) {
|
||||
const chick = game.chicks[index];
|
||||
if (chick) chick.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'CHICKS ONLY');
|
||||
const chick = takeChick(index);
|
||||
if (!chick) return;
|
||||
const { x, y } = chick;
|
||||
|
|
@ -669,6 +734,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
return;
|
||||
}
|
||||
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.totals.meatProduced = (game.totals.meatProduced || 0) + 1;
|
||||
meatEffect(game, x, y);
|
||||
|
|
@ -677,10 +744,16 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
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);
|
||||
if (!chick) return;
|
||||
const { x, y } = chick;
|
||||
addTruckCargo(chick.sex, truck);
|
||||
addTruckCargo(chick.sex, truck, chick.sourceQuality || 1);
|
||||
truckLoadEffect(game, x, y, chick.sex);
|
||||
countTruckCargo(game, chick.sex);
|
||||
const target = truckTarget(game);
|
||||
|
|
@ -751,11 +824,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function emitFromFacility(facility, product, bufferKey) {
|
||||
if (!isEquipmentPowered(game, facility)) return false;
|
||||
const out = facilityOutputCell(game, facility);
|
||||
if (!out || !facilityOutputHasConveyor(game, facility) || !outputCellFree(out)) return false;
|
||||
if (Math.max(0, facility[bufferKey] || 0) <= 0) return false;
|
||||
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();
|
||||
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);
|
||||
|
|
@ -771,11 +848,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
}
|
||||
|
||||
function resolveSausageMaker(index, machine) {
|
||||
if (!isEquipmentPowered(game, machine)) {
|
||||
const item = game.chicks[index];
|
||||
if (item) item.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
if (machine?.processingCooldown > 0) {
|
||||
const item = game.chicks[index];
|
||||
if (item) item.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'MEAT ONLY');
|
||||
const item = takeChick(index);
|
||||
if (!item) return;
|
||||
const { x, y } = item;
|
||||
|
|
@ -787,9 +870,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
|
|||
recordFacilityProcess(game, machine);
|
||||
setFacilityCooldownAfterProcess(game, machine);
|
||||
machine.meatBuffer = Math.max(0, machine.meatBuffer || 0) + 1;
|
||||
machine.meatQualityQueue = machine.meatQualityQueue || [];
|
||||
machine.meatQualityQueue.push(item.sourceQuality || 1);
|
||||
if (machine.meatBuffer >= 2) {
|
||||
machine.meatBuffer -= 2;
|
||||
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.totals.sausageProduced = (game.totals.sausageProduced || 0) + 1;
|
||||
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) {
|
||||
if (!isEquipmentPowered(game, trash)) {
|
||||
const chick = game.chicks[index];
|
||||
if (chick) chick.stoppedTimer = 0.25;
|
||||
return;
|
||||
}
|
||||
if (trash?.processingCooldown > 0) {
|
||||
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);
|
||||
if (!chick) return;
|
||||
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) {
|
||||
const chick = takeChick(index);
|
||||
if (!chick) return;
|
||||
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;
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -257,12 +257,23 @@ export function applyExplosionDamage(game, price) {
|
|||
|
||||
export function settleTruckRevenue(game) {
|
||||
const target = truckTarget(game);
|
||||
const targetCount = targetTruckCount(game.stats, target);
|
||||
const unitPrice = target === 'poop' ? Math.ceil(upgradedTruckPrice(game) * poopValueMultiplier(game)) : upgradedTruckPrice(game);
|
||||
const base = targetCount * unitPrice;
|
||||
const sausageCount = Math.max(0, game?.stats?.sausageShipped || 0);
|
||||
const cargo = Array.isArray(game.shippedCargo) ? game.shippedCargo : (Array.isArray(game.truckCargo) ? game.truckCargo : []);
|
||||
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 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);
|
||||
game.stats.pendingTruckRevenue = 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),
|
||||
conveyorTiles: [...game.conveyorTiles],
|
||||
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()],
|
||||
cardEffects: game.cardEffects,
|
||||
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.conveyorTiles = new Set(data.conveyorTiles || []);
|
||||
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.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, scannerQueueSpacing: 0 };
|
||||
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
|
||||
|
|
@ -78,6 +90,8 @@ export function restore(game, text) {
|
|||
game.selectionBox = null;
|
||||
game.pan = null;
|
||||
game.routingVersion = (game.routingVersion || 0) + 1;
|
||||
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
|
||||
game.power = { ...(game.power || {}), dirty: true };
|
||||
game.factoryGraph = null;
|
||||
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 { GRID, THEME } from '../core/config.js';
|
||||
import { cellCenter, parseKey, key } from '../core/utils.js';
|
||||
import { DIRS, GRID, THEME } from '../core/config.js';
|
||||
import { cellCenter, parseKey, key, inGrid } from '../core/utils.js';
|
||||
import { floating } from './effects.js';
|
||||
import { isEquipmentPowered } from './power.js';
|
||||
|
||||
const RULES = BALANCE.maintenance;
|
||||
|
||||
|
|
@ -69,6 +70,8 @@ export function ensureMaintenanceState(game) {
|
|||
const kind = facilityKind(f);
|
||||
if (kind === 'mixer') attachMaintenance(f, 'mixer', 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);
|
||||
const f = facilityFromTarget(game, 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);
|
||||
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) {
|
||||
for (const f of Object.values(game.facilities || {})) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 f of Object.values(game.facilities || {})) {
|
||||
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 } });
|
||||
}
|
||||
return targets;
|
||||
|
|
@ -251,7 +255,8 @@ export function activateRepairmanForDay(game) {
|
|||
ensureMaintenanceState(game);
|
||||
const rooms = game.maintenanceRooms || [];
|
||||
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 {
|
||||
id: `room-${room.id}`,
|
||||
roomId: room.id,
|
||||
|
|
@ -286,6 +291,7 @@ function resolveStoredTarget(game, stored) {
|
|||
function chooseRepairTarget(game) {
|
||||
let best = null;
|
||||
for (const t of equipmentMaintenanceTargets(game)) {
|
||||
if (!repairWorkCell(game, t)) continue;
|
||||
const w = targetWear(t);
|
||||
if (w <= 0.001) continue;
|
||||
if (!best || w > targetWear(best)) best = t;
|
||||
|
|
@ -311,8 +317,10 @@ export function updateRepairman(game, dt) {
|
|||
|
||||
function updateSingleRepairman(game, r, dt, assigned) {
|
||||
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);
|
||||
if (!target || targetWear(target) <= 0.001) {
|
||||
if (!target || targetWear(target) <= 0.001 || !repairWorkCell(game, target)) {
|
||||
if (r.target?.key) assigned.delete(r.target.key);
|
||||
target = chooseRepairTargetAvoiding(game, assigned);
|
||||
}
|
||||
|
|
@ -322,10 +330,34 @@ function updateSingleRepairman(game, r, dt, assigned) {
|
|||
}
|
||||
const tKey = targetKey(target);
|
||||
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 dx = target.center.x - r.x;
|
||||
const dy = target.center.y - r.y;
|
||||
const workCenter = cellCenter(workCell.col, workCell.row);
|
||||
const dx = workCenter.x - r.x;
|
||||
const dy = workCenter.y - r.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist > 8) {
|
||||
const step = Math.min(dist, speed * dt);
|
||||
|
|
@ -351,6 +383,7 @@ function chooseRepairTargetAvoiding(game, assigned = new Set()) {
|
|||
let best = null;
|
||||
for (const t of equipmentMaintenanceTargets(game)) {
|
||||
if (assigned.has(targetKey(t))) continue;
|
||||
if (!repairWorkCell(game, t)) continue;
|
||||
const w = targetWear(t);
|
||||
if (w <= 0.001) continue;
|
||||
if (!best || w > targetWear(best)) best = t;
|
||||
|
|
@ -358,6 +391,135 @@ function chooseRepairTargetAvoiding(game, assigned = new Set()) {
|
|||
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) {
|
||||
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) {
|
||||
const f = typeof facility === 'string' ? game?.facilities?.[facility] : facility;
|
||||
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) {
|
||||
const p = { col: f.col + (f.wCells || 3), row: f.row + Math.floor((f.hCells || 3) / 2) };
|
||||
return usableOwnedCell(game, p) ? p : null;
|
||||
|
|
@ -559,7 +559,7 @@ export function autoSideFor(scanner, chick) {
|
|||
|
||||
export function destinationLabel(dest) { return routeLabel(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) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { nearestGridEdge, layoutFacilityOnEdge, maintenanceRoomFootprint, gridFa
|
|||
import { farmAt, scannerAt, scannerCenter, scannerConnector, scannerFootprintCells, gridFacilityAt, facilityInputCell, facilityOutputCell, refreshRoutingAfterEdit } from './routing.js';
|
||||
import { snapshot } from './history.js';
|
||||
import { buildPrice } from './economy.js';
|
||||
import { markPowerDirty } from './power.js';
|
||||
|
||||
export function createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }) {
|
||||
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 === '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 === '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;
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +45,16 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
const c = cellCenter(p.col, p.row);
|
||||
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 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 || []) {
|
||||
|
|
@ -123,6 +136,22 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
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 };
|
||||
}
|
||||
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 === '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 } }
|
||||
|
|
@ -135,7 +164,14 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
const hit = equipmentAtPoint(p);
|
||||
if (!hit) return false;
|
||||
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;
|
||||
game.groupDrag = { start: p, selections, origins, historySnapshot: snapshot(game), committed: false, lastDCol: 0, lastDRow: 0 };
|
||||
game.multiSelected = selections;
|
||||
|
|
@ -194,6 +230,20 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
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) {
|
||||
const candidateRects = [];
|
||||
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) {
|
||||
if (origin.type === 'facility' && !origin.gridPlaced) continue;
|
||||
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'
|
||||
? scannerFootprintCells({ col, row })
|
||||
: (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 });
|
||||
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) {
|
||||
if (origin.type === 'eggFarm') {
|
||||
origin.obj.col = origin.col + dcol;
|
||||
|
|
@ -314,10 +396,12 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
|
|||
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.selected = game.multiSelected[0] || null;
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { routeFromFarmToScanner, facilityConnectionIssues } from './routing.js';
|
|||
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js';
|
||||
import { conveyorSpeedForGame } from './cards.js';
|
||||
import { maintenanceSummary } from './maintenance.js';
|
||||
import { powerSummary } from './power.js';
|
||||
|
||||
export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) {
|
||||
function escapeHtml(value) {
|
||||
|
|
@ -35,6 +36,8 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
if (!btn?.classList?.contains('tool-button')) continue;
|
||||
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) {
|
||||
const btn = ui.buttons[id];
|
||||
if (!btn) continue;
|
||||
|
|
@ -81,6 +84,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
|
|||
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`,
|
||||
`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>`,
|
||||
`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>' : '',
|
||||
|
|
@ -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 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 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.modalBody.innerHTML = `
|
||||
<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>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>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>DUD refund income</strong>${positive(r.dudRefundIncome || 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