Compare commits

..

2 commits

Author SHA1 Message Date
b54f36c0e8 hehehe 2026-06-08 22:05:54 +09:00
16bde0b87d hm 2026-06-08 18:26:53 +09:00
26 changed files with 2191 additions and 1373 deletions

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v27.0</title>
<title>Chick Sorter v27.6</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
@ -17,6 +17,7 @@
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">¥0</strong></div>
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
<div class="hud-card combo"><span>COMBO</span><strong id="comboCount">0</strong></div>
</div>
<div class="hud hud-top-right" aria-label="Day result details">
@ -35,8 +36,8 @@
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v27.0</span></h1>
<p>Build, drag, connect at least one valid EGG route, then start the next day.</p>
<h1>CHICK SORTER <span>v27.6</span></h1>
<p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
</div>
@ -54,6 +55,7 @@
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</span></button>
<button id="hireRepairmanButton" class="tool-button repair" type="button"><strong>Hire Repairman</strong><span>¥100 / next day</span></button>
<button id="expandGridButton" class="tool-button expand" type="button"><strong>Expand Grid</strong><span>¥1,300 / +10×10</span><small class="tool-flavor">Canvas buttons buy adjacent right or upper 10×10 lots. New ground includes blocked no-build tiles.</small></button>
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
</div>
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
@ -70,6 +72,8 @@
</div>
</div>
<div id="manualScannerMonitor" class="scanner-monitor" aria-label="Offscreen manual scanner monitor"></div>
<div class="control-dock" aria-label="Sorting controls">
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A -> Mixer</button>
<button id="scanner1TruckButton" class="sort-button" type="button">S1 D -> S2</button>
@ -77,6 +81,19 @@
<button id="scanner2TruckButton" class="sort-button" type="button">S2 -> Truck</button>
</div>
<div id="hoverTooltip" class="hover-tooltip" aria-hidden="true"></div>
<details id="debugPanel" class="debug-panel">
<summary>DBG</summary>
<div class="debug-body">
<label class="debug-check"><input id="debugInfiniteCash" type="checkbox" /> Infinite money</label>
<label class="debug-day">Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
<button id="debugSetDayButton" type="button">Set Day</button>
<label class="debug-card">Card <select id="debugCardSelect"></select></label>
<button id="debugGrantCardButton" type="button">Grant Card</button>
<button id="debugGrantAllCardsButton" type="button">All Cards</button>
<div id="debugReadout" class="debug-readout"></div>
</div>
</details>
</section>
</main>
@ -88,6 +105,6 @@
</div>
</div>
<script type="module" src="./src/game.js?v=27.0-cleanup"></script>
<script type="module" src="./src/game.js?v=27.6-direct-conveyor-chicks"></script>
</body>
</html>

View file

@ -1,27 +0,0 @@
# Chick Sorter v11.0 Routing / Compact Upgrades
Open `index.html` in a browser.
## v11.0 changes
- Egg Farm price is now JPY 300.
- Initial S2-to-Truck conveyor lane is longer.
- Egg Farm shutters reopen in Build phase after each day.
- Mixer and Truck upgrades have no level cap. Each upgrade increases income and matching fines by `ceil(base × 1.05^upgrade count)`.
- Manual Scanner and Conveyor upgrade choices are removed.
- Equipment click menu is a compact bubble instead of a large overlay.
- Explosion damage is `ceil(equipment price / 30)`.
- Spawn blocking now highlights the stopped chick instead of showing `WAIT`.
- Scanner/facility routing is more tolerant after manual conveyor dragging.
## Controls
Sorting:
- S1: A = Mixer, D = S2
- S2: Left Arrow = Shredder, Right Arrow = Truck
Build phase:
- Left drag empty area: box select
- Drag selected equipment: move selection
- Right drag: pan
- Ctrl+Z / Ctrl+Y: undo / redo

View file

