This commit is contained in:
33333-33333 2026-06-08 18:26:53 +09:00
commit 16bde0b87d
20 changed files with 460 additions and 988 deletions

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

@ -26,14 +26,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,6 +2,22 @@ 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,
@ -25,7 +41,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
};
}

View file

@ -20,6 +20,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 +68,10 @@ export function newTotalStats() {
loanRepayment: 0,
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
manualComboBonus: 0,
manualComboSuccess: 0,
manualComboFailure: 0,
repairWorkerWages: 0,
cardRerollCost: 0,
mixerPoopFine: 0,
truckPoopFine: 0,
@ -121,6 +131,8 @@ export function createGame() {
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: [] },
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null,
manualCombo: { count: 0, lastBonus: 0 },
pendingRepairWorkerWages: 0,
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + GRID.rows * GRID.cell + 84, target: null, repairedToday: 0 },
stats: newTurnStats(),
lastResult: null,
@ -175,8 +187,8 @@ const INITIAL_CONVEYOR_BACKBONE = Object.freeze([
]);
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: 4, row: 5, 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: 11, row: 8, 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([
@ -245,7 +257,7 @@ export function resetLayout(game) {
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

@ -3,7 +3,7 @@ import { buildToolButtonHtml } from './core/text.js';
import { createGame, resetLayout, newTurnStats } from './core/state.js';
import { clamp, pointToCell, cellCenter, yen } from './core/utils.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';
@ -108,6 +108,15 @@ function startNextTurn() {
game.truckCargo = [];
game.view = { x: 0, y: 0 };
game.stats = newTurnStats();
game.stats.zundaTax = game.lastStartFees?.zundaTax || 0;
game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0;
game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0;
const carriedStartCosts = game.stats.zundaTax + 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 +148,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 +199,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) {
@ -226,8 +269,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,6 +294,8 @@ 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);
}
canvas.addEventListener('contextmenu', event => event.preventDefault());
@ -300,7 +346,25 @@ ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); u
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(); } });
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)) 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(); }
});

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,6 @@
import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.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';
@ -49,6 +49,7 @@ export function drawAll(ctx, canvas, game, helpers) {
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,6 +61,41 @@ 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 18px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.strokeText('!', b.cx, b.y + 24);
ctx.fillText('!', b.cx, b.y + 24);
ctx.font = '900 9px 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();
}
@ -282,6 +318,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);
@ -296,7 +343,9 @@ function drawScanner(ctx, scanner, game) {
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'center';
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 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);
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);
@ -319,6 +368,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;

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

@ -3,7 +3,7 @@ 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 { 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';
@ -88,10 +88,6 @@ 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);
@ -144,6 +140,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 +278,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) {
@ -318,6 +392,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) {
@ -410,6 +485,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 +514,8 @@ 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,
removeSelected, switchScannerRole, showManualScannerMenu,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes,
routeFromFarmToScanner

View file

@ -199,6 +199,34 @@ export const CARD_DEFS = [
}
];
const CARD_TAGS = {
upgradeEgg: ['EGG', 'ACTIVE'],
upgradeAutoScanner: ['SCANNER', 'ACTIVE'],
upgradeMixer: ['ECONOMY', 'ACTIVE'],
upgradeTruck: ['ECONOMY', 'ACTIVE'],
upgradeTrash: ['POOP', 'ACTIVE'],
crowdedFarming: ['EGG', 'RISK', 'PASSIVE'],
hatchingFeed: ['EGG', 'POOP', 'PASSIVE'],
safetyCover: ['RISK', 'PASSIVE'],
preventiveMaintenance: ['MAINTENANCE', 'PASSIVE'],
durabilityCoating: ['MAINTENANCE', 'PASSIVE'],
bearing: ['CONVEYOR', 'PASSIVE'],
extraEggOutlet: ['EGG', 'RARE', 'PASSIVE'],
recyclingSubsidy: ['ECONOMY', 'RARE', 'PASSIVE'],
dynamite: ['RISK', 'RARE', 'ONE-SHOT'],
usedMachine: ['ECONOMY', 'RISK', 'RARE'],
newMachine: ['ECONOMY', 'ONE-SHOT'],
flattery: ['ECONOMY', 'RARE', 'ONE-SHOT'],
loan: ['ECONOMY', 'RISK', 'RARE'],
extraCards: ['RARE', 'ACTIVE'],
legalWork: ['ECONOMY', 'RARE', 'PASSIVE'],
chemicalWeaponSubsidy: ['POOP', 'ULTRA RARE', 'PASSIVE'],
laborExploitation: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE'],
rescueLoan: ['ECONOMY', 'ULTRA RARE', 'ONE-SHOT']
};
for (const card of CARD_DEFS) card.tags = CARD_TAGS[card.id] || [];
function effectCount(game, id) {
const effects = ensureCardState(game);
return Math.max(0, Number(effects[id]) || 0);
@ -226,10 +254,6 @@ 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'));
}
@ -247,13 +271,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);
@ -480,13 +497,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 +508,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)}%`;
}
@ -732,7 +738,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

@ -3,7 +3,7 @@ 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 { 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';
@ -284,7 +284,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,6 +296,51 @@ 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 sortChickByIndex(index, side, auto) {
const chick = game.chicks[index];
if (!chick || chick.stage !== 'queued') return false;
@ -299,9 +348,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (!scanner) return false;
const plan = outputRoute(game, side, { x: chick.x, y: chick.y }, scanner.id, true);
if (!plan) {
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;
@ -502,25 +556,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,6 +224,8 @@ 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 };
}
@ -269,21 +252,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 +305,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,5 @@
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({

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;
@ -271,7 +225,7 @@ export function commitFactoryGraphForDay(game) {
return graph;
}
export function graphSummary(graph) {
function graphSummary(graph) {
if (!graph) return null;
return {
version: graph.version,
@ -296,11 +250,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;
@ -453,13 +402,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 +494,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

@ -45,14 +45,17 @@ 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`
: '';
}
@ -104,7 +107,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
? `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> | 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 +146,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);
@ -164,6 +168,16 @@ 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 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(game.cash, 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 +187,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 +208,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 +224,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 +305,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

@ -202,6 +202,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 +247,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 +337,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;