diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..6f3a291
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "liveServer.settings.port": 5501
+}
\ No newline at end of file
diff --git a/assets/images/README.md b/assets/images/README.md
new file mode 100644
index 0000000..5f03f73
--- /dev/null
+++ b/assets/images/README.md
@@ -0,0 +1,16 @@
+# Image asset folder
+
+The game currently uses simple canvas placeholders. You can override them by adding images with these filenames:
+
+- `chick_male.png`
+- `chick_female.png`
+- `poop.png`
+- `conveyor.png`
+- `scanner_manual.png`
+- `scanner_auto.png`
+- `egg_farm.png`
+- `mixer.png`
+- `shredder.png`
+- `truck.png`
+
+The render code checks whether each image loads and falls back to simple shapes when it does not.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..7103dc6
--- /dev/null
+++ b/index.html
@@ -0,0 +1,98 @@
+
+
+
+
+
+ Chick Sorter v10.7 UI / Ports / Shutter
+
+
+
+
+
+
+
+
+
CASH¥200
+
TIME60.0s
+
PROFIT¥0
+
TURN1
+
PHASETitle
+
+
+
+
MIXER0
+
TRUCK F0
+
WRONG0
+
POOP0
+
+
+
+
NO ACTIVE EVENT
+
TRUCK TARGET: FEMALE
+
BELT: 52 px/s
+
+
+
+
+
+
CHICK SORTER v10.7
+
Build, drag, connect all receiver ports, then start the next turn.
+
+
+
+
+
+
+ Add / Edit Equipment
+
+ Build tools unlock after each turn.
+
+
+
+
+ Irregular One-Turn Event
+ Irregular forced events appear during Build phase.
+
+
+
+ Selected Facility
+ Click equipment in Build phase.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/README.md b/src/README.md
new file mode 100644
index 0000000..1394001
--- /dev/null
+++ b/src/README.md
@@ -0,0 +1,24 @@
+# Chick Sorter v6.0 Visibility Pass
+
+Open `index.html` in a browser.
+
+## v6 changes
+
+- During sorting, the Build/Upgrade panel is hidden.
+- During sorting, the HUD is reduced to the key items: Cash, Time, Profit, Turn, Phase.
+- Turn detail counters are shown between turns instead of competing with the play field.
+- Chicks are larger and have a stronger outline for dark backgrounds.
+- Background and grid lines are fainter to reduce visual noise.
+
+## Controls
+
+- Scanner 1: `A` = Mixer, `D` = Truck
+- Scanner 2: `←` = Mixer, `→` = Truck
+
+## Rules
+
+- Mixer: ¥5
+- Female truck: ¥10
+- Male truck: -¥10
+- Unsorted chicks randomly exit left or right at scanner output.
+- Conveyor speed upgrade is removed.
diff --git a/src/core/config.js b/src/core/config.js
new file mode 100644
index 0000000..351a922
--- /dev/null
+++ b/src/core/config.js
@@ -0,0 +1,63 @@
+export const VERSION = 'v10.7 UI / Ports / Shutter';
+
+export const TURN_SECONDS = 60;
+export const STARTING_CASH = 200;
+
+export const MIXER_PRICE = 5;
+export const TRUCK_PRICE = 10;
+export const POOP_RATE = 0.08;
+export const MIXER_HALF_SECONDS = 10;
+export const TRUCK_POOP_DECAY = 0.01;
+export const CONVEYOR_SPEED = 52;
+export const CONVEYOR_SPEED_GROWTH = 1.02;
+export const AUTO_SCANNER_COOLDOWN = 4.5;
+export const CONTRACT_EVENT_CHANCE = 0.70;
+export const CONTRACT_EVENT_FIRST_TURN = 8;
+
+export const GRID = { x: 190, y: 142, cols: 22, rows: 13, cell: 46 };
+
+export const BUILD_COSTS = {
+ conveyor: 30,
+ eggFarm: 220,
+ manualScanner: 260,
+ autoScanner: 360,
+ mixer: 450,
+ trash: 240,
+ truck: 450
+};
+
+export const FACILITY_PRICES = {
+ mixer: 450,
+ trash: 240,
+ truck: 450
+};
+
+export const DIRS = [
+ { name: 'right', dc: 1, dr: 0, angle: 0, opposite: 'left' },
+ { name: 'left', dc: -1, dr: 0, angle: Math.PI, opposite: 'right' },
+ { name: 'down', dc: 0, dr: 1, angle: Math.PI / 2, opposite: 'up' },
+ { name: 'up', dc: 0, dr: -1, angle: -Math.PI / 2, opposite: 'down' }
+];
+
+export const THEME = {
+ bg: '#eefbea',
+ ink: '#102015',
+ muted: '#526456',
+ green: '#22b94f',
+ greenSoft: '#b7f5c6',
+ mixerBlue: '#2477ff',
+ truckPink: '#ff6aa8',
+ wasteGreen: '#22b94f',
+ danger: '#c42424',
+ warn: '#f0a020',
+ white: '#ffffff',
+ poop: '#7b4a23',
+ male: '#62a9ff',
+ female: '#ff8bc6'
+};
+
+export const EFFECT_PRIORITY = {
+ ambient: 0,
+ important: 1,
+ critical: 2
+};
diff --git a/src/core/entities.js b/src/core/entities.js
new file mode 100644
index 0000000..b3f99b5
--- /dev/null
+++ b/src/core/entities.js
@@ -0,0 +1,94 @@
+import { BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js';
+import { nextSpawnDelay } from './state.js';
+import { currentPoopRate } from '../systems/contracts.js';
+
+export function createEggFarm(game, col, row) {
+ const farm = {
+ type: 'eggFarm', id: game.nextId++, col, row,
+ level: 1, nextSpawn: 2, lastInterval: 2,
+ price: BUILD_COSTS.eggFarm,
+ builtSession: game.buildSession
+ };
+ farm.nextSpawn = nextSpawnDelay(farm);
+ farm.lastInterval = farm.nextSpawn;
+ return farm;
+}
+
+export function createScanner(game, col, row, kind) {
+ const cost = kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner;
+ const manualCount = game.scanners.filter(s => s.kind === 'manual').length;
+ return {
+ type: 'scanner', id: game.nextId++, kind,
+ slot: kind === 'manual' ? manualCount : null,
+ role: manualCount % 2,
+ col, row, level: 1,
+ queue: [], cooldown: 0,
+ price: cost,
+ builtSession: game.buildSession,
+ autoMode: 'standard'
+ };
+}
+
+export function nearestGridEdge(point) {
+ const colFloat = (point.x - GRID.x) / GRID.cell;
+ const rowFloat = (point.y - GRID.y) / GRID.cell;
+ const col = Math.max(0, Math.min(GRID.cols - 1, Math.round(colFloat - 0.5)));
+ const row = Math.max(0, Math.min(GRID.rows - 1, Math.round(rowFloat - 0.5)));
+ const left = Math.abs(point.x - GRID.x);
+ const right = Math.abs(point.x - (GRID.x + GRID.cols * GRID.cell));
+ const top = Math.abs(point.y - GRID.y);
+ const bottom = Math.abs(point.y - (GRID.y + GRID.rows * GRID.cell));
+ const side = [['left', left], ['right', right], ['top', top], ['bottom', bottom]].sort((a, b) => a[1] - b[1])[0][0];
+ if (side === 'left') return { side, entry: { col: 0, row } };
+ if (side === 'right') return { side, entry: { col: GRID.cols - 1, row } };
+ if (side === 'top') return { side, entry: { col, row: 0 } };
+ return { side, entry: { col, row: GRID.rows - 1 } };
+}
+
+export function layoutFacilityOnEdge(facility, entry, side) {
+ const center = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 };
+ const edge = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell };
+ facility.entry = { ...entry };
+ facility.side = side;
+ if (side === 'left') { facility.x = edge.left - facility.w - 18; facility.y = center.y - facility.h / 2; }
+ else if (side === 'right') { facility.x = edge.right + 18; facility.y = center.y - facility.h / 2; }
+ else if (side === 'top') { facility.x = center.x - facility.w / 2; facility.y = edge.top - facility.h - 18; }
+ else { facility.x = center.x - facility.w / 2; facility.y = edge.bottom + 18; }
+ return facility;
+}
+
+export function createFacility(game, id, p, price) {
+ const { entry, side } = nearestGridEdge(p);
+ const spec = {
+ mixer: { name: 'Mixer', w: 164, h: 126, price: FACILITY_PRICES.mixer },
+ trash: { name: 'Waste Shredder', w: 180, h: 100, price: FACILITY_PRICES.trash },
+ truck: { name: 'Truck', w: 176, h: 138, price: FACILITY_PRICES.truck }
+ }[id] || { name: id[0].toUpperCase() + id.slice(1), w: 168, h: 110, price };
+ const f = {
+ type: 'facility', id,
+ name: spec.name,
+ x: 0, y: 0, w: spec.w, h: spec.h,
+ level: 1,
+ price: price || spec.price,
+ builtSession: game.buildSession
+ };
+ return layoutFacilityOnEdge(f, entry, side);
+}
+
+export function createChick(game, routeData) {
+ const sex = Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female');
+ const start = routeData.route[0];
+ return {
+ id: game.nextId++, sex,
+ x: start.x, y: start.y,
+ route: routeData.route,
+ targetIndex: 1,
+ stage: 'input',
+ scannerId: routeData.scannerId,
+ nextScannerId: null,
+ radius: sex === 'poop' ? 15 : 17,
+ bob: Math.random() * Math.PI * 2,
+ queueIndex: -1,
+ queueRoute: null
+ };
+}
diff --git a/src/core/state.js b/src/core/state.js
new file mode 100644
index 0000000..8f8d2e0
--- /dev/null
+++ b/src/core/state.js
@@ -0,0 +1,155 @@
+import { STARTING_CASH, TURN_SECONDS, BUILD_COSTS, FACILITY_PRICES, GRID } from './config.js';
+import { randomBetween } from './utils.js';
+
+export function newTurnStats() {
+ return {
+ mixerCount: 0,
+ truckFemale: 0,
+ truckMale: 0,
+ trashCount: 0,
+ poopSpawned: 0,
+ poopTruck: 0,
+ poopTrash: 0,
+ poopMixer: 0,
+ autoSorted: 0,
+ revenue: 0,
+ penalty: 0,
+ profit: 0,
+ pendingTruckRevenue: 0,
+ explosionDamage: 0,
+ correct: 0,
+ mistakes: 0,
+ contractBonus: 0,
+ contractResult: null
+ };
+}
+
+export function newTotalStats() {
+ return {
+ turnsCompleted: 0,
+ processed: 0,
+ correct: 0,
+ mistakes: 0,
+ revenue: 0,
+ penalty: 0,
+ profit: 0,
+ maxCash: STARTING_CASH,
+ autoSorted: 0,
+ poopTruck: 0,
+ poopTrash: 0,
+ poopMixer: 0,
+ explosionDamage: 0,
+ contractBonus: 0,
+ contractSuccess: 0,
+ contractFailed: 0
+ };
+}
+
+export function createGame() {
+ return {
+ phase: 'title',
+ cash: STARTING_CASH,
+ turn: 1,
+ timeLeft: TURN_SECONDS,
+ lastTimestamp: 0,
+ nextId: 10,
+ buildTool: null,
+ buildSession: 0,
+ undoStack: [],
+ redoStack: [],
+ selected: null,
+ multiSelected: [],
+ selectionBox: null,
+ groupDrag: null,
+ pan: null,
+ view: { x: 0, y: 0 },
+ debug: false,
+ chicks: [],
+ effects: [],
+ floatingTexts: [],
+ shake: { time: 0, strength: 0 },
+ truckCargo: [],
+ conveyorTiles: new Set(),
+ conveyorMeta: new Map(),
+ eggFarms: [],
+ scanners: [],
+ facilities: {},
+ branchCounters: new Map(),
+ mixerHalfTimer: 0,
+ congestion: new Map(),
+ lastExplodedComponent: new Map(),
+ contractOffer: null,
+ contractActive: null,
+ stats: newTurnStats(),
+ lastResult: null,
+ totals: newTotalStats()
+ };
+}
+
+export function getSpawnRange(farm) {
+ const ranges = [[1.95, 4.35], [1.55, 3.45], [1.25, 2.80], [1.05, 2.35]];
+ return ranges[(farm?.level || 1) - 1] || ranges[0];
+}
+
+export function nextSpawnDelay(farm) {
+ const [min, max] = getSpawnRange(farm);
+ return randomBetween(min, max);
+}
+
+function facilityBodyForEntry(id, entry, side) {
+ const sizes = {
+ mixer: { w: 164, h: 126, name: 'Mixer', price: FACILITY_PRICES.mixer },
+ trash: { w: 180, h: 100, name: 'Waste Shredder', price: FACILITY_PRICES.trash },
+ truck: { w: 176, h: 138, name: 'Truck', price: FACILITY_PRICES.truck }
+ };
+ const g = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell };
+ const c = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 };
+ const spec = sizes[id];
+ let x = c.x - spec.w / 2;
+ let y = c.y - spec.h / 2;
+ if (side === 'left') { x = g.left - spec.w - 18; y = c.y - spec.h / 2; }
+ else if (side === 'right') { x = g.right + 18; y = c.y - spec.h / 2; }
+ else if (side === 'bottom') { x = c.x - spec.w / 2; y = g.bottom + 18; }
+ else if (side === 'top') { x = c.x - spec.w / 2; y = g.top - spec.h - 18; }
+ return { type: 'facility', id, name: spec.name, x, y, w: spec.w, h: spec.h, level: 1, price: spec.price, entry: { ...entry }, side };
+}
+
+export function defaultFacilities() {
+ return {
+ // Machine bodies sit outside the build grid. Only these receiver cells sit on the grid edge.
+ mixer: facilityBodyForEntry('mixer', { col: 0, row: 6 }, 'left'),
+ trash: facilityBodyForEntry('trash', { col: 16, row: 12 }, 'bottom'),
+ truck: facilityBodyForEntry('truck', { col: 21, row: 7 }, 'right')
+ };
+}
+
+export function resetLayout(game) {
+ game.conveyorTiles.clear();
+ game.conveyorMeta.clear();
+ const initial = [
+ // Egg Farm -> S1 top input. The approach lane is long enough to read the flow.
+ [4, 1], [4, 2], [4, 3], [4, 4], [4, 5],
+ // S1 left output -> Mixer receiver on the left grid edge.
+ [3, 6], [2, 6], [1, 6], [0, 6],
+ // S1 right output -> S2 top input. This avoids touching S2's waste output lane.
+ [5, 6], [6, 6], [7, 6], [8, 6], [9, 6], [10, 6], [11, 6], [12, 6], [13, 6], [14, 6],
+ [14, 5], [15, 5], [16, 5], [17, 5], [17, 6],
+ // S2 left output -> Waste Shredder receiver on bottom grid edge.
+ [16, 7], [16, 8], [16, 9], [16, 10], [16, 11], [16, 12],
+ // S2 right output -> Truck receiver on the right grid edge.
+ [18, 7], [19, 7], [20, 7], [21, 7]
+ ]; for (const [col, row] of initial) {
+ const k = `${col},${row}`;
+ game.conveyorTiles.add(k);
+ game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: null });
+ }
+ game.facilities = defaultFacilities();
+ game.scanners = [
+ { type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 6, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' },
+ { type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 17, row: 7, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }
+ ];
+ const farm = { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null };
+ farm.nextSpawn = nextSpawnDelay(farm);
+ farm.lastInterval = farm.nextSpawn;
+ game.eggFarms = [farm];
+}
diff --git a/src/core/utils.js b/src/core/utils.js
new file mode 100644
index 0000000..c726dfc
--- /dev/null
+++ b/src/core/utils.js
@@ -0,0 +1,32 @@
+import { GRID } from './config.js';
+
+export function yen(value) { return `¥${Math.floor(value).toLocaleString('en-US')}`; }
+export function key(col, row) { return `${col},${row}`; }
+export function parseKey(k) { const [col, row] = String(k).split(',').map(Number); return { col, row }; }
+export function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
+export function randomBetween(min, max) { return min + Math.random() * (max - min); }
+export function inGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; }
+export function cellCenter(col, row) {
+ return { x: GRID.x + col * GRID.cell + GRID.cell / 2, y: GRID.y + row * GRID.cell + GRID.cell / 2 };
+}
+export function pointToCell(x, y) {
+ const col = Math.floor((x - GRID.x) / GRID.cell);
+ const row = Math.floor((y - GRID.y) / GRID.cell);
+ if (!inGrid(col, row)) return null;
+ return { col, row };
+}
+export function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
+export function sameCell(a, b) { return a && b && a.col === b.col && a.row === b.row; }
+export function lerp(a, b, t) { return a + (b - a) * t; }
+export function hexToRgb(hex) {
+ const s = hex.replace('#', '');
+ return { r: parseInt(s.slice(0, 2), 16), g: parseInt(s.slice(2, 4), 16), b: parseInt(s.slice(4, 6), 16) };
+}
+export function rgbToHex(r, g, b) {
+ const c = n => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0');
+ return `#${c(r)}${c(g)}${c(b)}`;
+}
+export function mixHex(a, b, t) {
+ const ca = hexToRgb(a), cb = hexToRgb(b);
+ return rgbToHex(lerp(ca.r, cb.r, t), lerp(ca.g, cb.g, t), lerp(ca.b, cb.b, t));
+}
diff --git a/src/game.js b/src/game.js
new file mode 100644
index 0000000..e75fc2f
--- /dev/null
+++ b/src/game.js
@@ -0,0 +1,424 @@
+import { VERSION, TURN_SECONDS, STARTING_CASH, POOP_RATE, CONVEYOR_SPEED, CONVEYOR_SPEED_GROWTH, AUTO_SCANNER_COOLDOWN, BUILD_COSTS, FACILITY_PRICES, GRID, THEME } from './core/config.js';
+import { createGame, resetLayout, newTurnStats, newTotalStats, nextSpawnDelay, getSpawnRange } from './core/state.js';
+import { createChick } from './core/entities.js';
+import { key, parseKey, clamp, randomBetween, pointToCell, cellCenter, distance, yen } from './core/utils.js';
+import { farmAt, scannerAt, scannerById, scannerBySlot, scannerCenter, scannerConnector, routeFromFarmToScanner, outputRoute, scannerOutputs, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, factoryReady, facilityConnectionIssues } from './systems/routing.js';
+import { MIXER_PRICE, TRUCK_PRICE, MIXER_HALF_SECONDS, maleTruckPenalty, truckPayoutMultiplier, positivePayout, applyCashDelta, spendCash, refundCash, settleTruckRevenue, finalScore } from './systems/economy.js';
+import { snapshot, record, undo, redo } from './systems/history.js';
+import { drawAll } from './render/draw.js';
+import { createBuildSystem } from './systems/buildSystem.js';
+import { createUISystem } from './systems/uiSystem.js';
+import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, eraseEffect, rageEffect, updateEffects } from './systems/effects.js';
+import { rollContractOffer, activateAcceptedContract, clearActiveContract, productionMultiplier, resolveContract, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './systems/contracts.js';
+
+const canvas = document.getElementById('gameCanvas');
+const ctx = canvas.getContext('2d');
+
+const ui = {
+ shell: document.getElementById('gameShell'),
+ money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'),
+ eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'),
+ turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'),
+ buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'), facilityPanel: document.getElementById('facilityPanel'),
+ modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
+ 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'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
+ }
+};
+
+const game = createGame();
+let build;
+let uiSystem;
+
+function conveyorSpeedForTurn(turn) {
+ return CONVEYOR_SPEED * Math.pow(CONVEYOR_SPEED_GROWTH, Math.max(0, turn - 1));
+}
+function currentConveyorSpeed() { return conveyorSpeedForTurn(game.turn); }
+
+function startGame() {
+ Object.assign(game, createGame());
+ game.phase = 'running';
+ resetLayout(game);
+ uiSystem.hideModal();
+ updateCongestion();
+ uiSystem.updatePanels();
+ uiSystem.updateUI();
+}
+
+function startNextTurn() {
+ if (game.phase !== 'build') return;
+ const issues = facilityConnectionIssues(game);
+ if (issues.length) {
+ build.fail(`Cannot start: ${issues[0]}`);
+ uiSystem.updatePanels();
+ return;
+ }
+ activateAcceptedContract(game);
+ game.phase = 'running';
+ game.turn += 1;
+ game.timeLeft = TURN_SECONDS;
+ game.chicks = [];
+ game.effects = [];
+ game.floatingTexts = [];
+ game.shake = { time: 0, strength: 0 };
+ game.truckCargo = [];
+ game.stats = newTurnStats();
+ for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; }
+ for (const farm of game.eggFarms) { farm.nextSpawn = currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; }
+ game.buildTool = null;
+ game.selected = null;
+ updateCongestion();
+ uiSystem.updatePanels();
+ uiSystem.updateUI();
+}
+
+function completeTurn() {
+ if (game.phase !== 'running') return;
+ const ship = settleTruckRevenue(game);
+ const contract = resolveContract(game, applyCashDelta);
+ clearActiveContract(game);
+ if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
+ game.lastResult = { ...game.stats, ship, contract, turn: game.turn, cash: game.cash };
+ game.phase = 'build';
+ game.truckCargo = [];
+ game.buildSession += 1;
+ game.undoStack = [];
+ game.redoStack = [];
+ game.timeLeft = 0;
+ game.totals.turnsCompleted += 1;
+ for (const scanner of game.scanners) scanner.queue = [];
+ game.contractOffer = rollContractOffer(game);
+ game.buildTool = null;
+ game.groupDrag = null;
+ game.selectionBox = null;
+ game.pan = null;
+ uiSystem.updatePanels();
+ uiSystem.showTurnResult(game.lastResult);
+}
+
+function closeFarmShutters() {
+ for (const farm of game.eggFarms) {
+ farm.shutterProgress = 0.02;
+ farm.shutterSparked = true;
+ const c = cellCenter(farm.col, farm.row);
+ shockwave(game, c.x, c.y, THEME.ink);
+ sparkBurst(game, c.x, c.y + 10, 10);
+ floating(game, c.x, c.y - 34, 'SHUT', THEME.ink);
+ }
+}
+
+function spawnChick(farm) {
+ const data = routeFromFarmToScanner(game, farm, true);
+ if (!data) { floating(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y - 18, 'NO ROUTE', THEME.danger); return false; }
+ const chick = createChick(game, data);
+ if (chick.sex === 'poop') game.stats.poopSpawned += 1;
+ const start = data.route[0];
+ if (isSpawnBlocked(start)) { explodeRoute(data.route, [{ sex: chick.sex, x: start.x, y: start.y }], 'LINE JAM!'); return false; }
+ game.chicks.push(chick);
+ spawnPulse(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y);
+ return true;
+}
+function isSpawnBlocked(start) { return game.chicks.some(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68); }
+function currentSpawnDelay(farm) { return nextSpawnDelay(farm) / productionMultiplier(game); }
+
+function update(timestamp) {
+ if (!game.lastTimestamp) game.lastTimestamp = timestamp;
+ const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
+ game.lastTimestamp = timestamp;
+ if (game.phase === 'running') updateRunning(dt);
+ updateEffects(game, dt, canvas, build.equipmentHitBoxes);
+ updateCongestion();
+ drawAll(ctx, canvas, game, { activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle });
+ uiSystem.updateUI();
+ requestAnimationFrame(update);
+}
+
+function updateRunning(dt) {
+ const producing = game.timeLeft > 0;
+ game.timeLeft = Math.max(0, game.timeLeft - dt);
+ if (producing && game.timeLeft <= 0) closeFarmShutters();
+ if (game.timeLeft <= 0) {
+ for (const farm of game.eggFarms) farm.shutterProgress = Math.min(1, (farm.shutterProgress || 0) + dt * 2.8);
+ }
+ if (game.mixerHalfTimer > 0) game.mixerHalfTimer = Math.max(0, game.mixerHalfTimer - dt);
+ if (producing && game.timeLeft > 0) {
+ for (const farm of game.eggFarms) {
+ farm.nextSpawn -= dt;
+ if (farm.nextSpawn <= 0) { spawnChick(farm); farm.nextSpawn = currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; }
+ }
+ }
+ for (let i = game.chicks.length - 1; i >= 0; i -= 1) {
+ const chick = game.chicks[i];
+ chick.bob += dt * 7;
+ if (chick.stage === 'queued') continue;
+ updateRoutedChick(chick, i, dt);
+ }
+ updateScannerQueues(dt);
+ updateCongestion();
+ checkCongestionExplosions(dt);
+ if (game.timeLeft <= 0 && game.chicks.length === 0) completeTurn();
+ uiSystem.checkGameOver();
+}
+
+function updateRoutedChick(chick, index, dt) {
+ if (blockedByFrontChick(chick)) return;
+ moveAlongRoute(chick, dt);
+ if (chick.route && chick.targetIndex < chick.route.length) return;
+ if (chick.stage === 'input' || chick.stage === 'toScanner') {
+ const scanner = scannerById(game, chick.stage === 'input' ? chick.scannerId : chick.nextScannerId);
+ if (!scanner) { removeChick(index, 'NO SCANNER'); return; }
+ enqueueChick(chick, scanner, chick.route);
+ return;
+ }
+ if (chick.stage === 'toMixer') resolveMixer(index);
+ else if (chick.stage === 'toTruck') resolveTruck(index);
+ else if (chick.stage === 'toTrash') resolveTrash(index);
+}
+function blockedByFrontChick(chick) {
+ if (!chick.route || chick.targetIndex >= chick.route.length) return false;
+ const next = chick.route[chick.targetIndex];
+ for (const other of game.chicks) {
+ if (other.id === chick.id || other.stage === 'flying') continue;
+ if (Math.hypot(other.x - next.x, other.y - next.y) < GRID.cell * 0.55 && routeProgress(other) >= routeProgress(chick)) return true;
+ }
+ return false;
+}
+function routeProgress(chick) { return chick.targetIndex || 0; }
+function moveAlongRoute(chick, dt) {
+ let remaining = currentConveyorSpeed() * dt;
+ while (remaining > 0 && chick.targetIndex < chick.route.length) {
+ const target = chick.route[chick.targetIndex];
+ const dx = target.x - chick.x, dy = target.y - chick.y;
+ const dist = Math.hypot(dx, dy);
+ if (dist <= 0.001) { chick.targetIndex += 1; continue; }
+ if (remaining >= dist) { chick.x = target.x; chick.y = target.y; chick.targetIndex += 1; remaining -= dist; }
+ else { chick.x += dx / dist * remaining; chick.y += dy / dist * remaining; remaining = 0; }
+ }
+}
+function enqueueChick(chick, scanner, routeOverride) {
+ chick.stage = 'queued';
+ chick.route = null;
+ chick.queueRoute = routeOverride ? routeOverride.map(p => ({ x: p.x, y: p.y })) : [scannerCenter(scanner)];
+ chick.scannerId = scanner.id;
+ if (!scanner.queue.includes(chick.id)) scanner.queue.push(chick.id);
+ positionScannerQueue(scanner);
+}
+function positionScannerQueue(scanner) {
+ scanner.queue = scanner.queue.filter(id => game.chicks.some(ch => ch.id === id && ch.stage === 'queued'));
+ const spacing = GRID.cell * 0.86;
+ scanner.queue.forEach((id, idx) => {
+ const chick = game.chicks.find(ch => ch.id === id);
+ if (!chick) return;
+ setPositionFromRouteEnd(chick, chick.queueRoute || [scannerCenter(scanner)], idx * spacing);
+ chick.queueIndex = idx;
+ });
+}
+function setPositionFromRouteEnd(chick, route, distanceBack) {
+ if (!route || !route.length) return;
+ if (route.length === 1 || distanceBack <= 0) { const end = route[route.length - 1]; chick.x = end.x; chick.y = end.y; return; }
+ let remain = distanceBack;
+ for (let i = route.length - 1; i > 0; i -= 1) {
+ const a = route[i - 1], b = route[i];
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
+ if (remain <= len) { const t = 1 - remain / len; chick.x = a.x + (b.x - a.x) * t; chick.y = a.y + (b.y - a.y) * t; return; }
+ remain -= len;
+ }
+ const start = route[0]; chick.x = start.x; chick.y = start.y;
+}
+function updateScannerQueues(dt) {
+ for (const scanner of game.scanners) {
+ if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt);
+ positionScannerQueue(scanner);
+ 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);
+ if (index < 0) { scanner.queue.shift(); continue; }
+ if (sortChickByIndex(index, autoSideFor(scanner, game.chicks[index]), true)) { scanner.cooldown = AUTO_SCANNER_COOLDOWN; game.stats.autoSorted += 1; game.totals.autoSorted += 1; }
+ }
+}
+function autoSideFor(scanner, chick) {
+ if (scanner.role === 0) return chick.sex === 'male' ? 'left' : 'right';
+ return chick.sex === 'poop' ? 'left' : 'right';
+}
+function activeQueuedChick(slot) {
+ const scanner = scannerBySlot(game, slot);
+ if (!scanner || !scanner.queue.length) return null;
+ return game.chicks.find(c => c.id === scanner.queue[0]) || null;
+}
+function sortSlot(slot, side) {
+ if (game.phase !== 'running') return;
+ const scanner = scannerBySlot(game, slot);
+ if (!scanner || !scanner.queue.length) return;
+ const index = game.chicks.findIndex(c => c.id === scanner.queue[0]);
+ if (index >= 0) sortChickByIndex(index, side, false);
+}
+function sortChickByIndex(index, side, auto) {
+ const chick = game.chicks[index];
+ if (!chick || chick.stage !== 'queued') return false;
+ const scanner = scannerById(game, chick.scannerId);
+ if (!scanner) return false;
+ const plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true);
+ if (!plan) { floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger); return false; }
+ scanner.queue = scanner.queue.filter(id => id !== chick.id);
+ chick.stage = plan.destination === 'scanner' ? 'toScanner' : `to${plan.destination[0].toUpperCase()}${plan.destination.slice(1)}`;
+ chick.route = plan.route; chick.targetIndex = 1; chick.nextScannerId = plan.nextScannerId || null; chick.queueRoute = null; chick.queueIndex = -1;
+ scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, auto ? THEME.green : destinationColor(plan.destination));
+ floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : destinationLabel(plan.destination), auto ? THEME.green : destinationColor(plan.destination));
+ return true;
+}
+function resolveMixer(index) {
+ const chick = game.chicks[index]; if (!chick) return;
+ const { x, y } = chick; game.chicks.splice(index, 1); game.stats.mixerCount += 1; game.totals.processed += 1;
+ if (chick.sex === 'poop') { game.mixerHalfTimer = MIXER_HALF_SECONDS; game.stats.poopMixer += 1; game.totals.poopMixer += 1; game.stats.mistakes += 1; game.totals.mistakes += 1; sludgeEffect(game, x, y); floating(game, x, y - 18, `HALF PAY ${MIXER_HALF_SECONDS}s`, THEME.ink); return; }
+ const amount = positivePayout(game, MIXER_PRICE); applyCashDelta(game, amount); if (chick.sex === 'male') { game.stats.correct += 1; game.totals.correct += 1; } else { game.stats.mistakes += 1; game.totals.mistakes += 1; } meatEffect(game, x, y); floating(game, x, y - 18, `+${yen(amount)}`, THEME.green); uiSystem.checkGameOver();
+}
+function resolveTruck(index) {
+ const chick = game.chicks[index]; if (!chick) return;
+ const { x, y } = chick; game.chicks.splice(index, 1); addTruckCargo(chick.sex); truckLoadEffect(game, x, y, chick.sex); game.totals.processed += 1;
+ const target = truckTarget(game);
+ const isTarget = isTargetTruckCargo(game, chick.sex);
+ if (chick.sex === 'poop') {
+ game.stats.poopTruck += 1; game.totals.poopTruck += 1;
+ if (isTarget) { game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(positivePayout(game, TRUCK_PRICE))}`, THEME.green); }
+ else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, `SOIL ${Math.round(truckPayoutMultiplier(game) * 100)}%`, THEME.ink); }
+ return;
+ }
+ if (chick.sex === 'female') {
+ game.stats.truckFemale += 1;
+ if (isTarget) { const displayAmount = positivePayout(game, Math.floor(TRUCK_PRICE * truckPayoutMultiplier(game))); game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(displayAmount)}`, THEME.green); }
+ else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, target === 'male' ? 'REJECT' : 'NO SALE', THEME.warn); }
+ return;
+ }
+ game.stats.truckMale += 1;
+ if (isTarget) { game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, `+${yen(positivePayout(game, TRUCK_PRICE))}`, THEME.green); return; }
+ if (shouldFineMaleTruck(game)) { const penalty = maleTruckPenalty(game); applyCashDelta(game, -penalty); game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); uiSystem.checkGameOver(); }
+ else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, 'REJECT', THEME.warn); }
+}
+function resolveTrash(index) {
+ const chick = game.chicks[index]; if (!chick) return;
+ const { x, y } = chick; game.chicks.splice(index, 1); shredEffect(game, x, y, chick.sex); game.stats.trashCount += 1; game.totals.processed += 1;
+ if (chick.sex === 'poop') { game.stats.poopTrash += 1; game.totals.poopTrash += 1; game.stats.correct += 1; game.totals.correct += 1; floating(game, x, y - 18, 'CLEAN', THEME.green); }
+ else { game.stats.mistakes += 1; game.totals.mistakes += 1; floating(game, x, y - 18, 'WASTE', THEME.ink); }
+}
+function removeChick(index, label) { const chick = game.chicks[index]; if (!chick) return; game.chicks.splice(index, 1); floating(game, chick.x, chick.y - 12, label, THEME.muted); }
+function addTruckCargo(sex) { const t = game.facilities.truck; if (!t) return; game.truckCargo.push({ sex, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) }); if (game.truckCargo.length > 45) game.truckCargo.shift(); }
+
+function conveyorKeyAtChick(chick) {
+ const cell = pointToCell(chick.x, chick.y);
+ if (cell) {
+ const k = key(cell.col, cell.row);
+ if (game.conveyorTiles.has(k)) return k;
+ }
+ // Fallback only when the chick is visually still on a conveyor center.
+ const nearest = nearestConveyorKey(game, chick.x, chick.y);
+ if (!nearest) return null;
+ const p = parseKey(nearest);
+ const c = cellCenter(p.col, p.row);
+ return Math.hypot(chick.x - c.x, chick.y - c.y) <= GRID.cell * 0.55 ? nearest : null;
+}
+
+function updateCongestion() {
+ const { components, cellToComponent } = buildConveyorComponents(game);
+ for (const chick of game.chicks) {
+ if (chick.stage === 'flying') continue;
+ const k = conveyorKeyAtChick(chick);
+ if (!k) continue;
+ const id = cellToComponent.get(k);
+ const comp = components.get(id);
+ if (comp) comp.count += 1;
+ }
+ for (const comp of components.values()) comp.ratio = comp.count / comp.capacity;
+ game.congestion = components;
+ game.componentLookup = cellToComponent;
+}
+function checkCongestionExplosions(dt) {
+ for (const comp of game.congestion.values()) {
+ if (comp.ratio > 0.8 && comp.ratio < 1) {
+ for (const chick of chicksInComponent(comp.id).slice(0, 3)) if (Math.random() < 0.06) rageEffect(game, chick.x, chick.y - chick.radius - 8);
+ }
+ if (comp.ratio >= 1) {
+ const last = game.lastExplodedComponent.get(comp.id) || 0;
+ const now = performance.now();
+ if (now - last > 900) { game.lastExplodedComponent.set(comp.id, now); explodeComponent(comp); }
+ }
+ }
+}
+function chicksInComponent(id) {
+ return game.chicks.filter(ch => { const k = conveyorKeyAtChick(ch); return k && game.componentLookup?.get(k) === id; });
+}
+function explodeComponent(comp) {
+ const victims = chicksInComponent(comp.id);
+ if (!victims.length) return;
+ for (const v of victims) { flyingDebris(game, v.sex, v.x, v.y); }
+ game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id));
+ for (const scanner of game.scanners) scanner.queue = scanner.queue.filter(id => game.chicks.some(c => c.id === id));
+ for (const k of comp.cells) { const p = parseKey(k); const c = cellCenter(p.col, p.row); shockwave(game, c.x, c.y, THEME.danger); sparkBurst(game, c.x, c.y, 6); smokeBurst(game, c.x, c.y, 4); }
+ shake(game, 18, .75); const first = parseKey(comp.cells[0]); const fc = cellCenter(first.col, first.row); floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger);
+}
+function explodeRoute(route, extras, label) {
+ const victims = game.chicks.filter(ch => route.some(p => Math.hypot(ch.x - p.x, ch.y - p.y) < GRID.cell));
+ game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id));
+ for (const v of victims) flyingDebris(game, v.sex, v.x, v.y);
+ for (const e of extras) flyingDebris(game, e.sex, e.x, e.y);
+ for (const p of route) { shockwave(game, p.x, p.y, THEME.danger); sparkBurst(game, p.x, p.y, 4); smokeBurst(game, p.x, p.y, 3); }
+ shake(game, 18, .75); if (route[0]) floating(game, route[0].x, route[0].y - 18, label, THEME.danger);
+}
+
+// Build/edit/selection operations live in src/systems/buildSystem.js.
+// HUD, panels, modal screens, and gameover rendering live in src/systems/uiSystem.js.
+// Hitbox generation lives in buildSystem.js.
+function rawCanvasPoint(event) { const r = canvas.getBoundingClientRect(); return { x: (event.clientX - r.left) * canvas.width / r.width, y: (event.clientY - r.top) * canvas.height / r.height }; }
+function canvasPoint(event) { const p = rawCanvasPoint(event); return { x: p.x - game.view.x, y: p.y - game.view.y }; }
+function startPan(event) { const p = rawCanvasPoint(event); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; }
+function updatePan(event) { if (!game.pan) return; const p = rawCanvasPoint(event); game.view.x = clamp(game.pan.viewX + p.x - game.pan.start.x, -620, 420); game.view.y = clamp(game.pan.viewY + p.y - game.pan.start.y, -340, 240); }
+function selectionForHit(hit) { if (!hit) return null; if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey }; return { type: hit.type, id: hit.ref.id }; }
+function clickSelect(event) {
+ const p = canvasPoint(event), hit = build.equipmentAtPoint(p);
+ const sel = selectionForHit(hit);
+ if (!sel) { game.selected = null; game.multiSelected = []; uiSystem.updatePanels(); return; }
+ game.selected = sel;
+ game.multiSelected = [sel];
+ uiSystem.updatePanels();
+ if (hit.type === 'scanner' && hit.ref.kind === 'auto') build.showAutoScannerMenu(hit.ref);
+}
+
+canvas.addEventListener('contextmenu', event => event.preventDefault());
+canvas.addEventListener('pointerdown', event => {
+ if (game.phase !== 'build') return;
+ canvas.setPointerCapture(event.pointerId);
+ const world = canvasPoint(event);
+ if (event.button === 2) { startPan(event); return; }
+ if (event.button !== 0) return;
+ if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; }
+ if (game.buildTool) { build.buildAtPoint(world); return; }
+ if (build.startGroupDrag(event)) return;
+ build.startSelectionBox(event);
+});
+canvas.addEventListener('pointermove', event => {
+ if (game.phase !== 'build') return;
+ if (game.pan && (event.buttons & 2)) updatePan(event);
+ if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event);
+ if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(event);
+ if (game.buildTool === 'conveyor' && (event.buttons & 1)) build.buildAtCell(pointToCell(canvasPoint(event).x, canvasPoint(event).y));
+ if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event));
+});
+canvas.addEventListener('pointerup', event => {
+ if (game.selectionBox) build.finishSelectionBox();
+ else if (game.groupDrag) {
+ if (!game.groupDrag.committed) clickSelect(event);
+ build.finishGroupDrag();
+ }
+ game.groupDrag = null; game.pan = null;
+ try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
+});
+canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; });
+
+ui.buttons.s1Left.addEventListener('click', () => sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => sortSlot(1, 'right'));
+ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
+ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); });
+window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } });
+
+build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() });
+uiSystem = createUISystem({ game, ui, build, startGame, activeQueuedChick });
+resetLayout(game); updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..cfd5067
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+ Chick Sorter v6.0 Visibility Pass
+
+
+
+
+
+
+
+
+
CASH¥140
+
TIME60.0s
+
PROFIT¥0
+
TURN1
+
PHASETitle
+
+
+
+
MIXER0
+
TRUCK F0
+
WRONG0
+
RANDOM0
+
+
+
+
+
+
CHICK SORTER v6.0
+
Industrial scanner junctions. Conveyor tiles can bend; scanners split to left/right output belts.
+
+
+
+
+
+
+ Build
+
+
+
+
+
+
+ Build tools unlock after each 60-second turn.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/render/draw.js b/src/render/draw.js
new file mode 100644
index 0000000..4c3b70d
--- /dev/null
+++ b/src/render/draw.js
@@ -0,0 +1,526 @@
+import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
+import { key, parseKey, cellCenter, mixHex, randomBetween } from '../core/utils.js';
+import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor } from '../systems/routing.js';
+import { truckPayoutMultiplier } from '../systems/economy.js';
+
+const ASSET_PATHS = {
+ chickMale: './assets/images/chick_male.png',
+ chickFemale: './assets/images/chick_female.png',
+ poop: './assets/images/poop.png',
+ conveyor: './assets/images/conveyor.png',
+ scannerManual: './assets/images/scanner_manual.png',
+ scannerAuto: './assets/images/scanner_auto.png',
+ eggFarm: './assets/images/egg_farm.png',
+ mixer: './assets/images/mixer.png',
+ shredder: './assets/images/shredder.png',
+ truck: './assets/images/truck.png'
+};
+
+export const assets = {};
+for (const [name, src] of Object.entries(ASSET_PATHS)) {
+ const img = new Image();
+ img.src = src;
+ img.loaded = false;
+ img.onload = () => { img.loaded = true; };
+ assets[name] = img;
+}
+
+// ART OVERLAY HOOK:
+// Drop replacement PNG/WebP files into assets/images/ using the names in ASSET_PATHS.
+// Each draw* function first checks whether an image loaded; if not, it falls back to simple flat shapes.
+// Keep these placeholder shapes simple so your later artwork can be layered over them without fighting the UI.
+
+export function drawAll(ctx, canvas, game, helpers) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ drawBackground(ctx, canvas);
+ ctx.save();
+ const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
+ const sy = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
+ ctx.translate(game.view.x + sx, game.view.y + sy);
+ drawGrid(ctx);
+ drawConveyors(ctx, game);
+ drawScannerConnectors(ctx, game);
+ drawFacilities(ctx, game);
+ drawEggFarms(ctx, game);
+ drawScanners(ctx, game);
+ drawChicks(ctx, game, helpers.activeQueuedChick);
+ drawSelectedTooltip(ctx, game, helpers.selectedObject, helpers.selectedTitle);
+ drawSelectionBox(ctx, game);
+ drawEffects(ctx, game);
+ drawFloatingTexts(ctx, game);
+ if (game.debug) drawDebug(ctx, game);
+ ctx.restore();
+ drawCanvasHints(ctx, game, canvas);
+}
+
+function rect(ctx, x, y, w, h, fill = true, stroke = true) {
+ ctx.beginPath(); ctx.rect(x, y, w, h); if (fill) ctx.fill(); if (stroke) ctx.stroke();
+}
+function label(ctx, x, y, text, color = THEME.ink) {
+ ctx.save(); ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = THEME.white; ctx.fillStyle = color; ctx.strokeText(text, x, y); ctx.fillText(text, x, y); ctx.restore();
+}
+function drawImageIfLoaded(ctx, image, x, y, w, h) {
+ if (!image?.loaded) return false;
+ ctx.drawImage(image, x, y, w, h);
+ return true;
+}
+function drawBackground(ctx, canvas) {
+ ctx.fillStyle = THEME.bg;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.save();
+ ctx.strokeStyle = 'rgba(16, 32, 21, 0.035)';
+ ctx.lineWidth = 1;
+ for (let x = 0; x < canvas.width; x += 32) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); }
+ for (let y = 0; y < canvas.height; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
+ ctx.restore();
+}
+function drawGrid(ctx) {
+ ctx.save();
+ ctx.fillStyle = 'rgba(255,255,255,.46)';
+ ctx.strokeStyle = 'rgba(16,32,21,.055)';
+ ctx.lineWidth = 1;
+ rect(ctx, GRID.x - 10, GRID.y - 10, GRID.cols * GRID.cell + 20, GRID.rows * GRID.cell + 20, true, true);
+ for (let c = 0; c <= GRID.cols; c += 1) {
+ const x = GRID.x + c * GRID.cell;
+ ctx.beginPath(); ctx.moveTo(x, GRID.y); ctx.lineTo(x, GRID.y + GRID.rows * GRID.cell); ctx.stroke();
+ }
+ for (let r = 0; r <= GRID.rows; r += 1) {
+ const y = GRID.y + r * GRID.cell;
+ ctx.beginPath(); ctx.moveTo(GRID.x, y); ctx.lineTo(GRID.x + GRID.cols * GRID.cell, y); ctx.stroke();
+ }
+ ctx.restore();
+}
+function componentRatio(game, k) {
+ const id = game.componentLookup?.get(k);
+ const data = id ? game.congestion.get(id) : null;
+ return data?.ratio || 0;
+}
+function drawConveyors(ctx, game) {
+ const edges = [];
+ const seen = new Set();
+ for (const k of game.conveyorTiles) {
+ const { col, row } = parseKey(k);
+ for (const n of getConveyorNeighbors(game, col, row)) {
+ const nk = key(n.col, n.row);
+ const e = [k, nk].sort().join('|');
+ if (seen.has(e)) continue;
+ seen.add(e);
+ const ratio = Math.max(componentRatio(game, k), componentRatio(game, nk));
+ edges.push({ a: cellCenter(col, row), b: cellCenter(n.col, n.row), ratio });
+ }
+ }
+ ctx.save();
+ ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ for (const layer of [
+ { width: 33, color: THEME.ink, alpha: 1 },
+ { width: 25, color: THEME.white, alpha: 1 },
+ { width: 17, color: THEME.greenSoft, alpha: 1 }
+ ]) {
+ ctx.globalAlpha = layer.alpha; ctx.strokeStyle = layer.color; ctx.lineWidth = layer.width;
+ for (const e of edges) { ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke(); }
+ }
+ for (const e of edges) {
+ const t = Math.max(0, (e.ratio - 0.5) / 0.5);
+ ctx.strokeStyle = mixHex(THEME.green, THEME.danger, t);
+ ctx.globalAlpha = e.ratio > 0.5 ? 0.35 + t * 0.55 : 0.8;
+ ctx.lineWidth = 6;
+ ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke();
+ }
+ for (const k of game.conveyorTiles) {
+ const c = cellCenter(...Object.values(parseKey(k)));
+ const ratio = componentRatio(game, k);
+ ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white;
+ ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
+ rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true);
+ if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
+ }
+ for (const farm of game.eggFarms) {
+ const data = routeFromFarmToScanner(game, farm);
+ if (data) drawRouteFlow(ctx, data.route, THEME.muted, 3);
+ }
+ const flowSegments = new Map();
+ for (const scanner of game.scanners) {
+ for (const side of ['left', 'right']) {
+ const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id);
+ if (!plan) continue;
+ addRouteFlowSegments(flowSegments, plan.route, plan.destination);
+ }
+ }
+ drawDestinationFlowSegments(ctx, flowSegments);
+ ctx.restore();
+}
+function drawRouteFlow(ctx, points, color, width) {
+ if (!points || points.length < 2) return;
+ ctx.save();
+ ctx.strokeStyle = color; ctx.lineWidth = width; ctx.globalAlpha = .78; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
+ ctx.stroke();
+ ctx.fillStyle = color; ctx.globalAlpha = .92;
+ for (let i = 0; i < points.length - 1; i += 1) {
+ const a = points[i], b = points[i + 1];
+ const dx = b.x - a.x, dy = b.y - a.y, dist = Math.hypot(dx, dy);
+ if (dist < 36) continue;
+ drawArrow(ctx, a.x + dx * .55, a.y + dy * .55, Math.atan2(dy, dx));
+ }
+ ctx.restore();
+}
+function flowSegmentKey(a, b) {
+ const ak = `${Math.round(a.x)},${Math.round(a.y)}`;
+ const bk = `${Math.round(b.x)},${Math.round(b.y)}`;
+ return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`;
+}
+function addRouteFlowSegments(map, points, dest) {
+ if (!points || points.length < 2) return;
+ for (let i = 0; i < points.length - 1; i += 1) {
+ const a = points[i], b = points[i + 1];
+ if (Math.hypot(b.x - a.x, b.y - a.y) < 8) continue;
+ const k = flowSegmentKey(a, b);
+ if (!map.has(k)) map.set(k, new Map());
+ const dests = map.get(k);
+ if (!dests.has(dest)) dests.set(dest, { a, b });
+ }
+}
+function drawDestinationFlowSegments(ctx, segments) {
+ const order = ['mixer', 'trash', 'truck', 'scanner'];
+ for (const dests of segments.values()) {
+ const entries = [...dests.entries()].sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
+ const count = entries.length;
+ entries.forEach(([dest, seg], index) => {
+ const offset = (index - (count - 1) / 2) * 8;
+ drawColoredFlowSegment(ctx, seg.a, seg.b, destinationColor(dest), offset);
+ });
+ }
+}
+function drawColoredFlowSegment(ctx, a, b, color, offset) {
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const dist = Math.hypot(dx, dy);
+ if (dist < 8) return;
+ const nx = -dy / dist, ny = dx / dist;
+ const ax = a.x + nx * offset, ay = a.y + ny * offset;
+ const bx = b.x + nx * offset, by = b.y + ny * offset;
+ ctx.save();
+ ctx.strokeStyle = color; ctx.lineWidth = 5; ctx.globalAlpha = .9; ctx.lineCap = 'round';
+ ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
+ ctx.fillStyle = color; ctx.globalAlpha = .98;
+ if (dist > 32) drawArrow(ctx, ax + (bx - ax) * .58, ay + (by - ay) * .58, Math.atan2(dy, dx));
+ ctx.restore();
+}
+function drawArrow(ctx, x, y, angle) {
+ ctx.save(); ctx.translate(x, y); ctx.rotate(angle);
+ ctx.beginPath(); ctx.moveTo(11, 0); ctx.lineTo(-6, -7); ctx.lineTo(-2, 0); ctx.lineTo(-6, 7); ctx.closePath(); ctx.fill();
+ ctx.restore();
+}
+function drawScannerConnectors(ctx, game) {
+ ctx.save();
+ ctx.lineWidth = 2;
+ ctx.font = '900 10px ui-monospace, monospace';
+ ctx.textAlign = 'center';
+ for (const scanner of game.scanners) {
+ const outputs = scannerOutputs(scanner);
+ const items = [
+ { p: scannerConnector(scanner, 'inputA'), label: 'IN', color: THEME.green },
+ { p: scannerConnector(scanner, 'left'), label: destinationLabel(outputs.left), color: destinationColor(outputs.left) },
+ { p: scannerConnector(scanner, 'right'), label: destinationLabel(outputs.right), color: destinationColor(outputs.right) }
+ ];
+ for (const item of items) {
+ if (!item.p) continue;
+ const p = cellCenter(item.p.col, item.p.row);
+ const connected = game.conveyorTiles.has(key(item.p.col, item.p.row));
+ ctx.fillStyle = connected ? item.color : '#f4c2c2';
+ ctx.strokeStyle = THEME.ink;
+ rect(ctx, p.x - 18, p.y - 12, 36, 24, true, true);
+ ctx.fillStyle = connected && item.color === THEME.white ? THEME.ink : THEME.white;
+ ctx.fillText(item.label, p.x, p.y + 4);
+ }
+ }
+ ctx.restore();
+}
+function drawEggFarms(ctx, game) { for (const farm of game.eggFarms) drawEggFarm(ctx, farm, game); }
+function drawEggFarm(ctx, farm, game) {
+ const c = cellCenter(farm.col, farm.row);
+ ctx.save();
+ if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { ctx.restore(); return; }
+ ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
+ rect(ctx, c.x - 28, c.y - 28, 56, 56, true, true);
+ ctx.fillStyle = THEME.green; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4);
+ ctx.font = '900 9px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.fillText(`L${farm.level}`, c.x, c.y + 13); ctx.fillText(`${farm.nextSpawn.toFixed(1)}s`, c.x, c.y + 26);
+ const shutter = Math.max(0, Math.min(1, farm.shutterProgress || 0));
+ if (shutter > 0) {
+ const h = 56 * shutter;
+ ctx.fillStyle = THEME.ink;
+ rect(ctx, c.x - 28, c.y - 28, 56, h, true, false);
+ ctx.strokeStyle = THEME.white; ctx.lineWidth = 2;
+ for (let yy = c.y - 24; yy < c.y - 28 + h; yy += 8) { ctx.beginPath(); ctx.moveTo(c.x - 24, yy); ctx.lineTo(c.x + 24, yy); ctx.stroke(); }
+ if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); }
+ }
+ drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
+ ctx.restore();
+}
+function drawScanners(ctx, game) { for (const s of game.scanners) drawScanner(ctx, s, game); }
+function drawScanner(ctx, scanner, game) {
+ const c = scannerCenter(scanner);
+ ctx.save();
+ const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
+ if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) { ctx.restore(); return; }
+ ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
+ ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
+ rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true);
+ ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink;
+ ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'center';
+ ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 12);
+ ctx.font = '900 10px ui-monospace, monospace';
+ ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 7);
+ const q = scanner.queue.length;
+ ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
+ ctx.fillText(`Q:${q}${scanner.kind === 'auto' ? ` CD:${scanner.cooldown.toFixed(1)}` : ''}`, c.x, c.y + 25);
+ drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
+ ctx.restore();
+}
+
+function targetForTruck(game) {
+ return game.contractActive?.target || game.contractOffer?.target || 'female';
+}
+function drawItemIcon(ctx, x, y, type) {
+ if (type === 'poop') drawTinyPoop(ctx, x, y);
+ else drawTinyChick(ctx, x, y, type, false);
+}
+function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
+ ctx.save();
+ ctx.fillStyle = 'rgba(255,255,255,.92)';
+ ctx.strokeStyle = THEME.ink;
+ ctx.lineWidth = 3;
+ rect(ctx, x, y, w, 32, true, true);
+ drawItemIcon(ctx, x + 18, y + 16, type);
+ ctx.fillStyle = THEME.ink;
+ ctx.font = '900 10px ui-monospace, monospace';
+ ctx.textAlign = 'left';
+ ctx.fillText(title, x + 34, y + 13);
+ if (subtitle) {
+ ctx.fillStyle = THEME.muted;
+ ctx.font = '900 8px ui-monospace, monospace';
+ ctx.fillText(subtitle, x + 34, y + 25);
+ }
+ ctx.restore();
+}
+function receiverTitle(id, game) {
+ if (id === 'mixer') return { title: 'IN: MALE', type: 'male', color: THEME.mixerBlue };
+ if (id === 'trash') return { title: 'IN: POOP', type: 'poop', color: THEME.wasteGreen };
+ if (id === 'truck') return { title: `IN: ${targetForTruck(game).toUpperCase()}`, type: targetForTruck(game), color: THEME.truckPink };
+ return { title: 'IN', type: 'female', color: THEME.green };
+}
+function drawFacilityReceiver(ctx, game, id) {
+ const f = game.facilities[id];
+ if (!f?.entry) return;
+ const c = cellCenter(f.entry.col, f.entry.row);
+ const info = receiverTitle(id, game);
+ ctx.save();
+ ctx.fillStyle = THEME.white;
+ ctx.strokeStyle = info.color;
+ ctx.lineWidth = 5;
+ rect(ctx, c.x - 22, c.y - 22, 44, 44, true, true);
+ ctx.strokeStyle = THEME.ink;
+ ctx.lineWidth = 2;
+ rect(ctx, c.x - 18, c.y - 18, 36, 36, false, true);
+ drawItemIcon(ctx, c.x, c.y - 3, info.type);
+ ctx.font = '900 8px ui-monospace, monospace';
+ ctx.textAlign = 'center';
+ ctx.fillStyle = THEME.ink;
+ ctx.fillText(info.title, c.x, c.y + 20);
+ ctx.restore();
+}
+function drawFacilities(ctx, game) {
+ if (game.facilities.mixer) drawMixer(ctx, game);
+ if (game.facilities.trash) drawTrash(ctx, game);
+ if (game.facilities.truck) drawTruck(ctx, game);
+ drawFacilityReceiver(ctx, game, 'mixer');
+ drawFacilityReceiver(ctx, game, 'trash');
+ drawFacilityReceiver(ctx, game, 'truck');
+}
+function drawExternalDuct(ctx, f) {
+ if (!f?.entry) return;
+ const c = cellCenter(f.entry.col, f.entry.row);
+ let bx = f.x + f.w / 2, by = f.y + f.h / 2;
+ if (f.side === 'left') { bx = f.x + f.w; by = c.y; }
+ else if (f.side === 'right') { bx = f.x; by = c.y; }
+ else if (f.side === 'top') { bx = c.x; by = f.y + f.h; }
+ else if (f.side === 'bottom') { bx = c.x; by = f.y; }
+ ctx.save();
+ ctx.strokeStyle = THEME.ink;
+ ctx.lineWidth = 9;
+ ctx.lineCap = 'round';
+ ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke();
+ ctx.strokeStyle = THEME.white; ctx.lineWidth = 5;
+ ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke();
+ ctx.restore();
+}
+function drawMixer(ctx, game) {
+ const m = game.facilities.mixer;
+ ctx.save();
+ drawExternalDuct(ctx, m);
+ if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { ctx.restore(); return; }
+ ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true);
+ ctx.fillStyle = THEME.ink; ctx.font = '900 17px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
+ ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(game.mixerHalfTimer > 0 ? `HALF PAY ${game.mixerHalfTimer.toFixed(1)}s` : `PAY ¥5`, m.x + m.w / 2, m.y + 48);
+ drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
+ ctx.strokeStyle = THEME.green; ctx.lineWidth = 5;
+ for (let i = 0; i < 3; i += 1) { ctx.beginPath(); ctx.arc(m.x + m.w / 2, m.y + 78, 16 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); }
+ drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
+ ctx.restore();
+}
+function drawTrash(ctx, game) {
+ const t = game.facilities.trash;
+ ctx.save();
+ drawExternalDuct(ctx, t);
+ if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
+ ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
+ ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25);
+ for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); }
+ drawTargetBadge(ctx, t.x + 14, t.y + t.h - 42, t.w - 28, 'SEND POOP', 'poop', 'shredder');
+ drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
+ ctx.restore();
+}
+function drawTruck(ctx, game) {
+ const t = game.facilities.truck;
+ ctx.save();
+ drawExternalDuct(ctx, t);
+ if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
+ ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
+ ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
+ ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`EST ${Math.round(truckPayoutMultiplier(game) * 100)}%`, t.x + t.w / 2, t.y + 42);
+ const truckTargetType = targetForTruck(game);
+ drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo');
+ ctx.fillStyle = THEME.greenSoft; rect(ctx, t.x + 13, t.y + 88, t.w - 26, t.h - 103, true, false);
+ for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
+ drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
+ ctx.restore();
+}
+function drawChicks(ctx, game, activeQueuedChick) {
+ const active1 = activeQueuedChick(0);
+ const active2 = activeQueuedChick(1);
+ for (const chick of game.chicks) {
+ const active = (active1 && active1.id === chick.id) || (active2 && active2.id === chick.id) || chick.queueIndex === 0;
+ drawChick(ctx, chick, active, game);
+ }
+}
+function drawChick(ctx, chick, active, game) {
+ const rage = game.componentLookup && nearestRatio(game, chick) > 0.8;
+ const y = chick.y + Math.sin(chick.bob) * (rage ? 5 : 1.5);
+ ctx.save();
+ if (active) { ctx.strokeStyle = THEME.green; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 8, 0, Math.PI * 2); ctx.stroke(); }
+ if (rage) { ctx.strokeStyle = THEME.danger; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 12 + Math.sin(chick.bob * 2) * 4, 0, Math.PI * 2); ctx.stroke(); }
+ if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius);
+ else {
+ const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale;
+ if (!drawImageIfLoaded(ctx, img, chick.x - chick.radius, y - chick.radius, chick.radius * 2, chick.radius * 2)) {
+ ctx.fillStyle = chick.sex === 'male' ? THEME.male : THEME.female;
+ ctx.strokeStyle = THEME.ink; ctx.lineWidth = 5;
+ ctx.beginPath(); ctx.arc(chick.x, y, chick.radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
+ ctx.fillStyle = THEME.ink; ctx.beginPath(); ctx.arc(chick.x - 6, y - 5, 2.5, 0, Math.PI * 2); ctx.arc(chick.x + 6, y - 5, 2.5, 0, Math.PI * 2); ctx.fill();
+ ctx.fillStyle = '#ffaa2e'; ctx.beginPath(); ctx.moveTo(chick.x, y + 2); ctx.lineTo(chick.x + 9, y + 6); ctx.lineTo(chick.x, y + 10); ctx.closePath(); ctx.fill();
+ }
+ }
+ ctx.restore();
+}
+function nearestRatio(game, chick) {
+ let best = null, bestD = Infinity;
+ for (const [k, id] of game.componentLookup.entries()) {
+ const p = parseKey(k); const c = cellCenter(p.col, p.row); const d = Math.hypot(chick.x - c.x, chick.y - c.y);
+ if (d < bestD) { bestD = d; best = id; }
+ }
+ return game.congestion.get(best)?.ratio || 0;
+}
+function drawPoop(ctx, x, y, radius) {
+ if (drawImageIfLoaded(ctx, assets.poop, x - radius, y - radius, radius * 2, radius * 2)) return;
+ ctx.save(); ctx.fillStyle = THEME.poop; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
+ ctx.beginPath(); ctx.arc(x, y + 7, radius * .86, Math.PI, 0, true); ctx.arc(x, y + 1, radius * .66, Math.PI, 0, true); ctx.arc(x, y - 5, radius * .44, Math.PI, 0, true); ctx.closePath(); ctx.fill(); ctx.stroke();
+ ctx.fillStyle = THEME.white; ctx.beginPath(); ctx.arc(x - 5, y + 1, 2, 0, Math.PI * 2); ctx.arc(x + 5, y + 1, 2, 0, Math.PI * 2); ctx.fill(); ctx.restore();
+}
+function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); }
+function drawTinyChick(ctx, x, y, sex, warning = false) {
+ ctx.save(); ctx.fillStyle = sex === 'male' ? THEME.male : THEME.female; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
+ if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); }
+ ctx.restore();
+}
+function selectionToken(type, id) { return `${type}:${id}`; }
+function isSelected(game, type, id) {
+ if (game.selected && game.selected.type === type && game.selected.id === id) return true;
+ return (game.multiSelected || []).some(sel => selectionToken(sel.type, sel.id) === selectionToken(type, id));
+}
+function drawSelection(ctx, game, type, id, x, y, w, h) {
+ if (!isSelected(game, type, id)) return;
+ ctx.save(); ctx.strokeStyle = THEME.green; ctx.lineWidth = 4; ctx.setLineDash([8, 5]); ctx.strokeRect(x - w / 2, y - h / 2, w, h); ctx.restore();
+}
+function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) {
+ if (game.phase !== 'build') return;
+ if ((game.multiSelected || []).length > 1) {
+ ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.96)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, 54 - game.view.x, 66 - game.view.y, 214, 42, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`${game.multiSelected.length} ITEMS SELECTED`, 65 - game.view.x, 83 - game.view.y); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore();
+ return;
+ }
+ const obj = selectedObject();
+ if (!obj) return;
+ let x = 80, y = 80;
+ if (obj.type === 'conveyor') { const p = parseKey(obj.id); const c = cellCenter(p.col, p.row); x = c.x + 22; y = c.y - 56; }
+ else if (obj.type === 'eggFarm' || obj.type === 'scanner') { const c = obj.type === 'eggFarm' ? cellCenter(obj.col, obj.row) : scannerCenter(obj); x = c.x + 40; y = c.y - 56; }
+ else if (obj.type === 'facility') { x = obj.x + obj.w / 2 - 76; y = obj.y - 48; }
+ ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, x, y, 176, 40, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(selectedTitle(obj), x + 9, y + 16); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag to move / panel to edit', x + 9, y + 31); ctx.restore();
+}
+function drawSelectionBox(ctx, game) {
+ if (game.phase !== 'build' || !game.selectionBox) return;
+ const b = game.selectionBox;
+ const x = Math.min(b.x1, b.x2), y = Math.min(b.y1, b.y2);
+ const w = Math.abs(b.x2 - b.x1), h = Math.abs(b.y2 - b.y1);
+ ctx.save();
+ ctx.fillStyle = 'rgba(34,185,79,.12)';
+ ctx.strokeStyle = THEME.green;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([7, 4]);
+ rect(ctx, x, y, w, h, true, true);
+ ctx.restore();
+}
+function drawEffects(ctx, game) {
+ const sorted = [...game.effects].sort((a, b) => (a.priority ?? EFFECT_PRIORITY.ambient) - (b.priority ?? EFFECT_PRIORITY.ambient));
+ for (const e of sorted) {
+ const alpha = Math.max(0, e.life / e.maxLife);
+ ctx.save(); ctx.globalAlpha = alpha;
+ if (e.type === 'meat') { ctx.fillStyle = THEME.green; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size * 1.5, e.size, true, false); }
+ else if (e.type === 'sludge') { ctx.fillStyle = THEME.poop; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size * 1.4, e.size, true, false); }
+ else if (e.type === 'spawn') { ctx.strokeStyle = THEME.green; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * 20, 0, Math.PI * 2); ctx.stroke(); }
+ else if (e.type === 'spark') { ctx.fillStyle = e.color || THEME.danger; rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size, e.size, true, false); }
+ else if (e.type === 'smoke') { ctx.fillStyle = 'rgba(16,32,21,.24)'; ctx.beginPath(); ctx.arc(e.x, e.y, e.size * (1.2 - alpha * .2), 0, Math.PI * 2); ctx.fill(); }
+ else if (e.type === 'shockwave') { ctx.strokeStyle = e.color || THEME.danger; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * (e.maxSize || 44), 0, Math.PI * 2); ctx.stroke(); }
+ else if (e.type === 'scannerPulse') { ctx.strokeStyle = e.color || THEME.green; ctx.lineWidth = 5; ctx.setLineDash([8, 6]); ctx.beginPath(); ctx.arc(e.x, e.y, e.size + (1 - alpha) * 38, 0, Math.PI * 2); ctx.stroke(); }
+ else if (e.type === 'shred') { ctx.fillStyle = e.color || THEME.ink; ctx.save(); ctx.translate(e.x, e.y); ctx.rotate(e.rot || 0); rect(ctx, -e.w / 2, -e.h / 2, e.w, e.h, true, false); ctx.restore(); }
+ else if (e.type === 'load') { ctx.strokeStyle = e.color || THEME.green; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(e.x - 14, e.y); ctx.lineTo(e.x + 14, e.y); ctx.moveTo(e.x, e.y - 14); ctx.lineTo(e.x, e.y + 14); ctx.stroke(); }
+ else if (e.type === 'erase') { ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; ctx.setLineDash([4, 4]); rect(ctx, e.x - e.size / 2, e.y - e.size / 2, e.size, e.size, false, true); }
+ else if (e.type === 'rage') { ctx.fillStyle = e.color || THEME.danger; ctx.font = '900 16px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', e.x, e.y); }
+ else if (e.type === 'flyingChick') { e.sex === 'poop' ? drawPoop(ctx, e.x, e.y, e.radius || 11) : drawTinyChick(ctx, e.x, e.y, e.sex, e.sex === 'male'); }
+ ctx.restore();
+ }
+}
+function drawFloatingTexts(ctx, game) {
+ ctx.save(); ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center';
+ for (const t of game.floatingTexts) { const alpha = Math.max(0, t.life / t.maxLife); ctx.globalAlpha = alpha; ctx.lineWidth = 5; ctx.strokeStyle = THEME.white; ctx.fillStyle = t.color; ctx.strokeText(t.text, t.x, t.y); ctx.fillText(t.text, t.x, t.y); }
+ ctx.restore();
+}
+function drawCanvasHints(ctx, game, canvas) {
+ ctx.save();
+ ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
+ // Visible version marker helps avoid browser/file-cache confusion when testing zips.
+ ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3;
+ rect(ctx, canvas.width - 284, 18, 260, 34, true, true);
+ ctx.fillStyle = THEME.green; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(VERSION, canvas.width - 270, 40);
+ ctx.font = '900 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink;
+ const lines = [];
+ if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2');
+ if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect all receiver ports`);
+ let y = 76;
+ for (const line of lines) { ctx.strokeStyle = THEME.white; ctx.lineWidth = 4; ctx.strokeText(line, 24, y); ctx.fillText(line, 24, y); y += 20; }
+ ctx.restore();
+}
+function drawDebug(ctx, game) {
+ ctx.save(); ctx.font = '900 9px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillStyle = THEME.danger;
+ for (const k of game.conveyorTiles) { const { col, row } = parseKey(k); const c = cellCenter(col, row); const comp = game.componentLookup?.get(k) || '?'; ctx.fillText(`${col},${row}/C${comp}`, c.x, c.y + 24); }
+ ctx.restore();
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..5efcfbd
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,352 @@
+:root {
+ --bg: #020303;
+ --screen: #050607;
+ --panel: rgba(10, 12, 14, 0.94);
+ --panel-solid: #0b0d0f;
+ --panel-2: #111417;
+ --line: rgba(132, 140, 148, 0.18);
+ --line-strong: rgba(177, 185, 193, 0.62);
+ --text: #d7dadd;
+ --muted: #8d949b;
+ --accent: #b4bac0;
+ --accent-2: #b99561;
+ --danger: #d66d6d;
+ --good: #9fd27d;
+ --shadow: 0 22px 70px rgba(0, 0, 0, 0.70);
+}
+
+* { box-sizing: border-box; }
+html, body { min-height: 100%; }
+body {
+ margin: 0;
+ background:
+ linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px),
+ linear-gradient(180deg, rgba(255,255,255,0.010) 1px, transparent 1px),
+ #020303;
+ background-size: 24px 24px;
+ color: var(--text);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ overflow-x: hidden;
+}
+
+button { font: inherit; }
+h1, h2, p { margin: 0; }
+
+#app {
+ width: min(100vw, 1680px);
+ margin: 0 auto;
+ padding: 0 8px 12px;
+}
+
+.game-shell {
+ position: relative;
+ width: 100%;
+ margin-top: 0;
+ min-height: min(100vh, 920px);
+ border: 1px solid #30363d;
+ border-top: 0;
+ border-radius: 0;
+ background: var(--screen);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+}
+
+.game-shell::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: linear-gradient(180deg, rgba(255,255,255,0.035), transparent 90px);
+ z-index: 1;
+}
+
+#gameCanvas {
+ position: relative;
+ z-index: 0;
+ display: block;
+ width: 100%;
+ height: min(calc(100vh - 10px), 900px);
+ min-height: 660px;
+ background: #050607;
+ touch-action: none;
+}
+
+.hud {
+ position: absolute;
+ z-index: 4;
+ display: grid;
+ gap: 6px;
+ pointer-events: none;
+}
+.hud-top-left {
+ top: 10px;
+ left: 10px;
+ grid-template-columns: repeat(5, minmax(78px, auto));
+}
+.hud-top-right {
+ top: 10px;
+ right: 10px;
+ grid-template-columns: repeat(4, minmax(78px, auto));
+}
+.hud-card {
+ min-width: 78px;
+ padding: 6px 8px;
+ border: 1px solid rgba(82, 91, 100, 0.76);
+ border-radius: 0;
+ background: rgba(9, 11, 13, 0.92);
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.018), 0 8px 20px rgba(0, 0, 0, 0.26);
+ backdrop-filter: blur(3px);
+}
+.hud-card span {
+ display: block;
+ font-size: 9px;
+ line-height: 1;
+ letter-spacing: 0.09em;
+ color: #7c858e;
+ margin-bottom: 4px;
+}
+.hud-card strong {
+ font-size: clamp(15px, 1.35vw, 21px);
+ line-height: 1;
+}
+.hud-card.cash strong { color: #d7b27d; }
+.hud-card.compact strong { font-size: clamp(13px, 1.1vw, 18px); }
+.hud-card.profit strong { color: var(--good); }
+
+.cash-negative { color: var(--danger) !important; }
+.cash-positive { color: var(--good) !important; }
+
+.upgrade-panel {
+ position: absolute;
+ z-index: 5;
+ left: 10px;
+ right: 10px;
+ bottom: 64px;
+ display: grid;
+ gap: 9px;
+ padding: 10px;
+ border: 1px solid #343a40;
+ border-radius: 0;
+ background: rgba(8, 10, 12, 0.94);
+ box-shadow: 0 20px 55px rgba(0, 0, 0, 0.56);
+ backdrop-filter: blur(4px);
+}
+.panel-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 14px;
+}
+.panel-head h1 {
+ font-size: 18px;
+ letter-spacing: 0.08em;
+}
+.panel-head h1 span {
+ color: #050607;
+ font-size: 11px;
+ background: #b4bac0;
+ border-radius: 0;
+ padding: 2px 7px;
+ vertical-align: middle;
+}
+.panel-head p,
+.mini-box,
+.upgrade-desc { color: var(--muted); }
+.panel-grid {
+ display: grid;
+ grid-template-columns: 260px minmax(420px, 1fr) 340px;
+ gap: 10px;
+}
+.upgrade-panel h2 {
+ color: var(--accent);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.10em;
+ margin-bottom: 7px;
+}
+.tool-list,
+.upgrade-list {
+ display: grid;
+ gap: 7px;
+}
+.tool-list { grid-template-columns: 1fr 1fr; }
+.tool-button,
+.primary-button,
+.sort-button,
+.buy-button {
+ border-radius: 0;
+ cursor: pointer;
+ font-weight: 850;
+ text-transform: uppercase;
+ letter-spacing: 0.035em;
+ transition: transform 0.06s ease, filter 0.12s ease, opacity 0.12s ease, border-color 0.12s ease;
+}
+.tool-button:active,
+.primary-button:active,
+.sort-button:active,
+.buy-button:active { transform: translateY(1px); }
+.tool-button:disabled,
+.primary-button:disabled,
+.sort-button:disabled,
+.buy-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.38;
+}
+.tool-button {
+ color: var(--text);
+ background: #111417;
+ border: 1px solid rgba(82, 91, 100, 0.76);
+ padding: 8px 9px;
+ text-align: left;
+ font-size: 11px;
+}
+.tool-button.active {
+ border-color: #b4bac0;
+ box-shadow: inset 0 0 0 1px rgba(180, 186, 192, 0.35);
+}
+.tool-button.danger.active {
+ border-color: var(--danger);
+ box-shadow: inset 0 0 0 1px rgba(214, 109, 109, 0.30);
+}
+.primary-button {
+ border: 1px solid #b4bac0;
+ padding: 9px 16px;
+ background: #b4bac0;
+ color: #050607;
+ white-space: nowrap;
+}
+.upgrade-list {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+.upgrade-item {
+ border: 1px solid #30363d;
+ border-radius: 0;
+ padding: 8px;
+ background: #0f1215;
+}
+.upgrade-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+.upgrade-head strong { font-size: 12px; }
+.upgrade-level { color: var(--muted); font-size: 11px; white-space: nowrap; }
+.upgrade-desc { font-size: 11px; line-height: 1.28; margin-bottom: 7px; }
+.buy-button {
+ color: var(--text);
+ background: #1a1f24;
+ border: 1px solid #4b535b;
+ padding: 6px 8px;
+ font-size: 11px;
+}
+.buy-button:not(:disabled):hover,
+.tool-button:not(:disabled):hover,
+.sort-button:not(:disabled):hover { filter: brightness(1.14); }
+.mini-box {
+ padding: 8px;
+ border: 1px solid #30363d;
+ border-radius: 0;
+ background: #080a0c;
+ font-size: 11px;
+ line-height: 1.45;
+}
+.mini-box strong { color: var(--text); }
+
+.control-dock {
+ position: absolute;
+ z-index: 7;
+ left: 50%;
+ bottom: 10px;
+ transform: translateX(-50%);
+ display: grid;
+ grid-template-columns: repeat(4, 150px);
+ gap: 8px;
+ padding: 6px;
+ border: 1px solid #343a40;
+ background: rgba(8, 10, 12, 0.92);
+}
+.sort-button {
+ border: 1px solid #4b535b;
+ min-height: 46px;
+ font-size: 12px;
+ color: #f0f1f2;
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.025);
+}
+.sort-button.mixer {
+ background: #1b2025;
+}
+.sort-button.truck {
+ background: #2a2118;
+ border-color: #705a3c;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 22px;
+ background: rgba(0, 0, 0, 0.72);
+}
+.modal.visible { display: flex; }
+.modal-card {
+ width: min(720px, 96vw);
+ border: 1px solid #4b535b;
+ border-radius: 0;
+ padding: 22px;
+ background: #090b0d;
+ box-shadow: 0 34px 110px rgba(0, 0, 0, 0.78);
+}
+.modal-card h2 {
+ font-size: 24px;
+ margin-bottom: 12px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.modal-card p { color: var(--text); line-height: 1.55; }
+.modal-card ul {
+ margin: 12px 0 0;
+ padding-left: 22px;
+ color: var(--muted);
+ line-height: 1.55;
+}
+.modal-card strong { color: var(--text); }
+.modal-actions {
+ margin-top: 18px;
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+@media (max-width: 1100px) {
+ #app { padding: 0; }
+ .game-shell { min-height: 100vh; }
+ #gameCanvas { height: 100vh; min-height: 680px; }
+ .hud-top-left,
+ .hud-top-right { grid-template-columns: repeat(2, minmax(74px, auto)); }
+ .panel-grid { grid-template-columns: 1fr; }
+ .upgrade-list { grid-template-columns: 1fr; }
+ .upgrade-panel { max-height: 44vh; overflow: auto; }
+ .control-dock { grid-template-columns: repeat(2, 150px); }
+}
+
+/* v6 visibility pass: play-first layering */
+.game-shell:not(.phase-build) .upgrade-panel {
+ display: none;
+}
+.game-shell.phase-running .hud-top-right {
+ display: none;
+}
+.game-shell:not(.phase-running) .control-dock {
+ display: none;
+}
+.game-shell.phase-build .upgrade-panel {
+ bottom: 10px;
+}
+.game-shell.phase-running .hud-card {
+ background: rgba(5, 7, 8, 0.84);
+ border-color: rgba(124, 133, 142, 0.42);
+}
diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js
new file mode 100644
index 0000000..380ab96
--- /dev/null
+++ b/src/systems/buildSystem.js
@@ -0,0 +1,542 @@
+import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, FACILITY_PRICES, GRID, THEME } from '../core/config.js';
+import { nextSpawnDelay, getSpawnRange } from '../core/state.js';
+import { createEggFarm, createScanner, createFacility, nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js';
+import { key, parseKey, clamp, pointToCell, cellCenter, distance, yen } from '../core/utils.js';
+import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner } from './routing.js';
+import { spendCash, refundCash } from './economy.js';
+import { snapshot, record } from './history.js';
+import { floating, eraseEffect } from './effects.js';
+
+export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) {
+ function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
+
+ function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; }
+
+ function rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; }
+ function rectsOverlap(a, b, margin = 10) {
+ return !(a.x + a.w + margin <= b.x || b.x + b.w + margin <= a.x || a.y + a.h + margin <= b.y || b.y + b.h + margin <= a.y);
+ }
+ function facilityOverlaps(candidate, ignoreIds = new Set()) {
+ const rect = rectOfFacility(candidate);
+ return Object.values(game.facilities).some(f => !ignoreIds.has(f.id) && rectsOverlap(rect, rectOfFacility(f), 12));
+ }
+
+ function isEquipmentCell(col, row, moving = null) {
+ if (!pointInGrid(col, row)) return false;
+ const k = key(col, row);
+ const existingFarm = farmAt(game, col, row);
+ if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true;
+ const existingScanner = scannerAt(game, col, row);
+ if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
+ if (game.conveyorTiles.has(k) && !(moving?.type === 'conveyor' && moving.oldKey === k)) return true;
+ return false;
+ }
+
+ function selectedObject() {
+ if (!game.selected) return null;
+ if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null;
+ if (game.selected.type === '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 === 'facility') return game.facilities[game.selected.id] || null;
+ return null;
+ }
+
+ function selectedTitle(obj) {
+ if (!obj) return 'Equipment';
+ if (obj.type === 'eggFarm') return `Egg Farm #${obj.id}`;
+ if (obj.type === 'scanner') return `${obj.kind === 'auto' ? 'Auto' : 'Manual'} Scanner #${obj.id}`;
+ if (obj.type === 'conveyor') return 'Conveyor Tile';
+ if (obj.type === 'facility') return obj.name;
+ return 'Equipment';
+ }
+
+ function equipmentPrice(hit) {
+ const obj = hit.ref || hit;
+ const type = hit.type || obj.type;
+ if (type === 'conveyor') return BUILD_COSTS.conveyor;
+ if (type === 'eggFarm') return obj.price || BUILD_COSTS.eggFarm;
+ if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner);
+ if (type === 'facility') return obj.price || FACILITY_PRICES[obj.id] || 300;
+ return 0;
+ }
+
+ function fail(reason) {
+ ui.buildStatus.textContent = reason;
+ floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger);
+ }
+
+ function buildAtCell(cell) {
+ if (!cell) return fail('Out of grid');
+ const { col, row } = cell;
+ if (game.buildTool === 'conveyor') {
+ if (game.conveyorTiles.has(key(col, row))) return fail('Cell occupied');
+ if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail('Cell occupied');
+ if (game.cash < BUILD_COSTS.conveyor) return fail('Not enough cash');
+ record(game);
+ spendCash(game, BUILD_COSTS.conveyor);
+ const k = key(col, row);
+ game.conveyorTiles.add(k);
+ game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: game.buildSession });
+ game.selected = { type: 'conveyor', id: k };
+ floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(BUILD_COSTS.conveyor)}`, THEME.ink);
+ return;
+ }
+ if (isEquipmentCell(col, row)) return fail('Cell occupied');
+ if (game.buildTool === 'eggFarm') buildFarm(col, row);
+ else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
+ else if (game.buildTool === 'autoScanner') buildScanner(col, row, 'auto');
+ }
+
+ function buildAtPoint(p) {
+ const cell = pointToCell(p.x, p.y);
+ if (['conveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell);
+ if (['mixer', 'trash', 'truck'].includes(game.buildTool)) return buildFacility(p, game.buildTool);
+ }
+
+ function buildFarm(col, row) {
+ if (game.cash < BUILD_COSTS.eggFarm) return fail('Not enough cash');
+ record(game);
+ spendCash(game, BUILD_COSTS.eggFarm);
+ const farm = createEggFarm(game, col, row);
+ game.eggFarms.push(farm);
+ game.selected = { type: 'eggFarm', id: farm.id };
+ floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(BUILD_COSTS.eggFarm)}`, THEME.ink);
+ }
+
+ function buildScanner(col, row, kind) {
+ const cost = kind === 'auto' ? BUILD_COSTS.autoScanner : BUILD_COSTS.manualScanner;
+ if (game.cash < cost) return fail('Not enough cash');
+ record(game);
+ spendCash(game, cost);
+ const scanner = createScanner(game, col, row, kind);
+ game.scanners.push(scanner);
+ game.selected = { type: 'scanner', id: scanner.id };
+ floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
+ }
+
+ function buildFacility(p, id) {
+ const cost = FACILITY_PRICES[id];
+ if (game.facilities[id]) return fail('Facility already exists');
+ if (game.cash < cost) return fail('Not enough cash');
+ const f = createFacility(game, id, p, cost);
+ if (facilityOverlaps(f)) return fail('Facility overlap');
+ record(game);
+ spendCash(game, cost);
+ game.facilities[id] = f;
+ game.selected = { type: 'facility', id };
+ floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
+ }
+
+ function refundForSameBuild(meta, x, y) {
+ if (!meta || meta.builtSession !== game.buildSession) return;
+ const amount = meta.price || 0;
+ if (amount > 0) {
+ refundCash(game, amount);
+ floating(game, x, y - 16, `REFUND +${yen(amount)}`, THEME.green);
+ }
+ }
+
+ function eraseAtPoint(p) {
+ const hit = equipmentAtPoint(p);
+ if (!hit) return fail('Nothing to erase');
+ record(game);
+ if (hit.type === 'conveyor') {
+ const c = parseKey(hit.oldKey);
+ const center = cellCenter(c.col, c.row);
+ refundForSameBuild(game.conveyorMeta.get(hit.oldKey), center.x, center.y);
+ game.conveyorTiles.delete(hit.oldKey);
+ game.conveyorMeta.delete(hit.oldKey);
+ eraseEffect(game, center.x, center.y);
+ }
+ if (hit.type === 'eggFarm') {
+ const c = cellCenter(hit.ref.col, hit.ref.row);
+ refundForSameBuild(hit.ref, c.x, c.y);
+ game.eggFarms = game.eggFarms.filter(f => f.id !== hit.ref.id);
+ eraseEffect(game, c.x, c.y);
+ }
+ if (hit.type === 'scanner') {
+ const c = scannerCenter(hit.ref);
+ refundForSameBuild(hit.ref, c.x, c.y);
+ game.scanners = game.scanners.filter(s => s.id !== hit.ref.id);
+ eraseEffect(game, c.x, c.y);
+ }
+ if (hit.type === 'facility') {
+ const f = hit.ref;
+ refundForSameBuild(f, 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);
+ }
+ game.selected = null;
+ game.multiSelected = [];
+ updatePanels();
+ }
+
+ function equipmentAtPoint(p) {
+ const f = Object.values(game.facilities).find(x => {
+ const inBody = p.x >= x.x && p.x <= x.x + x.w && p.y >= x.y && p.y <= x.y + x.h;
+ if (inBody) return true;
+ if (!x.entry) return false;
+ const c = cellCenter(x.entry.col, x.entry.row);
+ return Math.abs(p.x - c.x) <= GRID.cell * 0.46 && Math.abs(p.y - c.y) <= GRID.cell * 0.46;
+ });
+ if (f) return { type: 'facility', ref: f };
+ const cell = pointToCell(p.x, p.y);
+ if (!cell) return null;
+ const farm = farmAt(game, cell.col, cell.row);
+ if (farm) return { type: 'eggFarm', ref: farm };
+ const scanner = scannerAt(game, cell.col, cell.row);
+ if (scanner) return { type: 'scanner', ref: scanner };
+ const k = key(cell.col, cell.row);
+ if (game.conveyorTiles.has(k)) return { type: 'conveyor', oldKey: k, ref: { type: 'conveyor', id: k } };
+ return null;
+ }
+
+ function selectionToken(item) {
+ if (!item) return '';
+ if (item.type === 'conveyor') return `conveyor:${item.id || item.oldKey}`;
+ if (item.type === 'facility') return `facility:${item.id || item.ref?.id}`;
+ return `${item.type}:${item.id || item.ref?.id}`;
+ }
+
+ function selectionFromHit(hit) {
+ if (!hit) return null;
+ if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey };
+ return { type: hit.type, id: hit.ref.id };
+ }
+
+ function hitIsMultiSelected(hit) {
+ const token = selectionToken(selectionFromHit(hit));
+ return (game.multiSelected || []).some(sel => selectionToken(sel) === token);
+ }
+
+ function resolveSelection(sel) {
+ if (!sel) return null;
+ if (sel.type === 'eggFarm') return game.eggFarms.find(f => f.id === sel.id) || null;
+ if (sel.type === 'scanner') return game.scanners.find(sc => sc.id === sel.id) || null;
+ if (sel.type === 'facility') return game.facilities[sel.id] || null;
+ if (sel.type === 'conveyor') return game.conveyorTiles.has(sel.id) ? { type: 'conveyor', id: sel.id } : null;
+ return null;
+ }
+
+ function equipmentItems() {
+ const items = [];
+ for (const k of game.conveyorTiles) {
+ const p = parseKey(k);
+ const c = cellCenter(p.col, p.row);
+ items.push({ type: 'conveyor', id: k, center: c, 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 f of Object.values(game.facilities)) {
+ const bodyCenter = { x: f.x + f.w / 2, y: f.y + f.h / 2 };
+ const portCenter = f.entry ? cellCenter(f.entry.col, f.entry.row) : bodyCenter;
+ items.push({ type: 'facility', id: f.id, ref: f, center: bodyCenter, portCenter });
+ }
+ return items;
+ }
+
+ function pointInsideRect(p, rect) {
+ const x1 = Math.min(rect.x1, rect.x2), x2 = Math.max(rect.x1, rect.x2);
+ const y1 = Math.min(rect.y1, rect.y2), y2 = Math.max(rect.y1, rect.y2);
+ return p.x >= x1 && p.x <= x2 && p.y >= y1 && p.y <= y2;
+ }
+
+ function selectInRect(rect) {
+ const selected = [];
+ for (const item of equipmentItems()) {
+ if (pointInsideRect(item.center, rect) || (item.portCenter && pointInsideRect(item.portCenter, rect))) {
+ selected.push({ type: item.type, id: item.id });
+ }
+ }
+ game.multiSelected = selected;
+ game.selected = selected[0] || null;
+ updatePanels();
+ return selected.length;
+ }
+
+ function clearSelection() {
+ game.multiSelected = [];
+ game.selected = null;
+ updatePanels();
+ }
+
+ function startSelectionBox(event) {
+ const p = canvasPoint(event);
+ game.selectionBox = { x1: p.x, y1: p.y, x2: p.x, y2: p.y };
+ game.multiSelected = [];
+ game.selected = null;
+ updatePanels();
+ return true;
+ }
+
+ function updateSelectionBox(event) {
+ if (!game.selectionBox) return;
+ const p = canvasPoint(event);
+ game.selectionBox.x2 = p.x;
+ game.selectionBox.y2 = p.y;
+ }
+
+ function finishSelectionBox() {
+ if (!game.selectionBox) return 0;
+ const box = game.selectionBox;
+ const w = Math.abs(box.x2 - box.x1), h = Math.abs(box.y2 - box.y1);
+ game.selectionBox = null;
+ if (w < 6 && h < 6) { clearSelection(); return 0; }
+ return selectInRect(box);
+ }
+
+ function selectedSetForDrag(hit) {
+ const fromHit = selectionFromHit(hit);
+ if (!fromHit) return [];
+ if ((game.multiSelected || []).length && hitIsMultiSelected(hit)) return [...game.multiSelected];
+ return [fromHit];
+ }
+
+ function selectionOrigin(sel) {
+ const obj = resolveSelection(sel);
+ if (!obj) return null;
+ if (sel.type === 'conveyor') {
+ const cell = parseKey(sel.id);
+ return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: game.conveyorMeta.get(sel.id) || { price: BUILD_COSTS.conveyor, builtSession: null }, col: cell.col, row: cell.row };
+ }
+ if (sel.type === 'eggFarm' || sel.type === 'scanner') return { ...sel, obj, col: obj.col, row: obj.row };
+ if (sel.type === 'facility') return { ...sel, obj, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } };
+ return null;
+ }
+
+ function startGroupDrag(event) {
+ const p = canvasPoint(event);
+ const hit = equipmentAtPoint(p);
+ if (!hit) return false;
+ const selections = selectedSetForDrag(hit);
+ const origins = selections.map(selectionOrigin).filter(Boolean);
+ if (!origins.length) return false;
+ game.groupDrag = { start: p, selections, origins, historySnapshot: snapshot(game), committed: false, lastDCol: 0, lastDRow: 0 };
+ game.multiSelected = selections;
+ game.selected = selections[0] || null;
+ updatePanels();
+ return true;
+ }
+
+ function commitGroupDragHistory() {
+ if (!game.groupDrag || game.groupDrag.committed) return;
+ game.undoStack.push(game.groupDrag.historySnapshot);
+ game.redoStack = [];
+ game.groupDrag.committed = true;
+ }
+
+ function cellOccupiedByNonSelected(col, row, selectedTokens) {
+ const k = key(col, row);
+ const farm = farmAt(game, col, row);
+ if (farm && !selectedTokens.has(`eggFarm:${farm.id}`)) return true;
+ const scanner = scannerAt(game, col, row);
+ if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true;
+ if (game.conveyorTiles.has(k) && !selectedTokens.has(`conveyor:${k}`)) return true;
+ return false;
+ }
+
+ function facilityDragWouldOverlap(dx, dy, selectedTokens) {
+ const candidateRects = [];
+ for (const origin of game.groupDrag.origins) {
+ if (origin.type !== 'facility') continue;
+ const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
+ const { entry, side } = nearestGridEdge(nextCenter);
+ const draft = { ...origin.obj, entry: { ...entry }, side };
+ layoutFacilityOnEdge(draft, entry, side);
+ candidateRects.push({ id: origin.id, rect: rectOfFacility(draft) });
+ }
+ for (const c of candidateRects) {
+ for (const f of Object.values(game.facilities)) {
+ if (selectedTokens.has(`facility:${f.id}`)) continue;
+ if (rectsOverlap(c.rect, rectOfFacility(f), 12)) return true;
+ }
+ }
+ for (let i = 0; i < candidateRects.length; i += 1) {
+ for (let j = i + 1; j < candidateRects.length; j += 1) {
+ if (rectsOverlap(candidateRects[i].rect, candidateRects[j].rect, 12)) return true;
+ }
+ }
+ return false;
+ }
+
+ function updateGroupDrag(event) {
+ if (!game.groupDrag) return;
+ const p = canvasPoint(event);
+ const dx = p.x - game.groupDrag.start.x;
+ const dy = p.y - game.groupDrag.start.y;
+ if (Math.hypot(dx, dy) < 3) return;
+ const dcol = Math.round(dx / GRID.cell);
+ const drow = Math.round(dy / GRID.cell);
+ const selectedTokens = new Set(game.groupDrag.selections.map(selectionToken));
+ const targetCells = new Set();
+ for (const origin of game.groupDrag.origins) {
+ if (origin.type === 'facility') continue;
+ const col = origin.col + dcol, row = origin.row + drow;
+ if (!pointInGrid(col, row)) return fail('Selection outside grid');
+ const tk = key(col, row);
+ if (targetCells.has(tk)) return fail('Selection overlap');
+ if (cellOccupiedByNonSelected(col, row, selectedTokens)) return fail('Cell occupied');
+ targetCells.add(tk);
+ }
+ if (facilityDragWouldOverlap(dx, dy, selectedTokens)) return fail('Facility overlap');
+ commitGroupDragHistory();
+
+ const conveyorOrigins = game.groupDrag.origins.filter(o => o.type === 'conveyor');
+ for (const o of conveyorOrigins) {
+ const current = o.currentKey || o.oldKey;
+ game.conveyorTiles.delete(current);
+ game.conveyorMeta.delete(current);
+ }
+ for (const o of conveyorOrigins) {
+ const newK = key(o.col + dcol, o.row + drow);
+ game.conveyorTiles.add(newK);
+ game.conveyorMeta.set(newK, o.meta || { price: BUILD_COSTS.conveyor, builtSession: null });
+ o.currentKey = newK;
+ }
+ for (const origin of game.groupDrag.origins) {
+ if (origin.type === 'eggFarm') { origin.obj.col = origin.col + dcol; origin.obj.row = origin.row + drow; }
+ else if (origin.type === 'scanner') { origin.obj.col = origin.col + dcol; origin.obj.row = origin.row + drow; }
+ else if (origin.type === 'facility') {
+ const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
+ const { entry, side } = nearestGridEdge(nextCenter);
+ 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.multiSelected = [...game.groupDrag.selections];
+ game.selected = game.multiSelected[0] || null;
+ updatePanels();
+ }
+
+ function finishGroupDrag() {
+ if (!game.groupDrag) return;
+ game.groupDrag = null;
+ updatePanels();
+ }
+
+ function selectedUpgradeCost(obj) {
+ if (!obj || obj.type === 'conveyor') return null;
+ if (obj.type === 'eggFarm') return [0, 220, 640, 1500][obj.level] || null;
+ if (obj.type === 'scanner') return [0, 260, 720, 1600][obj.level] || null;
+ if (obj.type === 'facility') return [0, 300, 900][obj.level] || null;
+ return null;
+ }
+
+ function upgradeSelected() {
+ const obj = selectedObject();
+ const cost = selectedUpgradeCost(obj);
+ if (!cost) return fail('Max level or no upgrade');
+ if (game.cash < cost) return fail('Not enough cash');
+ record(game);
+ spendCash(game, cost);
+ obj.level += 1;
+ if (obj.type === 'eggFarm') {
+ obj.nextSpawn = nextSpawnDelay(obj);
+ obj.lastInterval = obj.nextSpawn;
+ }
+ updatePanels();
+ }
+
+ function removeSelected() {
+ const obj = selectedObject();
+ if (!obj) return;
+ if (obj.type === 'conveyor') eraseAtPoint(cellCenter(...Object.values(parseKey(obj.id))));
+ 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 });
+ }
+
+ function modalButton(text, onClick, className = 'primary-button') {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = className;
+ b.textContent = text;
+ b.addEventListener('click', onClick);
+ return b;
+ }
+
+ function hideModal() { ui.modal.classList.remove('visible'); }
+
+ function showAutoScannerMenu(scanner) {
+ ui.modalTitle.textContent = 'Auto Scanner Configuration';
+ ui.modalBody.innerHTML = `Auto scanner types will be split later. Current type is Standard.
- Role 0: male left, everything else right.
- Role 1: poop left, everything else right.
- Cooldown: ${AUTO_SCANNER_COOLDOWN}s.
`;
+ ui.modalActions.innerHTML = '';
+ const r0 = modalButton('Set Role 0', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); });
+ const r1 = modalButton('Set Role 1', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); });
+ const close = modalButton('Close', hideModal);
+ ui.modalActions.append(r0, r1, close);
+ ui.modal.classList.add('visible');
+ }
+
+ function switchScannerRole() {
+ const obj = selectedObject();
+ if (!obj || obj.type !== 'scanner') return;
+ if (obj.kind === 'auto') return showAutoScannerMenu(obj);
+ record(game);
+ obj.role = obj.role === 0 ? 1 : 0;
+ updatePanels();
+ }
+
+ function setBuildTool(tool) {
+ if (game.phase !== 'build') return;
+ game.buildTool = game.buildTool === tool ? null : tool;
+ game.selected = null;
+ game.multiSelected = [];
+ game.selectionBox = null;
+ updatePanels();
+ }
+
+ function equipmentHitBoxes() {
+ const boxes = [];
+ for (const [id, f] of Object.entries(game.facilities)) {
+ boxes.push({ id: `facility:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: f.x, y: f.y, w: f.w, h: f.h });
+ if (f.entry) { const c = cellCenter(f.entry.col, f.entry.row); boxes.push({ id: `facilityPort:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: c.x - 18, y: c.y - 18, w: 36, h: 36 }); }
+ }
+ for (const farm of game.eggFarms) { const c = cellCenter(farm.col, farm.row); boxes.push({ id: `eggFarm:${farm.id}`, price: equipmentPrice({ type: 'eggFarm', ref: farm }), x: c.x - 24, y: c.y - 24, w: 48, h: 48 }); }
+ for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 52, y: c.y - 36, w: 104, h: 72 }); }
+ for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: BUILD_COSTS.conveyor, x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); }
+ return boxes;
+ }
+
+ function selectedInfoLines(obj) {
+ const lines = [];
+ if (obj.type === 'eggFarm') {
+ const [min, max] = getSpawnRange(obj);
+ lines.push(`Price: ${yen(equipmentPrice(obj))}`);
+ lines.push(`Level: ${obj.level}/4`);
+ lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`);
+ } else if (obj.type === 'scanner') {
+ lines.push(`Price: ${yen(equipmentPrice(obj))}`);
+ lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`);
+ lines.push(`Role: ${obj.role === 0 ? 'Male→Left / Others→Right' : 'Poop→Left / Others→Right'}`);
+ lines.push(`Queue: ${obj.queue.length}`);
+ if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s`);
+ } else if (obj.type === 'conveyor') {
+ const meta = game.conveyorMeta.get(obj.id);
+ const comp = game.componentLookup?.get(obj.id);
+ const c = comp ? game.congestion.get(comp) : null;
+ lines.push(`Price: ${yen(BUILD_COSTS.conveyor)}`);
+ lines.push(`Cell: ${obj.id}`);
+ lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
+ lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.');
+ if (meta?.builtSession === game.buildSession) lines.push(`Same-build refund: ${yen(meta.price)}`);
+ } else if (obj.type === 'facility') {
+ lines.push(`Price: ${yen(equipmentPrice(obj))}`);
+ lines.push(`Level: ${obj.level}/3`);
+ if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
+ }
+ return lines;
+ }
+
+ return {
+ buildAtCell, buildAtPoint, eraseAtPoint, equipmentAtPoint,
+ startSelectionBox, updateSelectionBox, finishSelectionBox,
+ startGroupDrag, updateGroupDrag, finishGroupDrag,
+ selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost,
+ selectedInfoLines,
+ upgradeSelected, removeSelected, switchScannerRole,
+ showAutoScannerMenu, setBuildTool, fail,
+ isEquipmentCell, equipmentHitBoxes,
+ routeFromFarmToScanner
+ };
+}
diff --git a/src/systems/contracts.js b/src/systems/contracts.js
new file mode 100644
index 0000000..94b791b
--- /dev/null
+++ b/src/systems/contracts.js
@@ -0,0 +1,148 @@
+import { TURN_SECONDS, POOP_RATE, CONTRACT_EVENT_CHANCE, CONTRACT_EVENT_FIRST_TURN } from '../core/config.js';
+import { getSpawnRange } from '../core/state.js';
+
+export const CONTRACT_TARGETS = [
+ {
+ target: 'female',
+ title: 'Premium Female Shipment',
+ route: 'Female → Truck',
+ note: 'Only female chicks are valid truck cargo this turn.'
+ },
+ {
+ target: 'male',
+ title: 'Male Export Trial',
+ route: 'Male → Truck',
+ note: 'Male chicks are temporarily valid truck cargo. Male truck fines are suspended for valid male cargo.'
+ },
+ {
+ target: 'poop',
+ title: 'Contaminant Sample Export',
+ route: 'Poop → Truck',
+ note: 'Poop is temporarily valid truck cargo. Shipped poop does not soil this contract shipment.'
+ }
+];
+
+export function factoryValue(game) {
+ return [
+ ...game.eggFarms.map(f => f.price || 0),
+ ...game.scanners.map(s => s.price || 0),
+ ...Object.values(game.facilities).map(f => f.price || 0),
+ ...[...game.conveyorMeta.values()].map(m => m.price || 0)
+ ].reduce((a, b) => a + b, 0);
+}
+
+export function estimatedChicksPerTurn(game, productionMultiplier = 1) {
+ return game.eggFarms.reduce((sum, farm) => {
+ const [min, max] = getSpawnRange(farm);
+ const avg = Math.max(0.1, (min + max) / 2);
+ return sum + (TURN_SECONDS / avg) * productionMultiplier;
+ }, 0);
+}
+
+export function rollContractOffer(game) {
+ // No irregular events during the first seven running turns.
+ // Offers created after Turn 7 apply to Turn 8 or later.
+ const nextTurn = game.turn + 1;
+ if (nextTurn < CONTRACT_EVENT_FIRST_TURN) return null;
+ if (Math.random() > CONTRACT_EVENT_CHANCE) return null;
+ const template = CONTRACT_TARGETS[Math.floor(Math.random() * CONTRACT_TARGETS.length)];
+ const productionMultiplier = 2;
+ const poopMultiplier = 3;
+ const estimate = estimatedChicksPerTurn(game, productionMultiplier);
+ const targetAmount = Math.max(1, Math.ceil(estimate * 0.9));
+ const rate = Math.round((0.08 + Math.random() * 0.08) * 100) / 100;
+ const rewardByFactory = Math.floor(factoryValue(game) * rate);
+ const rewardByCash = Math.floor(Math.max(0, game.cash) * 0.25);
+ const reward = Math.max(rewardByFactory, rewardByCash);
+ return {
+ id: `contract-${game.turn}-${Date.now()}-${Math.floor(Math.random() * 9999)}`,
+ ...template,
+ forced: true,
+ productionMultiplier,
+ poopMultiplier,
+ targetAmount,
+ estimatedOutput: Math.round(estimate),
+ rewardRate: rate,
+ reward,
+ rewardByFactory,
+ rewardByCash,
+ createdTurn: game.turn,
+ oneTurnOnly: true
+ };
+}
+
+export function acceptContract(game) { return !!game.contractOffer; }
+
+export function declineContract(game) { return false; }
+
+export function activateAcceptedContract(game) {
+ // v10.1: limited events are forced. If an event is shown during Build phase,
+ // it automatically applies to the next turn; there is no accept/skip step.
+ game.contractActive = game.contractOffer ? { ...game.contractOffer } : null;
+ game.contractOffer = null;
+}
+
+export function clearActiveContract(game) {
+ game.contractActive = null;
+}
+
+export function productionMultiplier(game) {
+ return game.contractActive?.productionMultiplier || 1;
+}
+
+export function currentPoopRate(game) {
+ return Math.min(0.9, POOP_RATE * (game.contractActive?.poopMultiplier || 1));
+}
+
+export function truckTarget(game) {
+ return game.contractActive?.target || 'female';
+}
+
+export function targetTruckCount(stats, target) {
+ if (target === 'male') return stats.truckMale || 0;
+ if (target === 'poop') return stats.poopTruck || 0;
+ return stats.truckFemale || 0;
+}
+
+export function isTargetTruckCargo(game, sex) {
+ return truckTarget(game) === sex;
+}
+
+export function isPoopSoilActive(game) {
+ return truckTarget(game) !== 'poop';
+}
+
+export function shouldFineMaleTruck(game) {
+ return truckTarget(game) !== 'male';
+}
+
+export function resolveContract(game, applyCashDelta) {
+ const contract = game.contractActive;
+ if (!contract) return null;
+ const count = targetTruckCount(game.stats, contract.target);
+ const success = count >= contract.targetAmount;
+ const bonus = success ? contract.reward : 0;
+ if (bonus > 0) {
+ applyCashDelta(game, bonus);
+ game.stats.contractBonus = bonus;
+ game.totals.contractBonus += bonus;
+ game.totals.contractSuccess += 1;
+ } else {
+ game.stats.contractBonus = 0;
+ game.totals.contractFailed += 1;
+ }
+ const result = {
+ title: contract.title,
+ target: contract.target,
+ route: contract.route,
+ targetAmount: contract.targetAmount,
+ count,
+ success,
+ bonus,
+ reward: contract.reward,
+ productionMultiplier: contract.productionMultiplier,
+ poopMultiplier: contract.poopMultiplier
+ };
+ game.stats.contractResult = result;
+ return result;
+}
diff --git a/src/systems/economy.js b/src/systems/economy.js
new file mode 100644
index 0000000..1313166
--- /dev/null
+++ b/src/systems/economy.js
@@ -0,0 +1,59 @@
+import { MIXER_PRICE, TRUCK_PRICE, TRUCK_POOP_DECAY, MIXER_HALF_SECONDS } from '../core/config.js';
+import { factoryValue, targetTruckCount, truckTarget, isPoopSoilActive } from './contracts.js';
+
+export function truckPayoutMultiplier(game) {
+ return Math.max(0, 1 - TRUCK_POOP_DECAY * (isPoopSoilActive(game) ? game.stats.poopTruck : 0));
+}
+export function positivePayout(game, amount) {
+ const base = Math.max(0, Math.floor(amount));
+ return game.mixerHalfTimer > 0 ? Math.floor(base / 2) : base;
+}
+export function maleTruckPenalty(game) {
+ return Math.floor(30 * (game.turn / 2));
+}
+export function applyCashDelta(game, delta) {
+ game.cash += delta;
+ game.stats.profit += delta;
+ game.totals.profit += delta;
+ if (delta >= 0) { game.stats.revenue += delta; game.totals.revenue += delta; }
+ else { game.stats.penalty += Math.abs(delta); game.totals.penalty += Math.abs(delta); }
+ game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
+}
+export function spendCash(game, amount) {
+ game.cash -= amount;
+ game.stats.profit -= amount;
+ game.totals.profit -= amount;
+ game.stats.penalty += amount;
+ game.totals.penalty += amount;
+}
+export function refundCash(game, amount) {
+ if (amount <= 0) return;
+ game.cash += amount;
+ game.stats.profit += amount;
+ game.totals.profit += amount;
+ game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
+}
+export function settleTruckRevenue(game) {
+ const target = truckTarget(game);
+ const targetCount = targetTruckCount(game.stats, target);
+ const base = targetCount * TRUCK_PRICE;
+ const soilMultiplier = truckPayoutMultiplier(game);
+ const adjusted = positivePayout(game, Math.floor(base * soilMultiplier));
+ game.stats.pendingTruckRevenue = adjusted;
+ if (adjusted > 0) applyCashDelta(game, adjusted);
+ return { base, soilMultiplier, adjusted, target, targetCount };
+}
+export function finalScore(game) {
+ const value = factoryValue(game);
+ const score = Math.max(0, Math.floor(
+ game.cash +
+ game.totals.revenue +
+ value * 0.5 +
+ game.totals.correct * 5 +
+ game.totals.contractBonus * 0.5 -
+ game.totals.explosionDamage * 2 -
+ game.totals.penalty
+ ));
+ return { score, factoryValue: value };
+}
+export { MIXER_PRICE, TRUCK_PRICE, MIXER_HALF_SECONDS };
diff --git a/src/systems/effects.js b/src/systems/effects.js
new file mode 100644
index 0000000..38c2edc
--- /dev/null
+++ b/src/systems/effects.js
@@ -0,0 +1,93 @@
+import { EFFECT_PRIORITY, THEME } from '../core/config.js';
+import { randomBetween } from '../core/utils.js';
+import { applyCashDelta } from './economy.js';
+
+export function floating(game, x, y, text, color = THEME.ink) {
+ game.floatingTexts.push({ x, y, text, color, life: 1, maxLife: 1 });
+}
+export function shake(game, strength, time) {
+ game.shake.strength = Math.max(game.shake.strength, strength);
+ game.shake.time = Math.max(game.shake.time, time);
+}
+export function spawnPulse(game, x, y) {
+ game.effects.push({ type: 'spawn', priority: EFFECT_PRIORITY.ambient, x, y, life: .42, maxLife: .42, size: 12 });
+}
+export function scannerPulse(game, x, y, color = THEME.green) {
+ game.effects.push({ type: 'scannerPulse', priority: EFFECT_PRIORITY.important, x, y, color, life: .42, maxLife: .42, size: 24 });
+}
+export function meatEffect(game, x, y) {
+ for (let i = 0; i < 12; i += 1) game.effects.push({ type: 'meat', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-12, 16), vx: randomBetween(-60, 60), vy: randomBetween(-90, -25), life: randomBetween(.45, .85), maxLife: .85, size: randomBetween(4, 8) });
+}
+export function sludgeEffect(game, x, y) {
+ for (let i = 0; i < 16; i += 1) game.effects.push({ type: 'sludge', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-12, 16), vx: randomBetween(-46, 46), vy: randomBetween(-70, -20), life: randomBetween(.45, .9), maxLife: .9, size: randomBetween(4, 8) });
+}
+export function shredEffect(game, x, y, sex) {
+ const base = sex === 'poop' ? THEME.poop : (sex === 'male' ? THEME.male : THEME.female);
+ for (let i = 0; i < 22; i += 1) game.effects.push({ type: 'shred', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-16, 16), y: y + randomBetween(-8, 8), vx: randomBetween(-70, 70), vy: randomBetween(40, 160), color: i % 3 === 0 ? THEME.white : base, life: randomBetween(.45, .95), maxLife: .95, w: randomBetween(3, 7), h: randomBetween(10, 24), rot: randomBetween(-Math.PI, Math.PI) });
+ shockwave(game, x, y, THEME.green, 34, EFFECT_PRIORITY.important);
+}
+export function truckLoadEffect(game, x, y, sex) {
+ const color = sex === 'poop' ? THEME.poop : (sex === 'male' ? THEME.danger : THEME.green);
+ for (let i = 0; i < 8; i += 1) game.effects.push({ type: 'load', priority: EFFECT_PRIORITY.important, x: x + randomBetween(-18, 18), y: y + randomBetween(-18, 18), vx: randomBetween(-42, 42), vy: randomBetween(-90, -25), color, life: randomBetween(.35, .65), maxLife: .65, size: 8 });
+}
+export function shockwave(game, x, y, color = THEME.danger, maxSize = 52, priority = EFFECT_PRIORITY.critical) {
+ game.effects.push({ type: 'shockwave', priority, x, y, color, life: .55, maxLife: .55, size: 8, maxSize });
+}
+export function sparkBurst(game, x, y, count = 12, color = THEME.danger, priority = EFFECT_PRIORITY.critical) {
+ for (let i = 0; i < count; i += 1) {
+ game.effects.push({ type: 'spark', priority, x, y, vx: randomBetween(-260, 260), vy: randomBetween(-270, 120), life: randomBetween(.35, .9), maxLife: .9, size: randomBetween(3, 8), color });
+ }
+}
+export function smokeBurst(game, x, y, count = 10) {
+ for (let i = 0; i < count; i += 1) {
+ game.effects.push({ type: 'smoke', priority: EFFECT_PRIORITY.ambient, x: x + randomBetween(-10, 10), y: y + randomBetween(-10, 10), vx: randomBetween(-26, 26), vy: randomBetween(-60, -12), life: randomBetween(.6, 1.2), maxLife: 1.2, size: randomBetween(8, 18) });
+ }
+}
+export function flyingDebris(game, sex, x, y, baseAngle = null) {
+ const angle = baseAngle === null ? randomBetween(-Math.PI, Math.PI) : baseAngle + randomBetween(-0.9, 0.9);
+ const speed = randomBetween(260, 620);
+ game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8) });
+}
+export function eraseEffect(game, x, y) {
+ game.effects.push({ type: 'erase', priority: EFFECT_PRIORITY.important, x, y, life: .45, maxLife: .45, size: 54 });
+ sparkBurst(game, x, y, 8, THEME.ink, EFFECT_PRIORITY.important);
+}
+export function rageEffect(game, x, y) {
+ game.effects.push({ type: 'rage', priority: EFFECT_PRIORITY.important, x, y, vx: randomBetween(-12, 12), vy: randomBetween(-45, -10), life: .45, maxLife: .45, size: randomBetween(7, 12), color: THEME.danger });
+}
+export function updateEffects(game, dt, canvas, equipmentHitBoxes) {
+ if (game.shake.time > 0) {
+ game.shake.time = Math.max(0, game.shake.time - dt);
+ if (game.shake.time <= 0) game.shake.strength = 0;
+ }
+ for (const e of game.effects) {
+ e.life -= dt;
+ if (['meat', 'sludge', 'spark', 'smoke', 'shred', 'load', 'rage'].includes(e.type)) {
+ e.x += (e.vx || 0) * dt; e.y += (e.vy || 0) * dt; e.vy = (e.vy || 0) + 150 * dt;
+ } else if (e.type === 'flyingChick') {
+ e.x += e.vx * dt; e.y += e.vy * dt; e.vy += 360 * dt;
+ handleFlyingChickDamage(game, e, equipmentHitBoxes);
+ const sx = e.x + game.view.x, sy = e.y + game.view.y;
+ if (sx < -100 || sy < -100 || sx > canvas.width + 100 || sy > canvas.height + 100) e.life = 0;
+ }
+ }
+ game.effects = game.effects.filter(e => e.life > 0);
+ for (const t of game.floatingTexts) { t.life -= dt; t.y -= 34 * dt; }
+ game.floatingTexts = game.floatingTexts.filter(t => t.life > 0);
+}
+function handleFlyingChickDamage(game, e, equipmentHitBoxes) {
+ if (!e.hit) e.hit = new Set();
+ for (const box of equipmentHitBoxes()) {
+ if (e.hit.has(box.id)) continue;
+ if (e.x < box.x || e.x > box.x + box.w || e.y < box.y || e.y > box.y + box.h) continue;
+ e.hit.add(box.id);
+ const damage = Math.max(1, Math.floor(box.price / 15));
+ applyCashDelta(game, -damage);
+ game.stats.explosionDamage += damage;
+ game.totals.explosionDamage += damage;
+ floating(game, e.x, e.y - 10, `-${damage}`, THEME.danger);
+ shockwave(game, e.x, e.y, THEME.danger, 30);
+ sparkBurst(game, e.x, e.y, 5, THEME.danger);
+ shake(game, 5, 0.18);
+ }
+}
diff --git a/src/systems/history.js b/src/systems/history.js
new file mode 100644
index 0000000..d071d2f
--- /dev/null
+++ b/src/systems/history.js
@@ -0,0 +1,59 @@
+export function cleanScanner(scanner) {
+ return { ...scanner, queue: [], cooldown: scanner.cooldown || 0 };
+}
+export function snapshot(game) {
+ return JSON.stringify({
+ cash: game.cash,
+ stats: game.stats,
+ totals: game.totals,
+ nextId: game.nextId,
+ selected: game.selected,
+ multiSelected: game.multiSelected || [],
+ buildTool: game.buildTool,
+ view: game.view,
+ facilities: game.facilities,
+ eggFarms: game.eggFarms,
+ scanners: game.scanners.map(cleanScanner),
+ conveyorTiles: [...game.conveyorTiles],
+ conveyorMeta: [...game.conveyorMeta.entries()],
+ branchCounters: [...game.branchCounters.entries()]
+ });
+}
+export function restore(game, text) {
+ const data = JSON.parse(text);
+ game.cash = data.cash;
+ game.stats = data.stats;
+ game.totals = data.totals;
+ game.nextId = data.nextId;
+ game.selected = data.selected;
+ game.multiSelected = data.multiSelected || [];
+ game.buildTool = data.buildTool;
+ game.view = data.view || { x: 0, y: 0 };
+ game.facilities = data.facilities || {};
+ game.eggFarms = data.eggFarms || [];
+ 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.branchCounters = new Map(data.branchCounters || []);
+ game.groupDrag = null;
+ game.selectionBox = null;
+ game.pan = null;
+}
+export function record(game) {
+ if (game.phase !== 'build') return;
+ game.undoStack.push(snapshot(game));
+ if (game.undoStack.length > 80) game.undoStack.shift();
+ game.redoStack = [];
+}
+export function undo(game) {
+ if (game.phase !== 'build' || !game.undoStack.length) return false;
+ game.redoStack.push(snapshot(game));
+ restore(game, game.undoStack.pop());
+ return true;
+}
+export function redo(game) {
+ if (game.phase !== 'build' || !game.redoStack.length) return false;
+ game.undoStack.push(snapshot(game));
+ restore(game, game.redoStack.pop());
+ return true;
+}
diff --git a/src/systems/routing.js b/src/systems/routing.js
new file mode 100644
index 0000000..cc26228
--- /dev/null
+++ b/src/systems/routing.js
@@ -0,0 +1,270 @@
+import { DIRS, GRID } from '../core/config.js';
+import { key, parseKey, inGrid, cellCenter, distance, sameCell } from '../core/utils.js';
+
+export function scannerCenter(scanner) { return cellCenter(scanner.col, scanner.row); }
+export function scannerConnector(scanner, type) {
+ return {
+ inputA: { col: scanner.col, row: scanner.row - 1 },
+ // Bottom input is intentionally disabled. Scanners now have one input on top
+ // and two outputs on the left/right, which makes junction direction easier to read.
+ inputB: null,
+ left: { col: scanner.col - 1, row: scanner.row },
+ right: { col: scanner.col + 1, row: scanner.row }
+ }[type];
+}
+export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; }
+export function scannerAt(game, col, row) { return game.scanners.find(s => s.col === col && s.row === row) || null; }
+export function scannerById(game, id) { return game.scanners.find(s => s.id === id) || null; }
+export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; }
+
+export function getConveyorNeighbors(game, col, row) {
+ const result = [];
+ for (const d of DIRS) {
+ const nc = col + d.dc, nr = row + d.dr;
+ if (inGrid(nc, nr) && game.conveyorTiles.has(key(nc, nr))) result.push({ col: nc, row: nr, dir: d });
+ }
+ return result;
+}
+export function getAdjacentConveyors(game, col, row) {
+ return DIRS.map(d => ({ col: col + d.dc, row: row + d.dr }))
+ .filter(p => inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row)));
+}
+export function getInputScanner(game, col, row) {
+ return game.scanners.find(scanner => {
+ const a = scannerConnector(scanner, 'inputA');
+ return a && sameCell({ col, row }, a);
+ }) || null;
+}
+export function isInputConnector(game, col, row) { return !!getInputScanner(game, col, row); }
+export function isOutputConnector(game, col, row) {
+ return game.scanners.some(scanner => {
+ const l = scannerConnector(scanner, 'left');
+ const r = scannerConnector(scanner, 'right');
+ return sameCell({ col, row }, l) || sameCell({ col, row }, r);
+ });
+}
+export function inputCellsForScanner(game, scanner) {
+ return ['inputA'].map(t => scannerConnector(scanner, t))
+ .filter(p => p && inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row)));
+}
+export function routePoints(cells, extraEnd = null) {
+ const pts = cells.map(p => cellCenter(p.col, p.row));
+ if (extraEnd) pts.push(extraEnd);
+ return pts;
+}
+function dirBetween(a, b) {
+ const dc = b.col - a.col, dr = b.row - a.row;
+ return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null;
+}
+function isCross(game, col, row) {
+ const ns = getConveyorNeighbors(game, col, row);
+ const names = new Set(ns.map(n => n.dir.name));
+ return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down');
+}
+
+// Routing rule: a four-way cross conveyor is an overpass/crossing. Chicks never turn there.
+// The BFS state includes the incoming direction so every route that enters a cross must leave straight.
+export function bfsAllRoutes(game, start, isGoal) {
+ const startK = key(start.col, start.row);
+ if (!game.conveyorTiles.has(startK)) return [];
+ const startState = `${startK}|none`;
+ const queue = [{ col: start.col, row: start.row, incoming: 'none' }];
+ const visited = new Set([startState]);
+ const parent = new Map();
+ const found = [];
+ while (queue.length) {
+ const cur = queue.shift();
+ const curK = key(cur.col, cur.row);
+ const stateK = `${curK}|${cur.incoming}`;
+ if (isGoal(cur)) found.push(stateK);
+ for (const n of getConveyorNeighbors(game, cur.col, cur.row)) {
+ const outDir = dirBetween(cur, n);
+ if (cur.incoming !== 'none' && isCross(game, cur.col, cur.row) && outDir !== cur.incoming) continue;
+ const nextStateK = `${key(n.col, n.row)}|${outDir}`;
+ if (visited.has(nextStateK)) continue;
+ visited.add(nextStateK);
+ parent.set(nextStateK, stateK);
+ queue.push({ col: n.col, row: n.row, incoming: outDir });
+ }
+ }
+ return found.map(goalState => {
+ const reversed = [];
+ let cursor = goalState;
+ while (cursor) {
+ const [cellK] = cursor.split('|');
+ reversed.push(parseKey(cellK));
+ if (cellK === startK) break;
+ cursor = parent.get(cursor);
+ }
+ return reversed.reverse();
+ });
+}
+
+export function chooseRoundRobin(game, id, candidates, advance = false) {
+ if (!candidates.length) return null;
+ const sorted = [...candidates].sort((a, b) => {
+ const ka = a.key || JSON.stringify(a.route || a.cells || a);
+ const kb = b.key || JSON.stringify(b.route || b.cells || b);
+ return ka.localeCompare(kb);
+ });
+ const n = game.branchCounters.get(id) || 0;
+ if (advance) game.branchCounters.set(id, n + 1);
+ return sorted[n % sorted.length];
+}
+
+export function scannerOutputs(scanner) {
+ if (scanner.role === 0) return { left: 'mixer', right: 'scanner-role-1' };
+ return { left: 'trash', right: 'truck' };
+}
+export function destinationLabel(dest) {
+ return { mixer: 'MIXER', truck: 'TRUCK', trash: 'WASTE', 'scanner-role-1': 'NEXT', scanner: 'NEXT' }[dest] || String(dest).toUpperCase();
+}
+export function destinationColor(dest) {
+ return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
+}
+
+export function facilityEntryPoint(game, dest) {
+ const f = game.facilities[dest];
+ if (!f) return null;
+ if (f.entry) return cellCenter(f.entry.col, f.entry.row);
+ // Legacy fallback for older save data. New facilities use fixed receiver cells on the grid edge.
+ if (dest === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
+ if (dest === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
+ if (dest === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 };
+ return { x: f.x + f.w / 2, y: f.y + f.h / 2 };
+}
+export function facilityEntryCell(game, dest) {
+ const f = game.facilities[dest];
+ return f?.entry || null;
+}
+
+export function facilityEndpointCells(game, dest) {
+ const f = game.facilities[dest];
+ if (!f?.entry) return [];
+ const entry = f.entry;
+ const exactKey = key(entry.col, entry.row);
+ if (game.conveyorTiles.has(exactKey)) return [{ ...entry }];
+ // v10.4 connection tolerance: if the receiver port was moved to the grid edge
+ // but the player left the belt one cell next to it, treat that adjacent belt as
+ // the endpoint. This keeps moved scanners/conveyors/edge ports from appearing
+ // visually correct but failing to connect.
+ const adjacent = [];
+ for (const d of DIRS) {
+ const p = { col: entry.col + d.dc, row: entry.row + d.dr };
+ if (inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row))) adjacent.push(p);
+ }
+ return adjacent;
+}
+
+export function isFacilityEndpoint(game, p, dest, connector) {
+ if (sameCell(p, connector)) return false;
+ const f = game.facilities[dest];
+ // New facilities only route into their explicit receiver port, or one adjacent
+ // belt tile when the receiver itself has no tile. Arbitrary dead ends never
+ // auto-become machine exits.
+ if (f?.entry) return facilityEndpointCells(game, dest).some(e => sameCell(p, e));
+ const degree = getConveyorNeighbors(game, p.col, p.row).length;
+ if (degree > 1) return false;
+ const entry = facilityEntryPoint(game, dest);
+ if (!entry) return false;
+ return distance(cellCenter(p.col, p.row), entry) <= GRID.cell * 2.2;
+}
+
+export function routeFromFarmToScanner(game, farm, advance = false) {
+ const starts = getAdjacentConveyors(game, farm.col, farm.row).filter(p => !isOutputConnector(game, p.col, p.row));
+ const candidates = [];
+ for (const start of starts) {
+ const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row));
+ for (const cells of routes) {
+ const last = cells[cells.length - 1];
+ const scanner = getInputScanner(game, last.col, last.row);
+ if (!scanner) continue;
+ candidates.push({ key: `${scanner.id}:${last.col},${last.row}:${cells.length}`, scanner, cells });
+ }
+ }
+ const chosen = chooseRoundRobin(game, `farm:${farm.id}`, candidates, advance);
+ if (!chosen) return null;
+ return { scannerId: chosen.scanner.id, route: routePoints(chosen.cells, scannerCenter(chosen.scanner)) };
+}
+
+export function outputRoute(game, side, fromPoint, scannerId, advance = false) {
+ const scanner = scannerById(game, scannerId);
+ if (!scanner) return null;
+ const connector = scannerConnector(scanner, side);
+ if (!connector || !game.conveyorTiles.has(key(connector.col, connector.row))) return null;
+ const dest = scannerOutputs(scanner)[side];
+ if (dest === 'scanner-role-1') {
+ const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && inputCellsForScanner(game, s).length);
+ const candidates = [];
+ for (const target of targets) {
+ const inputKeys = new Set(inputCellsForScanner(game, target).map(p => key(p.col, p.row)));
+ const routes = bfsAllRoutes(game, connector, p => inputKeys.has(key(p.col, p.row)));
+ for (const cells of routes) candidates.push({ key: `s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells });
+ }
+ const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:toRole1`, candidates, advance);
+ if (!chosen) return null;
+ return { destination: 'scanner', nextScannerId: chosen.target.id, route: [{ x: fromPoint.x, y: fromPoint.y }, ...routePoints(chosen.cells, scannerCenter(chosen.target))] };
+ }
+ const routes = bfsAllRoutes(game, connector, p => isFacilityEndpoint(game, p, dest, connector));
+ const candidates = routes.map(cells => ({ key: `${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells }));
+ const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance);
+ if (!chosen) return null;
+ return { destination: dest, route: [{ x: fromPoint.x, y: fromPoint.y }, ...routePoints(chosen.cells, facilityEntryPoint(game, dest))] };
+}
+
+export function facilityConnectionIssues(game) {
+ const issues = [];
+ const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
+ for (const id of ['mixer', 'trash', 'truck']) {
+ const f = game.facilities[id];
+ if (!f) {
+ issues.push(`${labels[id]} is missing`);
+ continue;
+ }
+ const endpoints = facilityEndpointCells(game, id);
+ if (!endpoints.length) issues.push(`${labels[id]} receiver has no conveyor`);
+ }
+ return issues;
+}
+
+export function factoryReady(game) {
+ return facilityConnectionIssues(game).length === 0;
+}
+
+export function buildConveyorComponents(game) {
+ const components = new Map();
+ const cellToComponent = new Map();
+ let next = 1;
+ for (const k of game.conveyorTiles) {
+ if (cellToComponent.has(k)) continue;
+ const id = next++;
+ const queue = [parseKey(k)];
+ const cells = [];
+ cellToComponent.set(k, id);
+ while (queue.length) {
+ const cur = queue.shift();
+ const ck = key(cur.col, cur.row);
+ cells.push(ck);
+ for (const n of getConveyorNeighbors(game, cur.col, cur.row)) {
+ const nk = key(n.col, n.row);
+ if (cellToComponent.has(nk)) continue;
+ cellToComponent.set(nk, id);
+ queue.push({ col: n.col, row: n.row });
+ }
+ }
+ components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 });
+ }
+ return { components, cellToComponent };
+}
+
+export function nearestConveyorKey(game, x, y) {
+ let best = null;
+ let bestD = Infinity;
+ for (const k of game.conveyorTiles) {
+ const p = parseKey(k);
+ const c = cellCenter(p.col, p.row);
+ const d = Math.hypot(c.x - x, c.y - y);
+ if (d < bestD) { bestD = d; best = k; }
+ }
+ return bestD <= GRID.cell * 1.1 ? best : null;
+}
diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js
new file mode 100644
index 0000000..efe1758
--- /dev/null
+++ b/src/systems/uiSystem.js
@@ -0,0 +1,225 @@
+import { AUTO_SCANNER_COOLDOWN, BUILD_COSTS, CONVEYOR_SPEED, CONVEYOR_SPEED_GROWTH, CONTRACT_EVENT_FIRST_TURN, STARTING_CASH, VERSION } from '../core/config.js';
+import { yen } from '../core/utils.js';
+import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js';
+import { MIXER_PRICE, TRUCK_PRICE, maleTruckPenalty, truckPayoutMultiplier, finalScore } from './economy.js';
+import { productionMultiplier, truckTarget, targetTruckCount, factoryValue } from './contracts.js';
+
+export function createUISystem({ game, ui, build, startGame, activeQueuedChick }) {
+ function conveyorSpeedForTurn(turn) { return CONVEYOR_SPEED * Math.pow(CONVEYOR_SPEED_GROWTH, Math.max(0, turn - 1)); }
+ function displaySpeed() { return Math.round(conveyorSpeedForTurn(game.phase === 'build' ? game.turn + 1 : game.turn)); }
+
+ function updatePanels() {
+ updateToolButtons();
+ updateBuildStatus();
+ updateFacilityPanel();
+ updateContractPanel();
+ updateTurnSummary();
+ updateHistoryButtons();
+ }
+
+ function updateToolButtons() {
+ for (const [name, btn] of Object.entries(ui.buttons)) {
+ if (btn?.classList?.contains('tool-button')) btn.classList.toggle('active', game.buildTool === name || (name === 'trash' && game.buildTool === 'trash'));
+ }
+ }
+
+ function updateBuildStatus() {
+ ui.buildStatus.textContent = game.phase === 'build'
+ ? `BUILD: ${(game.buildTool || 'SELECT').toUpperCase()} | Right-drag pan | Left-drag select/move`
+ : 'Sorting: use scanner keys. Build after clearing the turn.';
+ }
+
+ function updateHistoryButtons() {
+ ui.buttons.undo.disabled = game.phase !== 'build' || game.undoStack.length === 0;
+ ui.buttons.redo.disabled = game.phase !== 'build' || game.redoStack.length === 0;
+ }
+
+ function updateTurnSummary() {
+ const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)).length;
+ const issues = facilityConnectionIssues(game);
+ ui.turnSummary.innerHTML = [
+ `Truck target: ${truckTarget(game).toUpperCase()} | Male fine: -${yen(maleTruckPenalty(game))}`,
+ `Belt: ${displaySpeed()}px/s | Farms connected: ${connected}/${game.eggFarms.length}`,
+ issues.length ? `Blocked: ${issues[0]}` : `All receiver ports connected.`
+ ].join('
');
+ }
+
+ function updateContractPanel() {
+ if (!ui.contractPanel) return;
+ const offer = game.contractOffer;
+ if (game.phase !== 'build') {
+ ui.contractPanel.innerHTML = 'Forced events appear in Build phase.';
+ return;
+ }
+ if (!offer) {
+ ui.contractPanel.className = 'contract-card empty';
+ const nextTurn = game.turn + 1;
+ if (nextTurn < CONTRACT_EVENT_FIRST_TURN) {
+ ui.contractPanel.innerHTML = `Event lock active.No irregular events during Turns 1–7. First possible forced event: Turn ${CONTRACT_EVENT_FIRST_TURN}.`;
+ } else {
+ ui.contractPanel.innerHTML = 'No event.Random forced events can appear later.';
+ }
+ return;
+ }
+ ui.contractPanel.className = 'contract-card accepted';
+ ui.contractPanel.innerHTML = `
+ FORCED NEXT TURN
+ ${offer.title}
+
+ Route: ${offer.route}
+ Target: ${offer.targetAmount} truck exports
+ Reward: ${yen(offer.reward)}
+ Output ×${offer.productionMultiplier} / Poop ×${offer.poopMultiplier}
+
`;
+ }
+
+ function updateFacilityPanel() {
+ const obj = build.selectedObject();
+ if (!obj) {
+ ui.facilityPanel.className = 'facility-panel-empty';
+ ui.facilityPanel.innerHTML = 'Click equipment to upgrade, remove, or move it.';
+ return;
+ }
+ ui.facilityPanel.className = 'facility-card';
+ const lines = build.selectedInfoLines(obj);
+ if (obj.type === 'facility' && obj.id === 'truck') lines.push(`Current truck multiplier: ${Math.round(truckPayoutMultiplier(game) * 100)}%`);
+ if (obj.type === 'facility' && obj.id === 'trash') lines.push('Items are shredded; no stored contents are visualized.');
+
+ const actions = [];
+ const cost = build.selectedUpgradeCost(obj);
+ if (cost) actions.push(``);
+ if (obj.type === 'scanner') actions.push(``);
+ const refundable = obj.builtSession === game.buildSession || game.conveyorMeta.get(obj.id)?.builtSession === game.buildSession;
+ actions.push(``);
+
+ ui.facilityPanel.innerHTML = `${build.selectedTitle(obj)}
${lines.map(x => `${x}
`).join('')}${actions.join('')}
`;
+ ui.facilityPanel.querySelector('[data-action="upgrade"]')?.addEventListener('click', build.upgradeSelected);
+ ui.facilityPanel.querySelector('[data-action="role"]')?.addEventListener('click', build.switchScannerRole);
+ ui.facilityPanel.querySelector('[data-action="remove"]')?.addEventListener('click', build.removeSelected);
+ }
+
+ function updateUI() {
+ ui.shell.classList.toggle('phase-running', game.phase === 'running');
+ ui.shell.classList.toggle('phase-build', game.phase === 'build');
+ ui.shell.classList.toggle('phase-gameover', game.phase === 'gameover');
+ ui.shell.classList.toggle('phase-title', game.phase === 'title');
+ ui.money.textContent = yen(game.cash);
+ ui.money.classList.toggle('cash-negative', game.cash < 0);
+ ui.money.classList.toggle('cash-positive', game.cash > STARTING_CASH);
+ ui.turn.textContent = game.turn;
+ ui.timeLeft.textContent = `${Math.max(0, game.timeLeft).toFixed(1)}s`;
+ ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && game.timeLeft <= 10);
+ ui.phase.textContent = phaseLabel();
+ updatePriorityStrip();
+ ui.turnProfit.textContent = yen(game.stats.profit);
+ ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0);
+ ui.turnProfit.classList.toggle('cash-positive', game.stats.profit > 0);
+ ui.mixerCount.textContent = game.stats.mixerCount;
+ ui.truckFemaleCount.textContent = game.stats.truckFemale;
+ ui.truckMaleCount.textContent = game.stats.truckMale;
+ ui.poopCount.textContent = game.stats.poopSpawned;
+ ui.buttons.s1Left.disabled = game.phase !== 'running' || !activeQueuedChick(0);
+ ui.buttons.s1Right.disabled = game.phase !== 'running' || !activeQueuedChick(0);
+ ui.buttons.s2Left.disabled = game.phase !== 'running' || !activeQueuedChick(1);
+ ui.buttons.s2Right.disabled = game.phase !== 'running' || !activeQueuedChick(1);
+ ui.buttons.nextTurn.disabled = game.phase !== 'build' || !factoryReady(game);
+ }
+
+ function updatePriorityStrip() {
+ if (!ui.eventBrief || !ui.targetBrief || !ui.speedBrief) return;
+ const target = truckTarget(game).toUpperCase();
+ ui.targetBrief.textContent = `TRUCK TARGET: ${target}`;
+ ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s`;
+ ui.speedBrief.title = 'Base speed rises by 2% at every new turn.';
+ const active = game.contractActive;
+ const offer = game.contractOffer;
+ if (active) {
+ ui.eventBrief.textContent = `ACTIVE EVENT: ${active.route} | Output ×${active.productionMultiplier} | Poop ×${active.poopMultiplier}`;
+ ui.eventBrief.className = 'event-brief active';
+ } else if (game.phase === 'build' && offer) {
+ ui.eventBrief.textContent = `NEXT TURN EVENT: ${offer.route} | Target ${offer.targetAmount} | Reward ${yen(offer.reward)}`;
+ ui.eventBrief.className = 'event-brief next';
+ } else if (game.phase === 'build' && game.turn + 1 < CONTRACT_EVENT_FIRST_TURN) {
+ ui.eventBrief.textContent = `NO EVENTS UNTIL TURN ${CONTRACT_EVENT_FIRST_TURN}`;
+ ui.eventBrief.className = 'event-brief locked';
+ } else {
+ ui.eventBrief.textContent = 'NO ACTIVE EVENT';
+ ui.eventBrief.className = 'event-brief';
+ }
+ }
+
+ function phaseLabel() {
+ if (game.phase === 'running') return game.timeLeft <= 0 ? 'Clearing' : 'Sorting';
+ if (game.phase === 'build') return 'Build';
+ if (game.phase === 'gameover') return 'Game Over';
+ return 'Title';
+ }
+
+ function showTitle() {
+ ui.modalTitle.textContent = `Chick Sorter ${VERSION}`;
+ ui.modalBody.innerHTML = `🐤Male→MixerS1: A
🐣Female→TruckS1: D, then S2: →
💩Poop→ShredderS1: D, then S2: ←
Build phase: connect all receiver ports before Next Turn.
`;
+ ui.modalActions.innerHTML = '';
+ ui.modalActions.appendChild(button('Start Game', startGame, 'primary-button'));
+ ui.modal.classList.add('visible');
+ }
+
+ function showTurnResult(r) {
+ ui.modalTitle.textContent = `Turn ${r.turn} Result`;
+ const contractHtml = r.contract ? `
+ Limited Contract
+
+
Contract${r.contract.title}
+
Rule${r.contract.route}
+
Progress${r.contract.count} / ${r.contract.targetAmount}
+
Bonus${r.contract.success ? '+' + yen(r.contract.bonus) : 'Failed'}
+
` : 'No limited contract active this turn.
';
+ ui.modalBody.innerHTML = `
+ Shipment
+
+
Truck target${r.ship.target.toUpperCase()}
+
Target cargo${r.ship.targetCount} × ${yen(TRUCK_PRICE)} = ${yen(r.ship.base)}
+
Poop in truck${r.poopTruck} poop → ${Math.round(r.ship.soilMultiplier * 100)}%
+
Final truck revenue${yen(r.ship.adjusted)}
+
Mixer${r.mixerCount} × ${yen(MIXER_PRICE)}
+
Wrong truck${r.truckMale} males
+
Explosion damage${yen(r.explosionDamage)}
+
Turn profit${yen(r.profit)}
+
Cash${yen(r.cash)}
+
+ ${contractHtml}`;
+ ui.modalActions.innerHTML = '';
+ ui.modalActions.appendChild(button('Build Phase', hideModal, 'primary-button'));
+ ui.modal.classList.add('visible');
+ }
+
+ function showGameOver() {
+ const processed = game.totals.processed;
+ const accuracy = processed ? Math.round(game.totals.correct / processed * 100) : 0;
+ const fs = finalScore(game);
+ ui.modalTitle.textContent = 'Game Over';
+ ui.modalBody.innerHTML = `Your cash went negative.
Final Score${fs.score}
Survival${game.totals.turnsCompleted} turns
Final Cash${yen(game.cash)}
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Total Penalty${yen(game.totals.penalty)}
Correct Sorts${game.totals.correct}
Accuracy${accuracy}%
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} truck / ${game.totals.poopMixer} mixer
Explosion Damage${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
`;
+ ui.modalActions.innerHTML = '';
+ ui.modalActions.appendChild(button('Restart', startGame, 'primary-button'));
+ ui.modal.classList.add('visible');
+ }
+
+ function button(text, onClick, className = 'primary-button') {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = className;
+ b.textContent = text;
+ b.addEventListener('click', onClick);
+ return b;
+ }
+
+ function hideModal() { ui.modal.classList.remove('visible'); }
+
+ function checkGameOver() {
+ if (game.phase !== 'gameover' && game.cash < 0) {
+ game.phase = 'gameover';
+ showGameOver();
+ }
+ }
+
+ return { updatePanels, updateUI, showTitle, showTurnResult, showGameOver, hideModal, checkGameOver };
+}
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..1a36dbe
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,119 @@
+:root {
+ --bg: #f2fff0;
+ --screen: #eefbea;
+ --panel: rgba(245, 255, 242, 0.96);
+ --ink: #102015;
+ --muted: #526456;
+ --line: #102015;
+ --green: #22b94f;
+ --green-soft: #b7f5c6;
+ --white: #ffffff;
+ --danger: #c42424;
+ --shadow: 8px 8px 0 rgba(16, 32, 21, 0.22);
+}
+* { box-sizing: border-box; }
+html, body { min-height: 100%; }
+body { margin: 0; background: var(--bg); color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; overflow-x: hidden; }
+button { font: inherit; }
+h1, h2, p { margin: 0; }
+#app { width: min(100vw, 1680px); margin: 0 auto; padding: 0 8px 12px; }
+.game-shell { position: relative; width: 100%; min-height: min(100vh, 920px); border: 4px solid var(--line); border-top: 0; background: var(--screen); overflow: hidden; box-shadow: var(--shadow); }
+#gameCanvas { display: block; width: 100%; height: min(calc(100vh - 10px), 900px); min-height: 720px; background: var(--screen); touch-action: none; }
+.hud { position: absolute; z-index: 4; display: grid; gap: 6px; pointer-events: none; }
+.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(5, minmax(86px, auto)); }
+.hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); }
+.hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); }
+.hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); }
+.hud-card.primary span { font-size: 10px; }
+.hud-card.primary strong { font-size: clamp(22px, 2vw, 32px); }
+.hud-card.time-critical { background: #fff0d1; box-shadow: inset 0 0 0 4px var(--danger), 4px 4px 0 rgba(16,32,21,.16); }
+.hud-card span { display: block; font-size: 9px; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; }
+.hud-card strong { font-size: clamp(15px, 1.3vw, 22px); line-height: 1; color: var(--ink); }
+.hud-card.cash strong, .hud-card.profit strong, .cash-positive { color: var(--green) !important; }
+.cash-negative { color: var(--danger) !important; }
+
+.priority-strip { position: absolute; z-index: 5; top: 76px; left: 10px; display: grid; grid-template-columns: minmax(280px, 1.2fr) minmax(170px, .75fr) minmax(130px, .55fr); gap: 8px; max-width: calc(100% - 420px); pointer-events: none; }
+.priority-strip > div { border: 3px solid var(--line); background: rgba(255,255,255,.94); padding: 8px 10px; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 rgba(16,32,21,.14); font-size: 12px; letter-spacing: .03em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.event-brief.active { background: #fff0d1; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.14); }
+.event-brief.next { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
+.event-brief.locked { background: #edf3ec; color: var(--muted); }
+.target-brief { color: var(--green); }
+.speed-brief { color: var(--ink); }
+
+.build-panel { position: absolute; z-index: 6; top: 118px; right: 12px; bottom: 12px; width: clamp(278px, 23vw, 360px); min-height: 0; padding: 10px; border: 4px solid var(--line); background: var(--panel); box-shadow: var(--shadow); overflow: auto; }
+.panel-head { display: grid; gap: 8px; margin-bottom: 10px; }
+.panel-head h1 { font-size: 15px; letter-spacing: .06em; }
+.panel-head h1 span { font-size: 10px; color: var(--white); background: var(--green); border: 2px solid var(--line); padding: 1px 6px; }
+.panel-head p { color: var(--muted); margin-top: 4px; font-size: 10px; line-height: 1.25; }
+.panel-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
+.build-panel h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }
+.large-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
+.history-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; grid-column: 1 / -1; }
+.tool-button.small { min-height: 40px; padding: 6px; }
+.tool-button, .primary-button, .sort-button, .facility-action { border: 3px solid var(--line); background: var(--white); color: var(--ink); font-weight: 900; text-transform: uppercase; cursor: pointer; box-shadow: 4px 4px 0 rgba(16,32,21,.18); }
+.tool-button { min-height: 50px; text-align: left; padding: 8px; }
+.tool-button strong { display: block; font-size: 12px; }
+.tool-button span { display: block; margin-top: 4px; font-size: 9px; color: var(--muted); }
+.tool-button.active { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.18); }
+.tool-button.danger.active { background: #ffdada; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); }
+.primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; }
+.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; }
+.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; }
+.facility-card h3 { color: var(--ink); font-size: 14px; margin: 0 0 6px; }
+.facility-card p { margin: 4px 0; }
+.facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
+.facility-action { padding: 7px 9px; font-size: 10px; }
+.facility-action.good { background: var(--green-soft); }
+.facility-action.warn { background: #fff0d1; }
+.facility-action.danger { background: #ffdada; }
+.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); }
+.sort-button { min-height: 46px; font-size: 12px; }
+.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); }
+.modal.visible { display: flex; }
+.modal-card { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); }
+.modal-card h2 { font-size: 26px; text-transform: uppercase; margin-bottom: 12px; }
+.modal-card p { line-height: 1.55; }
+.modal-card ul { margin: 12px 0 0; padding-left: 22px; line-height: 1.55; color: var(--muted); }
+.modal-actions { margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
+.route-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
+.route-card { border: 3px solid var(--line); background: #f7fff5; padding: 14px; display: grid; grid-template-columns: 44px 1fr 28px 1fr; align-items: center; gap: 4px 8px; }
+.route-card .route-icon { grid-row: 1 / span 2; font-size: 30px; text-align: center; }
+.route-card strong, .route-card em { font-style: normal; font-weight: 900; text-transform: uppercase; }
+.route-card b { font-size: 24px; color: var(--green); text-align: center; }
+.route-card small { grid-column: 2 / span 3; color: var(--muted); font-weight: 900; }
+.result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
+.result-grid div { border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
+.result-grid strong { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 6px; }
+.result-grid span { font-size: 16px; font-weight: 900; color: var(--ink); }
+.game-shell:not(.phase-build) .build-panel { display: none; }
+.game-shell.phase-running .hud-top-right { display: none; }
+.game-shell:not(.phase-running) .control-dock { display: none; }
+@media (max-width: 1180px) {
+ #app { padding: 0; }
+ .game-shell { min-height: 100vh; }
+ #gameCanvas { height: 100vh; }
+ .hud-top-left, .hud-top-right { grid-template-columns: repeat(2, minmax(76px, auto)); }
+ .priority-strip { top: 150px; left: 8px; right: 8px; max-width: none; grid-template-columns: 1fr; }
+ .build-panel { top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 42vh; }
+ .large-tools { grid-template-columns: 1fr 1fr; }
+ .control-dock { grid-template-columns: repeat(2, 150px); }
+ .route-cards, .result-grid { grid-template-columns: 1fr; }
+}
+
+.contract-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
+.contract-card.empty { color: var(--muted); background: rgba(255,255,255,.72); }
+.contract-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; }
+.contract-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; }
+.contract-card p { margin: 5px 0; }
+.contract-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
+.contract-card.declined { background: #f1f1f1; color: var(--muted); }
+.contract-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; }
+.contract-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; }
+.contract-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); }
+.contract-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
+.result-section-title { margin: 16px 0 8px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .12em; color: var(--green); }
+.result-section-title:first-child { margin-top: 0; }
+.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
+
+.compact-rules .mini-box { padding: 8px; font-size: 10px; }
+.primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; }