@ -1,7 +1,7 @@
// Central balance sheet for tuning gameplay.
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
export const BALANCE = {
version: 'v27.0 cleanup build',
version: 'v27.6 direct conveyor chick movement',
time: {
daySeconds: 60,
farmShutdownGraceSeconds: 10
@ -10,11 +10,18 @@ export const BALANCE = {
starting: 250
},
grid: {
x: 190,
y: 142,
cols: 22,
rows: 13,
cell: 46
x: 170,
y: 78,
cols: 20,
rows: 10,
cell: 46,
expansion: {
colsPerPurchase: 10,
rowsPerPurchase: 10,
costBase: 1000,
costMultiplier: 4 / 3,
truncateUnit: 100
}
},
map: {
blockedRatio: 0.075
@ -62,12 +69,232 @@ export const BALANCE = {
bearingSpeedMultiplier: 1.075
},
cards: {
definitions: [
{
id: 'upgradeEgg',
title: 'EGG Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'eggFarm',
description: 'Choose one EGG FARM on the map and raise it by 1 level.',
tags: ['EGG', 'ACTIVE']
},
{
id: 'upgradeAutoScanner',
title: 'AUTO SCANNER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'autoScanner',
description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.',
tags: ['SCANNER', 'ACTIVE']
},
{
id: 'upgradeMixer',
title: 'MIXER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'mixer',
description: 'Choose MIXER on the map and raise it by 1 level.',
tags: ['ECONOMY', 'ACTIVE']
},
{
id: 'upgradeTruck',
title: 'TRUCK Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'truck',
description: 'Choose TRUCK on the map and raise it by 1 level.',
tags: ['ECONOMY', 'ACTIVE']
},
{
id: 'upgradeTrash',
title: 'SHREDDER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'trash',
description: 'Choose SHREDDER and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.',
tags: ['POOP', 'ACTIVE']
},
{
id: 'crowdedFarming',
title: 'Crowded Farming Contract',
rarity: 'common',
type: 'instant',
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.',
tags: ['EGG', 'RISK', 'PASSIVE']
},
{
id: 'hatchingFeed',
title: 'Hatching Feed',
rarity: 'common',
type: 'instant',
description: 'EGG production interval -4%. Poop rate +3%. No card limit.',
tags: ['EGG', 'POOP', 'PASSIVE']
},
{
id: 'safetyCover',
title: 'Safety Cover',
rarity: 'common',
type: 'instant',
description: 'Explosion damage -5%. No card limit.',
tags: ['RISK', 'PASSIVE']
},
{
id: 'preventiveMaintenance',
title: 'Preventive Maintenance Manual',
rarity: 'common',
type: 'instant',
description: 'All equipment deterioration speed -5%. No card limit.',
tags: ['MAINTENANCE', 'PASSIVE']
},
{
id: 'dudFilter',
title: 'DUD Filter',
rarity: 'common',
type: 'instant',
description: 'DUD chance -5 percentage points. Stacks until DUD chance reaches 0%.',
tags: ['CARD', 'PASSIVE']
},
{
id: 'durabilityCoating',
title: 'Durability Coating',
rarity: 'common',
type: 'instant',
description: 'All equipment maximum durability +3%. No card limit.',
tags: ['MAINTENANCE', 'PASSIVE']
},
{
id: 'bearing',
title: 'High-Quality Bearing',
rarity: 'common',
type: 'instant',
description: 'Conveyor speed +7.5%.',
tags: ['CONVEYOR', 'PASSIVE']
},
{
id: 'extraEggOutlet',
title: 'Extra Egg Outlet',
rarity: 'rare',
type: 'equipmentUpgrade',
target: 'eggOutlet',
description: 'Choose one EGG FARM and add +1 output port to that EGG. Max +3 per EGG.',
tags: ['EGG', 'RARE', 'ACTIVE']
},
{
id: 'recyclingSubsidy',
title: 'Recycling Subsidy',
rarity: 'rare',
type: 'instant',
description: 'SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.',
tags: ['ECONOMY', 'RARE', 'PASSIVE']
},
{
id: 'dynamite',
title: 'Dynamite',
rarity: 'rare',
type: 'cellAction',
target: 'blockedCell',
description: 'Remove any 3 blocked cells with a burst effect.',
tags: ['RISK', 'RARE', 'ONE-SHOT']
},
{
id: 'usedMachine',
title: 'Used Machines',
rarity: 'rare',
type: 'instant',
description: 'Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.',
tags: ['ECONOMY', 'RISK', 'RARE']
},
{
id: 'newMachine',
title: 'New Machines',
rarity: 'common',
type: 'instant',
description: 'Cancel Used Machines and restore normal prices, refunds, and durability.',
tags: ['ECONOMY', 'ONE-SHOT']
},
{
id: 'flattery',
title: 'Flattery',
rarity: 'rare',
type: 'instant',
description: 'Next Fairies tribute -50%.',
tags: ['ECONOMY', 'RARE', 'ONE-SHOT']
},
{
id: 'loan',
title: 'Loan',
rarity: 'rare',
type: 'instant',
description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.',
tags: ['ECONOMY', 'RISK', 'RARE']
},
{
id: 'extraCards',
title: 'Extra Cards',
rarity: 'rare',
type: 'instant',
description: 'Draw 2 cards now and choose both.',
tags: ['RARE', 'ACTIVE']
},
{
id: 'legalWork',
title: 'Legal Work',
rarity: 'rare',
type: 'instant',
description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.',
tags: ['ECONOMY', 'RARE', 'PASSIVE']
},
{
id: 'chemicalWeaponSubsidy',
title: 'Chemical Weapons Subsidy',
rarity: 'ultraRare',
type: 'instant',
description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.',
tags: ['POOP', 'ULTRA RARE', 'PASSIVE']
},
{
id: 'laborExploitation',
title: 'Motivational Exploitation',
rarity: 'ultraRare',
type: 'instant',
description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.',
tags: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE']
},
{
id: 'rescueLoan',
title: 'Emergency Loan',
rarity: 'ultraRare',
type: 'instant',
description: 'If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.',
tags: ['ECONOMY', 'ULTRA RARE', 'ONE-SHOT']
}
],
defaultEffects: {
bearing: 0,
legalWork: 0,
fairiesFlatteryNext: 0,
crowdedFarming: 0,
extraEggOutlet: 0,
hatchingFeed: 0,
safetyCover: 0,
recyclingSubsidy: 0,
chemicalWeaponSubsidy: 0,
preventiveMaintenance: 0,
dudFilter: 0,
durabilityCoating: 0,
laborExploitation: 0,
usedMachineActive: false,
rescueLoanCharges: 0,
loans: []
},
commonWeight: 8,
rareWeight: 2,
ultraRareWeight: 0.45,
baseDraftSize: 3,
maxDudsPerDraft: 2,
dudChancePerCard: 0.30,
dudChanceReductionPerFilter: 0.05,
extraCardsAddedPicks: 2,
rerollCost: {
dayDivisor: 2,
@ -94,8 +321,8 @@ export const BALANCE = {
},
repairman: {
dailyCost: 100,
secondsPerOnePercent: 1 / 3,
walkSpeed: 140
secondsPerOnePercent: 0.5,
walkSpeed: 90
}
},
facilities: {

View file

@ -18,6 +18,8 @@ export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed;
export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier;
export const CARD_BALANCE = BALANCE.cards;
export const CARD_DEFS = BALANCE.cards.definitions;
export const CARD_DEFAULT_EFFECTS = BALANCE.cards.defaultEffects;
export const EGG_SPAWN_RANGES = BALANCE.production.eggSpawnRanges;
export const GRID = BALANCE.grid;
export const FACILITY_DEFS = BALANCE.facilities;
@ -26,14 +28,7 @@ export const BUILD_TOOL_IDS = ['conveyor', 'eggFarm', 'autoScanner', 'manualScan
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
export const INCOME_FACILITY_IDS = ['mixer', 'truck'];
// Compatibility exports. New code should prefer FACILITY_DEFS and ECONOMY.
export const BUILD_COSTS = Object.fromEntries(Object.entries(FACILITY_DEFS).map(([id, def]) => [id, def.price]));
export const FACILITY_PRICES = Object.fromEntries(MACHINE_FACILITY_IDS.map(id => [id, FACILITY_DEFS[id].price]));
export const MIXER_PRICE = ECONOMY.income.mixer;
export const TRUCK_PRICE = ECONOMY.income.truck;
export const POOP_FINE = ECONOMY.poopFine;
export const ZUNDA_TAX = ECONOMY.zundaTax;
export const INCOME_UPGRADE_RATE = ECONOMY.incomeUpgradeRate;
export const DIRS = [
{ name: 'right', dc: 1, dr: 0, angle: 0, opposite: 'left' },

View file

@ -2,10 +2,27 @@ import { BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
import { nextSpawnDelay } from './state.js';
import { currentPoopRate } from '../systems/contracts.js';
const DEFAULT_MANUAL_KEYS = [
[{ key: 'a', code: 'KeyA', label: 'A' }, { key: 'd', code: 'KeyD', label: 'D' }],
[{ key: 'arrowleft', code: 'ArrowLeft', label: 'Left' }, { key: 'arrowright', code: 'ArrowRight', label: 'Right' }],
[{ key: 'j', code: 'KeyJ', label: 'J' }, { key: 'l', code: 'KeyL', label: 'L' }],
[{ key: 'f', code: 'KeyF', label: 'F' }, { key: 'h', code: 'KeyH', label: 'H' }],
[{ key: 'q', code: 'KeyQ', label: 'Q' }, { key: 'e', code: 'KeyE', label: 'E' }],
[{ key: 'z', code: 'KeyZ', label: 'Z' }, { key: 'x', code: 'KeyX', label: 'X' }],
[{ key: 'u', code: 'KeyU', label: 'U' }, { key: 'o', code: 'KeyO', label: 'O' }],
[{ key: 'n', code: 'KeyN', label: 'N' }, { key: 'm', code: 'KeyM', label: 'M' }]
];
function manualKeysForSlot(slot) {
const pair = DEFAULT_MANUAL_KEYS[slot] || DEFAULT_MANUAL_KEYS[0];
return { left: { ...pair[0] }, right: { ...pair[1] } };
}
export function createEggFarm(game, col, row) {
const farm = {
type: 'eggFarm', id: game.nextId++, col, row,
level: 1, nextSpawn: 2, lastInterval: 2,
extraEggOutlet: 0,
price: BUILD_COSTS.eggFarm,
builtSession: game.buildSession
};
@ -25,7 +42,8 @@ export function createScanner(game, col, row, kind) {
queue: [], cooldown: 0,
price: cost,
builtSession: game.buildSession,
autoMode: 'standard'
autoMode: 'standard',
keys: kind === 'manual' ? manualKeysForSlot(manualCount) : null
};
}
@ -80,15 +98,17 @@ export function rollChickSex(game) {
export function createChick(game, routeData) {
const sex = rollChickSex(game);
const start = routeData.route[0];
const route = (routeData.route || []).map(p => ({ x: p.x, y: p.y }));
const start = route[0];
return {
id: game.nextId++, sex,
x: start.x, y: start.y,
route: routeData.route,
targetIndex: 1,
stage: 'input',
scannerId: routeData.scannerId,
route,
targetIndex: route.length > 1 ? 1 : route.length,
stage: routeData.stage || 'belt',
scannerId: routeData.scannerId ?? null,
nextScannerId: null,
pendingScannerId: null,
radius: sex === 'poop' ? 15 : 17,
bob: Math.random() * Math.PI * 2,
queueIndex: -1,

160
src/core/gridExpansion.js Normal file
View file

@ -0,0 +1,160 @@
import { BALANCE } from './balance.js';
import { GRID } from './config.js';
import { key, parseKey } from './utils.js';
export const GRID_CHUNK_SIZE = 10;
const INITIAL_CHUNKS_X = 2;
const INITIAL_CHUNKS_Y = 1;
function chunkKey(chunkX, chunkY) { return `${chunkX},${chunkY}`; }
function parseChunkKey(value) {
const [chunkX, chunkY] = String(value).split(',').map(Number);
return { chunkX, chunkY };
}
export function expansionCost(game) {
const cfg = BALANCE.grid.expansion || {};
const purchaseCount = Math.max(1, (game.gridExpansionPurchases || 0) + 1);
const raw = (cfg.costBase || 1000) * purchaseCount * (cfg.costMultiplier || (4 / 3));
const unit = Math.max(1, cfg.truncateUnit || 100);
return Math.floor(raw / unit) * unit;
}
export function resetOwnedCells(game) {
game.ownedCells = new Set();
game.ownedChunks = new Set();
game.gridChunkOriginY = 0;
game.gridExpansionPurchases = 0;
game.gridExpansionPurchasesByDirection = { right: 0, up: 0 };
for (let chunkY = 0; chunkY < INITIAL_CHUNKS_Y; chunkY += 1) {
for (let chunkX = 0; chunkX < INITIAL_CHUNKS_X; chunkX += 1) game.ownedChunks.add(chunkKey(chunkX, chunkY));
}
for (let row = 0; row < BALANCE.grid.rows; row += 1) {
for (let col = 0; col < BALANCE.grid.cols; col += 1) game.ownedCells.add(key(col, row));
}
}
export function ensureOwnedChunks(game) {
if (game.ownedChunks?.size) return game.ownedChunks;
game.ownedChunks = new Set();
if (game.ownedCells?.size) {
const originY = game.gridChunkOriginY || 0;
for (const k of game.ownedCells) {
const p = parseKey(k);
const chunkX = Math.floor(p.col / GRID_CHUNK_SIZE);
const chunkY = Math.floor(p.row / GRID_CHUNK_SIZE) + originY;
game.ownedChunks.add(chunkKey(chunkX, chunkY));
}
} else {
for (let chunkY = 0; chunkY < INITIAL_CHUNKS_Y; chunkY += 1) {
for (let chunkX = 0; chunkX < INITIAL_CHUNKS_X; chunkX += 1) game.ownedChunks.add(chunkKey(chunkX, chunkY));
}
}
return game.ownedChunks;
}
export function isOwnedCell(game, col, row) {
if (!game?.ownedCells || game.ownedCells.size === 0) return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows;
return game.ownedCells.has(key(col, row));
}
export function countOwnedCells(game) {
return game?.ownedCells?.size || (GRID.cols * GRID.rows);
}
export function lotFromChunk(game, chunkX, chunkY, direction = 'up') {
const originY = game.gridChunkOriginY || 0;
return {
direction,
key: chunkKey(chunkX, chunkY),
chunkX,
chunkY,
colStart: chunkX * GRID_CHUNK_SIZE,
rowStart: (chunkY - originY) * GRID_CHUNK_SIZE,
cols: GRID_CHUNK_SIZE,
rows: GRID_CHUNK_SIZE
};
}
export function expansionLots(game) {
const owned = ensureOwnedChunks(game);
const candidates = new Map();
for (const k of owned) {
const { chunkX, chunkY } = parseChunkKey(k);
const right = { chunkX: chunkX + 1, chunkY, direction: 'right' };
const up = { chunkX, chunkY: chunkY - 1, direction: 'up' };
for (const candidate of [right, up]) {
if (candidate.chunkX < 0 || candidate.chunkY > 0) continue;
const ck = chunkKey(candidate.chunkX, candidate.chunkY);
if (owned.has(ck) || candidates.has(ck)) continue;
candidates.set(ck, lotFromChunk(game, candidate.chunkX, candidate.chunkY, candidate.direction));
}
}
return [...candidates.values()].sort((a, b) => {
const dir = (a.direction === b.direction) ? 0 : (a.direction === 'right' ? -1 : 1);
if (dir) return dir;
if (a.chunkY !== b.chunkY) return a.chunkY - b.chunkY;
return a.chunkX - b.chunkX;
});
}
export function expansionLot(game, direction = 'right') {
const lots = expansionLots(game);
return lots.find(lot => lot.direction === direction) || lots[0] || null;
}
export function lotBounds(lot) {
return {
x: GRID.x + lot.colStart * GRID.cell,
y: GRID.y + lot.rowStart * GRID.cell,
w: lot.cols * GRID.cell,
h: lot.rows * GRID.cell
};
}
export function expansionButtonBounds(lot) {
const b = lotBounds(lot);
return {
x: b.x + b.w / 2 - 116,
y: b.y + b.h / 2 - 36,
w: 232,
h: 72,
cx: b.x + b.w / 2,
cy: b.y + b.h / 2
};
}
export function lotContainsPoint(lot, p) {
const b = expansionButtonBounds(lot);
return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
}
export function cellsInLot(lot) {
const cells = [];
for (let row = lot.rowStart; row < lot.rowStart + lot.rows; row += 1) {
for (let col = lot.colStart; col < lot.colStart + lot.cols; col += 1) cells.push({ col, row });
}
return cells;
}
export function shiftRowIndexedSet(set, rowDelta) {
const next = new Set();
for (const k of set || []) {
const p = parseKey(k);
next.add(key(p.col, p.row + rowDelta));
}
return next;
}
export function shiftRowIndexedMap(map, rowDelta) {
const next = new Map();
for (const [k, v] of map || []) {
const p = parseKey(k);
next.set(key(p.col, p.row + rowDelta), v);
}
return next;
}
export function ownChunk(game, chunkX, chunkY) {
ensureOwnedChunks(game).add(chunkKey(chunkX, chunkY));
}

View file

@ -41,3 +41,25 @@ export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] }
const target = Math.round(GRID.cols * GRID.rows * targetRatio);
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
}
export function generateBlockedCellsInRect({ seed, colStart = 0, colEnd = GRID.cols, rowStart = 0, rowEnd = GRID.rows, targetRatio = 0.20, reserved = [] } = {}) {
const rng = mulberry32(hashSeed(seed || createRunSeed()));
const reservedSet = new Set(reserved.map(p => typeof p === 'string' ? p : key(p.col, p.row)));
const candidates = [];
const c0 = Math.max(0, Math.floor(colStart));
const c1 = Math.max(c0, Math.floor(colEnd));
const r0 = Math.max(0, Math.floor(rowStart));
const r1 = Math.max(r0, Math.floor(rowEnd));
for (let row = r0; row < r1; row += 1) {
for (let col = c0; col < c1; col += 1) {
const k = key(col, row);
if (!reservedSet.has(k)) candidates.push({ col, row, k });
}
}
for (let i = candidates.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
const target = Math.round((c1 - c0) * (r1 - r0) * targetRatio);
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
}

View file

@ -1,7 +1,8 @@
import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_DEFS, GRID, EGG_SPAWN_RANGES } from './config.js';
import { BALANCE } from './balance.js';
import { createRunSeed, generateBlockedCells } from './mapGen.js';
import { randomBetween, key, inGrid } from './utils.js';
import { resetOwnedCells } from './gridExpansion.js';
import { randomBetween, key, inGrid, directionNameBetweenCells } from './utils.js';
export function newTurnStats() {
return {
@ -20,6 +21,12 @@ export function newTurnStats() {
pendingTruckRevenue: 0,
mixerRevenue: 0,
truckRevenue: 0,
chickShipmentIncome: 0,
poopShipmentIncome: 0,
manualComboBonus: 0,
manualComboSuccess: 0,
manualComboFailure: 0,
repairWorkerWages: 0,
zundaTax: 0,
fairiesTribute: 0,
loanRepayment: 0,
@ -62,6 +69,10 @@ export function newTotalStats() {
loanRepayment: 0,
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
manualComboBonus: 0,
manualComboSuccess: 0,
manualComboFailure: 0,
repairWorkerWages: 0,
cardRerollCost: 0,
mixerPoopFine: 0,
truckPoopFine: 0,
@ -86,6 +97,12 @@ export function createGame() {
lastTimestamp: 0,
nextId: 10,
runSeed: createRunSeed(),
gridCols: BALANCE.grid.cols,
gridRows: BALANCE.grid.rows,
gridExpansionPurchases: 0,
gridExpansionPurchasesByDirection: { right: 0, up: 0 },
gridChunkOriginY: 0,
ownedCells: new Set(),
blockedCells: new Set(),
buildTool: null,
buildSession: 0,
@ -96,7 +113,7 @@ export function createGame() {
selectionBox: null,
groupDrag: null,
pan: null,
view: { x: 0, y: 0 },
view: { x: 0, y: 0, scale: 1 },
hover: null,
chicks: [],
effects: [],
@ -118,10 +135,12 @@ export function createGame() {
lastExplodedComponent: new Map(),
contractOffer: null,
contractActive: null,
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null,
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + GRID.rows * GRID.cell + 84, target: null, repairedToday: 0 },
manualCombo: { count: 0, lastBonus: 0 },
pendingRepairWorkerWages: 0,
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + BALANCE.grid.rows * GRID.cell + 84, target: null, repairedToday: 0 },
stats: newTurnStats(),
lastResult: null,
totals: newTotalStats()
@ -153,34 +172,34 @@ function facilityBodyForEntry(id, 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: 5 }, 'left'),
trash: facilityBodyForEntry('trash', { col: 10, row: 12 }, 'bottom'),
truck: facilityBodyForEntry('truck', { col: 17, row: 12 }, 'bottom')
// Initial grid is 20 columns × 10 rows. Machine bodies sit outside the starter grid.
mixer: facilityBodyForEntry('mixer', { col: 0, row: 4 }, 'left'),
trash: facilityBodyForEntry('trash', { col: 12, row: 9 }, 'bottom'),
truck: facilityBodyForEntry('truck', { col: 18, row: 9 }, 'bottom')
};
}
const INITIAL_CONVEYOR_BACKBONE = Object.freeze([
// Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it.
[4, 1], [4, 2], [4, 3], [4, 4],
// Egg Farm -> S1 top input. The S1/S2 rows were moved down so unrelated
// belt streams keep at least one blank tile between side-by-side flows.
[1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [8, 2], [8, 3],
// S1 left output -> Mixer receiver on the left grid edge.
[3, 5], [2, 5], [1, 5], [0, 5],
[7, 4], [6, 4], [5, 4], [4, 4], [3, 4], [2, 4], [1, 4], [0, 4],
// S1 right output -> S2 top input.
[5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5],
[11, 6], [11, 7],
[9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5], [15, 6],
// S2 left output -> Waste Shredder receiver on bottom grid edge.
[10, 8], [10, 9], [10, 10], [10, 11], [10, 12],
[14, 7], [13, 7], [12, 7], [12, 8], [12, 9],
// S2 right output -> Truck receiver on the bottom grid edge.
[12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12]
[16, 7], [17, 7], [18, 7], [18, 8], [18, 9]
]);
const INITIAL_SCANNERS = Object.freeze([
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 5, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' },
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, row: 8, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 8, row: 4, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard', keys: { left: { key: 'a', code: 'KeyA', label: 'A' }, right: { key: 'd', code: 'KeyD', label: 'D' } } },
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 15, row: 7, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard', keys: { left: { key: 'arrowleft', code: 'ArrowLeft', label: 'Left' }, right: { key: 'arrowright', code: 'ArrowRight', label: 'Right' } } }
]);
const INITIAL_EGG_FARMS = Object.freeze([
{ type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
{ type: 'eggFarm', id: 1, col: 1, row: 0, level: 1, extraEggOutlet: 0, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
]);
function addConveyorTile(game, col, row, options = {}) {
@ -193,11 +212,34 @@ function addConveyorTile(game, col, row, options = {}) {
builtSession: null,
uses: 0,
durability: BALANCE.maintenance.durability.conveyor,
maintenanceType: 'conveyor'
maintenanceType: 'conveyor',
dir: options.dir || null,
outDirs: Array.isArray(options.outDirs) ? [...options.outDirs] : []
});
} else if (options.dir || options.outDirs) {
const meta = game.conveyorMeta.get(k);
if (options.dir) meta.dir = options.dir;
if (Array.isArray(options.outDirs)) meta.outDirs = [...new Set([...(meta.outDirs || []), ...options.outDirs])];
}
}
function ensureConveyorMeta(game, col, row) {
const k = key(col, row);
if (!game.conveyorMeta.has(k)) addConveyorTile(game, col, row);
return game.conveyorMeta.get(k);
}
function markInitialConveyorDirection(game, from, to) {
const dir = directionNameBetweenCells(from, to);
if (!dir) return;
const fromMeta = ensureConveyorMeta(game, from.col, from.row);
fromMeta.outDirs = Array.isArray(fromMeta.outDirs) ? fromMeta.outDirs : [];
if (!fromMeta.outDirs.includes(dir)) fromMeta.outDirs.push(dir);
fromMeta.dir = dir;
const toMeta = ensureConveyorMeta(game, to.col, to.row);
if (!toMeta.dir) toMeta.dir = dir;
}
function scannerPortCells(scanner) {
return [
{ col: scanner.col, row: scanner.row - 1 },
@ -231,7 +273,9 @@ function protectedInitialCells(game) {
}
function installInitialConveyorBackbone(game) {
for (const [col, row] of INITIAL_CONVEYOR_BACKBONE) addConveyorTile(game, col, row);
const cells = INITIAL_CONVEYOR_BACKBONE.map(([col, row]) => ({ col, row }));
for (const { col, row } of cells) addConveyorTile(game, col, row);
for (let i = 0; i < cells.length - 1; i += 1) markInitialConveyorDirection(game, cells[i], cells[i + 1]);
}
function scrubBlockedCellsFromInitialBackbone(game) {
@ -240,12 +284,27 @@ function scrubBlockedCellsFromInitialBackbone(game) {
}
export function resetLayout(game) {
GRID.x = BALANCE.grid.x;
GRID.y = BALANCE.grid.y;
GRID.cols = BALANCE.grid.cols;
GRID.rows = BALANCE.grid.rows;
game.gridCols = GRID.cols;
game.gridRows = GRID.rows;
resetOwnedCells(game);
game.gridOwnedCells = game.ownedCells.size;
if (game.repairman) {
game.repairman.x = GRID.x + GRID.cell * 0.5;
game.repairman.y = GRID.y + GRID.rows * GRID.cell + 84;
game.repairman.target = null;
game.repairman.active = false;
game.repairman.hiredForNextDay = false;
}
game.conveyorTiles.clear();
game.conveyorMeta.clear();
game.blockedCells = new Set();
installInitialConveyorBackbone(game);
game.facilities = defaultFacilities();
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, queue: [] }));
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [] }));
game.eggFarms = INITIAL_EGG_FARMS.map(farm => {
const nextSpawn = nextSpawnDelay(farm);
return { ...farm, nextSpawn, lastInterval: nextSpawn };

View file

@ -1,4 +1,4 @@
import { AUTO_SCANNER_COOLDOWN, BUILD_TOOL_IDS, FACILITY_DEFS, VERSION } from './config.js';
import { AUTO_SCANNER_COOLDOWN, FACILITY_DEFS, VERSION } from './config.js';
import { yen } from './utils.js';
export const TEXT = {
@ -10,6 +10,7 @@ export const TEXT = {
build: 'Build',
running: 'Sorting',
clearing: 'Clearing',
loanDecision: 'Emergency Loan',
gameover: 'Game Over'
},
actions: {
@ -52,10 +53,6 @@ export function equipmentName(id) {
return FACILITY_DEFS[id]?.name || String(id || '').replace(/^[a-z]/, c => c.toUpperCase());
}
export function shortEquipmentName(id) {
return FACILITY_DEFS[id]?.shortName || equipmentName(id);
}
export function buildToolPriceText(id) {
const def = FACILITY_DEFS[id];
if (!def) return '';
@ -83,10 +80,6 @@ export function buildToolButtonHtml(id) {
return `<strong>${equipmentName(id)}</strong><span>${buildToolPriceText(id)}</span>${flavor ? `<small class="tool-flavor">${flavor}</small>` : ''}`;
}
export function buildToolButtonIds() {
return BUILD_TOOL_IDS;
}
export function routeLabel(dest) {
return TEXT.routeLabels[dest] || String(dest).toUpperCase();
}

View file

@ -21,6 +21,18 @@ export function pointToCell(x, y) {
}
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 directionNameBetweenCells(a, b) {
if (!a || !b) return null;
const dc = Math.sign((b.col ?? 0) - (a.col ?? 0));
const dr = Math.sign((b.row ?? 0) - (a.row ?? 0));
if (Math.abs((b.col ?? 0) - (a.col ?? 0)) + Math.abs((b.row ?? 0) - (a.row ?? 0)) !== 1) return null;
if (dc === 1 && dr === 0) return 'right';
if (dc === -1 && dr === 0) return 'left';
if (dc === 0 && dr === 1) return 'down';
if (dc === 0 && dr === -1) return 'up';
return null;
}
export function lerp(a, b, t) { return a + (b - a) * t; }
export function hexToRgb(hex) {
const s = hex.replace('#', '');

View file

@ -1,15 +1,16 @@
import { TURN_SECONDS, THEME } from './core/config.js';
import { GRID, TURN_SECONDS, THEME } from './core/config.js';
import { buildToolButtonHtml } from './core/text.js';
import { createGame, resetLayout, newTurnStats } from './core/state.js';
import { clamp, pointToCell, cellCenter, yen } from './core/utils.js';
import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js';
import { expansionLots, lotBounds } from './core/gridExpansion.js';
import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js';
import { applyRescueLoanIfNeeded, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
import { undo, redo } from './systems/history.js';
import { drawAll } from './render/draw.js';
import { createBuildSystem } from './systems/buildSystem.js';
import { createUISystem } from './systems/uiSystem.js';
import { createChickSystem } from './systems/chickSystem.js';
import { conveyorSpeedForGame, createCardSystem } from './systems/cards.js';
import { CARD_DEFS, conveyorSpeedForGame, createCardSystem, debugGrantCard } from './systems/cards.js';
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js';
import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
@ -19,13 +20,25 @@ 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'),
money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'),
eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'),
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'),
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
hoverTooltip: document.getElementById('hoverTooltip'),
manualScannerMonitor: document.getElementById('manualScannerMonitor'),
hireRepairmanButton: document.getElementById('hireRepairmanButton'),
expandGridButton: document.getElementById('expandGridButton'),
debug: {
panel: document.getElementById('debugPanel'),
infiniteCash: document.getElementById('debugInfiniteCash'),
dayInput: document.getElementById('debugDayInput'),
setDay: document.getElementById('debugSetDayButton'),
cardSelect: document.getElementById('debugCardSelect'),
grantCard: document.getElementById('debugGrantCardButton'),
grantAllCards: document.getElementById('debugGrantAllCardsButton'),
readout: document.getElementById('debugReadout')
},
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')
@ -48,6 +61,15 @@ function initializeStaticText() {
}
}
function initializeDebugPanel() {
if (!ui.debug?.panel) return;
if (ui.debug.cardSelect) {
ui.debug.cardSelect.innerHTML = CARD_DEFS
.map(card => `<option value="${card.id}">${card.title}</option>`)
.join('');
}
}
const game = createGame();
let build;
let uiSystem;
@ -62,13 +84,60 @@ function currentConveyorSpeed(conveyorKey = null) {
function startGame() {
Object.assign(game, createGame());
game.phase = 'running';
game.view = { x: 0, y: 0 };
game.view = { x: 0, y: 0, scale: 1 };
resetLayout(game);
resetCameraToFactoryStart();
ensureMaintenanceState(game);
uiSystem.hideModal();
chicks.updateCongestion();
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout();
}
function debugSetDay() {
const day = Math.max(1, Math.floor(Number(ui.debug.dayInput?.value) || 1));
game.turn = day;
if (ui.debug.dayInput) ui.debug.dayInput.value = String(day);
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `DAY ${day}`, THEME.green);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(`Day set to ${day}.`);
}
function debugGrantSelectedCard() {
const id = ui.debug.cardSelect?.value;
const result = debugGrantCard(game, id);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(result.reason);
}
function debugGrantAllCards() {
const results = CARD_DEFS.map(card => debugGrantCard(game, card));
const ok = results.filter(result => result.ok).length;
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(`Granted ${ok}/${CARD_DEFS.length} cards.`);
}
function applyDebugState() {
if (ui.debug?.infiniteCash?.checked && game.cash < 999999) {
game.cash = 999999;
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
}
}
function updateDebugReadout(message = '') {
if (!ui.debug?.readout) return;
ui.debug.readout.textContent = [
message,
`D${game.turn}`,
yen(game.cash),
`Combo ${game.manualCombo?.count || 0}`,
`Rescue ${game.cardEffects?.rescueLoanCharges || 0}`,
`Chem ${game.cardEffects?.chemicalWeaponSubsidy || 0}`
].filter(Boolean).join(' | ');
}
function startNextTurn() {
@ -87,8 +156,8 @@ function startNextTurn() {
ensureMaintenanceState(game);
commitFactoryGraphForDay(game);
activateRepairmanForDay(game);
const zundaBasisCash = game.cash;
const zunda = collectZundaTax(game, zundaBasisCash);
const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const zunda = collectZundaTax(game, zundaBasisProfit);
const loanRepayment = collectLoanRepayments(game);
game.lastStartFees = { zundaTax: zunda.tax || 0, loanRepayment: loanRepayment.amount || 0, fairiesTribute: 0 };
if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger);
@ -106,8 +175,17 @@ function startNextTurn() {
game.floatingTexts = [];
game.shake = { time: 0, strength: 0 };
game.truckCargo = [];
game.view = { x: 0, y: 0 };
resetCameraToFactoryStart();
game.stats = newTurnStats();
game.stats.zundaTax = 0;
game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0;
game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0;
const carriedStartCosts = game.stats.loanRepayment + game.stats.repairWorkerWages;
if (carriedStartCosts > 0) {
game.stats.penalty += carriedStartCosts;
game.stats.profit -= carriedStartCosts;
}
game.pendingRepairWorkerWages = 0;
for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; }
for (const farm of game.eggFarms) { farm.nextSpawn = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; }
game.buildTool = null;
@ -139,17 +217,33 @@ function completeTurn() {
const ship = settleTruckRevenue(game);
const contract = resolveContract(game);
const chemical = collectChemicalWeaponSubsidy(game);
if (ship.adjusted > 0) {
const truck = game.facilities.truck;
const x = truck ? truck.x + truck.w / 2 : canvas.width / 2 - game.view.x;
const y = truck ? truck.y + 26 : 96 - game.view.y;
floating(game, x, y, `${ship.target === 'poop' ? 'POOP SHIP' : 'SHIP'} +${yen(ship.adjusted)}`, THEME.green);
}
if (contract?.bonus > 0) floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `CONTRACT +${yen(contract.bonus)}`, THEME.green);
if (chemical.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `CHEM SUBSIDY +${yen(chemical.amount)}`, THEME.green);
clearActiveContract(game);
const settlementDay = Math.max(1, (game.totals.turnsCompleted || 0) + 1);
const tribute = collectFairiesTribute(game, settlementDay);
game.lastBuildFees = { fairiesTribute: tribute.amount || 0, reduction: tribute.reduction || 0, turn: settlementDay };
if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger);
const rescue = applyRescueLoanIfNeeded(game);
if (rescue.amount > 0) floating(game, canvas.width / 2 - game.view.x, 176 - game.view.y, `RESCUE +${yen(rescue.amount)}`, THEME.green);
game.totals.turnsCompleted += 1;
game.lastResult = { ...game.stats, ship, contract, chemical, rescue, fairiesTribute: tribute, turn: game.turn, cash: game.cash };
if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
game.lastResult = { ...game.stats, ship, contract, chemical, rescue: { amount: 0, used: 0, remaining: game.cardEffects?.rescueLoanCharges || 0 }, fairiesTribute: tribute, turn: game.turn, cash: game.cash };
if (game.cash < 0) {
if ((game.cardEffects?.rescueLoanCharges || 0) > 0) {
game.phase = 'loanDecision';
uiSystem.showEmergencyLoanDecision(useEmergencyLoan, acceptGameOver);
return;
}
game.phase = 'gameover'; uiSystem.showGameOver(); return;
}
enterBuildPhase();
}
function enterBuildPhase() {
game.phase = 'build';
game.truckCargo = [];
game.chicks = [];
@ -174,6 +268,24 @@ function completeTurn() {
uiSystem.showTurnResult(game.lastResult);
}
function useEmergencyLoan() {
if ((game.cardEffects?.rescueLoanCharges || 0) <= 0) return acceptGameOver();
game.cardEffects.rescueLoanCharges -= 1;
game.stats.rescueLoan = (game.stats.rescueLoan || 0) + 500;
game.totals.rescueLoan = (game.totals.rescueLoan || 0) + 500;
const amount = applyRevenue(game, 500);
game.lastResult = { ...(game.lastResult || {}), ...game.stats, rescue: { amount: ((game.lastResult?.rescue?.amount || 0) + amount), used: ((game.lastResult?.rescue?.used || 0) + 1), remaining: game.cardEffects.rescueLoanCharges }, cash: game.cash };
floating(game, canvas.width / 2 - game.view.x, 176 - game.view.y, `RESCUE +${yen(amount)}`, THEME.green);
if (game.cash >= 0) enterBuildPhase();
else if ((game.cardEffects?.rescueLoanCharges || 0) > 0) { game.phase = 'loanDecision'; uiSystem.showEmergencyLoanDecision(useEmergencyLoan, acceptGameOver); }
else acceptGameOver();
}
function acceptGameOver() {
game.phase = 'gameover';
uiSystem.showGameOver();
}
function closeFarmShutters() {
if (game.shutdownTimeLeft <= 0) game.shutdownTimeLeft = game.shutdownGraceSeconds || 10;
for (const farm of game.eggFarms) {
@ -191,21 +303,96 @@ function update(timestamp) {
if (!game.lastTimestamp) game.lastTimestamp = timestamp;
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
game.lastTimestamp = timestamp;
applyDebugState();
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
chicks.updateCongestion();
drawAll(ctx, canvas, game, { activeQueuedChick: chicks.activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle });
uiSystem.updateUI();
updateManualScannerMonitor();
updateDebugReadout();
requestAnimationFrame(update);
}
// 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 viewScale() { return game.view?.scale || 1; }
function ensureView() {
if (!game.view) game.view = { x: 0, y: 0, scale: 1 };
if (game.view.scale == null) game.view.scale = 1;
}
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 canvasPoint(event) { const p = rawCanvasPoint(event); const s = viewScale(); return { x: (p.x - (game.view?.x || 0)) / s, y: (p.y - (game.view?.y || 0)) / s }; }
function worldToScreen(p) { const s = viewScale(); return { x: p.x * s + (game.view?.x || 0), y: p.y * s + (game.view?.y || 0) }; }
function cameraWorldBounds() {
const bounds = {
left: GRID.x - 90,
top: GRID.y - 130,
right: GRID.x + GRID.cols * GRID.cell + 260,
bottom: GRID.y + GRID.rows * GRID.cell + 220
};
if (game.phase === 'build') {
for (const lot of expansionLots(game)) {
const b = lotBounds(lot);
bounds.left = Math.min(bounds.left, b.x - 60);
bounds.top = Math.min(bounds.top, b.y - 80);
bounds.right = Math.max(bounds.right, b.x + b.w + 80);
bounds.bottom = Math.max(bounds.bottom, b.y + b.h + 80);
}
}
return bounds;
}
function normalizedClampRange(min, max) {
if (min <= max) return [min, max];
const mid = (min + max) / 2;
return [mid, mid];
}
function clampCamera() {
ensureView();
const s = viewScale();
const b = cameraWorldBounds();
const [minX, maxX] = normalizedClampRange(canvas.width - 160 - b.right * s, 110 - b.left * s);
const [minY, maxY] = normalizedClampRange(canvas.height - 150 - b.bottom * s, 150 - b.top * s);
game.view.x = clamp(game.view.x, minX, maxX);
game.view.y = clamp(game.view.y, minY, maxY);
}
function resetCameraToFactoryStart() {
ensureView();
game.view.scale = 1;
const farm = game.eggFarms?.[0];
const anchor = farm ? cellCenter(farm.col, farm.row) : { x: GRID.x, y: GRID.y };
// Keep the initial EGG and the left-side Mixer fully visible. The previous
// anchor placed the EGG at 150,150, which clipped the Mixer body at the left edge.
game.view.x = 280 - anchor.x;
game.view.y = 190 - anchor.y;
clampCamera();
}
function startPan(event) { const p = rawCanvasPoint(event); ensureView(); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; }
function updatePan(event) {
if (!game.pan) return;
const p = rawCanvasPoint(event);
ensureView();
game.view.x = game.pan.viewX + p.x - game.pan.start.x;
game.view.y = game.pan.viewY + p.y - game.pan.start.y;
clampCamera();
}
function zoomAt(event) {
event.preventDefault();
ensureView();
const raw = rawCanvasPoint(event);
const before = canvasPoint(event);
const factor = Math.exp(-event.deltaY * 0.0012);
const nextScale = clamp(viewScale() * factor, 0.55, 1.8);
game.view.scale = nextScale;
game.view.x = raw.x - before.x * nextScale;
game.view.y = raw.y - before.y * nextScale;
clampCamera();
hideHoverTooltip();
}
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 objectFromHit(hit) {
if (!hit) return null;
@ -226,8 +413,9 @@ function showHoverTooltip(event, hit) {
if (!obj) return hideHoverTooltip();
const title = build.selectedTitle(obj);
const flavor = build.flavorText ? build.flavorText(obj) : '';
const warning = build.disconnectedWarningFor ? build.disconnectedWarningFor(obj) : '';
const lines = build.selectedInfoLines(obj).slice(0, 7);
ui.hoverTooltip.innerHTML = `<strong>${title}</strong>${flavor ? `<em>${flavor}</em>` : ''}${lines.map(line => `<span>${line}</span>`).join('')}`;
ui.hoverTooltip.innerHTML = `<strong>${title}</strong>${warning ? `<em class="warning">${warning}</em>` : (flavor ? `<em>${flavor}</em>` : '')}${lines.map(line => `<span>${line}</span>`).join('')}`;
const x = Math.min(window.innerWidth - 330, event.clientX + 18);
const y = Math.min(window.innerHeight - 220, event.clientY + 18);
ui.hoverTooltip.style.left = `${Math.max(8, x)}px`;
@ -250,9 +438,119 @@ function clickSelect(event) {
game.selected = sel;
game.multiSelected = [sel];
uiSystem.updatePanels();
const obj = build.selectedObject();
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
}
function activeQueuedForScanner(scanner) {
const id = scanner?.queue?.[0];
return id == null ? null : game.chicks.find(chick => chick.id === id) || null;
}
function chickDisplay(chick) {
if (!chick) return '<span class="scanner-current empty">WAIT</span>';
const label = chick.sex === 'poop' ? '💩 POOP' : chick.sex === 'male' ? '♂ MALE' : '♀ FEMALE';
return `<span class="scanner-current ${chick.sex}">${label}</span>`;
}
function miniScannerGrid(scanner, chick) {
const farmCells = new Set(game.eggFarms.map(farm => key(farm.col, farm.row)));
const scannerCells = new Map(game.scanners.map(s => [key(s.col, s.row), s]));
const facilityEntryCells = new Set(Object.values(game.facilities || {}).map(f => f.entry ? key(f.entry.col, f.entry.row) : null).filter(Boolean));
const activeSex = chick?.sex || '';
const activeLabel = activeSex === 'poop' ? '💩' : activeSex === 'male' ? '♂' : activeSex === 'female' ? '♀' : '';
const cells = [];
for (let row = scanner.row - 2; row <= scanner.row + 2; row += 1) {
for (let col = scanner.col - 2; col <= scanner.col + 2; col += 1) {
const k = key(col, row);
const classes = ['mini-cell'];
let label = '';
if (col === scanner.col && row === scanner.row) {
classes.push('scanner');
if (activeSex) classes.push(activeSex);
label = activeLabel || 'S';
} else if (row === scanner.row - 1 && col === scanner.col) {
classes.push('input');
label = 'IN';
} else if (farmCells.has(k)) {
classes.push('egg');
label = 'E';
} else if (scannerCells.has(k)) {
classes.push('scanner-other');
label = 'S';
} else if (facilityEntryCells.has(k)) {
classes.push('port');
label = 'P';
} else if (game.conveyorTiles.has(k)) {
classes.push('belt');
label = '•';
} else if (game.blockedCells?.has?.(k)) {
classes.push('blocked');
label = '×';
}
cells.push(`<span class="${classes.join(' ')}">${label}</span>`);
}
}
return `<div class="scanner-mini-grid" aria-label="scanner vicinity">${cells.join('')}</div>`;
}
function scannerIsOffscreen(scanner) {
const screen = worldToScreen(cellCenter(scanner.col, scanner.row));
const margin = 76;
return screen.x < -margin || screen.y < -margin || screen.x > canvas.width + margin || screen.y > canvas.height + margin;
}
function updateManualScannerMonitor() {
if (!ui.manualScannerMonitor) return;
if (game.phase !== 'running') {
ui.manualScannerMonitor.classList.remove('visible');
ui.manualScannerMonitor.innerHTML = '';
return;
}
const scanners = game.scanners.filter(scanner => scanner.kind === 'manual' && scannerIsOffscreen(scanner));
if (!scanners.length) {
ui.manualScannerMonitor.classList.remove('visible');
ui.manualScannerMonitor.innerHTML = '';
return;
}
ui.manualScannerMonitor.innerHTML = scanners.map(scanner => {
const chick = activeQueuedForScanner(scanner);
const left = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left');
const right = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right');
const leftDest = scanner.role === 0 ? 'MIXER' : 'WASTE';
const rightDest = scanner.role === 0 ? 'S2' : 'TRUCK';
const disabled = chick ? '' : ' disabled';
return `
<section class="scanner-monitor-card">
<header><strong>S${(scanner.slot ?? 0) + 1}</strong><span>OFFSCREEN / Q:${scanner.queue.length}</span></header>
<div class="scanner-mini-map">
<span>IN</span><b>${chickDisplay(chick)}</b><span>SCANNER</span>
</div>
${miniScannerGrid(scanner, chick)}
<div class="scanner-monitor-actions">
<button type="button" data-scanner-id="${scanner.id}" data-side="left"${disabled}>${escapeHtml(left)} → ${leftDest}</button>
<button type="button" data-scanner-id="${scanner.id}" data-side="right"${disabled}>${escapeHtml(right)} → ${rightDest}</button>
</div>
</section>`;
}).join('');
ui.manualScannerMonitor.classList.add('visible');
}
ui.manualScannerMonitor?.addEventListener('click', event => {
const button = event.target.closest('button[data-scanner-id]');
if (!button || button.disabled) return;
const scanner = game.scanners.find(s => s.id === Number(button.dataset.scannerId));
if (!scanner) return;
chicks.sortScanner(scanner, button.dataset.side);
updateManualScannerMonitor();
});
canvas.addEventListener('contextmenu', event => event.preventDefault());
canvas.addEventListener('wheel', zoomAt, { passive: false });
canvas.addEventListener('pointerdown', event => {
if (game.phase !== 'build') return;
canvas.setPointerCapture(event.pointerId);
@ -260,8 +558,14 @@ canvas.addEventListener('pointerdown', event => {
if (event.button === 2) { startPan(event); return; }
if (event.button !== 0) return;
if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; }
const expansionOffer = build.expansionOfferAtPoint?.(world);
if (expansionOffer) { build.buyGridExpansion(expansionOffer); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); return; }
if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; }
if (game.buildTool) {
if (game.buildTool === 'conveyor') {
build.beginConveyorDrag(pointToCell(world.x, world.y));
return;
}
const hit = build.equipmentAtPoint(world);
if (hit) { game.buildTool = null; clickSelect(event); return; }
build.buildAtPoint(world);
@ -276,7 +580,7 @@ canvas.addEventListener('pointermove', event => {
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 === 'conveyor' && (event.buttons & 1)) build.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y));
if (game.buildTool === 'erase' && (event.buttons & 1)) build.eraseAtPoint(canvasPoint(event));
});
canvas.addEventListener('pointerup', event => {
@ -285,11 +589,12 @@ canvas.addEventListener('pointerup', event => {
if (!game.groupDrag.committed) clickSelect(event);
build.finishGroupDrag();
}
if (game.buildTool === 'conveyor') build.endConveyorDrag?.();
game.groupDrag = null; game.pan = null;
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
});
canvas.addEventListener('pointerleave', hideHoverTooltip);
canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); });
canvas.addEventListener('pointercancel', () => { build?.endConveyorDrag?.(); game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); });
document.addEventListener('pointerdown', event => {
if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return;
if (event.target.closest('#modal .modal-card')) return;
@ -299,8 +604,30 @@ document.addEventListener('pointerdown', event => {
ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right'));
ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
ui.hireRepairmanButton?.addEventListener('click', hireRepairman);
ui.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(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.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 (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } });
ui.expandGridButton?.addEventListener('click', () => { build?.buyGridExpansion?.('right'); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); });
ui.debug.setDay?.addEventListener('click', debugSetDay);
ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard);
ui.debug.grantAllCards?.addEventListener('click', debugGrantAllCards);
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } });
function scannerKeyMatches(scanner, side, event) {
const binding = scanner.keys?.[side];
if (!binding) return false;
return (binding.code && event.code === binding.code) || (binding.key && event.key.toLowerCase() === binding.key);
}
window.addEventListener('keydown', event => {
if (event.repeat) return;
const name = event.key.toLowerCase();
if (game.phase === 'running') {
for (const scanner of game.scanners.filter(s => s.kind === 'manual')) {
if (scannerKeyMatches(scanner, 'left', event)) { event.preventDefault(); chicks.sortScanner(scanner, 'left'); return; }
if (scannerKeyMatches(scanner, 'right', event)) { event.preventDefault(); chicks.sortScanner(scanner, 'right'); return; }
}
}
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); }
});
@ -308,4 +635,4 @@ build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () =>
chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() });
cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() });
uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick });
initializeStaticText(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);
initializeStaticText(); initializeDebugPanel(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); updateDebugReadout(); requestAnimationFrame(update);

View file

@ -1,106 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v27.0</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app">
<section id="gameShell" class="game-shell" aria-label="Game screen">
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
<div class="hud hud-top-left" aria-label="Run status">
<div class="hud-card cash"><span>CASH</span><strong id="money">¥250</strong></div>
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">¥0</strong></div>
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
</div>
<div class="hud hud-top-right" aria-label="Day result details">
<div class="hud-card"><span>MIXER</span><strong id="mixerCount">0</strong></div>
<div class="hud-card"><span>TRUCK F</span><strong id="truckFemaleCount">0</strong></div>
<div class="hud-card"><span>WRONG</span><strong id="truckMaleCount">0</strong></div>
<div class="hud-card"><span>POOP</span><strong id="poopCount">0</strong></div>
</div>
<div class="priority-strip" aria-label="Important day info">
<div id="eventBrief" class="event-brief">NO ACTIVE EVENT</div>
<div id="targetBrief" class="target-brief">TRUCK TARGET: FEMALE</div>
<div id="speedBrief" class="speed-brief">BELT: 52 px/s</div>
</div>
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v27.0</span></h1>
<p>Build, drag, connect at least one valid EGG route, then start the next day.</p>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
</div>
<div class="panel-grid">
<section class="equipment-section">
<h2>Add / Edit Equipment</h2>
<div class="large-tools">
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 2.25s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</span></button>
<button id="hireRepairmanButton" class="tool-button repair" type="button"><strong>Hire Repairman</strong><span>¥100 / next day</span></button>
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
</div>
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
</section>
<section class="contract-section">
<h2>Irregular One-Day Event</h2>
<div id="contractPanel" class="contract-card empty">Irregular forced events appear during Build phase.</div>
</section>
<section class="rules-section compact-rules">
<h2>Status</h2>
<div id="turnSummary" class="mini-box"></div>
</section>
</div>
</div>
<div class="control-dock" aria-label="Sorting controls">
<button id="scanner1MixerButton" class="sort-button" type="button">S1 A -> Mixer</button>
<button id="scanner1TruckButton" class="sort-button" type="button">S1 D -> S2</button>
<button id="scanner2MixerButton" class="sort-button" type="button">S2 -> Waste</button>
<button id="scanner2TruckButton" class="sort-button" type="button">S2 -> Truck</button>
</div>
<div id="hoverTooltip" class="hover-tooltip" aria-hidden="true"></div>
<details id="debugPanel" class="debug-panel">
<summary>DBG</summary>
<div class="debug-body">
<label><input id="debugInfiniteCash" type="checkbox" /> Infinite cash</label>
<label>Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
<button id="debugSetDayButton" type="button">Set Day</button>
<label>Upgrade <select id="debugUpgradeSelect"></select></label>
<button id="debugApplyUpgradeButton" type="button">Free Buy</button>
<div id="debugObservations" class="debug-readout"></div>
<div id="debugEventLog" class="debug-readout event-log"></div>
</div>
</details>
</section>
</main>
<div id="modal" class="modal visible">
<div class="modal-card">
<h2 id="modalTitle">Chick Sorter</h2>
<div id="modalBody"></div>
<div id="modalActions" class="modal-actions"></div>
</div>
</div>
<script type="module" src="./game.js?v=27.0-combo-settlement"></script>
</body>
</html>

View file

@ -1,6 +1,7 @@
import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.js';
import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js';
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
import { cardTargetBounds } from '../systems/cards.js';
import { wearRatio } from '../systems/maintenance.js';
@ -42,13 +43,17 @@ export function drawAll(ctx, canvas, game, helpers) {
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);
const scale = game.view?.scale || 1;
ctx.translate((game.view?.x || 0) + sx, (game.view?.y || 0) + sy);
ctx.scale(scale, scale);
drawGrid(ctx, game);
drawExpansionOffers(ctx, game);
drawConveyors(ctx, game);
drawScannerConnectors(ctx, game);
drawFacilities(ctx, game);
drawEggFarms(ctx, game);
drawScanners(ctx, game);
drawDisconnectedWarnings(ctx, game);
drawChicks(ctx, game, helpers.activeQueuedChick);
drawRepairman(ctx, game);
drawCardTargetOverlay(ctx, canvas, game);
@ -60,11 +65,46 @@ export function drawAll(ctx, canvas, game, helpers) {
drawCanvasHints(ctx, game, canvas);
}
function warningBounds(item) {
if (item.type === 'eggFarm') {
const c = cellCenter(item.ref.col, item.ref.row);
return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y };
}
const f = item.ref;
return { x: f.x, y: f.y, w: f.w, h: f.h, cx: f.x + f.w / 2, cy: f.y + f.h / 2 };
}
function drawDisconnectedWarnings(ctx, game) {
if (game.phase !== 'build') return;
const warnings = disconnectedBuildWarnings(game);
for (const item of warnings) {
const b = warningBounds(item);
ctx.save();
ctx.fillStyle = 'rgba(196,36,36,.22)';
ctx.strokeStyle = THEME.danger;
ctx.lineWidth = 5;
ctx.setLineDash([10, 6]);
rect(ctx, b.x, b.y, b.w, b.h, true, true);
ctx.setLineDash([]);
ctx.fillStyle = THEME.danger;
ctx.strokeStyle = THEME.white;
ctx.lineWidth = 4;
ctx.font = '900 16px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.strokeText('!', b.cx, b.y + 24);
ctx.fillText('!', b.cx, b.y + 24);
ctx.font = '900 11px ui-monospace, monospace';
ctx.strokeText('NO ROUTE', b.cx, b.y + b.h - 8);
ctx.fillText('NO ROUTE', b.cx, b.y + b.h - 8);
ctx.restore();
}
}
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();
ctx.save(); ctx.font = '900 12px 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;
@ -81,22 +121,38 @@ function drawBackground(ctx, canvas) {
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 ownedCellKeys(game) {
if (game.ownedCells?.size) return [...game.ownedCells];
const cells = [];
for (let row = 0; row < GRID.rows; row += 1) for (let col = 0; col < GRID.cols; col += 1) cells.push(key(col, row));
return cells;
}
function drawGrid(ctx, game) {
ctx.save();
const cells = ownedCellKeys(game);
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 (const k of cells) {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
rect(ctx, c.x - GRID.cell / 2, c.y - GRID.cell / 2, GRID.cell, GRID.cell, true, true);
}
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.strokeStyle = 'rgba(16,32,21,.16)';
ctx.lineWidth = 3;
for (const k of cells) {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
const x = c.x - GRID.cell / 2, y = c.y - GRID.cell / 2;
if (!isOwnedCell(game, p.col - 1, p.row)) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + GRID.cell); ctx.stroke(); }
if (!isOwnedCell(game, p.col + 1, p.row)) { ctx.beginPath(); ctx.moveTo(x + GRID.cell, y); ctx.lineTo(x + GRID.cell, y + GRID.cell); ctx.stroke(); }
if (!isOwnedCell(game, p.col, p.row - 1)) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + GRID.cell, y); ctx.stroke(); }
if (!isOwnedCell(game, p.col, p.row + 1)) { ctx.beginPath(); ctx.moveTo(x, y + GRID.cell); ctx.lineTo(x + GRID.cell, y + GRID.cell); ctx.stroke(); }
}
for (const k of game.blockedCells || []) {
const p = parseKey(k);
if (!isOwnedCell(game, p.col, p.row)) continue;
const c = cellCenter(p.col, p.row);
ctx.fillStyle = 'rgba(16,32,21,.30)';
ctx.strokeStyle = 'rgba(16,32,21,.60)';
@ -106,6 +162,40 @@ function drawGrid(ctx, game) {
ctx.restore();
}
function drawExpansionOffers(ctx, game) {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return;
const cost = expansionCost(game);
const affordable = game.cash >= cost;
for (const lot of expansionLots(game)) {
const b = lotBounds(lot);
ctx.save();
ctx.fillStyle = affordable ? 'rgba(183,245,198,.34)' : 'rgba(255,255,255,.22)';
ctx.strokeStyle = affordable ? THEME.green : THEME.muted;
ctx.lineWidth = 5;
ctx.setLineDash([16, 10]);
rect(ctx, b.x, b.y, b.w, b.h, true, true);
ctx.setLineDash([]);
const button = expansionButtonBounds(lot);
const cx = button.cx;
const cy = button.cy;
ctx.fillStyle = 'rgba(255,255,255,.94)';
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 4;
rect(ctx, button.x, button.y, button.w, button.h, true, true);
ctx.fillStyle = affordable ? THEME.green : THEME.danger;
ctx.font = '900 16px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(lot.direction === 'right' ? 'BUY RIGHT LOT' : 'BUY UPPER LOT', cx, cy - 12);
ctx.fillStyle = THEME.ink;
ctx.font = '900 16px ui-monospace, monospace';
ctx.fillText(`${yen(cost)} / 10×10`, cx, cy + 15);
ctx.fillStyle = THEME.muted;
ctx.font = '900 11px ui-monospace, monospace';
ctx.fillText('blocked tiles included', cx, cy + 30);
ctx.restore();
}
}
function drawDirtOverlay(ctx, x, y, w, h, target, radius = 0) {
const wear = wearRatio(target);
if (wear < 0.50) return;
@ -200,8 +290,22 @@ function addMarkerFromRoute(game, markers, route) {
if (!list.some(x => Math.abs(Math.sin((x - angle) / 2)) < 0.01)) list.push(angle);
}
}
function explicitConveyorAngles(meta) {
if (!meta) return [];
const names = [];
if (Array.isArray(meta.outDirs)) names.push(...meta.outDirs);
if (meta.dir) names.push(meta.dir);
return [...new Set(names)]
.map(name => DIRS.find(d => d.name === name)?.angle)
.filter(angle => Number.isFinite(angle));
}
function collectConveyorDirectionMarkers(game) {
const markers = new Map();
for (const [k, meta] of game.conveyorMeta || []) {
const angles = explicitConveyorAngles(meta);
if (angles.length) markers.set(k, angles);
}
for (const farm of game.eggFarms) addMarkerFromRoute(game, markers, routeFromFarmToScanner(game, farm)?.route);
for (const scanner of game.scanners) {
for (const side of ['left', 'right']) addMarkerFromRoute(game, markers, outputRoute(game, side, scannerCenter(scanner), scanner.id)?.route);
@ -230,7 +334,7 @@ function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) {
function drawScannerConnectors(ctx, game) {
ctx.save();
ctx.lineWidth = 2;
ctx.font = '900 10px ui-monospace, monospace';
ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'center';
for (const scanner of game.scanners) {
const outputs = scannerOutputs(scanner);
@ -258,13 +362,18 @@ function drawEggFarm(ctx, farm, game) {
ctx.save();
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) {
drawDirtOverlay(ctx, c.x - 29, c.y - 29, 58, 58, farm);
const outlets = 1 + Math.max(0, Number(farm.extraEggOutlet) || 0);
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x + 9, c.y + 12, 30, 18, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`O${outlets}`, c.x + 24, c.y + 24);
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
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);
ctx.fillStyle = THEME.green; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4);
const outlets = 1 + Math.max(0, Number(farm.extraEggOutlet) || 0);
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.fillText(`L${farm.level} O${outlets}`, 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;
@ -272,7 +381,7 @@ function drawEggFarm(ctx, farm, game) {
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); }
if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); }
}
drawDirtOverlay(ctx, c.x - 28, c.y - 28, 56, 56, farm);
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
@ -282,6 +391,17 @@ function drawScanners(ctx, game) { for (const s of game.scanners) drawScanner(ct
function drawScanner(ctx, scanner, game) {
const c = scannerCenter(scanner);
ctx.save();
if (scanner.kind === 'manual' && (game.manualCombo?.count || 0) >= 10) {
const combo = game.manualCombo.count;
ctx.save();
ctx.globalAlpha = Math.min(0.55, 0.16 + combo / 120);
ctx.strokeStyle = combo >= 30 ? THEME.warn : THEME.green;
ctx.lineWidth = Math.min(14, 5 + Math.floor(combo / 10));
ctx.shadowColor = ctx.strokeStyle;
ctx.shadowBlur = Math.min(28, 8 + combo);
rect(ctx, c.x - 60, c.y - 44, 120, 88, false, true);
ctx.restore();
}
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) {
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 54, c.y - 38, 108, 76, scanner);
@ -293,19 +413,21 @@ function drawScanner(ctx, scanner, game) {
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.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center';
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 14);
ctx.font = '900 9px ui-monospace, monospace';
ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 3);
ctx.font = '900 11px ui-monospace, monospace';
const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left');
const rightKey = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right');
ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y + 3);
const q = scanner.queue.length;
if (scanner.kind === 'manual') {
drawManualKeyboardIcon(ctx, scanner, c);
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 8px ui-monospace, monospace';
ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`Q:${q}`, c.x, c.y + 32);
} else {
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 10px ui-monospace, monospace';
ctx.font = '900 12px ui-monospace, monospace';
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 25);
drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner);
}
@ -319,6 +441,8 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
const labels = slot === 0
? [{ side: 'left', text: 'A' }, { side: 'right', text: 'D' }]
: [{ side: 'left', text: '←' }, { side: 'right', text: '→' }];
labels[0].text = scanner.keys?.left?.label || labels[0].text;
labels[1].text = scanner.keys?.right?.label || labels[1].text;
const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null;
const baseX = c.x - 30;
const baseY = c.y + 8;
@ -327,7 +451,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
ctx.fillStyle = '#f7fff5';
ctx.strokeStyle = THEME.ink;
rect(ctx, baseX - 4, baseY - 4, 68, 25, true, true);
ctx.font = '900 13px ui-monospace, monospace';
ctx.font = '900 19px ui-monospace, monospace';
ctx.textAlign = 'center';
for (let i = 0; i < labels.length; i += 1) {
const keyDef = labels[i];
@ -346,7 +470,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
ctx.fillText(keyDef.text, x + 14, y + 13);
}
ctx.fillStyle = THEME.muted;
ctx.font = '900 6px ui-monospace, monospace';
ctx.font = '900 8px ui-monospace, monospace';
ctx.fillText('KEYBOARD', c.x, baseY - 7);
ctx.restore();
}
@ -366,12 +490,12 @@ function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
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.font = '900 12px 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.font = '900 10px ui-monospace, monospace';
ctx.fillText(subtitle, x + 34, y + 25);
}
ctx.restore();
@ -396,7 +520,7 @@ function drawFacilityReceiver(ctx, game, id) {
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.font = '900 10px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillStyle = THEME.ink;
ctx.fillText(info.title, c.x, c.y + 20);
@ -433,8 +557,8 @@ function drawMixer(ctx, game) {
drawExternalDuct(ctx, m);
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m); drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true);
ctx.fillStyle = THEME.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(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48);
ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48);
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(); }
@ -448,7 +572,7 @@ function drawTrash(ctx, game) {
drawExternalDuct(ctx, t);
if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
ctx.fillStyle = THEME.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);
ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25);
for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); }
drawTargetBadge(ctx, t.x + 14, t.y + t.h - 42, t.w - 28, 'SEND POOP', 'poop', 'shredder');
drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t);
@ -461,7 +585,7 @@ function drawTruck(ctx, game) {
drawExternalDuct(ctx, t);
if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
ctx.fillStyle = THEME.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.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + t.w / 2, t.y + 42);
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');
@ -515,7 +639,7 @@ 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); }
if (warning) { ctx.fillStyle = THEME.danger; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('!', x, y + 15); }
ctx.restore();
}
function selectionToken(type, id) { return `${type}:${id}`; }
@ -536,7 +660,7 @@ function drawSelection(ctx, game, type, id, x, y, w, h) {
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();
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 11px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore();
return;
}
// Single equipment inspection is handled by the DOM hover tooltip.
@ -551,7 +675,8 @@ function drawCardTargetOverlay(ctx, canvas, game) {
// so every non-candidate object is reliably dimmed even after panning.
ctx.fillStyle = 'rgba(0,0,0,.66)';
ctx.beginPath();
ctx.rect(-game.view.x, -game.view.y, canvas.width, canvas.height);
const scale = game.view?.scale || 1;
ctx.rect(-(game.view?.x || 0) / scale, -(game.view?.y || 0) / scale, canvas.width / scale, canvas.height / scale);
for (const item of items) {
const b = item.bounds;
const pad = 8;
@ -563,11 +688,11 @@ function drawCardTargetOverlay(ctx, canvas, game) {
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3;
rect(ctx, 260 - game.view.x, 96 - game.view.y, 420, 42, true, true);
rect(ctx, (260 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 420 / scale, 42 / scale, true, true);
ctx.fillStyle = THEME.danger;
ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(isBlockedCellMode ? 'NO BLOCKED CELL. PRESS ESC.' : 'NO VALID UPGRADE TARGET. PRESS ESC.', 470 - game.view.x, 122 - game.view.y);
ctx.fillText(isBlockedCellMode ? 'NO BLOCKED CELL. PRESS ESC.' : 'NO VALID UPGRADE TARGET. PRESS ESC.', (470 - (game.view?.x || 0)) / scale, (122 - (game.view?.y || 0)) / scale);
ctx.restore();
return;
}
@ -576,11 +701,11 @@ function drawCardTargetOverlay(ctx, canvas, game) {
ctx.lineWidth = 3;
const remaining = Math.max(1, game.cardTargetPick?.remaining || 1);
const msg = isBlockedCellMode ? `CLICK BLOCKED CELL: ${remaining} LEFT. PRESS ESC TO CANCEL.` : 'CLICK AN UPGRADEABLE MACHINE. PRESS ESC TO CANCEL.';
rect(ctx, 216 - game.view.x, 96 - game.view.y, 560, 42, true, true);
rect(ctx, (216 - (game.view?.x || 0)) / scale, (96 - (game.view?.y || 0)) / scale, 560 / scale, 42 / scale, true, true);
ctx.fillStyle = THEME.ink;
ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(msg, 496 - game.view.x, 122 - game.view.y);
ctx.fillText(msg, (496 - (game.view?.x || 0)) / scale, (122 - (game.view?.y || 0)) / scale);
ctx.restore();
}
@ -618,7 +743,7 @@ function drawEffects(ctx, game) {
}
}
function drawFloatingTexts(ctx, game) {
ctx.save(); ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center';
ctx.save(); ctx.font = '900 19px 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();
}
@ -649,7 +774,7 @@ function drawRepairman(ctx, game) {
ctx.beginPath(); ctx.moveTo(r.x - 11, r.y + 18); ctx.lineTo(r.x - 20, r.y + 24); ctx.stroke();
ctx.beginPath(); ctx.moveTo(r.x + 11, r.y + 18); ctx.lineTo(r.x + 20, r.y + 24); ctx.stroke();
ctx.fillStyle = THEME.ink;
ctx.font = '900 9px ui-monospace, monospace';
ctx.font = '900 11px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(r.active ? 'FIX' : 'HIRED', r.x, r.y + 33);
if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green);
@ -659,12 +784,12 @@ function drawRepairman(ctx, game) {
function drawCanvasHints(ctx, game, canvas) {
ctx.save();
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
// Visible version marker helps avoid browser/file-cache confusion when testing zips.
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3;
rect(ctx, canvas.width - 284, 18, 260, 34, true, true);
ctx.fillStyle = THEME.green; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText(VERSION, canvas.width - 270, 40);
ctx.font = '900 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink;
ctx.font = '900 19px 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 1 EGG route to any exit`);

View file

@ -1,623 +0,0 @@
: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: 9px; }
.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: 9px; 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: 9px; 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: 12px; 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: 9px; }
.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: 9px; }
.primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; }
.formula-box {
border: 3px solid var(--line);
background: #f7fff5;
padding: 10px;
margin: 10px 0;
line-height: 1.5;
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.equipment-menu-lines {
border: 3px solid var(--line);
background: #ffffff;
padding: 10px;
margin: 8px 0;
color: var(--muted);
line-height: 1.45;
}
.equipment-menu-lines p { margin: 4px 0; }
/* v11 compact clicked-equipment bubble */
.modal.equipment-popover {
background: transparent;
align-items: flex-start;
justify-content: flex-start;
padding: 0;
pointer-events: none;
}
.modal.equipment-popover .modal-card {
position: absolute;
left: var(--popover-x, 16px);
top: var(--popover-y, 16px);
width: min(250px, calc(100vw - 24px));
max-height: min(280px, calc(100vh - 24px));
overflow: auto;
padding: 8px;
border-width: 3px;
pointer-events: auto;
box-shadow: 6px 6px 0 rgba(16,32,21,.18);
}
.modal.equipment-popover .modal-card h2 {
font-size: 14px;
margin-bottom: 6px;
}
.modal.equipment-popover .modal-actions {
margin-top: 8px;
gap: 6px;
justify-content: flex-start;
}
.modal.equipment-popover .modal-actions button {
padding: 5px 7px;
font-size: 9px;
}
.equipment-menu-lines.compact {
padding: 7px;
margin: 6px 0;
font-size: 9px;
}
.formula-box.compact {
padding: 7px;
margin: 6px 0;
font-size: 9px;
}
/* v15 pre-build card draft */
.card-choices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 220px));
justify-content: center;
align-items: stretch;
gap: 10px;
margin-top: 14px;
}
.card-choice {
min-height: 132px;
border: 3px solid var(--line);
background: #f7fff5;
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.16);
padding: 12px;
text-align: left;
cursor: pointer;
font-weight: 900;
}
.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); }
.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; }
.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; }
.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; }
.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); }
.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); }
.card-choice.ultra-rare span { background: #c42424; color: #fff; }
.muted-card-note { color: var(--muted); font-weight: 700; }
.card-targets { display: grid; gap: 8px; margin-top: 14px; }
.card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
.card-target-button:hover { background: var(--green-soft); }
/* v19 readability pass */
body { font-size: 16px; }
.hud-card span { font-size: 11px; }
.hud-card strong { font-size: clamp(18px, 1.5vw, 25px); }
.priority-strip > div { font-size: 14px; }
.panel-head h1 { font-size: 18px; }
.panel-head p { font-size: 11px; }
.build-panel h2 { font-size: 13px; }
.tool-button strong { font-size: 14px; }
.tool-button span { font-size: 11px; }
.mini-box, .facility-panel-empty, .facility-card, .contract-card { font-size: 12px; }
.facility-card h3 { font-size: 14px; }
.sort-button { font-size: 14px; }
.modal-card h2 { font-size: 29px; }
.card-choice strong { font-size: 16px; }
.card-choice small { font-size: 13px; }
.tool-button.unaffordable, .tool-button.already-built { opacity: .42; filter: grayscale(.7); }
.hover-tooltip {
position: fixed;
z-index: 30;
width: min(320px, calc(100vw - 20px));
display: none;
border: 3px solid var(--line);
background: rgba(255,255,255,.96);
color: var(--ink);
box-shadow: 6px 6px 0 rgba(16,32,21,.18);
padding: 10px;
pointer-events: none;
line-height: 1.35;
}
.hover-tooltip.visible { display: block; }
.hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; }
.hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; }
.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; }
.debug-panel {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 12;
border: 3px solid var(--line);
background: rgba(255,255,255,.94);
box-shadow: 4px 4px 0 rgba(16,32,21,.16);
max-width: 330px;
}
.debug-panel summary {
cursor: pointer;
padding: 5px 8px;
font-weight: 900;
font-size: 12px;
user-select: none;
}
.debug-body {
display: grid;
gap: 7px;
padding: 8px;
border-top: 3px solid var(--line);
font-size: 12px;
}
.debug-body label { display: grid; gap: 3px; font-weight: 900; color: var(--muted); }
.debug-body input, .debug-body select, .debug-body button {
border: 2px solid var(--line);
background: var(--white);
color: var(--ink);
font: inherit;
font-weight: 900;
padding: 5px 6px;
}
.debug-body button { cursor: pointer; background: var(--green-soft); }
.card-choice.dud {
background: #eeeeee;
color: #777;
filter: grayscale(1);
border-style: dashed;
box-shadow: 5px 5px 0 rgba(16,32,21,.10);
}
.card-choice.dud span { background: #f8f8f8; }
.card-choice.flung {
pointer-events: none;
transform: translate(var(--fling-x, 480px), var(--fling-y, -320px)) rotate(var(--fling-r, 68deg)) scale(.68);
opacity: 0;
transition: transform .34s cubic-bezier(.18,.84,.26,.99), opacity .28s ease-in;
}
/* v20 Phase 1 observability */
.debug-readout {
border: 2px solid var(--line);
background: rgba(247,255,245,.92);
padding: 6px;
display: grid;
gap: 3px;
line-height: 1.25;
}
.debug-readout strong {
display: block;
color: var(--ink);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
}
.debug-readout span {
display: block;
color: var(--muted);
font-size: 11px;
overflow-wrap: anywhere;
}
.debug-readout.event-log {
max-height: 160px;
overflow: auto;
}
.route-card .route-icon.male,
.route-card .route-icon.female,
.route-card .route-icon.poop {
display: inline-grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 999px;
border: 3px solid var(--line, #102015);
background: var(--white, #fff);
line-height: 1;
}
.route-card .route-icon.male { color: #2477ff; background: #d9ecff; }
.route-card .route-icon.female { color: #d62a85; background: #ffe0f0; }
.route-card .route-icon.poop { color: #7b4a23; background: #fff0d1; font-size: 24px; }
.restart-button { min-width: 160px; }
.restart-fallback-note { margin-top: 12px; color: var(--muted, #526456); font-weight: 700; }
/* v24.4 hover-only build-button flavor / cashout guidance / compact game-over */
.tool-button .tool-flavor {
display: block;
max-height: 0;
opacity: 0;
overflow: hidden;
margin-top: 0;
color: var(--muted);
font-size: 8px;
line-height: 1.22;
text-transform: none;
letter-spacing: 0;
transition: max-height .12s ease, opacity .12s ease, margin-top .12s ease;
}
.tool-button:hover .tool-flavor {
max-height: 42px;
opacity: 1;
margin-top: 5px;
}
.large-tools .tool-button { min-height: 58px; }
.large-tools .tool-button:hover { min-height: 78px; }
.history-tools .tool-button.small { min-height: 40px; }
.primary-button.cashout-emphasis,
.tool-button.cashout-emphasis {
opacity: 1 !important;
filter: none !important;
animation: cashoutPulse 0.9s ease-in-out infinite alternate;
}
.primary-button.cashout-emphasis {
box-shadow: inset 0 0 0 4px #fff0d1, 6px 6px 0 rgba(16,32,21,.22);
}
.tool-button.cashout-emphasis {
background: #fff0d1;
box-shadow: inset 0 0 0 3px var(--danger), 6px 6px 0 rgba(16,32,21,.20);
}
@keyframes cashoutPulse {
from { transform: translate(0, 0); }
to { transform: translate(-1px, -1px); }
}
.modal.gameover-modal { padding: 12px; }
.modal.gameover-modal .modal-card {
width: min(640px, 92vw);
max-height: min(82vh, 720px);
overflow: auto;
padding: 16px;
}
.modal.gameover-modal .modal-card h2 {
font-size: 22px;
margin-bottom: 8px;
}
.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; }
.gameover-summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
.gameover-breakdown {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
.modal.gameover-modal .result-grid div { padding: 7px; }
.modal.gameover-modal .result-grid strong { font-size: 9px; margin-bottom: 3px; }
.modal.gameover-modal .result-grid span { font-size: 12px; }
.gameover-details {
margin-top: 10px;
border: 3px solid var(--line);
background: #f7fff5;
padding: 8px;
}
.gameover-details summary {
cursor: pointer;
font-weight: 900;
text-transform: uppercase;
}
.modal.gameover-modal .formula-box.compact { font-size: 9px; line-height: 1.35; }
@media (max-width: 720px) {
.gameover-summary, .gameover-breakdown { grid-template-columns: 1fr 1fr; }
}
/* v26.0 readability and stable build-menu tooltip pass */
body { font-size: 18px; }
.build-panel { width: clamp(330px, 26vw, 420px); }
.panel-head h1 { font-size: 20px; }
.panel-head h1 span { font-size: 12px; }
.panel-head p { font-size: 13px; line-height: 1.35; }
.build-panel h2 { font-size: 14px; }
.priority-strip > div { font-size: 15px; }
.hud-card span { font-size: 12px; }
.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); }
.mini-box, .contract-card, .facility-panel-empty, .facility-card { font-size: 14px; line-height: 1.45; }
.compact-rules .mini-box { font-size: 13px; }
.tool-button {
position: relative;
overflow: visible;
min-height: 62px;
padding: 10px;
}
.large-tools .tool-button { min-height: 62px; }
.large-tools .tool-button:hover { min-height: 62px; }
.tool-button strong { font-size: 15px; line-height: 1.15; }
.tool-button span { font-size: 12px; }
.tool-button .tool-flavor {
display: none;
position: absolute;
z-index: 60;
left: 0;
top: calc(100% + 7px);
width: min(300px, calc(100vw - 40px));
max-height: none;
opacity: 1;
overflow: visible;
margin: 0;
padding: 10px 12px;
border: 3px solid var(--line);
background: rgba(255,255,255,.98);
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
font-size: 13px;
line-height: 1.35;
text-transform: none;
letter-spacing: 0;
pointer-events: none;
transition: none;
}
.tool-button .tool-flavor::before {
content: '';
position: absolute;
left: 16px;
top: -10px;
width: 16px;
height: 16px;
border-left: 3px solid var(--line);
border-top: 3px solid var(--line);
background: rgba(255,255,255,.98);
transform: rotate(45deg);
}
.tool-button:hover .tool-flavor {
display: block;
max-height: none;
opacity: 1;
margin: 0;
}
.primary-button { font-size: 16px; padding: 13px 16px; }
#nextTurnButton { min-height: 56px; font-size: 18px; }
@keyframes cashoutPulse {
from { box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); filter: brightness(1); }
to { box-shadow: inset 0 0 0 5px #fff0d1, 4px 4px 0 rgba(16,32,21,.20); filter: brightness(1.08); }
}
.card-choices { grid-template-columns: repeat(auto-fill, minmax(230px, 270px)); gap: 14px; }
.card-choice { min-height: 164px; padding: 15px; }
.card-choice strong { font-size: 18px; }
.card-choice span { font-size: 11px; }
.card-choice small { font-size: 15px; line-height: 1.4; }
.muted-card-note { font-size: 15px; line-height: 1.45; }
.modal-card { width: min(980px, 96vw); }
.modal-card p, .modal-card li { font-size: 16px; }
.modal-actions .reroll-button {
order: 99;
flex: 1 1 100%;
min-height: 64px;
margin-top: 8px;
font-size: 20px;
text-align: center;
justify-content: center;
background: #fff0d1;
box-shadow: inset 0 0 0 4px var(--green), 6px 6px 0 rgba(16,32,21,.20);
}
.modal-actions .reroll-button:disabled { box-shadow: 4px 4px 0 rgba(16,32,21,.12); }
.hover-tooltip strong { font-size: 17px; }
.hover-tooltip span { font-size: 14px; }
.sort-button { font-size: 16px; min-height: 56px; }
.control-dock { grid-template-columns: repeat(4, 172px); }
@media (max-width: 1180px) {
.build-panel { width: auto; }
.control-dock { grid-template-columns: repeat(2, 172px); }
}
/* v27 combo / tags / settlement clarity */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0 0 8px;
}
.card-tags i {
display: inline-block;
border: 2px solid var(--line);
background: rgba(255,255,255,.86);
color: var(--ink);
font-style: normal;
font-size: 10px;
font-weight: 900;
padding: 1px 6px;
letter-spacing: .05em;
}
.settlement-hero {
border: 4px solid var(--line);
background: #f7fff5;
padding: 14px;
margin-bottom: 12px;
box-shadow: 5px 5px 0 rgba(16,32,21,.14);
}
.settlement-hero.positive { box-shadow: inset 0 0 0 4px var(--green), 5px 5px 0 rgba(16,32,21,.14); }
.settlement-hero.negative { box-shadow: inset 0 0 0 4px var(--danger), 5px 5px 0 rgba(16,32,21,.14); }
.settlement-hero strong { display: block; font-size: 34px; line-height: 1; color: var(--green); }
.settlement-hero.negative strong { color: var(--danger); }
.settlement-hero span { display: block; margin-top: 8px; font-weight: 900; color: var(--muted); }
.settlement-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.settlement-grid div {
border: 3px solid var(--line);
background: rgba(255,255,255,.9);
padding: 9px;
}
.settlement-grid strong { display: block; font-size: 11px; text-transform: uppercase; color: var(--muted); }
.settlement-grid span { display: block; margin-top: 5px; font-size: 20px; font-weight: 900; color: var(--ink); }
.settlement-table {
width: 100%;
border-collapse: collapse;
border: 3px solid var(--line);
background: #fff;
margin: 10px 0;
}
.settlement-table td {
border-bottom: 2px solid rgba(16,32,21,.18);
padding: 7px 9px;
font-size: 15px;
font-weight: 900;
}
.settlement-table td:last-child { text-align: right; }
.settlement-table tr.income td:last-child { color: var(--green); }
.settlement-table tr.cost td:last-child { color: var(--danger); }
.settlement-notes {
display: grid;
gap: 5px;
border: 3px solid var(--line);
background: #f7fff5;
padding: 10px;
color: var(--muted);
font-weight: 800;
}
.key-config-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin: 10px 0;
}
.key-config-grid label {
display: grid;
gap: 4px;
font-weight: 900;
color: var(--muted);
}
.key-config-grid input,
.key-config-grid select {
border: 3px solid var(--line);
background: var(--white);
color: var(--ink);
padding: 7px 8px;
font: inherit;
font-weight: 900;
}
.modal.rescue-modal .modal-card {
width: min(560px, 92vw);
}
.hover-tooltip .cash-negative { color: var(--danger) !important; font-weight: 900; }
@media (max-width: 720px) {
.settlement-grid { grid-template-columns: 1fr 1fr; }
.settlement-hero strong { font-size: 26px; }
}

View file

@ -1,9 +1,12 @@
import { BALANCE } from '../core/balance.js';
import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
import { getSpawnRange } from '../core/state.js';
import { createEggFarm, createScanner, createFacility } from '../core/entities.js';
import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js';
import { generateBlockedCellsInRect } from '../core/mapGen.js';
import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js';
import { key, parseKey, pointToCell, cellCenter, yen, directionNameBetweenCells } from '../core/utils.js';
import { TEXT, equipmentName } from '../core/text.js';
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js';
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js';
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { autoScannerCooldownSeconds } from './cards.js';
import { record } from './history.js';
@ -14,8 +17,95 @@ import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScanne
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 isBlockedCell(col, row) { return game.blockedCells?.has?.(key(col, row)); }
let lastConveyorBuildCell = null;
let conveyorDragRecordedDirectionEdit = false;
function gridExpansionCost() { return expansionCost(game); }
function syncGridSizeToGame() {
game.gridCols = GRID.cols;
game.gridRows = GRID.rows;
game.gridOwnedCells = countOwnedCells(game);
}
function shiftWorldRowsDown(rowDelta) {
if (!rowDelta) return;
GRID.y -= rowDelta * GRID.cell;
GRID.rows += rowDelta;
game.gridChunkOriginY = (game.gridChunkOriginY || 0) - Math.floor(rowDelta / 10);
game.ownedCells = shiftRowIndexedSet(game.ownedCells, rowDelta);
game.blockedCells = shiftRowIndexedSet(game.blockedCells, rowDelta);
game.conveyorTiles = shiftRowIndexedSet(game.conveyorTiles, rowDelta);
game.conveyorMeta = shiftRowIndexedMap(game.conveyorMeta, rowDelta);
for (const farm of game.eggFarms || []) farm.row += rowDelta;
for (const scanner of game.scanners || []) scanner.row += rowDelta;
for (const facility of Object.values(game.facilities || {})) {
if (facility.entry) facility.entry.row += rowDelta;
}
}
function addOwnedLotCells(lot) {
game.ownedCells = game.ownedCells || new Set();
for (const cell of cellsInLot(lot)) game.ownedCells.add(key(cell.col, cell.row));
}
function expansionOfferAtPoint(p) {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null;
return expansionLots(game).find(lot => lot && lotContainsPoint(lot, p)) || null;
}
function resolveExpansionRequest(request = 'right') {
if (request && typeof request === 'object' && Number.isFinite(request.chunkX) && Number.isFinite(request.chunkY)) return request;
const direction = ['right', 'up'].includes(request) ? request : 'right';
return expansionLot(game, direction);
}
function buyGridExpansion(request = 'right') {
const offer = resolveExpansionRequest(request);
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return { ok: false, reason: 'Cannot expand now.' };
if (!offer) return { ok: false, reason: 'Invalid expansion lot.' };
const direction = offer.direction || 'right';
const cost = gridExpansionCost();
if (game.cash < cost) {
const reason = `Need ${yen(cost - game.cash)} more.`;
fail(reason);
return { ok: false, reason };
}
record(game);
spendCash(game, cost);
if (!game.gridExpansionPurchasesByDirection) game.gridExpansionPurchasesByDirection = { right: 0, up: 0 };
const originY = game.gridChunkOriginY || 0;
if (offer.chunkY < originY) {
const chunksToAdd = originY - offer.chunkY;
shiftWorldRowsDown(chunksToAdd * (BALANCE.grid.expansion?.rowsPerPurchase || 10));
}
const lot = lotFromChunk(game, offer.chunkX, offer.chunkY, direction);
if (lot.colStart + lot.cols > GRID.cols) GRID.cols = lot.colStart + lot.cols;
if (lot.rowStart + lot.rows > GRID.rows) GRID.rows = lot.rowStart + lot.rows;
addOwnedLotCells(lot);
ownChunk(game, lot.chunkX, lot.chunkY);
game.gridExpansionPurchases = (game.gridExpansionPurchases || 0) + 1;
game.gridExpansionPurchasesByDirection[direction] = (game.gridExpansionPurchasesByDirection[direction] || 0) + 1;
syncGridSizeToGame();
const blocked = generateBlockedCellsInRect({
seed: `${game.runSeed}:grid-expansion:${lot.chunkX},${lot.chunkY}:${game.gridExpansionPurchases}`,
colStart: lot.colStart,
colEnd: lot.colStart + lot.cols,
rowStart: lot.rowStart,
rowEnd: lot.rowStart + lot.rows,
targetRatio: BALANCE.map.blockedRatio
});
for (const k of blocked) game.blockedCells.add(k);
refreshRoutingAfterEdit(game);
const addedCells = lot.cols * lot.rows;
const b = lotBounds(lot);
floating(game, b.x + b.w / 2, b.y + b.h / 2, `${direction.toUpperCase()} +${addedCells} CELLS -${yen(cost)}`, THEME.green);
updatePanels();
return { ok: true, direction, cost, addedCells, blocked: blocked.size, chunkX: lot.chunkX, chunkY: lot.chunkY };
}
function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows && isOwnedCell(game, col, row); }
function isBlockedCell(col, row) { return !isOwnedCell(game, col, row) || game.blockedCells?.has?.(key(col, row)); }
function rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; }
function rectsOverlap(a, b, margin = 10) {
@ -88,33 +178,138 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return '';
}
function canSellHit(hit) {
return !lastProtectedSaleReason(hit);
}
function fail(reason) {
ui.buildStatus.textContent = reason;
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger);
}
function conveyorMetaForCell(cell) {
if (!cell) return null;
const k = key(cell.col, cell.row);
return game.conveyorMeta.get(k) || null;
}
function normalizeOutDirs(meta) {
if (!meta) return [];
if (!Array.isArray(meta.outDirs)) meta.outDirs = [];
meta.outDirs = [...new Set(meta.outDirs.filter(dir => ['right', 'left', 'down', 'up'].includes(dir)))];
return meta.outDirs;
}
function markConveyorDirection(from, to) {
const dir = directionNameBetweenCells(from, to);
if (!dir) return false;
const fromKey = key(from.col, from.row);
const toKey = key(to.col, to.row);
const fromMeta = game.conveyorMeta.get(fromKey);
const toMeta = game.conveyorMeta.get(toKey);
if (!fromMeta || !toMeta) return false;
const outDirs = normalizeOutDirs(fromMeta);
const changed = !outDirs.includes(dir) || fromMeta.dir !== dir || !toMeta.dir;
if (!outDirs.includes(dir)) outDirs.push(dir);
fromMeta.dir = dir;
if (!toMeta.dir) toMeta.dir = dir;
return changed;
}
function buildConveyorCell(cell, incomingDir = null) {
if (!cell) return { ok: false, reason: TEXT.fail.outOfGrid };
const { col, row } = cell;
const k = key(col, row);
if (game.conveyorTiles.has(k)) return { ok: false, exists: true };
if (isBlockedCell(col, row)) return { ok: false, reason: 'Cannot build on blocked ground.' };
if (farmAt(game, col, row) || scannerAt(game, col, row)) return { ok: false, reason: TEXT.fail.cellOccupied };
const cost = buildPrice('conveyor', game);
if (game.cash < cost) return { ok: false, reason: TEXT.fail.notEnoughCash };
record(game);
spendCash(game, cost);
game.conveyorTiles.add(k);
const quality = buildQualityPatch();
game.conveyorMeta.set(k, {
price: cost,
builtSession: game.buildSession,
uses: 0,
durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier),
maintenanceType: 'conveyor',
dir: incomingDir || null,
outDirs: [],
...quality
});
game.selected = { type: 'conveyor', id: k };
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
return { ok: true, cell, key: k };
}
function handleConveyorDragCell(cell) {
if (!cell) return fail(TEXT.fail.outOfGrid);
const currentKey = key(cell.col, cell.row);
const exists = game.conveyorTiles.has(currentKey);
const previous = lastConveyorBuildCell;
if (previous && previous.col === cell.col && previous.row === cell.row) return;
if (exists) {
if (previous && markConveyorDirection(previous, cell)) {
if (!conveyorDragRecordedDirectionEdit) {
// Direction edits are intentionally free; record once per drag gesture so undo can restore a major reroute.
record(game);
conveyorDragRecordedDirectionEdit = true;
}
refreshRoutingAfterEdit(game);
}
game.selected = { type: 'conveyor', id: currentKey };
lastConveyorBuildCell = { col: cell.col, row: cell.row };
return;
}
const incomingDir = previous ? directionNameBetweenCells(previous, cell) : null;
const result = buildConveyorCell(cell, incomingDir);
if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
if (previous) markConveyorDirection(previous, cell);
lastConveyorBuildCell = { col: cell.col, row: cell.row };
refreshRoutingAfterEdit(game);
}
function stepTowardCell(from, to) {
if (!from || !to) return to;
const dc = to.col - from.col;
const dr = to.row - from.row;
if (dc === 0 && dr === 0) return from;
if (Math.abs(dc) >= Math.abs(dr)) return { col: from.col + Math.sign(dc), row: from.row };
return { col: from.col, row: from.row + Math.sign(dr) };
}
function beginConveyorDrag(cell) {
conveyorDragRecordedDirectionEdit = false;
lastConveyorBuildCell = null;
handleConveyorDragCell(cell);
}
function continueConveyorDrag(cell) {
if (!cell) return;
if (!lastConveyorBuildCell) return handleConveyorDragCell(cell);
let cursor = lastConveyorBuildCell;
let guard = 0;
while ((cursor.col !== cell.col || cursor.row !== cell.row) && guard < 64) {
const next = stepTowardCell(cursor, cell);
handleConveyorDragCell(next);
cursor = lastConveyorBuildCell || next;
guard += 1;
if (!lastConveyorBuildCell) break;
}
}
function endConveyorDrag() {
lastConveyorBuildCell = null;
conveyorDragRecordedDirectionEdit = false;
}
function buildAtCell(cell) {
if (!cell) return fail(TEXT.fail.outOfGrid);
const { col, row } = cell;
if (game.buildTool === 'conveyor') {
if (game.conveyorTiles.has(key(col, row))) return fail(TEXT.fail.cellOccupied);
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail(TEXT.fail.cellOccupied);
const cost = buildPrice('conveyor', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
record(game);
spendCash(game, cost);
const k = key(col, row);
game.conveyorTiles.add(k);
const quality = buildQualityPatch();
game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession, uses: 0, durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier), maintenanceType: 'conveyor', ...quality });
game.selected = { type: 'conveyor', id: k };
const result = buildConveyorCell(cell, null);
if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
refreshRoutingAfterEdit(game);
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
return;
}
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
@ -144,6 +339,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
function buildScanner(col, row, kind) {
if (kind === 'manual' && game.scanners.filter(s => s.kind === 'manual').length >= 8) return fail('Manual Scanner limit reached (8 max).');
const cost = kind === 'auto' ? buildPrice('autoScanner', game) : buildPrice('manualScanner', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
record(game);
@ -281,11 +477,88 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
ui.modal.classList.add('visible', 'equipment-popover');
}
function formatKeyBinding(binding) {
return binding?.label || binding?.key || '?';
}
function bindingFromEvent(event) {
const label = event.key === ' ' ? 'Space' : event.key.length === 1 ? event.key.toUpperCase() : event.key.replace(/^Arrow/, '');
return { key: event.key.toLowerCase(), code: event.code, label };
}
function keyConflict(scanner, side, binding) {
for (const s of game.scanners.filter(item => item.kind === 'manual')) {
for (const candidateSide of ['left', 'right']) {
if (s.id === scanner.id && candidateSide === side) continue;
const existing = s.keys?.[candidateSide];
if (existing && (existing.code === binding.code || existing.key === binding.key)) return true;
}
}
return false;
}
function showManualScannerMenu(scanner, message = '') {
if (!scanner || scanner.kind !== 'manual') return;
ui.modalTitle.textContent = `Manual Scanner #${scanner.id}`;
const left = formatKeyBinding(scanner.keys?.left);
const right = formatKeyBinding(scanner.keys?.right);
const conflict = manualKeyConflicts(scanner);
ui.modalBody.innerHTML = `
<div class="equipment-menu-lines compact">
<p>Left route key: <b>${left}</b></p>
<p>Right route key: <b>${right}</b></p>
<p>Role: ${scanner.role === 0 ? 'Male left / others right' : 'Poop left / others right'}</p>
${message ? `<p class="cash-positive">${message}</p>` : ''}
${conflict ? `<p class="cash-negative">${conflict}</p>` : ''}
</div>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(modalButton('Set Left Key', () => captureManualKey(scanner, 'left'), 'facility-action warn'));
ui.modalActions.appendChild(modalButton('Set Right Key', () => captureManualKey(scanner, 'right'), 'facility-action warn'));
ui.modalActions.appendChild(modalButton('Close', hideModal, 'facility-action'));
positionEquipmentPopover(scanner);
ui.modal.classList.add('visible', 'equipment-popover');
}
function manualKeyConflicts(scanner) {
const own = [
{ side: 'left', binding: scanner.keys?.left },
{ side: 'right', binding: scanner.keys?.right }
];
if (own[0].binding && own[1].binding && (own[0].binding.code === own[1].binding.code || own[0].binding.key === own[1].binding.key)) return 'Left and right keys conflict.';
for (const item of own) {
if (!item.binding) continue;
for (const other of game.scanners.filter(s => s.kind === 'manual' && s.id !== scanner.id)) {
if (other.keys?.left && (other.keys.left.code === item.binding.code || other.keys.left.key === item.binding.key)) return `Key conflicts with Manual Scanner #${other.id} left.`;
if (other.keys?.right && (other.keys.right.code === item.binding.code || other.keys.right.key === item.binding.key)) return `Key conflicts with Manual Scanner #${other.id} right.`;
}
}
return '';
}
function captureManualKey(scanner, side) {
ui.modalBody.innerHTML = `<div class="equipment-menu-lines compact"><p>Press a key for ${side.toUpperCase()} route.</p><p>Esc cancels.</p></div>`;
ui.modalActions.innerHTML = '';
const onKey = event => {
event.preventDefault();
event.stopPropagation();
window.removeEventListener('keydown', onKey, true);
if (event.key === 'Escape') return showManualScannerMenu(scanner);
const binding = bindingFromEvent(event);
const conflict = keyConflict(scanner, side, binding);
record(game);
scanner.keys = scanner.keys || { left: null, right: null };
scanner.keys[side] = binding;
updatePanels();
showManualScannerMenu(scanner, conflict ? `${binding.label} assigned, but another scanner already uses it.` : `${side.toUpperCase()} set to ${binding.label}.`);
};
window.addEventListener('keydown', onKey, true);
}
function switchScannerRole() {
const obj = selectedObject();
if (!obj || obj.type !== 'scanner') return;
if (obj.kind === 'auto') return showAutoScannerMenu(obj);
updatePanels();
return showManualScannerMenu(obj);
}
function selectedPopoverWorldPoint(obj) {
@ -300,8 +573,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function positionEquipmentPopover(obj) {
const rect = canvas.getBoundingClientRect();
const p = selectedPopoverWorldPoint(obj);
const screenX = rect.left + ((p.x + game.view.x) / canvas.width) * rect.width + 18;
const screenY = rect.top + ((p.y + game.view.y) / canvas.height) * rect.height - 12;
const scale = game.view?.scale || 1;
const screenX = rect.left + ((p.x * scale + (game.view?.x || 0)) / canvas.width) * rect.width + 18;
const screenY = rect.top + ((p.y * scale + (game.view?.y || 0)) / canvas.height) * rect.height - 12;
const maxX = Math.max(12, window.innerWidth - 274);
const maxY = Math.max(12, window.innerHeight - 220);
ui.modal.style.setProperty('--popover-x', `${Math.max(12, Math.min(maxX, screenX))}px`);
@ -318,6 +592,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
${['mixer', 'truck'].includes(obj.id) ? '<p class="formula-box compact">Income/Fine = ceil(base × 1.05^upgrades)</p>' : ''}`;
ui.modalActions.innerHTML = '';
if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn'));
if (obj.type === 'scanner' && obj.kind === 'manual') ui.modalActions.appendChild(modalButton('Set Keys', () => showManualScannerMenu(obj), 'facility-action warn'));
const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj };
const saleReason = lastProtectedSaleReason(hit);
if (saleReason) {
@ -335,6 +610,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function setBuildTool(tool) {
if (game.phase !== 'build') return;
endConveyorDrag();
game.buildTool = game.buildTool === tool ? null : tool;
game.selected = null;
game.multiSelected = [];
@ -382,7 +658,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
lines.push(`Cell: ${obj.id}`);
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`);
lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.');
lines.push('Flow follows drawn directions. Branches choose randomly; full branches are avoided when possible.');
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`);
} else if (obj.type === 'facility') {
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
@ -410,6 +686,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return lines;
}
function disconnectedWarningFor(obj) {
if (!obj || game.phase !== 'build') return '';
const match = disconnectedBuildWarnings(game).find(item => item.type === obj.type && item.id === obj.id);
if (!match) return '';
return match.message || 'Not connected to conveyor route';
}
function flavorText(obj) {
if (!obj) return '';
@ -432,8 +715,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
updateGroupDrag: selection.updateGroupDrag,
finishGroupDrag: selection.finishGroupDrag,
selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost,
selectedInfoLines, flavorText,
removeSelected, switchScannerRole,
selectedInfoLines, flavorText, disconnectedWarningFor,
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
removeSelected, switchScannerRole, showManualScannerMenu,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes,
routeFromFarmToScanner

View file

@ -1,4 +1,4 @@
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
import { nextSpawnDelay } from '../core/state.js';
import { cellCenter, key, yen } from '../core/utils.js';
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
@ -11,193 +11,7 @@ const RARE_WEIGHT = CARD_BALANCE.rareWeight;
const ULTRA_RARE_WEIGHT = CARD_BALANCE.ultraRareWeight || 0.45;
const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize;
const DEFAULT_EFFECTS = Object.freeze({
bearing: 0,
legalWork: 0,
fairiesFlatteryNext: 0,
crowdedFarming: 0,
extraEggOutlet: 0,
hatchingFeed: 0,
safetyCover: 0,
recyclingSubsidy: 0,
chemicalWeaponSubsidy: 0,
preventiveMaintenance: 0,
durabilityCoating: 0,
laborExploitation: 0,
usedMachineActive: false,
rescueLoanCharges: 0,
loans: []
});
export const CARD_DEFS = [
{
id: 'upgradeEgg',
title: 'EGG Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'eggFarm',
description: 'Choose one EGG FARM on the map and raise it by 1 level.'
},
{
id: 'upgradeAutoScanner',
title: 'AUTO SCANNER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'autoScanner',
description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.'
},
{
id: 'upgradeMixer',
title: 'MIXER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'mixer',
description: 'Choose MIXER on the map and raise it by 1 level.'
},
{
id: 'upgradeTruck',
title: 'TRUCK Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'truck',
description: 'Choose TRUCK on the map and raise it by 1 level.'
},
{
id: 'upgradeTrash',
title: 'SHREDDER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'trash',
description: 'Choose SHREDDER and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.'
},
{
id: 'crowdedFarming',
title: 'Crowded Farming Contract',
rarity: 'common',
type: 'instant',
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.'
},
{
id: 'hatchingFeed',
title: 'Hatching Feed',
rarity: 'common',
type: 'instant',
description: 'EGG production interval -4%. Poop rate +3%. No card limit.'
},
{
id: 'safetyCover',
title: 'Safety Cover',
rarity: 'common',
type: 'instant',
description: 'Explosion damage -5%. No card limit.'
},
{
id: 'preventiveMaintenance',
title: 'Preventive Maintenance Manual',
rarity: 'common',
type: 'instant',
description: 'All equipment deterioration speed -5%. No card limit.'
},
{
id: 'durabilityCoating',
title: 'Durability Coating',
rarity: 'common',
type: 'instant',
description: 'All equipment maximum durability +3%. No card limit.'
},
{
id: 'bearing',
title: 'High-Quality Bearing',
rarity: 'common',
type: 'instant',
description: 'Conveyor speed +7.5%.'
},
{
id: 'extraEggOutlet',
title: 'Extra Egg Outlet',
rarity: 'rare',
type: 'instant',
description: 'EGG output ports +1. Each connected belt can receive eggs. Max 3 cards.'
},
{
id: 'recyclingSubsidy',
title: 'Recycling Subsidy',
rarity: 'rare',
type: 'instant',
description: 'SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.'
},
{
id: 'dynamite',
title: 'Dynamite',
rarity: 'rare',
type: 'cellAction',
target: 'blockedCell',
description: 'Remove any 3 blocked cells with a burst effect.'
},
{
id: 'usedMachine',
title: 'Used Machines',
rarity: 'rare',
type: 'instant',
description: 'Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.'
},
{
id: 'newMachine',
title: 'New Machines',
rarity: 'common',
type: 'instant',
description: 'Cancel Used Machines and restore normal prices, refunds, and durability.'
},
{
id: 'flattery',
title: 'Flattery',
rarity: 'rare',
type: 'instant',
description: 'Next Fairies tribute -50%.'
},
{
id: 'loan',
title: 'Loan',
rarity: 'rare',
type: 'instant',
description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.'
},
{
id: 'extraCards',
title: 'Extra Cards',
rarity: 'rare',
type: 'instant',
description: 'Draw 2 cards now and choose both.'
},
{
id: 'legalWork',
title: 'Legal Work',
rarity: 'rare',
type: 'instant',
description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.'
},
{
id: 'chemicalWeaponSubsidy',
title: 'Chemical Weapons Subsidy',
rarity: 'ultraRare',
type: 'instant',
description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.'
},
{
id: 'laborExploitation',
title: 'Motivational Exploitation',
rarity: 'ultraRare',
type: 'instant',
description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.'
},
{
id: 'rescueLoan',
title: 'Emergency Loan',
rarity: 'ultraRare',
type: 'instant',
description: 'If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.'
}
];
export const CARD_DEFS = CARD_DEFINITIONS;
function effectCount(game, id) {
const effects = ensureCardState(game);
@ -205,8 +19,8 @@ function effectCount(game, id) {
}
export function ensureCardState(game) {
if (!game.cardEffects) game.cardEffects = { ...DEFAULT_EFFECTS, loans: [] };
for (const [k, v] of Object.entries(DEFAULT_EFFECTS)) {
if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS, loans: [] };
for (const [k, v] of Object.entries(CARD_DEFAULT_EFFECTS)) {
if (game.cardEffects[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v;
}
if (game.cardEffects.flattery != null) {
@ -226,12 +40,8 @@ export function eggProductionDelayMultiplier(game) {
return crowded * feed;
}
export function poopRateCardMultiplier(game) {
return Math.pow(1.03, effectCount(game, 'hatchingFeed'));
}
export function extraEggOutletCount(game) {
return Math.min(3, effectCount(game, 'extraEggOutlet'));
export function extraEggOutletCount(_game, farm = null) {
return Math.min(3, Math.max(0, Number(farm?.extraEggOutlet) || 0));
}
export function conveyorSpeedForGame(game, conveyorKey = null) {
@ -247,13 +57,6 @@ export function autoScannerCooldownSeconds(scanner, game = null) {
return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner)) : base;
}
export function flatteryReductionForTurn(game, turn = game.turn) {
const day = Math.max(1, Math.floor(Number(turn) || 1));
const base = Math.ceil(day * 10);
const charges = Math.max(0, ensureCardState(game).fairiesFlatteryNext || 0);
return charges > 0 ? Math.ceil(base * (1 - Math.pow(0.5, charges))) : 0;
}
export function rerollCost(game) {
const draft = game.cardDraft || { rerolls: 0 };
const nextRerollCount = Math.max(1, (draft.rerolls || 0) + 1);
@ -264,11 +67,48 @@ export function cardById(id) {
return CARD_DEFS.find(card => card.id === id) || null;
}
export function debugGrantCard(game, cardOrId) {
const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId;
ensureCardState(game);
if (!card || card.type === 'dud') return { ok: false, reason: 'Card not found.' };
if (card.id === 'extraCards') {
game.cardDraft = game.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
game.cardDraft.choices.push(...dealCards(game, 2));
return { ok: true, reason: `${card.title} added draft cards.` };
}
if (card.type === 'instant') {
applyInstantCard(game, card);
return { ok: true, reason: `${card.title} granted.` };
}
if (card.type === 'equipmentUpgrade') {
const target = targetsForCard(game, card)[0];
if (!target) return { ok: false, reason: `No valid target for ${card.title}.` };
applyEquipmentUpgrade(game, target);
return { ok: true, reason: `${card.title} applied.` };
}
if (card.type === 'cellAction' && card.target === 'blockedCell') {
const targets = targetsForCard(game, card).slice(0, 3);
if (!targets.length) return { ok: false, reason: 'No blocked cells to remove.' };
for (const target of targets) {
game.blockedCells.delete(key(target.col, target.row));
const c = cellCenter(target.col, target.row);
eraseEffect(game, c.x, c.y);
}
refreshRoutingAfterEdit(game);
return { ok: true, reason: `${card.title} removed ${targets.length} blocked cells.` };
}
return { ok: false, reason: `${card.title} cannot be debug-granted.` };
}
function targetLabel(game, target) {
if (target.type === 'blockedCell') return `Blocked cell ${target.col},${target.row}`;
if (target.type === 'eggFarm') {
return `EGG #${target.id} L${target.level} -> L${target.level + 1}`;
}
if (target.type === 'eggOutlet') {
const held = Math.max(0, Number(target.farm?.extraEggOutlet) || 0);
return `EGG #${target.id} OUTLET ${1 + held} -> ${2 + held}`;
}
if (target.type === 'scanner') {
return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`;
}
@ -311,6 +151,9 @@ export function targetsForCard(game, cardOrId) {
if (!card) return [];
if (card.target === 'blockedCell') return blockedCellTargets(game);
if (card.target === 'eggFarm') return game.eggFarms.filter(f => (f.level || 1) < 4);
if (card.target === 'eggOutlet') return game.eggFarms
.filter(f => Math.max(0, Number(f.extraEggOutlet) || 0) < 3)
.map(f => ({ type: 'eggOutlet', id: f.id, col: f.col, row: f.row, farm: f }));
if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto');
if (card.target === 'mixer') return game.facilities.mixer ? [game.facilities.mixer] : [];
if (card.target === 'truck') return game.facilities.truck ? [game.facilities.truck] : [];
@ -321,16 +164,26 @@ export function targetsForCard(game, cardOrId) {
function cardAtCap(game, card) {
const effects = ensureCardState(game);
if (card.id === 'crowdedFarming') return (effects.crowdedFarming || 0) >= 10;
if (card.id === 'extraEggOutlet') return (effects.extraEggOutlet || 0) >= 3;
if (card.id === 'recyclingSubsidy') return (effects.recyclingSubsidy || 0) >= 3;
if (card.id === 'dudFilter') {
const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
const step = Math.max(0.0001, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0.05));
return (effects.dudFilter || 0) >= Math.ceil(base / step);
}
if (card.id === 'laborExploitation') return !!effects.laborExploitation;
if (card.id === 'usedMachine') return !!effects.usedMachineActive;
if (card.id === 'newMachine') return !effects.usedMachineActive;
return false;
}
function repairmanCardVisible(game) {
const r = game.repairman || {};
return !!(r.active || r.hiredForNextDay || game.pendingRepairWorkerWages > 0);
}
function availableCards(game) {
return CARD_DEFS.filter(card => {
if (card.id === 'laborExploitation' && !repairmanCardVisible(game)) return false;
if (cardAtCap(game, card)) return false;
if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false;
return true;
@ -365,11 +218,18 @@ function dudCard() {
};
}
function applyDudsToChoices(choices) {
function dudChanceForGame(game) {
const effects = ensureCardState(game);
const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
const step = Math.max(0, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0));
return Math.max(0, base - step * Math.max(0, effects.dudFilter || 0));
}
function applyDudsToChoices(game, choices) {
const result = [...choices];
let duds = 0;
const maxDuds = Math.max(0, CARD_BALANCE.maxDudsPerDraft || 0);
const chance = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
const chance = dudChanceForGame(game);
for (let i = 0; i < result.length && duds < maxDuds; i += 1) {
if (Math.random() < chance) {
result[i] = dudCard();
@ -392,13 +252,14 @@ export function dealCards(game, count = BASE_DRAFT_SIZE) {
const i = pool.findIndex(card => card.id === picked.id);
if (i >= 0) pool.splice(i, 1);
}
return applyDudsToChoices(choices);
return applyDudsToChoices(game, choices);
}
function targetKey(target) {
if (!target) return '';
if (target.type === 'blockedCell') return `blockedCell:${target.id}`;
if (target.type === 'eggFarm') return `eggFarm:${target.id}`;
if (target.type === 'eggOutlet') return `eggOutlet:${target.id}`;
if (target.type === 'scanner') return `scanner:${target.id}`;
if (target.type === 'facility') return `facility:${target.id}`;
return `${target.type}:${target.id}`;
@ -410,7 +271,7 @@ function boundsForTarget(target) {
const c = cellCenter(target.col, target.row);
return { x: c.x - GRID.cell * 0.48, y: c.y - GRID.cell * 0.48, w: GRID.cell * 0.96, h: GRID.cell * 0.96, cx: c.x, cy: c.y };
}
if (target.type === 'eggFarm') {
if (target.type === 'eggFarm' || target.type === 'eggOutlet') {
const c = cellCenter(target.col, target.row);
return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y };
}
@ -443,6 +304,15 @@ function targetAtPoint(game, p) {
}
function applyEquipmentUpgrade(game, target) {
if (target.type === 'eggOutlet') {
const farm = target.farm || game.eggFarms.find(f => f.id === target.id);
if (!farm) return;
farm.extraEggOutlet = Math.min(3, Math.max(0, Number(farm.extraEggOutlet) || 0) + 1);
const c = cellCenter(farm.col, farm.row);
floating(game, c.x, c.y - 30, `OUTLET ${1 + farm.extraEggOutlet}`, THEME.green);
refreshRoutingAfterEdit(game);
return;
}
target.level = (target.level || 1) + 1;
if (target.type === 'eggFarm') {
target.nextSpawn = nextSpawnDelay(target);
@ -467,12 +337,12 @@ function applyInstantCard(game, card) {
if (card.id === 'bearing') inc('bearing');
if (card.id === 'legalWork') inc('legalWork');
if (card.id === 'crowdedFarming') effects.crowdedFarming = Math.min(10, (effects.crowdedFarming || 0) + 1);
if (card.id === 'extraEggOutlet') effects.extraEggOutlet = Math.min(3, (effects.extraEggOutlet || 0) + 1);
if (card.id === 'hatchingFeed') inc('hatchingFeed');
if (card.id === 'safetyCover') inc('safetyCover');
if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1);
if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy');
if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance');
if (card.id === 'dudFilter') inc('dudFilter');
if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); }
if (card.id === 'flattery') inc('fairiesFlatteryNext');
if (card.id === 'usedMachine') effects.usedMachineActive = true;
@ -480,13 +350,7 @@ function applyInstantCard(game, card) {
if (card.id === 'laborExploitation') effects.laborExploitation = 1;
if (card.id === 'rescueLoan') {
inc('rescueLoanCharges');
if (game.cash < 0) {
effects.rescueLoanCharges = Math.max(0, (effects.rescueLoanCharges || 0) - 1);
applyRevenue(game, 500);
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, '+¥500 RESCUE', THEME.green);
} else {
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green);
}
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green);
}
if (card.id === 'loan') {
const day = Math.max(1, Math.floor(Number(game.turn) || 1));
@ -497,11 +361,6 @@ function applyInstantCard(game, card) {
}
}
function formatMultiplier(value) {
const rounded = Math.round(Number(value || 0) * 100) / 100;
return `x${rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')}`;
}
function formatPercent(value) {
return `${Math.round(Number(value || 0) * 100)}%`;
}
@ -554,12 +413,24 @@ function cardDescription(game, card) {
const next = Math.min(10, (e.crowdedFarming || 0) + 1);
return `Held ${e.crowdedFarming || 0}/10. After pick: EGG interval ${formatPercent(Math.max(0.5, 1 - 0.05 * next))}, deterioration ${formatPercent(Math.min(1.4, 1 + 0.04 * next))}.`;
}
if (card.id === 'extraEggOutlet') return `Held ${e.extraEggOutlet || 0}/3. After pick: up to ${1 + Math.min(3, (e.extraEggOutlet || 0) + 1)} EGG output ports.`;
if (card.id === 'extraEggOutlet') {
const targets = targetsForCard(game, card);
const held = Math.max(0, Number(targets[0]?.farm?.extraEggOutlet) || 0);
return `Choose one EGG. Output ports ${1 + held} -> ${2 + held}. Max 4 ports per EGG.`;
}
if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`;
if (card.id === 'safetyCover') return `Held ${e.safetyCover || 0}. After pick: explosion damage ${formatPercent(Math.pow(0.95, (e.safetyCover || 0) + 1))}.`;
if (card.id === 'recyclingSubsidy') return `Held ${e.recyclingSubsidy || 0}/3. After pick: normal SELL refund ${Math.round(Math.min(0.8, 0.5 + 0.1 * ((e.recyclingSubsidy || 0) + 1)) * 100)}%.`;
if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(2000 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 100 truck-shipped poop each day.`;
if (card.id === 'preventiveMaintenance') return `Held ${e.preventiveMaintenance || 0}. After pick: deterioration speed ${formatPercent(Math.pow(0.95, (e.preventiveMaintenance || 0) + 1))}.`;
if (card.id === 'dudFilter') {
const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
const step = Math.max(0, Math.min(1, CARD_BALANCE.dudChanceReductionPerFilter || 0.05));
const held = Math.max(0, e.dudFilter || 0);
const before = Math.max(0, base - step * held);
const after = Math.max(0, base - step * (held + 1));
return `Held ${held}. DUD chance per card ${formatPercent(before)} -> ${formatPercent(after)}.`;
}
if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`;
if (card.id === 'flattery') return `Next Fairies tribute -50%. Held for next tribute: ${e.fairiesFlatteryNext || 0}.`;
if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.';
@ -732,7 +603,8 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
const kind = card.type === 'dud' ? 'dud' : (card.rarity === 'ultraRare' ? 'ultra-rare' : (card.rarity === 'rare' ? 'rare' : 'common'));
b.className = `card-choice ${kind}`;
const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'ultraRare' ? 'ULTRA RARE' : (card.rarity === 'rare' ? 'RARE' : 'COMMON'));
b.innerHTML = `<strong>${card.title}</strong><span>${rarity}</span><small>${cardDescription(game, card)}</small>`;
const tags = [rarity, ...(card.tags || []).filter(tag => tag !== rarity)].slice(0, 5);
b.innerHTML = `<strong>${card.title}</strong><div class="card-tags">${tags.map(tag => `<span>${tag}</span>`).join('')}</div><small>${cardDescription(game, card)}</small>`;
b.addEventListener('click', () => chooseCard(card, b));
b.dataset.index = String(index);
return b;

View file

@ -1,9 +1,9 @@
import { GRID, THEME } from '../core/config.js';
import { DIRS, GRID, THEME } from '../core/config.js';
import { createChick } from '../core/entities.js';
import { nextSpawnDelay } from '../core/state.js';
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js';
import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus } from './economy.js';
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid } from '../core/utils.js';
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js';
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js';
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
@ -17,35 +17,23 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function spawnChick(farm) {
const farmCenter = cellCenter(farm.col, farm.row);
const maxOutputs = 1 + extraEggOutletCount(game);
const routes = [];
const usedStarts = new Set();
const maxAttempts = Math.max(maxOutputs * 5, 5);
for (let attempt = 0; attempt < maxAttempts && routes.length < maxOutputs; attempt += 1) {
const data = routeFromFarmToScanner(game, farm, true);
if (!data) break;
const start = data.route?.[0];
if (!start) continue;
const startKey = `${Math.round(start.x)}:${Math.round(start.y)}`;
if (usedStarts.has(startKey) && routes.length > 0) continue;
usedStarts.add(startKey);
routes.push(data);
}
if (!routes.length) {
const maxOutputs = 1 + extraEggOutletCount(game, farm);
const starts = farmOutputStarts(farm, maxOutputs);
if (!starts.length) {
suppressUnconnectedFarmSpawn(farm, farmCenter);
return true;
}
let spawned = 0;
let blocked = 0;
for (const data of routes) {
const start = data.route[0];
for (const startCell of starts) {
const start = cellCenter(startCell.col, startCell.row);
const blocker = spawnTileBlocker(start);
if (blocker) {
blocker.stoppedTimer = 0.85;
blocked += 1;
continue;
}
const chick = createChick(game, data);
const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] });
if (chick.sex === 'poop') countPoopSpawned(game);
game.chicks.push(chick);
recordFarmProduction(game, farm);
@ -66,6 +54,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
}
function farmOutputStarts(farm, limit) {
const starts = DIRS
.map(d => ({ col: farm.col + d.dc, row: farm.row + d.dr }))
.filter(p => inGrid(p.col, p.row) && game.conveyorTiles.has(key(p.col, p.row)));
const scored = starts.map(p => ({ p, blocked: targetBlockedByChick(null, cellCenter(p.col, p.row)) ? 1 : 0, roll: Math.random() }));
scored.sort((a, b) => a.blocked - b.blocked || a.roll - b.roll);
return scored.slice(0, Math.max(1, limit || 1)).map(item => item.p);
}
function spawnTileBlocker(start) {
return game.chicks.find(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null;
@ -138,32 +135,132 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
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);
if (chick.pendingScannerId) {
const scanner = scannerById(game, chick.pendingScannerId);
chick.pendingScannerId = null;
if (!scanner) removeChick(index, 'NO SCANNER');
else 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);
advanceByCurrentConveyor(chick, index);
}
function blockedByFrontChick(chick) {
if (!chick.route || chick.targetIndex >= chick.route.length) return false;
const next = chick.route[chick.targetIndex];
return targetBlockedByChick(chick, next);
}
function targetBlockedByChick(chick, target) {
if (!target) return false;
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;
if (chick && other.id === chick.id) continue;
if (other.stage === 'flying') continue;
if (Math.hypot(other.x - target.x, other.y - target.y) < GRID.cell * 0.55) return true;
}
return false;
}
function routeProgress(chick) {
return chick.targetIndex || 0;
function appendPoint(points, p) {
if (!p) return;
const last = points[points.length - 1];
if (!last || Math.hypot(last.x - p.x, last.y - p.y) > 1) points.push({ x: p.x, y: p.y });
}
function conveyorOutDirNames(cellKey) {
const meta = game.conveyorMeta?.get?.(cellKey) || {};
const dirs = [];
if (Array.isArray(meta.outDirs)) dirs.push(...meta.outDirs);
if (meta.dir) dirs.push(meta.dir);
return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
}
function dirByName(name) {
return DIRS.find(d => d.name === name) || null;
}
function scannerAtBodyCell(col, row) {
return game.scanners.find(scanner => scanner.col === col && scanner.row === row) || null;
}
function scannerReceivingFrom(cell, dirName) {
const d = dirByName(dirName);
if (!d) return null;
const scanner = scannerAtBodyCell(cell.col + d.dc, cell.row + d.dr);
if (!scanner) return null;
const input = scannerConnector(scanner, 'inputA');
return input && input.col === cell.col && input.row === cell.row ? scanner : null;
}
function facilityAtEntryCell(cell) {
return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null;
}
function dirExitsToFacility(facility, dirName) {
return (facility.side === 'left' && dirName === 'left')
|| (facility.side === 'right' && dirName === 'right')
|| (facility.side === 'top' && dirName === 'up')
|| (facility.side === 'bottom' && dirName === 'down');
}
function movementOptionFromDir(cell, dirName) {
const d = dirByName(dirName);
if (!d) return null;
const next = { col: cell.col + d.dc, row: cell.row + d.dr };
if (inGrid(next.col, next.row) && game.conveyorTiles.has(key(next.col, next.row))) {
return { type: 'conveyor', dirName, target: cellCenter(next.col, next.row), nextCell: next };
}
const scanner = scannerReceivingFrom(cell, dirName);
if (scanner) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
const facilityPair = facilityAtEntryCell(cell);
if (facilityPair && dirExitsToFacility(facilityPair[1], dirName)) {
return { type: 'facility', dirName, facilityId: facilityPair[0], facility: facilityPair[1], target: cellCenter(cell.col, cell.row) };
}
return null;
}
function pickMovementOption(chick, cell, dirs) {
const candidates = dirs.map(dir => movementOptionFromDir(cell, dir)).filter(Boolean);
if (!candidates.length) return null;
const moving = candidates.filter(opt => opt.type !== 'facility');
const empty = moving.filter(opt => !targetBlockedByChick(chick, opt.target));
const pool = empty.length ? empty : candidates.filter(opt => opt.type === 'facility');
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function setSingleSegmentRoute(chick, target) {
const route = [];
appendPoint(route, { x: chick.x, y: chick.y });
appendPoint(route, target);
chick.route = route.length ? route : [{ x: chick.x, y: chick.y }];
chick.targetIndex = chick.route.length > 1 ? 1 : chick.route.length;
}
function advanceByCurrentConveyor(chick, index) {
const cell = pointToCell(chick.x, chick.y);
if (!cell) return removeChick(index, 'OFF GRID');
const currentKey = key(cell.col, cell.row);
if (!game.conveyorTiles.has(currentKey)) return removeChick(index, 'OFF BELT');
const dirs = conveyorOutDirNames(currentKey);
if (!dirs.length) {
chick.stoppedTimer = 0.25;
return;
}
const option = pickMovementOption(chick, cell, dirs);
if (!option) {
chick.stoppedTimer = 0.25;
return;
}
if (option.type === 'facility') {
if (option.facilityId === 'mixer') resolveMixer(index);
else if (option.facilityId === 'truck') resolveTruck(index);
else if (option.facilityId === 'trash') resolveTrash(index);
else removeChick(index, 'DONE');
return;
}
if (option.type === 'scanner') chick.pendingScannerId = option.scanner.id;
setSingleSegmentRoute(chick, option.target);
}
function conveyorKeyFromPoint(p) {
@ -284,7 +381,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function sortSlot(slot, side) {
if (game.phase !== 'running') return;
const scanner = scannerBySlot(game, slot);
if (!scanner) return;
sortScanner(scanner, side);
}
function sortScanner(scanner, side) {
if (game.phase !== 'running' || !scanner || scanner.kind !== 'manual') return;
scanner.keyPressTime = 0.18;
scanner.keyPressSide = side;
if (!scanner.queue.length) return;
@ -292,25 +393,100 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (index >= 0) sortChickByIndex(index, side, false);
}
function manualScanSucceeded(scanner, chick, side) {
return scanner.kind === 'manual' && autoSideFor(scanner, chick) === side;
}
function resetManualCombo(scanner) {
const previous = game.manualCombo?.count || 0;
if (!game.manualCombo) game.manualCombo = { count: 0, lastBonus: 0 };
game.manualCombo.count = 0;
game.manualCombo.lastBonus = 0;
game.stats.manualComboFailure = (game.stats.manualComboFailure || 0) + 1;
game.totals.manualComboFailure = (game.totals.manualComboFailure || 0) + 1;
if (previous >= 10) {
const c = scannerCenter(scanner);
shake(game, 10, 0.25);
floating(game, c.x, c.y - 36, 'COMBO RESET', THEME.danger);
}
}
function awardManualCombo(scanner) {
if (!game.manualCombo) game.manualCombo = { count: 0, lastBonus: 0 };
game.manualCombo.count = Math.max(0, game.manualCombo.count || 0) + 1;
game.stats.manualComboSuccess = (game.stats.manualComboSuccess || 0) + 1;
game.totals.manualComboSuccess = (game.totals.manualComboSuccess || 0) + 1;
const combo = game.manualCombo.count;
game.manualCombo.lastBonus = 0;
if (combo < 10) return 0;
const trials = Math.max(1, Math.floor(combo / 10));
const manualScannerCount = game.scanners.filter(s => s.kind === 'manual').length;
const chance = Math.min(1, (20 + manualScannerCount * 10) / 100);
let successfulTrials = 0;
for (let i = 0; i < trials; i += 1) {
if (Math.random() < chance) successfulTrials += 1;
}
const bonus = Math.floor(successfulTrials * Math.max(1, game.turn || 1) * 5 / 4);
if (bonus > 0) {
applyRevenue(game, bonus);
game.stats.manualComboBonus = (game.stats.manualComboBonus || 0) + bonus;
game.totals.manualComboBonus = (game.totals.manualComboBonus || 0) + bonus;
game.manualCombo.lastBonus = bonus;
const c = scannerCenter(scanner);
floating(game, c.x, c.y - 42, `COMBO +${yen(bonus)}`, THEME.green);
}
return bonus;
}
function scannerOutputStartCell(scanner, side) {
const connector = scannerConnector(scanner, side);
if (!connector) return null;
const connectorKey = key(connector.col, connector.row);
if (game.conveyorTiles.has(connectorKey)) return { ...connector };
const graph = ensureFactoryGraph(game);
const keys = (graph.scannerPorts.get(scanner.id)?.[side]?.keys || []).filter(Boolean);
if (!keys.length) return null;
const parsed = keys.map(parseKey);
const exact = parsed.find(p => p.col === connector.col && p.row === connector.row);
return exact || parsed[0] || null;
}
function scannerReleaseRoute(chick, scanner, side, startCell) {
const route = [];
appendPoint(route, { x: chick.x, y: chick.y });
const connector = scannerConnector(scanner, side);
if (connector) appendPoint(route, cellCenter(connector.col, connector.row));
appendPoint(route, cellCenter(startCell.col, startCell.row));
return route;
}
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) {
const startCell = scannerOutputStartCell(scanner, side);
if (!startCell) {
if (!auto && scanner.kind === 'manual') resetManualCombo(scanner);
floating(game, chick.x, chick.y - 16, side === 'left' ? 'NO LEFT BELT' : 'NO RIGHT BELT', THEME.danger);
return false;
}
if (!auto && scanner.kind === 'manual') {
if (manualScanSucceeded(scanner, chick, side)) awardManualCombo(scanner);
else resetManualCombo(scanner);
}
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.stage = 'belt';
chick.route = scannerReleaseRoute(chick, scanner, side, startCell);
chick.targetIndex = chick.route.length > 1 ? 1 : chick.route.length;
chick.scannerId = null;
chick.nextScannerId = null;
chick.pendingScannerId = 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));
const pulseColor = auto ? THEME.green : (side === 'left' ? THEME.mixerBlue : THEME.truckPink);
scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, pulseColor);
floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : side.toUpperCase(), pulseColor);
return true;
}
@ -502,25 +678,12 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
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);
}
return {
activeQueuedChick,
currentSpawnDelay,
sortSlot,
updateCongestion,
sortScanner,
updateRunning
};
}

View file

@ -2,7 +2,7 @@ import { TURN_SECONDS, POOP_RATE, CONTRACT_EVENT_CHANCE, CONTRACT_EVENT_FIRST_TU
import { getSpawnRange } from '../core/state.js';
import { applyRevenue } from './economy.js';
export const CONTRACT_TARGETS = [
const CONTRACT_TARGETS = [
{
target: 'female',
title: 'Premium Female Shipment',
@ -107,10 +107,6 @@ 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';
}

View file

@ -82,10 +82,6 @@ export function shredderBonusChance(gameOrCount) {
return Math.min(1, percent / 100);
}
export function shredderBonusExpectedPerItem(gameOrCount) {
return shredderBonusChance(gameOrCount);
}
export function rollShredderBonus(game) {
const cards = shredderUpgradeCount(game);
const chance = shredderBonusChance(cards);
@ -117,10 +113,6 @@ export function zundaTaxInfo(cash, game = null) {
return { profit, exemption, maxRateCash, taxable, rate, ratePercent: Math.round(rate * 100), tax, step, legalWork };
}
export function zundaTaxForCash(cash, game = null) {
return zundaTaxInfo(cash, game).tax;
}
export function fairiesTributeInfo(game, turn = game.turn) {
const day = Math.max(1, Math.floor(Number(turn) || 1));
const base = Math.ceil(day * ECONOMY.fairiesTribute.perDay);
@ -146,18 +138,7 @@ export function explosionDamageForPrice(price, game = null) {
return Math.max(1, Math.ceil(base * explosionDamageMultiplier(game)));
}
export function upgradeCostFor(obj) {
if (!obj) return null;
if (obj.type === 'eggFarm') return FACILITY_DEFS.eggFarm.upgradeCosts[obj.level] || null;
if (obj.type === 'facility' && INCOME_FACILITY_IDS.includes(obj.id)) {
const level = obj.level || 1;
if (level <= 1) return 300;
return Math.ceil(900 * Math.pow(1.65, level - 2));
}
return null;
}
export function positivePayout(_game, amount) {
function positivePayout(_game, amount) {
return Math.max(0, Math.ceil(amount));
}
@ -243,16 +224,21 @@ export function settleTruckRevenue(game) {
const adjusted = positivePayout(game, base);
game.stats.pendingTruckRevenue = adjusted;
game.stats.truckRevenue = adjusted;
if (target === 'poop') game.stats.poopShipmentIncome = adjusted;
else game.stats.chickShipmentIncome = adjusted;
if (adjusted > 0) applyRevenue(game, adjusted);
return { base, adjusted, target, targetCount, unitPrice };
}
export function collectZundaTax(game, basisCash = game.cash) {
const info = zundaTaxInfo(basisCash, game);
export function collectZundaTax(game, basisProfit = 0) {
const info = zundaTaxInfo(basisProfit, game);
if (info.tax <= 0) return info;
game.stats.zundaTax += info.tax;
game.totals.zundaTax += info.tax;
applyPenalty(game, info.tax);
// ZUNDA TAX is settled between days. It reduces cash and lifetime totals,
// but it is intentionally excluded from the next day's NET display.
game.totals.zundaTax = (game.totals.zundaTax || 0) + info.tax;
game.totals.penalty = (game.totals.penalty || 0) + info.tax;
game.totals.profit = (game.totals.profit || 0) - info.tax;
game.cash -= info.tax;
return info;
}
@ -269,21 +255,6 @@ export function collectFairiesTribute(game, turn = game.turn) {
return info;
}
export function applyRescueLoanIfNeeded(game) {
if (!game?.cardEffects || game.cash >= 0) return { amount: 0, used: 0, remaining: Math.max(0, game?.cardEffects?.rescueLoanCharges || 0) };
let used = 0;
let amount = 0;
while (game.cash < 0 && (game.cardEffects.rescueLoanCharges || 0) > 0) {
game.cardEffects.rescueLoanCharges -= 1;
used += 1;
amount += 500;
game.stats.rescueLoan = (game.stats.rescueLoan || 0) + 500;
game.totals.rescueLoan = (game.totals.rescueLoan || 0) + 500;
applyRevenue(game, 500);
}
return { amount, used, remaining: Math.max(0, game.cardEffects.rescueLoanCharges || 0) };
}
export function collectChemicalWeaponSubsidy(game) {
const cards = effectCount(game, 'chemicalWeaponSubsidy');
const poopTruck = Math.max(0, game?.stats?.poopTruck || 0);
@ -337,6 +308,3 @@ export function finalScore(game) {
));
return { score, factoryValue: value, base, dailyEarned, dayPenalty, correctBonus, explosionPenalty, days };
}
export const MIXER_PRICE = ECONOMY.income.mixer;
export const TRUCK_PRICE = ECONOMY.income.truck;

View file

@ -1,39 +0,0 @@
const MAX_EVENTS = 160;
export function emitGameEvent(game, type, payload = {}) {
if (!game) return null;
if (!Array.isArray(game.eventLog)) game.eventLog = [];
const id = game.nextEventId || 1;
game.nextEventId = id + 1;
const event = {
id,
at: Date.now(),
day: game.turn || 1,
phase: game.phase || 'unknown',
type,
payload: sanitizePayload(payload)
};
game.eventLog.push(event);
if (game.eventLog.length > MAX_EVENTS) game.eventLog.splice(0, game.eventLog.length - MAX_EVENTS);
return event;
}
export function recentGameEvents(game, limit = 8) {
const events = Array.isArray(game?.eventLog) ? game.eventLog : [];
return events.slice(Math.max(0, events.length - limit)).reverse();
}
export function clearGameEvents(game) {
if (game) game.eventLog = [];
}
function sanitizePayload(payload) {
if (!payload || typeof payload !== 'object') return payload;
const out = {};
for (const [k, v] of Object.entries(payload)) {
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean' || v == null) out[k] = v;
else if (Array.isArray(v)) out[k] = v.slice(0, 12).map(x => typeof x === 'object' ? JSON.stringify(x).slice(0, 80) : x);
else out[k] = JSON.stringify(v).slice(0, 120);
}
return out;
}

View file

@ -1,5 +1,7 @@
import { BALANCE } from '../core/balance.js';
import { GRID } from '../core/config.js';
export function cleanScanner(scanner) {
return { ...scanner, queue: [], cooldown: scanner.cooldown || 0 };
return { ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [], cooldown: scanner.cooldown || 0 };
}
export function snapshot(game) {
return JSON.stringify({
@ -7,6 +9,16 @@ export function snapshot(game) {
stats: game.stats,
totals: game.totals,
nextId: game.nextId,
gridX: GRID.x,
gridY: GRID.y,
gridCols: game.gridCols || GRID.cols,
gridRows: game.gridRows || GRID.rows,
gridExpansionPurchases: game.gridExpansionPurchases || 0,
gridExpansionPurchasesByDirection: game.gridExpansionPurchasesByDirection || { right: 0, up: 0 },
gridChunkOriginY: game.gridChunkOriginY || 0,
ownedCells: [...(game.ownedCells || [])],
ownedChunks: [...(game.ownedChunks || [])],
blockedCells: [...(game.blockedCells || [])],
selected: game.selected,
multiSelected: game.multiSelected || [],
buildTool: game.buildTool,
@ -28,10 +40,24 @@ export function restore(game, text) {
game.stats = data.stats;
game.totals = data.totals;
game.nextId = data.nextId;
GRID.x = data.gridX ?? BALANCE.grid.x;
GRID.y = data.gridY ?? BALANCE.grid.y;
GRID.cols = data.gridCols || BALANCE.grid.cols;
GRID.rows = data.gridRows || BALANCE.grid.rows;
game.gridCols = GRID.cols;
game.gridRows = GRID.rows;
game.gridExpansionPurchases = data.gridExpansionPurchases || 0;
game.gridExpansionPurchasesByDirection = data.gridExpansionPurchasesByDirection || { right: 0, up: 0 };
game.gridChunkOriginY = data.gridChunkOriginY || 0;
game.ownedCells = new Set(data.ownedCells || []);
game.ownedChunks = new Set(data.ownedChunks || []);
game.gridOwnedCells = game.ownedCells.size || (GRID.cols * GRID.rows);
game.blockedCells = new Set(data.blockedCells || []);
game.selected = data.selected;
game.multiSelected = data.multiSelected || [];
game.buildTool = data.buildTool;
game.view = data.view || { x: 0, y: 0 };
game.view = data.view || { x: 0, y: 0, scale: 1 };
if (game.view.scale == null) game.view.scale = 1;
game.facilities = data.facilities || {};
game.eggFarms = data.eggFarms || [];
game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 }));

View file

@ -101,10 +101,6 @@ export function performanceFactor(target) {
return Math.max(RULES.minimumPerformance, 1 - t * (1 - RULES.minimumPerformance));
}
export function degradationPercent(target) {
return Math.round(wearRatio(target) * 100);
}
export function conveyorTarget(game, conveyorKey) {
const meta = game.conveyorMeta?.get(conveyorKey);
return meta ? { type: 'conveyor', key: conveyorKey, meta } : null;
@ -221,6 +217,8 @@ export function hireRepairmanForNextDay(game) {
const cost = repairmanDailyCost(game);
if (game.cash < cost) return { ok: false, reason: `Need ${yen(cost - game.cash)} more.` };
spendCash(game, cost);
game.pendingRepairWorkerWages = (game.pendingRepairWorkerWages || 0) + cost;
game.totals.repairWorkerWages = (game.totals.repairWorkerWages || 0) + cost;
game.repairman.hiredForNextDay = true;
return { ok: true, cost };
}
@ -285,7 +283,7 @@ export function updateRepairman(game, dt) {
if (targetWear(target) <= 0.001) r.target = null;
}
export function worstDegradation(game) {
function worstDegradation(game) {
let worst = 0;
let label = 'none';
for (const t of equipmentMaintenanceTargets(game)) {

View file

@ -29,14 +29,6 @@ function adjacentCells(point) {
return DIRS.map(d => ({ col: point.col + d.dc, row: point.row + d.dr })).filter(p => inGrid(p.col, p.row));
}
function conveyorAt(game, point) {
return !!point && inGrid(point.col, point.row) && game.conveyorTiles.has(key(point.col, point.row));
}
function exactConveyorCell(game, point) {
return conveyorAt(game, point) ? [{ ...point }] : [];
}
function visualPortConveyorCells(game, point, blocked = [], preferred = []) {
if (!point || !inGrid(point.col, point.row)) return [];
const result = [];
@ -57,21 +49,6 @@ function graphPortConveyorCells(game, point, blocked = [], preferred = []) {
return exact.length ? exact : cells;
}
// Kept for explicit non-port use.
export function exactOrAdjacentConveyorCells(game, point, blocked = [], preferred = []) {
if (!point || !inGrid(point.col, point.row)) return [];
const result = [];
const add = p => {
if (!p || !inGrid(p.col, p.row) || !game.conveyorTiles.has(key(p.col, p.row))) return;
if (blocked.some(b => b && sameCell(p, b))) return;
if (!result.some(x => sameCell(x, p))) result.push({ ...p, viaTolerance: !sameCell(p, point) });
};
add(point);
for (const p of preferred) add(p);
for (const p of adjacentCells(point)) add(p);
return result;
}
// -----------------------------------------------------------------------------
// Strict connection tests
// -----------------------------------------------------------------------------
@ -95,34 +72,11 @@ export function facilityEntryPoint(game, dest) {
return { x: f.x + f.w / 2, y: f.y + f.h / 2 };
}
export function facilityEntryCell(game, dest) {
return game.facilities[dest]?.entry || null;
}
export function facilityEndpointCells(game, dest) {
const f = game.facilities[dest];
if (!f?.entry) return [];
return visualPortConveyorCells(game, f.entry);
}
export function portHasConveyor(game, port) {
return visualPortConveyorCells(game, port).length > 0;
}
export function scannerPortHasConveyor(game, scanner, type) {
if (type === 'inputA') return scannerInputCells(game, scanner).length > 0;
return outputStartCells(game, scanner, type).length > 0;
}
export function getInputScanner(game, col, row) {
return game.scanners.find(scanner => scannerInputCells(game, scanner).some(p => sameCell({ col, row }, p))) || null;
}
export function isInputConnector(game, col, row) { return !!getInputScanner(game, col, row); }
export function isOutputConnector(game, col, row) {
return game.scanners.some(scanner => sameCell({ col, row }, scannerConnector(scanner, 'left')) || sameCell({ col, row }, scannerConnector(scanner, 'right')));
}
export function inputCellsForScanner(game, scanner) { return scannerInputCells(game, scanner); }
export function refreshRoutingAfterEdit(game) {
game.branchCounters?.clear?.();
game.routeCache = null;
@ -140,6 +94,14 @@ export function refreshRoutingAfterEdit(game) {
function graphNodeKey(col, row) { return key(col, row); }
function graphCell(k) { return parseKey(k); }
function conveyorOutDirNames(game, cellKey) {
const meta = game.conveyorMeta?.get?.(cellKey) || {};
const dirs = [];
if (Array.isArray(meta.outDirs)) dirs.push(...meta.outDirs);
if (meta.dir) dirs.push(meta.dir);
return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
}
function emptyFactoryGraph(game) {
return {
version: game.routingVersion || 0,
@ -178,7 +140,9 @@ export function buildFactoryGraph(game) {
for (const k of graph.cells) {
const p = graphCell(k);
for (const d of DIRS) {
const explicitDirs = conveyorOutDirNames(game, k);
const dirs = explicitDirs.length ? DIRS.filter(d => explicitDirs.includes(d.name)) : DIRS;
for (const d of dirs) {
const nk = graphNodeKey(p.col + d.dc, p.row + d.dr);
if (graph.cells.has(nk)) graph.adjacency.get(k).push({ key: nk, dir: d.name });
}
@ -271,7 +235,7 @@ export function commitFactoryGraphForDay(game) {
return graph;
}
export function graphSummary(graph) {
function graphSummary(graph) {
if (!graph) return null;
return {
version: graph.version,
@ -296,11 +260,6 @@ export function getConveyorNeighbors(game, col, row) {
return (graph.adjacency.get(k) || []).map(n => ({ ...graphCell(n.key), dir: DIRS.find(d => d.name === n.dir) || { name: n.dir, dc: 0, dr: 0 } }));
}
export function getAdjacentConveyors(game, col, row) {
const graph = ensureFactoryGraph(game);
return adjacentCells({ col, row }).filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
}
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;
@ -321,38 +280,68 @@ export function bfsAllRoutes(game, start, isGoal) {
if (isGoal.cacheKey && graph.pathCache.has(cacheKey)) return graph.pathCache.get(cacheKey).map(path => path.map(p => ({ ...p })));
const startState = `${startK}|none`;
const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none' }];
const visited = new Set([startState]);
const parent = new Map();
const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none', depth: 0 }];
const depthByState = new Map([[startState, 0]]);
const parents = new Map();
const found = [];
let bestGoalDepth = Infinity;
const maxParentsPerState = 8;
const maxGoalStates = 24;
const maxPaths = 80;
while (queue.length) {
const cur = queue.shift();
if (cur.depth > bestGoalDepth) break;
const curStateK = `${cur.key}|${cur.incoming}`;
if (isGoal(cur)) found.push(curStateK);
if (isGoal(cur)) {
found.push(curStateK);
bestGoalDepth = cur.depth;
if (found.length >= maxGoalStates) break;
continue;
}
for (const n of graph.adjacency.get(cur.key) || []) {
const next = graphCell(n.key);
const outDir = dirBetween(cur, next);
if (cur.incoming !== 'none' && isCrossInGraph(graph, cur.key) && outDir !== cur.incoming) continue;
const nextDepth = cur.depth + 1;
if (nextDepth > bestGoalDepth) continue;
const nextStateK = `${n.key}|${outDir}`;
if (visited.has(nextStateK)) continue;
visited.add(nextStateK);
parent.set(nextStateK, curStateK);
queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir });
const knownDepth = depthByState.get(nextStateK);
if (knownDepth == null) {
depthByState.set(nextStateK, nextDepth);
parents.set(nextStateK, [curStateK]);
queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir, depth: nextDepth });
} else if (knownDepth === nextDepth) {
const list = parents.get(nextStateK) || [];
if (list.length < maxParentsPerState && !list.includes(curStateK)) list.push(curStateK);
parents.set(nextStateK, list);
}
}
}
const paths = found.map(goalState => {
const reversed = [];
let cursor = goalState;
while (cursor) {
const [cellK] = cursor.split('|');
reversed.push(graphCell(cellK));
if (cellK === startK) break;
cursor = parent.get(cursor);
const paths = [];
const expand = (state, suffix) => {
if (paths.length >= maxPaths) return;
const [cellK] = state.split('|');
const nextSuffix = [graphCell(cellK), ...suffix];
if (state === startState || cellK === startK) {
paths.push(nextSuffix);
return;
}
return reversed.reverse();
});
if (isGoal.cacheKey) graph.pathCache.set(cacheKey, paths.map(path => path.map(p => ({ ...p }))));
return paths;
for (const parent of parents.get(state) || []) expand(parent, nextSuffix);
};
for (const goalState of found) expand(goalState, []);
const unique = [];
const seen = new Set();
for (const path of paths) {
const sig = path.map(p => graphNodeKey(p.col, p.row)).join('>');
if (seen.has(sig)) continue;
seen.add(sig);
unique.push(path);
}
if (isGoal.cacheKey) graph.pathCache.set(cacheKey, unique.map(path => path.map(p => ({ ...p }))));
return unique;
}
export function buildConveyorComponents(game) {
@ -414,6 +403,25 @@ function routeWithScannerTarget(fromPoint, connector, cells, targetScanner) {
return points;
}
function chickCellOccupancyScore(game, cell, depth = 0) {
const c = cellCenter(cell.col, cell.row);
let score = 0;
for (const chick of game.chicks || []) {
if (chick.stage === 'flying' || chick.stage === 'queued') continue;
const d = Math.hypot(chick.x - c.x, chick.y - c.y);
if (d < GRID.cell * 0.50) score += depth <= 1 ? 100 : Math.max(1, 12 - depth);
}
return score;
}
function candidateCongestionScore(game, candidate) {
const cells = candidate.cells || [];
let score = 0;
const limit = Math.min(cells.length, 8);
for (let i = 0; i < limit; i += 1) score += chickCellOccupancyScore(game, cells[i], i);
return score;
}
export function chooseRoundRobin(game, id, candidates, advance = false) {
if (!candidates.length) return null;
const sorted = [...candidates].sort((a, b) => {
@ -427,9 +435,14 @@ export function chooseRoundRobin(game, id, candidates, advance = false) {
const kb = b.key || JSON.stringify(b.route || b.cells || b);
return ka.localeCompare(kb);
});
if (!advance || sorted.length === 1) return sorted[0];
const bestBias = sorted[0].roleBias ?? 0;
let pool = sorted.filter(item => (item.roleBias ?? 0) === bestBias);
const bestScore = Math.min(...pool.map(item => candidateCongestionScore(game, item)));
pool = pool.filter(item => candidateCongestionScore(game, item) === bestScore);
const n = game.branchCounters.get(id) || 0;
if (advance) game.branchCounters.set(id, n + 1);
return sorted[n % sorted.length];
game.branchCounters.set(id, n + 1);
return pool[Math.floor(Math.random() * pool.length)];
}
export function scannerRules(scanner) {
@ -453,13 +466,6 @@ export function destinationColor(dest) {
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
}
export function isFacilityEndpoint(game, p, dest, connector) {
if (sameCell(p, connector)) return false;
const graph = ensureFactoryGraph(game);
const port = graph.facilityPorts.get(dest);
return !!port?.connected && (port.keys || [port.key]).includes(graphNodeKey(p.col, p.row));
}
export function routeFromFarmToScanner(game, farm, advance = false) {
const graph = ensureFactoryGraph(game);
const starts = (graph.farmOutputs.get(farm.id) || []).map(graphCell);
@ -552,6 +558,44 @@ function outputRouteReachesFacility(game, scanner, visited = new Set()) {
return false;
}
function scannerOutputReachesDestination(game, scanner, dest, visited = new Set()) {
if (!scanner || visited.has(scanner.id)) return false;
visited.add(scanner.id);
for (const side of ['left', 'right']) {
const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id, false);
if (!plan) continue;
if (plan.destination === dest) return true;
if (plan.destination === 'scanner') {
const next = scannerById(game, plan.nextScannerId);
if (scannerOutputReachesDestination(game, next, dest, visited)) return true;
}
}
return false;
}
export function eggFarmHasValidRoute(game, farm) {
return !!routeFromFarmToScanner(game, farm, false)?.scannerId;
}
export function facilityHasValidRoute(game, dest) {
const graph = ensureFactoryGraph(game);
if (!graph.facilityPorts.get(dest)?.connected) return false;
return game.scanners.some(scanner => scannerOutputReachesDestination(game, scanner, dest));
}
export function disconnectedBuildWarnings(game) {
if (game.phase !== 'build') return [];
const warnings = [];
for (const farm of game.eggFarms || []) {
if (!eggFarmHasValidRoute(game, farm)) warnings.push({ type: 'eggFarm', id: farm.id, ref: farm, message: 'This Egg will not produce chicks' });
}
for (const id of MACHINE_FACILITY_IDS) {
const facility = game.facilities?.[id];
if (facility && !facilityHasValidRoute(game, id)) warnings.push({ type: 'facility', id, ref: facility, message: 'This facility cannot receive items' });
}
return warnings;
}
export function minimumStartConnectionIssues(game) {
const graph = ensureFactoryGraph(game);
for (const farm of game.eggFarms || []) {

View file

@ -159,6 +159,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (origin.type !== 'facility') continue;
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
const { entry, side } = nearestGridEdge(nextCenter);
if (!pointInGrid(entry.col, entry.row)) return true;
if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true;
const draft = { ...origin.obj, entry: { ...entry }, side };
layoutFacilityOnEdge(draft, entry, side);

View file

@ -22,6 +22,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
hasBuildableOption = true;
if (game.cash >= buildPrice(id, game)) return false;
}
if (build?.gridExpansionCost && game.cash >= build.gridExpansionCost()) return false;
return hasBuildableOption;
}
@ -45,18 +46,30 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
const priceSpan = btn.querySelector('span');
if (priceSpan) priceSpan.textContent = id === 'conveyor' ? `${yen(price)} / tile` : yen(price);
const uniqueAlreadyBuilt = MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id];
const manualLimitReached = id === 'manualScanner' && game.scanners.filter(s => s.kind === 'manual').length >= 8;
const unaffordable = game.cash < price;
btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt;
btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt || manualLimitReached;
if (btn.disabled && game.buildTool === id) game.buildTool = null;
btn.classList.toggle('unaffordable', unaffordable);
btn.classList.toggle('already-built', uniqueAlreadyBuilt);
btn.classList.toggle('already-built', uniqueAlreadyBuilt || manualLimitReached);
btn.title = unaffordable
? `Need ${yen(price - game.cash)} more`
: uniqueAlreadyBuilt
: manualLimitReached
? 'Manual Scanner limit reached (8 max)'
: uniqueAlreadyBuilt
? `${FACILITY_DEFS[id]?.name || id} already exists`
: '';
}
if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
if (ui.expandGridButton && build?.gridExpansionCost) {
const cost = build.gridExpansionCost();
const priceSpan = ui.expandGridButton.querySelector('span');
if (priceSpan) priceSpan.textContent = `${yen(cost)} / +10×10`;
const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || game.cash < cost;
ui.expandGridButton.disabled = blocked;
ui.expandGridButton.classList.toggle('unaffordable', game.cash < cost);
ui.expandGridButton.title = game.cash < cost ? `Need ${yen(cost - game.cash)} more` : `Add the next right 10×10 lot, or click an upper/right lot button on the canvas. Next purchase count: ${(game.gridExpansionPurchases || 0) + 1}`;
}
if (ui.hireRepairmanButton) {
const hired = !!game.repairman?.hiredForNextDay;
const cost = repairmanDailyCost(game);
@ -64,7 +77,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.hireRepairmanButton.disabled = blocked;
ui.hireRepairmanButton.classList.toggle('active', hired);
ui.hireRepairmanButton.classList.toggle('unaffordable', game.cash < cost);
ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 3% durability per second.`;
ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 2% durability per second.`;
}
const cashout = noAffordableBuildTools();
ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout);
@ -90,21 +103,22 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function updateTurnSummary() {
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length;
const issues = facilityConnectionIssues(game);
const tax = zundaTaxInfo(game.cash, game);
const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const tax = zundaTaxInfo(zundaBasisProfit, game);
const tributeDay = game.phase === 'running' ? Math.max(1, (game.totals.turnsCompleted || 0) + 1) : Math.max(1, Number(game.turn) || 1);
const tribute = fairiesTributeInfo(game, tributeDay);
const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null;
const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0;
ui.turnSummary.innerHTML = [
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`,
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Grid box: <b>${game.gridRows || 10}×${game.gridCols || 20}</b> | Owned: <b>${game.gridOwnedCells || game.ownedCells?.size || ((game.gridRows || 10) * (game.gridCols || 20))}</b> cells | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`,
`Truck target: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`,
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`,
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | basis net ${yen(zundaBasisProfit)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
lastTribute != null
? `Fairies tribute paid before Build: <b>-${yen(lastTribute)}</b>${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}`
: `Fairies tribute before Build: <b>-${yen(tribute.amount)}</b>${tribute.reduction ? ` | Flattery -${yen(tribute.reduction)}` : ''}`,
`Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Repairman: <b>${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}</b>`,
`Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Belt: <b>${displaySpeed()}px/s</b> | Farms with scanner route: <b>${connected}/${game.eggFarms.length}</b>`,
`Manual combo: <b>${game.manualCombo?.count || 0}</b> | Next expansion: <b>${yen(build?.gridExpansionCost ? build.gridExpansionCost() : 0)}</b> | Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Belt: <b>${displaySpeed()}px/s</b> | Farms with scanner route: <b>${connected}/${game.eggFarms.length}</b>`,
game.cardEffects?.usedMachineActive ? '<span class="cash-negative">Procurement: USED MACHINE MODE / no refunds / 60% durability</span>' : '',
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">${TEXT.status.allPortsConnected}</span>`
].join('<br>');
@ -143,6 +157,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
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.shell.classList.toggle('phase-loanDecision', game.phase === 'loanDecision');
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);
@ -152,6 +167,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
: `${Math.max(0, game.timeLeft).toFixed(1)}s`;
ui.timeLeft.closest?.('.hud-card')?.classList.toggle('time-critical', game.phase === 'running' && (game.timeLeft <= 10 || (game.timeLeft <= 0 && game.shutdownTimeLeft > 0)));
ui.phase.textContent = phaseLabel();
if (ui.comboCount) ui.comboCount.textContent = game.manualCombo?.count || 0;
updatePriorityStrip();
ui.turnProfit.textContent = yen(game.stats.profit);
ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0);
@ -164,7 +180,17 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
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);
const nextTax = zundaTaxInfo(game.cash, game).tax;
const s1 = game.scanners.find(s => s.kind === 'manual' && s.slot === 0);
const s2 = game.scanners.find(s => s.kind === 'manual' && s.slot === 1);
if (s1) {
ui.buttons.s1Left.textContent = `S1 ${s1.keys?.left?.label || 'A'} -> Mixer`;
ui.buttons.s1Right.textContent = `S1 ${s1.keys?.right?.label || 'D'} -> S2`;
}
if (s2) {
ui.buttons.s2Left.textContent = `S2 ${s2.keys?.left?.label || 'Left'} -> Waste`;
ui.buttons.s2Right.textContent = `S2 ${s2.keys?.right?.label || 'Right'} -> Truck`;
}
const nextTax = zundaTaxInfo(Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)), game).tax;
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA ${yen(nextTax)}` : TEXT.actions.nextDay;
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
}
@ -173,7 +199,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
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.textContent = `BELT: ${displaySpeed()} px/s | COMBO: ${game.manualCombo?.count || 0}`;
ui.speedBrief.title = `Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${CONVEYOR_SPEED_MAX}px/s.`;
const active = game.contractActive;
const offer = game.contractOffer;
@ -194,6 +220,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function phaseLabel() {
if (game.phase === 'running') return game.timeLeft <= 0 ? TEXT.phases.clearing : TEXT.phases.running;
if (game.phase === 'loanDecision') return TEXT.phases.loanDecision;
if (game.phase === 'build') return TEXT.phases.build;
if (game.phase === 'gameover') return TEXT.phases.gameover;
return TEXT.phases.title;
@ -209,16 +236,50 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
}
function showTurnResult(r) {
const positive = value => `<span class="${value > 0 ? 'cash-positive' : ''}">${value > 0 ? '+' : ''}${yen(value)}</span>`;
const negative = value => `<span class="${value > 0 ? 'cash-negative' : ''}">${value > 0 ? '-' : ''}${yen(value)}</span>`;
const netClass = r.profit >= 0 ? 'cash-positive' : 'cash-negative';
const penalties = (r.mixerPoopFine || 0) + (r.truckPoopFine || 0) + (r.maleTruckFine || 0) + (r.explosionDamage || 0) + (r.cardRerollCost || 0);
ui.modalTitle.textContent = `Day ${r.turn} Settlement`;
ui.modalBody.innerHTML = `
<p class="formula-box compact">${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}</p>
<p><strong>Final profit: ${yen(r.profit)}</strong></p>`;
<div class="settlement-net ${netClass}"><span>Net result</span><strong>${yen(r.profit)}</strong></div>
<div class="result-grid settlement-grid">
<div><strong>Chick shipment income</strong>${positive(r.chickShipmentIncome || 0)}</div>
<div><strong>Poop shipment income</strong>${positive(r.poopShipmentIncome || 0)}</div>
<div><strong>Mixer income</strong>${positive(r.mixerRevenue || 0)}</div>
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
<div><strong>Chemical subsidy</strong>${positive(r.chemicalWeaponSubsidy || 0)}</div>
<div><strong>Contract bonus</strong>${positive(r.contractBonus || 0)}</div>
<div><strong>Penalties</strong>${negative(penalties)}</div>
<div><strong>Fairies fee</strong>${negative(r.fairiesTribute?.amount || r.fairiesTribute || 0)}</div>
<div><strong>Repair worker wages</strong>${negative(r.repairWorkerWages || 0)}</div>
<div><strong>Loan repayments</strong>${negative(r.loanRepayment || 0)}</div>
</div>
<p class="formula-box compact">Income ${yen(r.revenue)} - losses ${yen(r.penalty)} = <b class="${netClass}">${yen(r.profit)}</b></p>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button('Choose Upgrade Card', beginCardDraft || hideModal, 'primary-button'));
ui.modal.classList.remove('equipment-popover', 'gameover-modal');
ui.modal.classList.add('visible');
}
function showEmergencyLoanDecision(onUse, onDecline) {
const charges = Math.max(0, game.cardEffects?.rescueLoanCharges || 0);
ui.modalTitle.textContent = 'Emergency Loan Available';
ui.modalBody.innerHTML = `
<p class="gameover-reason">End-of-day cash is below ${yen(0)}.</p>
<div class="result-grid settlement-grid">
<div><strong>Current money</strong><span class="cash-negative">${yen(game.cash)}</span></div>
<div><strong>Emergency Loan</strong><span class="cash-positive">+${yen(500)}</span></div>
<div><strong>Cards remaining</strong><span>${charges}</span></div>
</div>
<p class="formula-box compact">Use one Emergency Loan to gain ${yen(500)} immediately. If cash is still negative, another decision will be shown if another card remains.</p>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button('Accept Game Over', onDecline, 'facility-action danger'));
ui.modalActions.appendChild(button('Use Emergency Loan', onUse, 'primary-button'));
ui.modal.classList.remove('equipment-popover', 'gameover-modal');
ui.modal.classList.add('visible');
}
function showGameOver() {
const processed = game.totals.processed;
@ -256,5 +317,5 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
// Bankruptcy is intentionally checked only in completeTurn(), after all day-end revenue, tribute, and rescue-loan effects are settled.
}
return { updatePanels, updateUI, showTitle, showTurnResult, showGameOver, hideModal, checkGameOver };
return { updatePanels, updateUI, showTitle, showTurnResult, showEmergencyLoanDecision, showGameOver, hideModal, checkGameOver };
}

