This commit is contained in:
33333-33333 2026-06-09 10:38:29 +09:00
commit 383a46955c
18 changed files with 755 additions and 254 deletions

60
dist/game.js vendored

File diff suppressed because one or more lines are too long

View file

@ -12,9 +12,9 @@
<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 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 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>
@ -46,25 +46,20 @@
<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="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>・・30 / tile</span></button>
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>・・50 / 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 / 3.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>・・260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>・・450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>・・240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>・・450</span></button>
<button id="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>
<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>
@ -88,6 +83,7 @@
<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>
<button id="debugZeroTimerButton" type="button">Timer 0</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>

View file

@ -53,7 +53,7 @@ export const BALANCE = {
},
production: {
poopRate: 0.08,
autoScannerCooldown: 2.25,
autoScannerCooldown: 3.5,
autoScannerUpgradeRate: 0.95,
autoScannerMinCooldown: 0.5,
eggSpawnRanges: [
@ -171,6 +171,40 @@ export const BALANCE = {
description: 'Conveyor speed +7.5%.',
tags: ['CONVEYOR', 'PASSIVE']
},
{
id: 'sparePartsBin',
title: 'SPARE PARTS BIN',
rarity: 'common',
type: 'instant',
description: 'Restores 2% durability to every machine. A box of almost-compatible parts.',
tags: ['MAINTENANCE', 'ACTIVE'],
weightBase: 100,
weightLossPerCopy: 2
},
{
id: 'dudRefund',
title: 'DUD REFUND',
rarity: 'common',
type: 'instant',
description: 'DUDs now come with paperwork. Gain JPY 50 per copy when you pick a DUD.',
tags: ['CARD', 'ECONOMY', 'PASSIVE']
},
{
id: 'freeReroll',
title: 'FREE REROLL',
rarity: 'common',
type: 'instant',
description: 'The first bad ideas are free.',
tags: ['CARD', 'PASSIVE']
},
{
id: 'composter',
title: 'COMPOSTER',
rarity: 'common',
type: 'instant',
description: 'Turns shredder waste into suspicious fertilizer. Gain JPY 3 per copy when SHREDDER processes poop.',
tags: ['POOP', 'ECONOMY', 'PASSIVE']
},
{
id: 'extraEggOutlet',
title: 'Extra Egg Outlet',
@ -283,6 +317,10 @@ export const BALANCE = {
preventiveMaintenance: 0,
dudFilter: 0,
durabilityCoating: 0,
sparePartsBin: 0,
dudRefund: 0,
freeReroll: 0,
composter: 0,
laborExploitation: 0,
usedMachineActive: false,
rescueLoanCharges: 0,
@ -330,6 +368,10 @@ export const BALANCE = {
id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30,
buildable: true, upgradeable: false
},
boostConveyor: {
id: 'boostConveyor', type: 'conveyor', name: 'Boost Conveyor', shortName: 'BOOST BELT', price: 50,
buildable: true, upgradeable: false, speedMultiplier: 2
},
eggFarm: {
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]

View file

@ -24,7 +24,7 @@ export const EGG_SPAWN_RANGES = BALANCE.production.eggSpawnRanges;
export const GRID = BALANCE.grid;
export const FACILITY_DEFS = BALANCE.facilities;
export const BUILD_TOOL_IDS = ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck'];
export const BUILD_TOOL_IDS = ['conveyor', 'boostConveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck'];
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
export const INCOME_FACILITY_IDS = ['mixer', 'truck'];

View file

@ -33,17 +33,19 @@ export function createEggFarm(game, col, row) {
export function createScanner(game, col, row, kind) {
const cost = kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price;
const manualCount = game.scanners.filter(s => s.kind === 'manual').length;
const usedManualSlots = new Set(game.scanners.filter(s => s.kind === 'manual').map(s => s.slot));
let manualSlot = 0;
while (usedManualSlots.has(manualSlot)) manualSlot += 1;
return {
type: 'scanner', id: game.nextId++, kind,
slot: kind === 'manual' ? manualCount : null,
role: manualCount % 2,
slot: kind === 'manual' ? manualSlot : null,
role: kind === 'manual' ? manualSlot % 2 : 0,
col, row, level: 1,
queue: [], cooldown: 0,
price: cost,
builtSession: game.buildSession,
autoMode: 'standard',
keys: kind === 'manual' ? manualKeysForSlot(manualCount) : null
keys: kind === 'manual' ? manualKeysForSlot(manualSlot) : null
};
}

View file

@ -33,6 +33,9 @@ export function newTurnStats() {
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
cardRerollCost: 0,
dudRefundIncome: 0,
composterIncome: 0,
sparePartsRepair: 0,
mixerPoopFine: 0,
truckPoopFine: 0,
maleTruckFine: 0,
@ -74,6 +77,9 @@ export function newTotalStats() {
manualComboFailure: 0,
repairWorkerWages: 0,
cardRerollCost: 0,
dudRefundIncome: 0,
composterIncome: 0,
sparePartsRepair: 0,
mixerPoopFine: 0,
truckPoopFine: 0,
maleTruckFine: 0,
@ -135,7 +141,7 @@ 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, dudFilter: 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, sparePartsBin: 0, dudRefund: 0, freeReroll: 0, composter: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null,
manualCombo: { count: 0, lastBonus: 0 },
@ -182,15 +188,15 @@ export function defaultFacilities() {
const INITIAL_CONVEYOR_BACKBONE = Object.freeze([
// 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],
[1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [8, 2],
// S1 left output -> Mixer receiver on the left grid edge.
[7, 4], [6, 4], [5, 4], [4, 4], [3, 4], [2, 4], [1, 4], [0, 4],
// S1 right output -> S2 top input.
[9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5], [15, 6],
[10, 4], [11, 4], [12, 4], [13, 4], [14, 4], [15, 4], [15, 5],
// S2 left output -> Waste Shredder receiver on bottom grid edge.
[14, 7], [13, 7], [12, 7], [12, 8], [12, 9],
// S2 right output -> Truck receiver on the bottom grid edge.
[16, 7], [17, 7], [18, 7], [18, 8], [18, 9]
[17, 7], [18, 7], [18, 8], [18, 9]
]);
const INITIAL_SCANNERS = Object.freeze([
@ -213,13 +219,19 @@ function addConveyorTile(game, col, row, options = {}) {
uses: 0,
durability: BALANCE.maintenance.durability.conveyor,
maintenanceType: 'conveyor',
kind: options.kind || 'conveyor',
speedMultiplier: options.speedMultiplier || 1,
dir: options.dir || null,
outDirs: Array.isArray(options.outDirs) ? [...options.outDirs] : []
outDirs: Array.isArray(options.outDirs) ? [...options.outDirs] : [],
branchMode: options.branchMode || 'random'
});
} 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])];
if (!meta.branchMode) meta.branchMode = 'random';
if (!meta.kind) meta.kind = 'conveyor';
if (!meta.speedMultiplier) meta.speedMultiplier = 1;
}
}
@ -242,9 +254,20 @@ function markInitialConveyorDirection(game, from, to) {
function scannerPortCells(scanner) {
return [
{ col: scanner.col, row: scanner.row - 1 },
{ col: scanner.col, row: scanner.row - 2 },
{ col: scanner.col - 1, row: scanner.row },
{ col: scanner.col + 1, row: scanner.row }
{ col: scanner.col + 2, row: scanner.row }
].filter(p => inGrid(p.col, p.row));
}
function scannerBodyCells(scanner) {
return [
{ col: scanner.col, row: scanner.row - 1 },
{ col: scanner.col + 1, row: scanner.row - 1 },
{ col: scanner.col, row: scanner.row },
{ col: scanner.col + 1, row: scanner.row },
{ col: scanner.col, row: scanner.row + 1 },
{ col: scanner.col + 1, row: scanner.row + 1 }
].filter(p => inGrid(p.col, p.row));
}
@ -261,7 +284,7 @@ function protectedInitialCells(game) {
const reserved = new Set([...game.conveyorTiles]);
const add = p => { if (p && inGrid(p.col, p.row)) reserved.add(key(p.col, p.row)); };
for (const scanner of game.scanners) {
add(scanner);
for (const p of scannerBodyCells(scanner)) add(p);
for (const p of scannerPortCells(scanner)) add(p);
}
for (const farm of game.eggFarms) {

View file

@ -56,7 +56,7 @@ export function equipmentName(id) {
export function buildToolPriceText(id) {
const def = FACILITY_DEFS[id];
if (!def) return '';
if (id === 'conveyor') return `${yen(def.price)} / tile`;
if (id === 'conveyor' || id === 'boostConveyor') return `${yen(def.price)} / tile`;
if (id === 'autoScanner') return `${yen(def.price)} / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s cooldown`;
if (id === 'trash') return yen(def.price);
return yen(def.price);
@ -65,8 +65,9 @@ export function buildToolPriceText(id) {
export function buildToolFlavorText(id) {
const flavors = {
conveyor: 'A narrow green belt. Routes decide whether chicks live, ship, or become invoices.',
boostConveyor: 'Moves chicks twice as fast. Wears down like a normal belt.',
eggFarm: 'A tiny gatehouse producing questionable eggs on schedule.',
autoScanner: 'An automated judge. Faster than hands, still very sure of itself.',
autoScanner: 'Slower than hands without upgrades.',
manualScanner: 'A manual checkpoint. The operator is the algorithm.',
mixer: 'Male chicks become revenue here. Do not feed it poop.',
trash: 'A polite shredder for poop and other regrets.',

View file

@ -28,12 +28,12 @@ const ui = {
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'),
zeroTimer: document.getElementById('debugZeroTimerButton'),
cardSelect: document.getElementById('debugCardSelect'),
grantCard: document.getElementById('debugGrantCardButton'),
grantAllCards: document.getElementById('debugGrantAllCardsButton'),
@ -41,7 +41,7 @@ const ui = {
},
buttons: {
s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'),
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), 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')
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), boostConveyor: document.getElementById('buildBoostConveyorButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
}
};
@ -49,6 +49,7 @@ const ui = {
function initializeStaticText() {
const buttonToolMap = {
conveyor: 'conveyor',
boostConveyor: 'boostConveyor',
eggFarm: 'eggFarm',
autoScanner: 'autoScanner',
manualScanner: 'manualScanner',
@ -105,9 +106,22 @@ function debugSetDay() {
updateDebugReadout(`Day set to ${day}.`);
}
function debugZeroTimer() {
game.timeLeft = 0;
if (game.phase === 'running') closeFarmShutters();
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, 'TIMER 0', THEME.warn);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout('Timer set to 0.');
}
function debugGrantSelectedCard() {
const id = ui.debug.cardSelect?.value;
const result = debugGrantCard(game, id);
if (ui.debug.grantCard) {
ui.debug.grantCard.textContent = result.ok ? 'Granted' : 'Grant failed';
window.setTimeout(() => { if (ui.debug.grantCard) ui.debug.grantCard.textContent = 'Grant Card'; }, 900);
}
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(result.reason);
@ -440,6 +454,19 @@ function clickSelect(event) {
uiSystem.updatePanels();
const obj = build.selectedObject();
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj);
if (obj?.type === 'scanner' && obj.kind === 'auto') build.showAutoScannerMenu(obj);
}
function cancelBuildAction() {
game.buildTool = null;
game.selected = null;
game.multiSelected = [];
game.selectionBox = null;
if (game.cardTargetPick?.mode === 'autoScannerMenu') game.cardTargetPick = null;
uiSystem.hideModal();
hideHoverTooltip();
uiSystem.updatePanels();
uiSystem.updateUI();
}
function escapeHtml(value) {
@ -557,13 +584,21 @@ canvas.addEventListener('pointerdown', event => {
const world = canvasPoint(event);
if (event.button === 2) { startPan(event); return; }
if (event.button !== 0) return;
if (game.cardTargetPick?.mode === 'autoScannerMenu') { cancelBuildAction(); 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 (build.cycleBranchModeAtPoint?.(world)) { uiSystem.updatePanels(); uiSystem.updateUI(); return; }
if (game.buildTool === 'erase') {
if (!build.equipmentAtPoint(world)) { cancelBuildAction(); return; }
build.eraseAtPoint(world);
return;
}
if (game.buildTool) {
if (game.buildTool === 'conveyor') {
build.beginConveyorDrag(pointToCell(world.x, world.y));
const cell = pointToCell(world.x, world.y);
if (!cell && !['mixer', 'trash', 'truck'].includes(game.buildTool)) { cancelBuildAction(); return; }
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') {
build.beginConveyorDrag(cell);
return;
}
const hit = build.equipmentAtPoint(world);
@ -580,7 +615,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.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y));
if ((game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') && (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 => {
@ -589,7 +624,7 @@ canvas.addEventListener('pointerup', event => {
if (!game.groupDrag.committed) clickSelect(event);
build.finishGroupDrag();
}
if (game.buildTool === 'conveyor') build.endConveyorDrag?.();
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') build.endConveyorDrag?.();
game.groupDrag = null; game.pan = null;
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
});
@ -598,14 +633,14 @@ canvas.addEventListener('pointercancel', () => { build?.endConveyorDrag?.(); gam
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;
uiSystem.hideModal();
cancelBuildAction();
}, true);
ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right'));
ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.boostConveyor.addEventListener('click', () => build.setBuildTool('boostConveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
ui.hireRepairmanButton?.addEventListener('click', hireRepairman);
ui.expandGridButton?.addEventListener('click', () => { build?.buyGridExpansion?.('right'); clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); });
ui.debug.setDay?.addEventListener('click', debugSetDay);
ui.debug.zeroTimer?.addEventListener('click', debugZeroTimer);
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(); } });
@ -626,7 +661,11 @@ window.addEventListener('keydown', event => {
}
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); }
if (event.key === 'Escape' && game.cardTargetPick?.pending) {
event.preventDefault();
if (game.cardTargetPick.mode === 'autoScannerMenu') cancelBuildAction();
else cardSystem.cancelTargetPick();
}
});

View file

@ -254,18 +254,79 @@ function drawConveyors(ctx, game) {
const directionMarkers = collectConveyorDirectionMarkers(game);
for (const k of game.conveyorTiles) {
const c = cellCenter(...Object.values(parseKey(k)));
const meta = game.conveyorMeta.get(k) || {};
const ratio = componentRatio(game, k);
ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white;
const baseFill = meta.kind === 'boostConveyor' ? '#fff2b8' : THEME.white;
ctx.fillStyle = ratio > 0.5 ? mixHex(baseFill, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : baseFill;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true);
drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta: game.conveyorMeta.get(k) });
if (meta.kind === 'boostConveyor') {
ctx.save();
ctx.strokeStyle = THEME.warn;
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(c.x - 15, c.y + 13); ctx.lineTo(c.x - 3, c.y - 13); ctx.lineTo(c.x + 2, c.y - 2); ctx.lineTo(c.x + 15, c.y - 13); ctx.stroke();
ctx.restore();
}
drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta });
drawSelection(ctx, game, 'conveyor', k, c.x, c.y, 38, 38);
drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []);
drawBranchModeLabel(ctx, c, meta, reachableConveyorDirs(game, k, meta));
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
}
ctx.restore();
}
function branchModeText(mode) {
return { random: 'RND', pass: 'PASS', up: 'UP', right: 'RT', down: 'DN', left: 'LF' }[mode || 'random'] || 'RND';
}
function neighborPointsIntoCell(game, p, dirName) {
const d = DIRS.find(item => item.name === dirName);
if (!d) return false;
const meta = game.conveyorMeta?.get?.(key(p.col + d.dc, p.row + d.dr));
if (!meta) return false;
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
return names.includes(d.opposite);
}
function reachableConveyorDirs(game, k, meta) {
const p = parseKey(k);
const neighborNames = DIRS
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(name => neighborNames.includes(name) && !neighborPointsIntoCell(game, p, name));
const inferred = neighborNames.filter(name => !neighborPointsIntoCell(game, p, name));
return explicit.length ? explicit : inferred;
}
function effectiveConveyorDirs(game, k, meta) {
const p = parseKey(k);
const neighborNames = DIRS
.filter(d => game.conveyorTiles.has(key(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(name => neighborNames.includes(name) && !neighborPointsIntoCell(game, p, name));
const inferred = neighborNames.filter(name => !neighborPointsIntoCell(game, p, name));
return explicit.length ? explicit : inferred;
}
function drawBranchModeLabel(ctx, center, meta, dirs) {
if (dirs.length < 3) return;
ctx.save();
ctx.font = '900 11px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.lineWidth = 3;
ctx.fillStyle = 'rgba(255,255,255,.96)';
const mode = meta.branchMode === 'pass' && dirs.length >= 4 ? 'pass' : (dirs.includes(meta.branchMode) ? meta.branchMode : 'random');
ctx.strokeStyle = mode !== 'random' ? THEME.warn : THEME.green;
rect(ctx, center.x - 24, center.y + 11, 48, 24, true, true);
ctx.fillStyle = mode !== 'random' ? THEME.warn : THEME.ink;
const text = branchModeText(mode);
ctx.fillText(text, center.x, center.y + 27);
ctx.restore();
}
function cellKeyFromPoint(game, p) {
const cell = pointToCell(p.x, p.y);
if (!cell) return null;
@ -290,12 +351,9 @@ function addMarkerFromRoute(game, markers, route) {
if (!list.some(x => Math.abs(Math.sin((x - angle) / 2)) < 0.01)) list.push(angle);
}
}
function explicitConveyorAngles(meta) {
function explicitConveyorAngles(game, k, 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)]
return effectiveConveyorDirs(game, k, meta)
.map(name => DIRS.find(d => d.name === name)?.angle)
.filter(angle => Number.isFinite(angle));
}
@ -303,7 +361,7 @@ function explicitConveyorAngles(meta) {
function collectConveyorDirectionMarkers(game) {
const markers = new Map();
for (const [k, meta] of game.conveyorMeta || []) {
const angles = explicitConveyorAngles(meta);
const angles = explicitConveyorAngles(game, k, meta);
if (angles.length) markers.set(k, angles);
}
for (const farm of game.eggFarms) addMarkerFromRoute(game, markers, routeFromFarmToScanner(game, farm)?.route);
@ -399,39 +457,39 @@ function drawScanner(ctx, scanner, game) {
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);
rect(ctx, c.x - 54, c.y - 76, 108, 152, 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);
if (drawImageIfLoaded(ctx, img, c.x - 50, c.y - 73, 100, 146)) {
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 50, c.y - 73, 100, 146, scanner);
else drawManualKeyboardIcon(ctx, scanner, c);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 108, 154);
ctx.restore(); return;
}
ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true);
rect(ctx, c.x - 50, c.y - 73, 100, 146, true, true);
ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink;
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.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 48);
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);
ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y - 28);
const q = scanner.queue.length;
if (scanner.kind === 'manual') {
drawManualKeyboardIcon(ctx, scanner, c);
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`Q:${q}`, c.x, c.y + 32);
ctx.fillText(`Q:${q}`, c.x, c.y + 54);
} else {
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
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);
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 54);
drawDirtOverlay(ctx, c.x - 50, c.y - 73, 100, 146, scanner);
}
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 108, 154);
ctx.restore();
}
@ -445,7 +503,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
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;
const baseY = c.y + 18;
ctx.save();
ctx.lineWidth = 3;
ctx.fillStyle = '#f7fff5';
@ -506,9 +564,13 @@ function receiverTitle(id, game) {
if (id === 'truck') return { title: `IN: ${targetForTruck(game).toUpperCase()}`, type: targetForTruck(game), color: THEME.truckPink };
return { title: 'IN', type: 'female', color: THEME.green };
}
function drawFacilityReceiver(ctx, game, id) {
const f = game.facilities[id];
function facilityKind(fOrId) {
return typeof fOrId === 'string' ? fOrId : (fOrId?.baseId || fOrId?.id || '');
}
function drawFacilityReceiver(ctx, game, facility) {
const f = typeof facility === 'string' ? game.facilities[facility] : facility;
if (!f?.entry) return;
const id = facilityKind(f);
const c = cellCenter(f.entry.col, f.entry.row);
const info = receiverTitle(id, game);
ctx.save();
@ -527,12 +589,13 @@ function drawFacilityReceiver(ctx, game, id) {
ctx.restore();
}
function drawFacilities(ctx, game) {
if (game.facilities.mixer) drawMixer(ctx, game);
if (game.facilities.trash) drawTrash(ctx, game);
if (game.facilities.truck) drawTruck(ctx, game);
drawFacilityReceiver(ctx, game, 'mixer');
drawFacilityReceiver(ctx, game, 'trash');
drawFacilityReceiver(ctx, game, 'truck');
for (const f of Object.values(game.facilities || {})) {
const kind = facilityKind(f);
if (kind === 'mixer') drawMixer(ctx, game, f);
else if (kind === 'trash') drawTrash(ctx, game, f);
else if (kind === 'truck') drawTruck(ctx, game, f);
}
for (const f of Object.values(game.facilities || {})) drawFacilityReceiver(ctx, game, f);
}
function drawExternalDuct(ctx, f) {
if (!f?.entry) return;
@ -551,11 +614,10 @@ function drawExternalDuct(ctx, f) {
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bx, by); ctx.stroke();
ctx.restore();
}
function drawMixer(ctx, game) {
const m = game.facilities.mixer;
function drawMixer(ctx, game, m = game.facilities.mixer) {
ctx.save();
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; }
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', m.id, 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 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);
@ -563,27 +625,25 @@ function drawMixer(ctx, game) {
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(); }
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);
drawSelection(ctx, game, 'facility', m.id, m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
ctx.restore();
}
function drawTrash(ctx, game) {
const t = game.facilities.trash;
function drawTrash(ctx, game, t = game.facilities.trash) {
ctx.save();
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; }
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', t.id, 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 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);
drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore();
}
function drawTruck(ctx, game) {
const t = game.facilities.truck;
function drawTruck(ctx, game, t = game.facilities.truck) {
ctx.save();
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; }
if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { drawSelection(ctx, game, 'facility', t.id, 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 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);
@ -591,7 +651,7 @@ function drawTruck(ctx, game) {
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo');
ctx.fillStyle = THEME.greenSoft; rect(ctx, t.x + 13, t.y + 88, t.w - 26, t.h - 103, true, false);
for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore();
}
function drawChicks(ctx, game, activeQueuedChick) {
@ -700,7 +760,9 @@ function drawCardTargetOverlay(ctx, canvas, game) {
ctx.strokeStyle = THEME.ink;
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.';
const msg = game.cardTargetPick?.mode === 'autoScannerMenu'
? 'AUTO SCANNER ROUTE SETTINGS. CLICK EMPTY AREA TO CLOSE.'
: isBlockedCellMode ? `CLICK BLOCKED CELL: ${remaining} LEFT. PRESS ESC TO CANCEL.` : 'CLICK AN UPGRADEABLE MACHINE. PRESS ESC TO CANCEL.';
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';

View file

@ -1,12 +1,12 @@
import { BALANCE } from '../core/balance.js';
import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
import { DIRS, MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
import { getSpawnRange } from '../core/state.js';
import { createEggFarm, createScanner, createFacility } from '../core/entities.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, disconnectedBuildWarnings } from './routing.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, 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';
@ -19,6 +19,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
let lastConveyorBuildCell = null;
let conveyorDragRecordedDirectionEdit = false;
let conveyorDragStartCell = null;
let conveyorDragMoved = false;
const BRANCH_MODES = ['random', 'pass', 'up', 'right', 'down', 'left'];
const BRANCH_LABELS = { random: 'RND', pass: 'PASS', up: 'UP', right: 'RT', down: 'DN', left: 'LF' };
const BRANCH_MARKER = { w: 48, h: 24, y: 23 };
function gridExpansionCost() { return expansionCost(game); }
@ -128,6 +133,20 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return false;
}
function scannerPlacementBlocked(col, row, moving = null) {
const draft = { col, row };
for (const cell of scannerFootprintCells(draft)) {
if (!pointInGrid(cell.col, cell.row) || isBlockedCell(cell.col, cell.row)) return true;
const k = key(cell.col, cell.row);
const existingFarm = farmAt(game, cell.col, cell.row);
if (existingFarm) return true;
const existingScanner = scannerAt(game, cell.col, cell.row);
if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
if (game.conveyorTiles.has(k)) return true;
}
return false;
}
function selectedObject() {
if (!game.selected) return null;
if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null;
@ -141,12 +160,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (!obj) return 'Equipment';
if (obj.type === 'eggFarm') return `${equipmentName('eggFarm')} #${obj.id}`;
if (obj.type === 'scanner') return `${obj.kind === 'auto' ? equipmentName('autoScanner') : equipmentName('manualScanner')} #${obj.id}`;
if (obj.type === 'conveyor') return equipmentName('conveyor');
if (obj.type === 'conveyor') return game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor' ? equipmentName('boostConveyor') : equipmentName('conveyor');
if (obj.type === 'facility') return obj.name;
return 'Equipment';
}
function equipmentPrice(hit) {
if (hit?.type === 'conveyor') return game.conveyorMeta?.get(hit.oldKey || hit.ref?.id)?.price || equipmentBasePrice(hit);
return equipmentBasePrice(hit);
}
@ -171,8 +191,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') {
return game.eggFarms.length <= 1 ? 'Cannot sell the last EGG FARM.' : '';
}
if ((hit?.type === 'facility' || obj.type === 'facility') && ['mixer', 'trash', 'truck'].includes(obj.id)) {
const count = Object.values(game.facilities).filter(f => f.id === obj.id).length;
if ((hit?.type === 'facility' || obj.type === 'facility') && ['mixer', 'trash', 'truck'].includes(obj.baseId || obj.id)) {
const kind = obj.baseId || obj.id;
const count = Object.values(game.facilities).filter(f => (f.baseId || f.id) === kind).length;
if (count <= 1) return `Cannot sell the last ${obj.shortName || obj.name || obj.id}.`;
}
return '';
@ -193,9 +214,47 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (!meta) return [];
if (!Array.isArray(meta.outDirs)) meta.outDirs = [];
meta.outDirs = [...new Set(meta.outDirs.filter(dir => ['right', 'left', 'down', 'up'].includes(dir)))];
if (!BRANCH_MODES.includes(meta.branchMode)) meta.branchMode = 'random';
if (!meta.kind) meta.kind = 'conveyor';
if (!meta.speedMultiplier) meta.speedMultiplier = meta.kind === 'boostConveyor' ? 2 : 1;
return meta.outDirs;
}
function conveyorNeighborOutDirs(cell) {
if (!cell) return [];
return DIRS.filter(d => game.conveyorTiles.has(key(cell.col + d.dc, cell.row + d.dr))).map(d => d.name);
}
function neighborPointsIntoCell(cell, dirName) {
const d = DIRS.find(item => item.name === dirName);
if (!cell || !d) return false;
const nk = key(cell.col + d.dc, cell.row + d.dr);
const meta = game.conveyorMeta.get(nk);
if (!meta) return false;
const incoming = d.opposite;
const dirs = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
return dirs.includes(incoming);
}
function reachableOutDirsForKey(k) {
if (!game.conveyorTiles.has(k)) return [];
const cell = parseKey(k);
const meta = game.conveyorMeta.get(k);
const explicit = normalizeOutDirs(meta);
const neighbors = conveyorNeighborOutDirs(cell);
const explicitReachable = [...new Set([...explicit, meta?.dir].filter(Boolean))]
.filter(dir => neighbors.includes(dir) && !neighborPointsIntoCell(cell, dir));
const inferredExits = neighbors.filter(dir => !neighborPointsIntoCell(cell, dir));
const reachable = explicitReachable.length ? explicitReachable : inferredExits;
if (meta) {
meta.outDirs = explicit.filter(dir => neighbors.includes(dir));
if (meta.dir && !neighbors.includes(meta.dir)) meta.dir = meta.outDirs[0] || null;
if (meta.branchMode !== 'random' && meta.branchMode !== 'pass' && !reachable.includes(meta.branchMode)) meta.branchMode = 'random';
if (meta.branchMode === 'pass' && reachable.length < 4) meta.branchMode = 'random';
}
return reachable;
}
function markConveyorDirection(from, to) {
const dir = directionNameBetweenCells(from, to);
if (!dir) return false;
@ -212,6 +271,38 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return changed;
}
function availableConveyorOutDirs(cell) {
if (!cell) return [];
return reachableOutDirsForKey(key(cell.col, cell.row));
}
function cycleSingleConveyorDirection(cell) {
if (!cell) return false;
const k = key(cell.col, cell.row);
const meta = game.conveyorMeta.get(k);
if (!meta) return false;
const choices = availableConveyorOutDirs(cell);
if (!choices.length) return false;
const explicit = normalizeOutDirs(meta);
const current = meta.dir && choices.includes(meta.dir) ? meta.dir : explicit.find(dir => choices.includes(dir));
const next = choices[(Math.max(-1, choices.indexOf(current)) + 1) % choices.length];
if (!next) return false;
record(game);
meta.dir = next;
meta.outDirs = [next];
meta.branchMode = 'random';
game.selected = { type: 'conveyor', id: k };
const c = cellCenter(cell.col, cell.row);
floating(game, c.x, c.y - 22, branchModeLabel(next), THEME.ink);
refreshRoutingAfterEdit(game);
updatePanels();
return true;
}
function conveyorToolDef() {
return BALANCE.facilities[game.buildTool === 'boostConveyor' ? 'boostConveyor' : 'conveyor'];
}
function buildConveyorCell(cell, incomingDir = null) {
if (!cell) return { ok: false, reason: TEXT.fail.outOfGrid };
const { col, row } = cell;
@ -219,7 +310,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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);
const toolDef = conveyorToolDef();
const kind = toolDef.id || 'conveyor';
const cost = buildPrice(kind, game);
if (game.cash < cost) return { ok: false, reason: TEXT.fail.notEnoughCash };
record(game);
spendCash(game, cost);
@ -231,8 +324,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
uses: 0,
durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier),
maintenanceType: 'conveyor',
kind,
speedMultiplier: toolDef.speedMultiplier || 1,
dir: incomingDir || null,
outDirs: [],
branchMode: 'random',
...quality
});
game.selected = { type: 'conveyor', id: k };
@ -246,6 +342,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const exists = game.conveyorTiles.has(currentKey);
const previous = lastConveyorBuildCell;
if (previous && previous.col === cell.col && previous.row === cell.row) return;
if (previous) conveyorDragMoved = true;
if (exists) {
if (previous && markConveyorDirection(previous, cell)) {
@ -280,6 +377,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function beginConveyorDrag(cell) {
conveyorDragRecordedDirectionEdit = false;
conveyorDragStartCell = cell ? { col: cell.col, row: cell.row } : null;
conveyorDragMoved = false;
lastConveyorBuildCell = null;
handleConveyorDragCell(cell);
}
@ -299,6 +398,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
function endConveyorDrag() {
if (!conveyorDragMoved && conveyorDragStartCell && game.conveyorTiles.has(key(conveyorDragStartCell.col, conveyorDragStartCell.row))) {
cycleSingleConveyorDirection(conveyorDragStartCell);
}
conveyorDragStartCell = null;
conveyorDragMoved = false;
lastConveyorBuildCell = null;
conveyorDragRecordedDirectionEdit = false;
}
@ -306,14 +410,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function buildAtCell(cell) {
if (!cell) return fail(TEXT.fail.outOfGrid);
const { col, row } = cell;
if (game.buildTool === 'conveyor') {
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') {
const result = buildConveyorCell(cell, null);
if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
refreshRoutingAfterEdit(game);
return;
}
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
if (isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied);
if ((game.buildTool === 'manualScanner' || game.buildTool === 'autoScanner') ? scannerPlacementBlocked(col, row) : isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied);
if (game.buildTool === 'eggFarm') buildFarm(col, row);
else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
else if (game.buildTool === 'autoScanner') buildScanner(col, row, 'auto');
@ -321,7 +425,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function buildAtPoint(p) {
const cell = pointToCell(p.x, p.y);
if (['conveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell);
if (['conveyor', 'boostConveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell);
if (MACHINE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
}
@ -342,6 +446,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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);
if (scannerPlacementBlocked(col, row)) return fail(TEXT.fail.cellOccupied);
record(game);
spendCash(game, cost);
const scanner = applyBuildQuality(createScanner(game, col, row, kind));
@ -354,16 +459,18 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function buildFacility(p, id) {
const cost = buildPrice(id, game);
if (game.facilities[id]) return fail(TEXT.fail.facilityExists);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
const f = applyBuildQuality(createFacility(game, id, p, cost));
const storageId = game.facilities[id] ? `${id}:${game.nextId++}` : id;
f.baseId = id;
f.id = storageId;
if (f.entry && isBlockedCell(f.entry.col, f.entry.row)) return fail('Receiver cell is blocked.');
if (facilityOverlaps(f)) return fail(TEXT.fail.facilityOverlap);
record(game);
spendCash(game, cost);
game.facilities[id] = f;
game.facilities[storageId] = f;
refreshRoutingAfterEdit(game);
game.selected = { type: 'facility', id };
game.selected = { type: 'facility', id: storageId };
floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
}
@ -371,7 +478,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const resale = resaleValueFor(hit, game);
if (resale.amount <= 0) return;
refundCash(game, resale.amount);
const label = resale.sameBuild ? 'REFUND' : 'SOLD 50%';
const label = resale.sameBuild ? 'REFUND' : 'SOLD';
floating(game, x, y - 16, `${label} +${yen(resale.amount)}`, THEME.green);
}
@ -461,6 +568,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
function hideModal() {
if (game.cardTargetPick?.mode === 'autoScannerMenu') game.cardTargetPick = null;
ui.modal.classList.remove('visible', 'equipment-popover');
ui.modal.style.removeProperty('--popover-x');
ui.modal.style.removeProperty('--popover-y');
@ -468,13 +576,26 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function showAutoScannerMenu(scanner) {
ui.modalTitle.textContent = 'Auto Scanner';
ui.modalBody.innerHTML = `<p>Standard auto scanner.</p><ul><li>Role 0: male left, others right.</li><li>Role 1: poop left, others right.</li><li>Cooldown: ${autoScannerCooldownSeconds(scanner).toFixed(1)}s.</li></ul>`;
game.cardTargetPick = {
pending: true,
mode: 'autoScannerMenu',
cardId: null,
targetKeys: [`scanner:${scanner.id}`],
remaining: 1
};
ui.modalBody.innerHTML = `
<div class="equipment-menu-lines compact">
<p>Left / right output routing for this AUTO scanner.</p>
<p>Current: ${scanner.role === 0 ? 'Male left / others right' : 'Poop left / others right'}</p>
<p>Cooldown: ${autoScannerCooldownSeconds(scanner).toFixed(1)}s.</p>
</div>`;
ui.modalActions.innerHTML = '';
const r0 = modalButton('Set Role 0', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); });
const r1 = modalButton('Set Role 1', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); });
ui.modalActions.append(r0, r1);
const r0 = modalButton('Male Left / Others Right', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); }, scanner.role === 0 ? 'facility-action warn' : 'facility-action');
const r1 = modalButton('Poop Left / Others Right', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); }, scanner.role === 1 ? 'facility-action warn' : 'facility-action');
ui.modalActions.append(r0, r1, modalButton('Close', hideModal, 'facility-action'));
positionEquipmentPopover(scanner);
ui.modal.classList.add('visible', 'equipment-popover');
updatePanels();
}
function formatKeyBinding(binding) {
@ -507,7 +628,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
<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>`;
@ -625,14 +745,64 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (f.entry) { const c = cellCenter(f.entry.col, f.entry.row); boxes.push({ id: `facilityPort:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: c.x - 18, y: c.y - 18, w: 36, h: 36 }); }
}
for (const farm of game.eggFarms) { const c = cellCenter(farm.col, farm.row); boxes.push({ id: `eggFarm:${farm.id}`, price: equipmentPrice({ type: 'eggFarm', ref: farm }), x: c.x - 24, y: c.y - 24, w: 48, h: 48 }); }
for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 52, y: c.y - 36, w: 104, h: 72 }); }
for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: buildPrice('conveyor', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); }
for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 50, y: c.y - 73, w: 100, h: 146 }); }
for (const k of game.conveyorTiles) {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
const meta = game.conveyorMeta.get(k) || {};
boxes.push({ id: `conveyor:${k}`, price: meta.price || buildPrice(meta.kind === 'boostConveyor' ? 'boostConveyor' : 'conveyor', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 });
}
return boxes;
}
function branchModeLabel(mode) {
return BRANCH_LABELS[mode] || BRANCH_LABELS.random;
}
function branchOutDirsForKey(k) {
return reachableOutDirsForKey(k);
}
function isBranchKey(k) {
const dirs = branchOutDirsForKey(k);
return dirs.length >= 3;
}
function branchSwitcherAtPoint(p) {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null;
for (const k of game.conveyorTiles) {
if (!isBranchKey(k)) continue;
const cell = parseKey(k);
const c = cellCenter(cell.col, cell.row);
const left = c.x - BRANCH_MARKER.w / 2;
const top = c.y + BRANCH_MARKER.y - BRANCH_MARKER.h / 2;
if (p.x >= left && p.x <= left + BRANCH_MARKER.w && p.y >= top && p.y <= top + BRANCH_MARKER.h) return k;
}
return null;
}
function cycleBranchModeAtPoint(p) {
const k = branchSwitcherAtPoint(p);
if (!k) return false;
const meta = game.conveyorMeta.get(k);
if (!meta) return false;
const reachable = reachableOutDirsForKey(k);
if (reachable.length < 2) return false;
record(game);
const current = BRANCH_MODES.includes(meta.branchMode) ? meta.branchMode : 'random';
const modes = ['random', ...(reachable.length >= 4 ? ['pass'] : []), ...DIRS.map(d => d.name).filter(mode => reachable.includes(mode))];
meta.branchMode = modes[(Math.max(0, modes.indexOf(current)) + 1) % modes.length];
game.selected = { type: 'conveyor', id: k };
const c = cellCenter(...Object.values(parseKey(k)));
floating(game, c.x, c.y - 22, branchModeLabel(meta.branchMode), THEME.ink);
updatePanels();
return true;
}
function selectedInfoLines(obj) {
ensureMaintenanceState(game);
const lines = [];
const facilityKind = obj?.type === 'facility' ? (obj.baseId || obj.id) : '';
if (obj.type === 'eggFarm') {
const [min, max] = getSpawnRange(obj);
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
@ -644,7 +814,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`);
lines.push(`Role: ${obj.role === 0 ? 'Male left / Others right' : 'Poop left / Others right'}`);
if (obj.kind === 'auto') lines.push(`Role: ${obj.role === 0 ? 'Male left / Others right' : 'Poop left / Others right'}`);
lines.push(`Queue: ${obj.queue.length}`);
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj, game).toFixed(1)}s`);
if (obj.kind === 'auto') lines.push(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`);
@ -656,25 +826,27 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const c = comp ? game.congestion.get(comp) : null;
lines.push(`Price: ${yen(equipmentPrice({ type: 'conveyor', oldKey: obj.id, ref: obj }))}`);
lines.push(`Cell: ${obj.id}`);
lines.push(`Type: ${meta?.kind === 'boostConveyor' ? 'BOOST BELT' : 'Normal Belt'} | Speed x${Number(meta?.speedMultiplier || 1).toFixed(1)}`);
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`);
lines.push('Flow follows drawn directions. Branches choose randomly; full branches are avoided when possible.');
lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`);
lines.push('Click the branch marker to cycle Random / Up / Right / Down / Left.');
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))}`);
{ const reason = lastProtectedSaleReason({ type: obj.type, ref: obj }); lines.push(reason ? `Sale: blocked (${reason})` : `Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); }
lines.push(['mixer', 'truck', 'trash'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`);
if (obj.id === 'truck') lines.push('Durability: none | Always normal');
if (['mixer', 'trash'].includes(obj.id)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj.id).toFixed(2)}s`);
if (obj.id === 'mixer') {
if (facilityKind === 'truck') lines.push('Durability: none | Always normal');
if (['mixer', 'trash'].includes(facilityKind)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj).toFixed(2)}s`);
if (facilityKind === 'mixer') {
lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`);
lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`);
}
if (obj.id === 'truck') {
if (facilityKind === 'truck') {
lines.push(`Income: ${yen(upgradedTruckPrice(game))} per target cargo | upgrade x${incomeMultiplier(game, 'truck').toFixed(3)}`);
lines.push(`Poop shipment fine: -${yen(truckPoopPenalty(game))}`);
}
if (obj.id === 'trash') {
if (facilityKind === 'trash') {
const cards = shredderUpgradeCount(game);
const chance = shredderBonusChance(cards);
lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`);
@ -696,13 +868,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function flavorText(obj) {
if (!obj) return '';
if (obj.type === 'conveyor' && game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor') return 'Moves chicks twice as fast. Wears down like a normal belt.';
if (obj.type === 'conveyor') return 'A stubborn belt tile. It only cares about the next cell.';
if (obj.type === 'eggFarm') return 'A tiny gatehouse producing questionable eggs on schedule.';
if (obj.type === 'scanner' && obj.kind === 'auto') return 'An automated judge. Faster than hands, still very sure of itself.';
if (obj.type === 'scanner' && obj.kind === 'auto') return 'Slower than hands without upgrades.';
if (obj.type === 'scanner') return 'A manual checkpoint. The operator is the algorithm.';
if (obj.type === 'facility' && obj.id === 'mixer') return 'Male chicks become revenue here. Do not feed it poop.';
if (obj.type === 'facility' && obj.id === 'trash') return 'Poop goes in. Sometimes coins come out, for reasons best left unaudited.';
if (obj.type === 'facility' && obj.id === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'mixer') return 'Male chicks become revenue here. Do not feed it poop.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'trash') return 'Poop goes in. Sometimes coins come out, for reasons best left unaudited.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.';
return 'Factory equipment.';
}
@ -718,6 +891,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
selectedInfoLines, flavorText, disconnectedWarningFor,
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
branchSwitcherAtPoint, cycleBranchModeAtPoint,
removeSelected, switchScannerRole, showManualScannerMenu,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes,

View file

@ -3,7 +3,7 @@ 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';
import { floating, shake, eraseEffect, shockwave, sparkBurst, smokeBurst } from './effects.js';
import { averageConveyorPerformance, autoScannerDelayMultiplier, ensureMaintenanceState } from './maintenance.js';
import { averageConveyorPerformance, autoScannerDelayMultiplier, ensureMaintenanceState, repairAllEquipment } from './maintenance.js';
import { scannerCenter, refreshRoutingAfterEdit } from './routing.js';
const COMMON_WEIGHT = CARD_BALANCE.commonWeight;
@ -31,6 +31,7 @@ export function ensureCardState(game) {
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
if (!game.cardDraft) game.cardDraft = { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;
if (game.cardDraft.freeRerolls == null) game.cardDraft.freeRerolls = 0;
return game.cardEffects;
}
@ -47,8 +48,9 @@ export function extraEggOutletCount(_game, farm = null) {
export function conveyorSpeedForGame(game, conveyorKey = null) {
const effects = ensureCardState(game);
const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0));
const beltMultiplier = conveyorKey ? Math.max(0.1, Number(game?.conveyorMeta?.get?.(conveyorKey)?.speedMultiplier) || 1) : 1;
const perf = conveyorKey ? 1 : averageConveyorPerformance(game);
return Math.min(CONVEYOR_SPEED_MAX, speed * perf);
return Math.min(CONVEYOR_SPEED_MAX, speed * perf) * beltMultiplier;
}
export function autoScannerCooldownSeconds(scanner, game = null) {
@ -155,9 +157,11 @@ export function targetsForCard(game, cardOrId) {
.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] : [];
if (card.target === 'trash') return game.facilities.trash && rawShredderUpgradeCount(game) < shredderBonusMaxCards() ? [game.facilities.trash] : [];
if (card.target === 'mixer') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'mixer');
if (card.target === 'truck') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'truck');
if (card.target === 'trash') return rawShredderUpgradeCount(game) < shredderBonusMaxCards()
? Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'trash')
: [];
return [];
}
@ -190,17 +194,22 @@ function availableCards(game) {
});
}
function rarityWeight(card) {
function cardWeight(game, card) {
if (card.weightBase != null) {
const held = effectCount(game, card.id);
return Math.max(0, Number(card.weightBase) - Math.max(0, Number(card.weightLossPerCopy) || 0) * held);
}
if (card.rarity === 'ultraRare') return ULTRA_RARE_WEIGHT;
if (card.rarity === 'rare') return RARE_WEIGHT;
return COMMON_WEIGHT;
}
function weightedPick(pool) {
const total = pool.reduce((sum, card) => sum + rarityWeight(card), 0);
function weightedPick(game, pool) {
const total = pool.reduce((sum, card) => sum + cardWeight(game, card), 0);
if (total <= 0) return pool[Math.floor(Math.random() * pool.length)];
let roll = Math.random() * total;
for (const card of pool) {
roll -= rarityWeight(card);
roll -= cardWeight(game, card);
if (roll <= 0) return card;
}
return pool[pool.length - 1];
@ -247,7 +256,7 @@ export function dealCards(game, count = BASE_DRAFT_SIZE) {
let pool = [...source];
while (choices.length < count && source.length) {
if (!pool.length) pool = [...source];
const picked = weightedPick(pool);
const picked = weightedPick(game, pool);
choices.push(picked);
const i = pool.findIndex(card => card.id === picked.id);
if (i >= 0) pool.splice(i, 1);
@ -277,7 +286,7 @@ function boundsForTarget(target) {
}
if (target.type === 'scanner') {
const c = scannerCenter(target);
return { x: c.x - 58, y: c.y - 42, w: 116, h: 84, cx: c.x, cy: c.y };
return { x: c.x - 54, y: c.y - 77, w: 108, h: 154, cx: c.x, cy: c.y };
}
if (target.type === 'facility') {
return { x: target.x, y: target.y, w: target.w, h: target.h, cx: target.x + target.w / 2, cy: target.y + target.h / 2 };
@ -287,6 +296,13 @@ function boundsForTarget(target) {
export function cardTargetBounds(game) {
if (!game.cardTargetPick?.pending) return [];
if (game.cardTargetPick.mode === 'autoScannerMenu') {
const allowed = new Set(game.cardTargetPick.targetKeys || []);
return game.scanners
.filter(scanner => scanner.kind === 'auto' && allowed.has(targetKey(scanner)))
.map(scanner => ({ key: targetKey(scanner), label: 'AUTO ROUTE SETTINGS', target: scanner, bounds: boundsForTarget(scanner) }))
.filter(item => item.bounds);
}
const allowed = new Set(game.cardTargetPick.targetKeys || []);
return targetsForCard(game, game.cardTargetPick.cardId)
.filter(target => allowed.has(targetKey(target)))
@ -344,6 +360,16 @@ function applyInstantCard(game, card) {
if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance');
if (card.id === 'dudFilter') inc('dudFilter');
if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); }
if (card.id === 'sparePartsBin') {
inc('sparePartsBin');
const repaired = repairAllEquipment(game, 0.02);
game.stats.sparePartsRepair = (game.stats.sparePartsRepair || 0) + repaired;
game.totals.sparePartsRepair = (game.totals.sparePartsRepair || 0) + repaired;
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'REPAIR +2%', THEME.green);
}
if (card.id === 'dudRefund') inc('dudRefund');
if (card.id === 'freeReroll') inc('freeReroll');
if (card.id === 'composter') inc('composter');
if (card.id === 'flattery') inc('fairiesFlatteryNext');
if (card.id === 'usedMachine') effects.usedMachineActive = true;
if (card.id === 'newMachine') effects.usedMachineActive = false;
@ -432,6 +458,10 @@ function cardDescription(game, card) {
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 === 'sparePartsBin') return `Held ${e.sparePartsBin || 0}. Immediately restores 2% durability to every maintained machine. Offer weight: ${Math.max(0, 100 - 2 * Math.max(0, e.sparePartsBin || 0))}.`;
if (card.id === 'dudRefund') return `Held ${e.dudRefund || 0}. After pick: DUD clicks award ${yen(50 * ((e.dudRefund || 0) + 1))}.`;
if (card.id === 'freeReroll') return `Held ${e.freeReroll || 0}. After pick: each card screen starts with ${(e.freeReroll || 0) + 1} free reroll${(e.freeReroll || 0) + 1 === 1 ? '' : 's'}.`;
if (card.id === 'composter') return `Held ${e.composter || 0}. After pick: SHREDDER poop grants ${yen(3 * ((e.composter || 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.';
if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.';
@ -456,7 +486,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
function prepareDraft() {
ensureCardState(game);
game.cardTargetPick = null;
game.cardDraft = { pending: true, choices: dealCards(game, BASE_DRAFT_SIZE), rerolls: 0, picksRemaining: 1 };
game.cardDraft = { pending: true, choices: dealCards(game, BASE_DRAFT_SIZE), rerolls: 0, freeRerolls: effectCount(game, 'freeReroll'), picksRemaining: 1 };
}
function finishDraft() {
@ -496,6 +526,13 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
}
function chooseDud(card, buttonEl) {
const refund = effectCount(game, 'dudRefund') * 50;
if (refund > 0) {
const amount = applyRevenue(game, refund);
game.stats.dudRefundIncome = (game.stats.dudRefundIncome || 0) + amount;
game.totals.dudRefundIncome = (game.totals.dudRefundIncome || 0) + amount;
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, `DUD REFUND +${yen(amount)}`, THEME.green);
}
shake(game, 34, 0.62);
if (document.body?.animate) {
document.body.animate([
@ -566,8 +603,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
const card = cardById(game.cardTargetPick.cardId);
const target = targetAtPoint(game, p);
if (!card || !target) {
const label = game.cardTargetPick.mode === 'blockedCell' ? 'SELECT BLOCKED CELL' : 'SELECT UPGRADE TARGET';
floating(game, p.x, p.y - 18, label, THEME.danger);
cancelTargetPick();
return true;
}
if (card.id === 'dynamite') {
@ -587,6 +623,14 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
}
function doReroll() {
const free = Math.max(0, game.cardDraft?.freeRerolls || 0);
if (free > 0) {
game.cardDraft.freeRerolls = free - 1;
game.cardDraft.rerolls += 1;
redrawChoices(BASE_DRAFT_SIZE);
showDraft();
return;
}
const cost = rerollCost(game);
if (game.cash < cost) return;
applyPenalty(game, cost);
@ -617,6 +661,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
if (!game.cardDraft.choices?.length) redrawChoices(BASE_DRAFT_SIZE);
const choices = game.cardDraft.choices || [];
const cost = rerollCost(game);
const free = Math.max(0, game.cardDraft?.freeRerolls || 0);
const remaining = Math.max(1, game.cardDraft.picksRemaining || 1);
ui.modalTitle.textContent = 'Choose Upgrade Card';
ui.modalBody.innerHTML = `<p class="muted-card-note">Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Dud cards can be flicked away without consuming a pick. Equipment cards target machines; Dynamite targets blocked cells.</p><div id="cardChoices" class="card-choices"></div>`;
@ -626,11 +671,18 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
const reroll = document.createElement('button');
reroll.type = 'button';
reroll.className = 'facility-action warn reroll-button';
reroll.textContent = `Reroll ${yen(cost)}`;
reroll.disabled = game.cash < cost;
reroll.title = reroll.disabled ? 'Not enough cash' : `Reroll count today: ${(game.cardDraft.rerolls || 0) + 1}`;
reroll.textContent = free > 0 ? `Reroll (FREE x${free})` : `Reroll ${yen(cost)}`;
reroll.disabled = free <= 0 && game.cash < cost;
reroll.title = free > 0 ? `${free} free reroll${free === 1 ? '' : 's'} remaining.` : (reroll.disabled ? 'Not enough cash' : '');
reroll.addEventListener('click', doReroll);
ui.modalActions.appendChild(reroll);
const skip = document.createElement('button');
skip.type = 'button';
skip.className = 'facility-action';
skip.textContent = 'No Card';
skip.title = 'Skip this upgrade pick and enter Build phase.';
skip.addEventListener('click', finishDraft);
ui.modalActions.appendChild(skip);
ui.modal.classList.remove('equipment-popover');
ui.modal.classList.add('visible');
}

View file

@ -1,7 +1,7 @@
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, inGrid } from '../core/utils.js';
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid, directionNameBetweenCells } 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';
@ -104,7 +104,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
updateScannerQueues(dt);
updateCongestion();
if (game.timeLeft > 0 || game.shutdownTimeLeft > 0) checkCongestionExplosions();
if (game.timeLeft <= 0) {
if (game.chicks.length === 0) completeTurn();
else if (game.shutdownTimeLeft <= 0) blowOffRemainingForCleanup();
@ -172,7 +171,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
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));
const explicit = [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
const cell = parseKey(cellKey);
const inferred = DIRS
.filter(d => movementOptionFromDir(cell, d.name))
.map(d => d.name);
const cleanExplicit = explicit.filter(dir => inferred.includes(dir) && !neighborPointsIntoCell(cell, dir));
const inferredExits = inferred.filter(dir => !neighborPointsIntoCell(cell, dir));
return cleanExplicit.length ? cleanExplicit : inferredExits;
}
function dirByName(name) {
@ -180,7 +186,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
function scannerAtBodyCell(col, row) {
return game.scanners.find(scanner => scanner.col === col && scanner.row === row) || null;
return game.scanners.find(scanner => scanner.col <= col && col <= scanner.col + 1 && scanner.row - 1 <= row && row <= scanner.row + 1) || null;
}
function scannerReceivingFrom(cell, dirName) {
@ -196,6 +202,10 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null;
}
function facilityKind(f) {
return f?.baseId || f?.id || '';
}
function dirExitsToFacility(facility, dirName) {
return (facility.side === 'left' && dirName === 'left')
|| (facility.side === 'right' && dirName === 'right')
@ -219,9 +229,32 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return null;
}
function pickMovementOption(chick, cell, dirs) {
function neighborPointsIntoCell(cell, dirName) {
const d = dirByName(dirName);
if (!d) return false;
const meta = game.conveyorMeta?.get?.(key(cell.col + d.dc, cell.row + d.dr));
if (!meta) return false;
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
return names.includes(d.opposite);
}
function optionAvailableForBranch(chick, opt) {
return opt && (opt.type === 'facility' || !targetBlockedByChick(chick, opt.target));
}
function pickMovementOption(chick, cell, dirs, branchMode = 'random') {
const candidates = dirs.map(dir => movementOptionFromDir(cell, dir)).filter(Boolean);
if (!candidates.length) return null;
if (branchMode === 'pass') {
const previous = chick.prevConveyorCell ? parseKey(chick.prevConveyorCell) : null;
const forward = previous ? directionNameBetweenCells(previous, cell) : null;
const pass = forward ? candidates.find(opt => opt.dirName === forward) : null;
if (optionAvailableForBranch(chick, pass)) return pass;
}
if (branchMode && branchMode !== 'random') {
const preferred = candidates.find(opt => opt.dirName === branchMode);
if (optionAvailableForBranch(chick, preferred)) return preferred;
}
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');
@ -242,24 +275,27 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
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 meta = game.conveyorMeta?.get(currentKey) || {};
const dirs = conveyorOutDirNames(currentKey);
if (!dirs.length) {
chick.stoppedTimer = 0.25;
return;
}
const option = pickMovementOption(chick, cell, dirs);
const option = pickMovementOption(chick, cell, dirs, meta.branchMode || 'random');
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);
const kind = facilityKind(option.facility);
if (kind === 'mixer') resolveMixer(index, option.facility);
else if (kind === 'truck') resolveTruck(index, option.facility);
else if (kind === 'trash') resolveTrash(index, option.facility);
else removeChick(index, 'DONE');
return;
}
if (option.type === 'scanner') chick.pendingScannerId = option.scanner.id;
chick.prevConveyorCell = currentKey;
setSingleSegmentRoute(chick, option.target);
}
@ -497,8 +533,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return chick;
}
function resolveMixer(index) {
const mixer = game.facilities.mixer;
function resolveMixer(index, mixer = game.facilities.mixer) {
if (mixer?.processingCooldown > 0) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
@ -508,8 +543,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (!chick) return;
const { x, y } = chick;
countMixer(game);
recordFacilityProcess(game, 'mixer');
setFacilityCooldownAfterProcess(game, 'mixer');
recordFacilityProcess(game, mixer);
setFacilityCooldownAfterProcess(game, mixer);
if (chick.sex === 'poop') {
const penalty = applyMixerPoopFine(game);
countPoopDestination(game, 'mixer');
@ -525,11 +560,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
floating(game, x, y - 18, `+${yen(amount)}`, THEME.green);
}
function resolveTruck(index) {
function resolveTruck(index, truck = game.facilities.truck) {
const chick = takeChick(index);
if (!chick) return;
const { x, y } = chick;
addTruckCargo(chick.sex);
addTruckCargo(chick.sex, truck);
truckLoadEffect(game, x, y, chick.sex);
countTruckCargo(game, chick.sex);
const target = truckTarget(game);
@ -567,8 +602,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
}
function resolveTrash(index) {
const trash = game.facilities.trash;
function resolveTrash(index, trash = game.facilities.trash) {
if (trash?.processingCooldown > 0) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
@ -579,13 +613,20 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const { x, y } = chick;
shredEffect(game, x, y, chick.sex);
countTrash(game);
recordFacilityProcess(game, 'trash');
setFacilityCooldownAfterProcess(game, 'trash');
recordFacilityProcess(game, trash);
setFacilityCooldownAfterProcess(game, trash);
const bonus = applyShredderBonus(game);
const bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
if (chick.sex === 'poop') {
countPoopDestination(game, 'trash');
countCorrect(game);
const compost = Math.max(0, Number(game.cardEffects?.composter) || 0) * 3;
if (compost > 0) {
const compostAmount = applyRevenue(game, compost);
game.stats.composterIncome = (game.stats.composterIncome || 0) + compostAmount;
game.totals.composterIncome = (game.totals.composterIncome || 0) + compostAmount;
floating(game, x, y - 36, `COMPOST +${yen(compostAmount)}`, THEME.green);
}
floating(game, x, y - 18, `CLEAN${bonusText}`, bonus.amount > 0 ? THEME.green : THEME.green);
} else {
countMistake(game);
@ -599,8 +640,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
floating(game, chick.x, chick.y - 12, label, THEME.muted);
}
function addTruckCargo(sex) {
const t = game.facilities.truck;
function addTruckCargo(sex, truck = game.facilities.truck) {
const t = truck || game.facilities.truck;
if (!t) return;
game.truckCargo.push({ sex, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) });
if (game.truckCargo.length > 45) game.truckCargo.shift();

View file

@ -21,7 +21,7 @@ function explosionDamageMultiplier(game) {
export function equipmentBasePrice(objOrHit) {
const obj = objOrHit?.ref || objOrHit || {};
const type = objOrHit?.type || obj.type;
if (type === 'conveyor') return FACILITY_DEFS.conveyor.price;
if (type === 'conveyor') return obj.price || FACILITY_DEFS[obj.kind]?.price || FACILITY_DEFS.conveyor.price;
if (type === 'eggFarm') return obj.price || FACILITY_DEFS.eggFarm.price;
if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price);
if (type === 'facility') return obj.price || FACILITY_DEFS[obj.id]?.price || 0;

View file

@ -68,6 +68,7 @@ export function restore(game, text) {
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
game.cardDraft = data.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;
if (game.cardDraft.freeRerolls == null) game.cardDraft.freeRerolls = 0;
game.cardTargetPick = data.cardTargetPick || null;
game.groupDrag = null;
game.selectionBox = null;

View file

@ -48,6 +48,9 @@ export function ensureMaintenanceState(game) {
for (const [k, meta] of game.conveyorMeta || []) {
if (meta.uses == null) meta.uses = 0;
if (meta.maintenanceType == null) meta.maintenanceType = 'conveyor';
if (!meta.kind) meta.kind = 'conveyor';
if (!meta.speedMultiplier) meta.speedMultiplier = meta.kind === 'boostConveyor' ? 2 : 1;
if (!meta.branchMode) meta.branchMode = 'random';
const desired = durabilityCapFor(game, 'conveyor', qualityMultiplierOf(meta));
if (meta.durability == null || meta.durability < desired) meta.durability = desired;
}
@ -138,9 +141,20 @@ export function eggSpawnDelayMultiplier(farm) {
return delayMultiplier(farm);
}
export function facilityProcessingDelay(game, id) {
function facilityKind(target) {
return target?.baseId || target?.id || target;
}
function facilityFromTarget(game, target) {
if (!target) return null;
if (typeof target === 'object') return target;
return game.facilities?.[target] || null;
}
export function facilityProcessingDelay(game, target) {
ensureMaintenanceState(game);
const f = game.facilities?.[id];
const f = facilityFromTarget(game, target);
const id = facilityKind(f || target);
if (!f || !['mixer', 'trash'].includes(id)) return 0;
attachMaintenance(f, id);
const t = Math.max(0, delayMultiplier(f) - 1);
@ -169,25 +183,26 @@ export function recordAutoScan(game, scanner) {
scanner.maintenance.uses = Math.min(scanner.maintenance.durability, (scanner.maintenance.uses || 0) + degradationUseMultiplier(game));
}
export function recordFacilityProcess(game, id) {
export function recordFacilityProcess(game, target) {
ensureMaintenanceState(game);
const f = game.facilities?.[id];
const f = facilityFromTarget(game, target);
const id = facilityKind(f || target);
if (!f || !['mixer', 'trash'].includes(id)) return;
attachMaintenance(f, id);
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game));
}
export function setFacilityCooldownAfterProcess(game, id) {
const f = game.facilities?.[id];
export function setFacilityCooldownAfterProcess(game, target) {
const f = facilityFromTarget(game, target);
if (!f) return 0;
const delay = facilityProcessingDelay(game, id);
const delay = facilityProcessingDelay(game, f);
f.processingCooldown = Math.max(f.processingCooldown || 0, delay);
return delay;
}
export function updateProcessingCooldowns(game, dt) {
for (const id of ['mixer', 'trash']) {
const f = game.facilities?.[id];
for (const f of Object.values(game.facilities || {})) {
if (!['mixer', 'trash'].includes(f.baseId || f.id)) continue;
if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
}
}
@ -195,7 +210,7 @@ export function updateProcessingCooldowns(game, dt) {
export function equipmentMaintenanceTargets(game) {
ensureMaintenanceState(game);
const targets = [];
for (const [k, meta] of game.conveyorMeta || []) targets.push({ type: 'conveyor', key: k, meta, label: `Belt ${k}`, center: cellCenter(parseKey(k).col, parseKey(k).row) });
for (const [k, meta] of game.conveyorMeta || []) targets.push({ type: 'conveyor', key: k, meta, label: `${meta.kind === 'boostConveyor' ? 'Boost Belt' : 'Belt'} ${k}`, center: cellCenter(parseKey(k).col, parseKey(k).row) });
for (const farm of game.eggFarms || []) targets.push({ type: 'eggFarm', ref: farm, label: `EGG #${farm.id}`, center: cellCenter(farm.col, farm.row) });
for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) });
for (const id of ['mixer', 'trash']) {
@ -211,6 +226,18 @@ function targetDurability(t) { return t.meta ? durabilityOf(t) : durabilityOf(t.
function reduceTargetUses(t, amount) { setUses(t.meta ? t : t.ref, Math.max(0, targetUses(t) - amount)); }
function targetKey(t) { return t.type === 'conveyor' ? `conveyor:${t.key}` : `${t.type}:${t.ref?.id || t.ref?.type || t.type}`; }
export function repairAllEquipment(game, percent = 0.02) {
ensureMaintenanceState(game);
const pct = Math.max(0, Number(percent) || 0);
let repaired = 0;
for (const t of equipmentMaintenanceTargets(game)) {
const before = targetUses(t);
reduceTargetUses(t, targetDurability(t) * pct);
repaired += Math.max(0, before - targetUses(t)) / targetDurability(t) * 100;
}
return repaired;
}
export function hireRepairmanForNextDay(game) {
ensureMaintenanceState(game);
if (game.repairman.hiredForNextDay) return { ok: false, reason: 'Repairman already hired.' };

View file

@ -5,11 +5,29 @@ import { key, parseKey, inGrid, cellCenter, distance, sameCell } from '../core/u
// -----------------------------------------------------------------------------
// Object lookup
// -----------------------------------------------------------------------------
export function scannerCenter(scanner) { return cellCenter(scanner.col, scanner.row); }
export function scannerFootprintCells(scanner) {
if (!scanner) return [];
return [
{ col: scanner.col, row: scanner.row - 1 },
{ col: scanner.col + 1, row: scanner.row - 1 },
{ col: scanner.col, row: scanner.row },
{ col: scanner.col + 1, row: scanner.row },
{ col: scanner.col, row: scanner.row + 1 },
{ col: scanner.col + 1, row: scanner.row + 1 }
];
}
export function scannerCenter(scanner) {
const c = cellCenter(scanner.col, scanner.row);
return { x: c.x + GRID.cell / 2, y: c.y };
}
export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; }
export function scannerAt(game, col, row) { return game.scanners.find(s => s.col === col && s.row === row) || null; }
export function scannerAt(game, col, row) { return game.scanners.find(s => scannerFootprintCells(s).some(p => p.col === col && p.row === row)) || null; }
export function scannerById(game, id) { return game.scanners.find(s => s.id === id) || null; }
export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; }
function facilityKind(f) { return f?.baseId || f?.id || ''; }
function facilityEntriesForKind(game, kind) {
return Object.entries(game.facilities || {}).filter(([, f]) => facilityKind(f) === kind);
}
// -----------------------------------------------------------------------------
// Port definitions. Ports prefer the exact connector cell, but also accept
@ -17,10 +35,10 @@ export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kin
// -----------------------------------------------------------------------------
export function scannerConnector(scanner, type) {
return {
inputA: { col: scanner.col, row: scanner.row - 1 },
inputA: { col: scanner.col, row: scanner.row - 2 },
inputB: null,
left: { col: scanner.col - 1, row: scanner.row },
right: { col: scanner.col + 1, row: scanner.row }
right: { col: scanner.col + 2, row: scanner.row }
}[type];
}
@ -54,21 +72,22 @@ function graphPortConveyorCells(game, point, blocked = [], preferred = []) {
// -----------------------------------------------------------------------------
export function scannerInputCells(game, scanner) {
const connector = scannerConnector(scanner, 'inputA');
return visualPortConveyorCells(game, connector, [ { col: scanner.col, row: scanner.row } ]);
return visualPortConveyorCells(game, connector, scannerFootprintCells(scanner));
}
function outputStartCells(game, scanner, side) {
const connector = scannerConnector(scanner, side);
return visualPortConveyorCells(game, connector, [ { col: scanner.col, row: scanner.row } ]);
return visualPortConveyorCells(game, connector, scannerFootprintCells(scanner));
}
export function facilityEntryPoint(game, dest) {
const f = game.facilities[dest];
const f = game.facilities[dest] || facilityEntriesForKind(game, dest)[0]?.[1];
if (!f) return null;
if (f.entry) return cellCenter(f.entry.col, f.entry.row);
if (dest === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
if (dest === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
if (dest === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 };
const kind = facilityKind(f) || dest;
if (kind === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
if (kind === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
if (kind === 'trash') return { x: f.x + f.w / 2, y: f.y + 8 };
return { x: f.x + f.w / 2, y: f.y + f.h / 2 };
}
@ -102,6 +121,25 @@ function conveyorOutDirNames(game, cellKey) {
return [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
}
function neighborPointsIntoCell(game, p, dirName) {
const d = DIRS.find(item => item.name === dirName);
if (!d) return false;
const meta = game.conveyorMeta?.get?.(graphNodeKey(p.col + d.dc, p.row + d.dr));
if (!meta) return false;
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
return names.includes(d.opposite);
}
function inferredConveyorOutDirNames(game, cellKey) {
const p = graphCell(cellKey);
const neighbors = DIRS
.filter(d => game.conveyorTiles.has(graphNodeKey(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir) && !neighborPointsIntoCell(game, p, dir));
const inferred = neighbors.filter(dir => !neighborPointsIntoCell(game, p, dir));
return explicit.length ? explicit : inferred;
}
function emptyFactoryGraph(game) {
return {
version: game.routingVersion || 0,
@ -140,8 +178,8 @@ export function buildFactoryGraph(game) {
for (const k of graph.cells) {
const p = graphCell(k);
const explicitDirs = conveyorOutDirNames(game, k);
const dirs = explicitDirs.length ? DIRS.filter(d => explicitDirs.includes(d.name)) : DIRS;
const outDirs = inferredConveyorOutDirNames(game, k);
const dirs = DIRS.filter(d => outDirs.includes(d.name));
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 });
@ -191,7 +229,7 @@ function indexGraphPorts(game, graph) {
const ports = {};
for (const type of ['inputA', 'left', 'right']) {
const cell = scannerConnector(scanner, type);
const cells = graphPortConveyorCells(game, cell, [ { col: scanner.col, row: scanner.row } ])
const cells = graphPortConveyorCells(game, cell, scannerFootprintCells(scanner))
.filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
const cellKeys = cells.map(p => graphNodeKey(p.col, p.row));
const connected = cellKeys.length > 0;
@ -265,13 +303,13 @@ function dirBetween(a, b) {
return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null;
}
function isCrossInGraph(graph, cellKey) {
function isPassCross(game, graph, cellKey) {
if (game.conveyorMeta?.get?.(cellKey)?.branchMode !== 'pass') return false;
const ns = graph.adjacency.get(cellKey) || [];
const names = new Set(ns.map(n => n.dir));
return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down');
}
// A four-way cross conveyor is an overpass/crossing. Chicks never turn there.
export function bfsAllRoutes(game, start, isGoal) {
const graph = ensureFactoryGraph(game);
const startK = graphNodeKey(start.col, start.row);
@ -302,7 +340,7 @@ export function bfsAllRoutes(game, start, isGoal) {
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;
if (cur.incoming !== 'none' && isPassCross(game, graph, cur.key) && outDir !== cur.incoming) continue;
const nextDepth = cur.depth + 1;
if (nextDepth > bestGoalDepth) continue;
const nextStateK = `${n.key}|${outDir}`;
@ -524,20 +562,23 @@ function routeToNextScanner(game, scanner, side, fromPoint, connector, starts, a
function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest, advance) {
const graph = ensureFactoryGraph(game);
const port = graph.facilityPorts.get(dest);
if (!port?.connected) return null;
const ports = [...graph.facilityPorts.entries()]
.filter(([id, port]) => port?.connected && facilityKind(game.facilities?.[id]) === dest);
if (!ports.length) return null;
const candidates = [];
for (const start of starts) {
const goalKeys = (port.keys || [port.key]).filter(Boolean);
if (!goalKeys.length) continue;
const isGoal = p => goalKeys.includes(graphNodeKey(p.col, p.row));
isGoal.cacheKey = `toFacility:${start.col},${start.row}:${dest}:v${graph.version}`;
const routes = bfsAllRoutes(game, start, isGoal);
for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells });
for (const [facilityId, port] of ports) {
const goalKeys = (port.keys || [port.key]).filter(Boolean);
if (!goalKeys.length) continue;
const isGoal = p => goalKeys.includes(graphNodeKey(p.col, p.row));
isGoal.cacheKey = `toFacility:${start.col},${start.row}:${facilityId}:v${graph.version}`;
const routes = bfsAllRoutes(game, start, isGoal);
for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:${facilityId}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells, facilityId });
}
}
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance);
if (!chosen) return null;
return { destination: dest, route: routeWithConnector(fromPoint, connector, chosen.cells, facilityEntryPoint(game, dest)) };
return { destination: dest, facilityId: chosen.facilityId, route: routeWithConnector(fromPoint, connector, chosen.cells, facilityEntryPoint(game, chosen.facilityId)) };
}
// -----------------------------------------------------------------------------
@ -579,8 +620,7 @@ export function eggFarmHasValidRoute(game, farm) {
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));
return [...graph.facilityPorts.entries()].some(([id, port]) => port?.connected && facilityKind(game.facilities?.[id]) === dest);
}
export function disconnectedBuildWarnings(game) {
@ -590,8 +630,10 @@ export function disconnectedBuildWarnings(game) {
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' });
for (const [facilityId, facility] of facilityEntriesForKind(game, id)) {
const graph = ensureFactoryGraph(game);
if (!graph.facilityPorts.get(facilityId)?.connected) warnings.push({ type: 'facility', id: facilityId, ref: facility, message: 'This facility cannot receive items' });
}
}
return warnings;
}
@ -605,7 +647,7 @@ export function minimumStartConnectionIssues(game) {
if (outputRouteReachesFacility(game, scanner)) return [];
}
const farmCount = game.eggFarms?.length || 0;
const exitCount = ['mixer', 'trash', 'truck'].filter(id => graph.facilityPorts.get(id)?.connected).length;
const exitCount = [...graph.facilityPorts.entries()].filter(([id, port]) => port?.connected && ['mixer', 'trash', 'truck'].includes(facilityKind(game.facilities?.[id]))).length;
if (!farmCount) return ['Build at least one EGG before starting the next day.'];
if (!graph.metrics.farmOutputs) return ['Connect at least one EGG to a conveyor.'];
if (!game.scanners?.length) return ['Build at least one scanner and connect it to an EGG route.'];
@ -624,12 +666,14 @@ export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), opt
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
for (const id of MACHINE_FACILITY_IDS) {
const f = game.facilities[id];
if (!f) {
const facilities = facilityEntriesForKind(game, id);
if (!facilities.length) {
issues.push(`${labels[id] || id} is missing`);
continue;
}
if (!graph.facilityPorts.get(id)?.connected) issues.push(`${labels[id] || id} receiver has no conveyor`);
for (const [facilityId] of facilities) {
if (!graph.facilityPorts.get(facilityId)?.connected) issues.push(`${labels[id] || id} receiver has no conveyor`);
}
}
for (const scanner of game.scanners) {

View file

@ -1,7 +1,7 @@
import { GRID } from '../core/config.js';
import { key, parseKey, cellCenter } from '../core/utils.js';
import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js';
import { farmAt, scannerAt, scannerCenter, refreshRoutingAfterEdit } from './routing.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, refreshRoutingAfterEdit } from './routing.js';
import { snapshot } from './history.js';
import { buildPrice } from './economy.js';
@ -192,12 +192,15 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
for (const origin of game.groupDrag.origins) {
if (origin.type === 'facility') continue;
const col = origin.col + dcol, row = origin.row + drow;
if (!pointInGrid(col, row)) return fail('Selection outside grid');
if (game.blockedCells?.has?.(key(col, row))) return fail('Selection hits blocked ground');
const tk = key(col, row);
if (targetCells.has(tk)) return fail('Selection overlap');
if (cellOccupiedByNonSelected(col, row, selectedTokens)) return fail('Cell occupied');
targetCells.add(tk);
const cells = origin.type === 'scanner' ? scannerFootprintCells({ col, row }) : [{ col, row }];
for (const cell of cells) {
if (!pointInGrid(cell.col, cell.row)) return fail('Selection outside grid');
if (game.blockedCells?.has?.(key(cell.col, cell.row))) return fail('Selection hits blocked ground');
const tk = key(cell.col, cell.row);
if (targetCells.has(tk)) return fail('Selection overlap');
if (cellOccupiedByNonSelected(cell.col, cell.row, selectedTokens)) return fail('Cell occupied');
targetCells.add(tk);
}
}
if (facilityDragWouldOverlap(dx, dy, selectedTokens)) return fail('Facility overlap');
commitGroupDragHistory();

View file

@ -18,11 +18,9 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return false;
let hasBuildableOption = false;
for (const id of BUILD_TOOL_IDS) {
if (MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id]) continue;
hasBuildableOption = true;
if (game.cash >= buildPrice(id, game)) return false;
}
if (build?.gridExpansionCost && game.cash >= build.gridExpansionCost()) return false;
return hasBuildableOption;
}
@ -44,32 +42,20 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
if (!btn) continue;
const price = buildPrice(id, game);
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];
if (priceSpan) priceSpan.textContent = (id === 'conveyor' || id === 'boostConveyor') ? `${yen(price)} / tile` : yen(price);
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 || manualLimitReached;
btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || manualLimitReached;
if (btn.disabled && game.buildTool === id) game.buildTool = null;
btn.classList.toggle('unaffordable', unaffordable);
btn.classList.toggle('already-built', uniqueAlreadyBuilt || manualLimitReached);
btn.classList.toggle('already-built', manualLimitReached);
btn.title = unaffordable
? `Need ${yen(price - game.cash)} more`
: 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);
@ -110,15 +96,15 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
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> | 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`,
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Grid box: <b>${game.gridRows || 10}x${game.gridCols || 20}</b> | Owned: <b>${game.gridOwnedCells || game.ownedCells?.size || ((game.gridRows || 10) * (game.gridCols || 20))}</b> cells | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`,
`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> | basis net ${yen(zundaBasisProfit)} | 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)} x${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>`,
`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>`,
`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>');
@ -165,7 +151,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.timeLeft.textContent = game.phase === 'running' && game.timeLeft <= 0 && game.shutdownTimeLeft > 0
? `+${game.shutdownTimeLeft.toFixed(1)}s`
: `${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.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();
@ -228,7 +214,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function showTitle() {
ui.modalTitle.textContent = TEXT.versionTitle;
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon male" aria-label="male chick">♂</span><strong>Male</strong><b>-></b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon female" aria-label="female chick">♀</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon poop" aria-label="poop">💩</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.</p>`;
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon male" aria-label="male chick">髫ィ蜷カ繝サ/span><strong>Male</strong><b>-></b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon female" aria-label="female chick">髫ィ蜊債€</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon poop" aria-label="poop">・滓ィ頑c</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.</p>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button(TEXT.actions.startGame, startGame, 'primary-button'));
ui.modal.classList.remove('equipment-popover', 'gameover-modal');
@ -247,6 +233,8 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
<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>Composter income</strong>${positive(r.composterIncome || 0)}</div>
<div><strong>DUD refund income</strong>${positive(r.dudRefundIncome || 0)}</div>
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
<div><strong>Chemical subsidy</strong>${positive(r.chemicalWeaponSubsidy || 0)}</div>
<div><strong>Contract bonus</strong>${positive(r.contractBonus || 0)}</div>
@ -289,7 +277,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.modalBody.innerHTML = `<p class="gameover-reason">Your cash went negative.</p>
<div class="result-grid gameover-summary"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div></div>
<details class="gameover-details"><summary>Score breakdown</summary>
<div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base ÷ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value 繝サ繝サ繝サ0.5 + Contract bonus 繝サ繝サ繝サ0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base 繝サ繝サ・ス・キ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct繝サ繝サ繝サ - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid gameover-breakdown"><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>
</details>`;
ui.modalActions.innerHTML = '';