View file

@ -20,7 +20,7 @@ h1, h2, p { margin: 0; }
.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-left { top: 10px; left: 10px; grid-template-columns: repeat(6, minmax(82px, 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); }
@ -29,7 +29,7 @@ h1, h2, p { margin: 0; }
.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; }
.hud-card.cash strong, .hud-card.profit strong, .hud-card.combo 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; }
@ -66,6 +66,76 @@ h1, h2, p { margin: 0; }
.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; }
.debug-panel {
position: absolute;
z-index: 9;
left: 10px;
bottom: 10px;
width: min(292px, calc(100% - 20px));
border: 3px solid var(--line);
background: rgba(255,255,255,.94);
box-shadow: 4px 4px 0 rgba(16,32,21,.16);
font-weight: 900;
}
.debug-panel summary {
cursor: pointer;
padding: 4px 8px;
color: var(--danger);
font-size: 14px;
line-height: 1.1;
}
.debug-body {
display: grid;
grid-template-columns: 1fr 74px;
gap: 5px;
padding: 6px;
border-top: 3px solid var(--line);
font-size: 13px;
line-height: 1.15;
}
.debug-body label,
.debug-body button,
.debug-body select,
.debug-body input {
min-width: 0;
font: inherit;
}
.debug-check,
.debug-card {
grid-column: 1 / -1;
}
.debug-day {
display: grid;
grid-template-columns: 34px 1fr;
align-items: center;
gap: 4px;
}
.debug-body button {
border: 2px solid var(--line);
background: var(--white);
cursor: pointer;
font-weight: 900;
padding: 4px 5px;
line-height: 1.05;
}
.debug-body input[type="number"],
.debug-body select {
width: 100%;
border: 2px solid var(--line);
background: var(--white);
padding: 3px 4px;
min-height: 28px;
}
.debug-readout {
grid-column: 1 / -1;
border: 2px solid var(--line);
background: #f7fff5;
color: var(--muted);
padding: 5px;
line-height: 1.2;
max-height: 48px;
overflow: hidden;
}
.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); }
@ -202,6 +272,8 @@ h1, h2, p { margin: 0; }
.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); }
.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; }
.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; }
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 7px 0; }
.card-tags span { margin: 0; padding: 2px 5px; font-size: 8px; line-height: 1.1; letter-spacing: .04em; }
.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; }
.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); }
.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); }
@ -245,6 +317,7 @@ body { font-size: 16px; }
.hover-tooltip.visible { display: block; }
.hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; }
.hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; }
.hover-tooltip em.warning { color: var(--danger); }
.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; }
@ -334,6 +407,22 @@ body { font-size: 16px; }
margin-bottom: 8px;
}
.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; }
.settlement-net {
border: 3px solid var(--line);
background: #f7fff5;
padding: 10px 12px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-weight: 900;
text-transform: uppercase;
}
.settlement-net strong { font-size: 24px; }
.settlement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
.settlement-grid div { padding: 8px; }
.settlement-grid span { display: block; font-size: 14px; }
.gameover-summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
@ -460,3 +549,156 @@ body { font-size: 18px; }
.build-panel { width: auto; }
.control-dock { grid-template-columns: repeat(2, 172px); }
}
/* v27.1 expansion/readability pass */
body { font-size: 20px; }
.panel-head h1 { font-size: 22px; }
.panel-head h1 span { font-size: 13px; }
.panel-head p { font-size: 14px; line-height: 1.35; }
.build-panel h2 { font-size: 15px; }
.priority-strip > div { font-size: 16px; }
.hud-card span { font-size: 13px; }
.hud-card strong { font-size: clamp(22px, 1.8vw, 30px); }
.mini-box, .contract-card { font-size: 15px; line-height: 1.5; }
.compact-rules .mini-box { font-size: 14px; }
.tool-button strong { font-size: 16px; line-height: 1.15; }
.tool-button span { font-size: 13px; line-height: 1.2; }
.tool-button .tool-flavor {
left: auto;
right: 0;
width: min(320px, calc(100vw - 40px));
font-size: 14px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.tool-button .tool-flavor::before { left: auto; right: 18px; }
.tool-button.unaffordable .tool-flavor,
.tool-button.unaffordable:hover .tool-flavor,
.tool-button:disabled .tool-flavor,
.tool-button:disabled:hover .tool-flavor { display: none; }
.facility-action { font-size: 12px; }
.primary-button { font-size: 17px; }
#nextTurnButton { font-size: 19px; }
.card-choice strong { font-size: 19px; }
.card-choice span { font-size: 12px; }
.card-choice small { font-size: 16px; line-height: 1.45; }
.muted-card-note { font-size: 16px; }
.modal-card p, .modal-card li { font-size: 17px; }
.modal.equipment-popover .modal-card h2 { font-size: 16px; }
.modal.equipment-popover .modal-actions button { font-size: 11px; }
.equipment-menu-lines.compact, .formula-box.compact { font-size: 11px; }
.hover-tooltip strong { font-size: 18px; }
.hover-tooltip span { font-size: 15px; }
.sort-button { font-size: 17px; }
/* v27.3 offscreen scanner monitor / expansion UI pass */
.scanner-monitor {
position: absolute;
z-index: 8;
left: 12px;
bottom: 92px;
display: none;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 8px;
width: min(560px, calc(100% - 24px));
pointer-events: auto;
}
.scanner-monitor.visible { display: grid; }
.scanner-monitor-card {
border: 3px solid var(--line);
background: rgba(255,255,255,.95);
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
padding: 9px;
font-weight: 900;
}
.scanner-monitor-card header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 7px;
text-transform: uppercase;
}
.scanner-monitor-card header strong { font-size: 20px; color: var(--green); }
.scanner-monitor-card header span { font-size: 12px; color: var(--muted); }
.scanner-mini-map {
display: grid;
grid-template-columns: 42px 1fr 82px;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.scanner-mini-map > span,
.scanner-mini-map > b {
border: 2px solid var(--line);
background: #f7fff5;
padding: 7px 6px;
text-align: center;
font-size: 13px;
min-height: 36px;
}
.scanner-current { display: block; color: var(--ink); }
.scanner-current.male { color: #2477ff; }
.scanner-current.female { color: #d62a85; }
.scanner-current.poop { color: #7b4a23; }
.scanner-current.empty { color: var(--muted); }
.scanner-mini-grid {
display: grid;
grid-template-columns: repeat(5, 27px);
gap: 3px;
justify-content: center;
align-items: center;
padding: 6px;
margin: 0 0 8px;
border: 2px solid var(--line);
background: #f6f1df;
}
.scanner-mini-grid .mini-cell {
width: 27px;
height: 27px;
display: grid;
place-items: center;
border: 1px solid rgba(22,38,25,.28);
background: #fffdfa;
color: var(--muted);
font-size: 11px;
font-weight: 900;
line-height: 1;
}
.scanner-mini-grid .mini-cell.belt { background: #e7f7e4; color: var(--green-dark); }
.scanner-mini-grid .mini-cell.input { background: #fff3bf; color: var(--ink); font-size: 9px; }
.scanner-mini-grid .mini-cell.scanner { background: var(--green); color: #fff; border-color: var(--line); font-size: 16px; }
.scanner-mini-grid .mini-cell.scanner.male { background: #2477ff; }
.scanner-mini-grid .mini-cell.scanner.female { background: #d62a85; }
.scanner-mini-grid .mini-cell.scanner.poop { background: #7b4a23; }
.scanner-mini-grid .mini-cell.scanner-other { background: #b7f5c6; color: var(--ink); }
.scanner-mini-grid .mini-cell.egg { background: #fff7c4; color: #7a5a00; }
.scanner-mini-grid .mini-cell.port { background: #dbe7ff; color: #27427d; }
.scanner-mini-grid .mini-cell.blocked { background: #d7cfc1; color: #6d4d35; }
.scanner-monitor-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.scanner-monitor-actions button {
border: 3px solid var(--line);
background: var(--green-soft);
color: var(--ink);
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 900;
min-height: 44px;
box-shadow: 3px 3px 0 rgba(16,32,21,.18);
}
.scanner-monitor-actions button:disabled {
background: #edf3ec;
color: var(--muted);
opacity: .62;
cursor: not-allowed;
}
@media (max-width: 1180px) {
.scanner-monitor { bottom: 132px; width: min(520px, calc(100% - 16px)); left: 8px; }
}