From ca40a0cb734cb8b80b8cb0786f72c52961eab894 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sun, 7 Jun 2026 22:04:17 +0900 Subject: [PATCH 1/3] cards and lot of --- README.md | 61 +++--- index.html | 6 +- src/core/config.js | 7 +- src/core/state.js | 7 + src/game.js | 29 ++- src/render/draw.js | 54 +++++ src/systems/buildSystem.js | 39 +--- src/systems/cards.js | 393 +++++++++++++++++++++++++++++++++++++ src/systems/chickSystem.js | 5 +- src/systems/economy.js | 36 +++- src/systems/history.js | 10 +- src/systems/uiSystem.js | 41 ++-- styles.css | 28 +++ 13 files changed, 624 insertions(+), 92 deletions(-) create mode 100644 src/systems/cards.js diff --git a/README.md b/README.md index eb0daf6..7309df5 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,40 @@ -# Chick Sorter v14.0 Zunda Tax / Resale +# Chick Sorter v16.1 Card Target Overlay Fix -## Changes in this build -- Changed Egg Farm build price to `¥250`. -- Replaced the previous daily Fairies fee with `ZUNDA TAX`, paid when `Next Day` is pressed. -- `ZUNDA TAX` taxes only cash above `¥500`: `ceil((cash - 500) × rate)`, with 5% added per `¥500` band and a 95% cap. -- Removing equipment from a previous Build phase now sells it for 50% of its build price; same-Build removals still refund 100%. -- Removed visible `STOPPED` text/highlight. Blocked chicks simply stay still. -- Removed route-overlay arrows/colored flow arrows. Conveyor movement is now shown by small direction triangles inside belt tiles. -- Moved the starter S1 position lower so the Egg Farm and S1 have more distance. -- Moved the Truck to the bottom side of the grid and connected the starter S2-to-Truck belt to the new receiver. -- Made scanner and facility ports strict: a belt must occupy the exact port cell to count as connected. -- Expanded `Next Day` validation to scanner inputs/outputs and planned output routes, not just machine receiver cells. -- Build buttons for unaffordable equipment, or already-built unique machines, are disabled and visually faded. -- Removed Close buttons from clicked-equipment/upgrade popovers. Click outside the popover to close it. -- Kept `src/index.html` and `src/styles.css` removed; the project uses the root `index.html` and `styles.css` only. -- Continued centralized price/income/fine/facility definitions in `src/core/config.js`, shared text in `src/core/text.js`, and economy logic in `src/systems/economy.js`. +This build refines the v15 card and tax system. -## Structure ideas for the next cleanup pass -- Move HTML button content to a data-rendered build panel instead of hard-coded markup. -- Give every equipment object a `defId`, so runtime state never duplicates names, prices, sizes, or upgrade rules. -- Store routes as cell arrays and convert to pixel paths only at movement/draw time. -- Cache route plans per build session, then invalidate once on build/erase/drag instead of recomputing every draw. -- Split route validation into errors and warnings, so optional/unused equipment can be supported later without blocking the day. -- Remove legacy fallback paths after save compatibility is no longer needed. +## v16.1 changes +- Fixed equipment improvement target-picking overlay by importing the target bounds helper into the renderer. +- Non-candidate equipment now receives a full dark overlay, and valid upgrade candidates are explicitly white-highlighted. +- Added a fallback message when a target picker has no valid target, so the game no longer appears frozen. + +## v16 changes + +- `Extra Cards` is now immediate. + - Selecting it consumes the current card pick, then grants 2 more card selections in the same draft. + - It no longer increases future draft size. +- Equipment improvement cards now target machines on the map. + - Valid targets are highlighted in white. + - Non-target areas are darkened. + - Click a highlighted machine to apply the upgrade. + - `Esc` cancels target selection and returns to the card draft. +- ZUNDA TAX and Fairies tribute no longer contaminate the next day’s profit display. + - They are still paid when `Next Day` is pressed. + - They still affect cash and total outflow. + - They are not counted inside the new day’s `Turn Profit`. +- ZUNDA TAX exemption / Legal Work behavior was rechecked and kept explicit. + - Base exemption: ¥500. + - `Legal Work`: exemption +¥250 and 95% cap point +¥250. + - Tax rate: 5% from the first yen above the exemption. + - +5% per ¥500 taxable band. + - Maximum rate: 95%. +- EGG FARM price remains ¥250. + +## Active ZUNDA TAX formula + +```text +taxable = max(0, cash - exemption) +rate = min(95%, ceil(taxable / 500) × 5%) +ZUNDA TAX = ceil(taxable × rate) +``` + +With no `Legal Work`, `exemption = ¥500`, so ¥501 starts at 5% and ¥10,000 reaches 95%. diff --git a/index.html b/index.html index 1e94edb..f09f495 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Chick Sorter v14.0 Zunda Tax / Resale + Chick Sorter v15.0 Cards / Tribute @@ -35,7 +35,7 @@
-

CHICK SORTER v14.0

+

CHICK SORTER v15.0

Build, drag, connect all receiver ports, then start the next day.

@@ -92,6 +92,6 @@
- + diff --git a/src/core/config.js b/src/core/config.js index fe441e5..5f825d1 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,4 +1,4 @@ -export const VERSION = 'v14.0 Zunda Tax / Resale'; +export const VERSION = 'v16.1 Card Target Overlay Fix'; export const TURN_SECONDS = 60; export const STARTING_CASH = 250; @@ -11,10 +11,15 @@ export const ECONOMY = { poopFine: 10, zundaTax: { exemption: 500, + legalWorkExemptionBonus: 250, + maxRateCash: 10000, stepAmount: 500, stepRate: 0.05, maxRate: 0.95 }, + fairiesTribute: { + perDay: 10 + }, incomeUpgradeRate: 1.05, explosionDamageDivisor: 30, maleTruckFinePerHalfDay: 30 diff --git a/src/core/state.js b/src/core/state.js index 4db4271..22d7190 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -19,6 +19,8 @@ export function newTurnStats() { mixerRevenue: 0, truckRevenue: 0, zundaTax: 0, + fairiesTribute: 0, + cardRerollCost: 0, mixerPoopFine: 0, truckPoopFine: 0, maleTruckFine: 0, @@ -47,6 +49,8 @@ export function newTotalStats() { explosionDamage: 0, contractBonus: 0, zundaTax: 0, + fairiesTribute: 0, + cardRerollCost: 0, mixerPoopFine: 0, truckPoopFine: 0, maleTruckFine: 0, @@ -92,6 +96,9 @@ export function createGame() { lastExplodedComponent: new Map(), contractOffer: null, contractActive: null, + cardEffects: { bearing: 0, legalWork: 0, flattery: 0 }, + cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 }, + cardTargetPick: null, stats: newTurnStats(), lastResult: null, totals: newTotalStats() diff --git a/src/game.js b/src/game.js index e62fcf6..80ce6fb 100644 --- a/src/game.js +++ b/src/game.js @@ -1,14 +1,15 @@ -import { TURN_SECONDS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, THEME } from './core/config.js'; +import { TURN_SECONDS, THEME } from './core/config.js'; import { buildToolButtonHtml } from './core/text.js'; import { createGame, resetLayout, newTurnStats } from './core/state.js'; import { clamp, pointToCell, cellCenter, yen } from './core/utils.js'; import { facilityConnectionIssues } from './systems/routing.js'; -import { collectZundaTax, settleTruckRevenue } from './systems/economy.js'; +import { collectFairiesTribute, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; import { undo, redo } from './systems/history.js'; import { drawAll } from './render/draw.js'; import { createBuildSystem } from './systems/buildSystem.js'; import { createUISystem } from './systems/uiSystem.js'; import { createChickSystem } from './systems/chickSystem.js'; +import { conveyorSpeedForGame, createCardSystem } from './systems/cards.js'; import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js'; import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js'; @@ -48,8 +49,9 @@ const game = createGame(); let build; let uiSystem; let chicks; +let cardSystem; -function currentConveyorSpeed() { return Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED); } +function currentConveyorSpeed() { return conveyorSpeedForGame(game); } function startGame() { Object.assign(game, createGame()); @@ -64,6 +66,11 @@ function startGame() { function startNextTurn() { if (game.phase !== 'build') return; + if (game.cardDraft?.pending || game.cardTargetPick?.pending) { + build.fail(game.cardTargetPick?.pending ? 'Choose highlighted upgrade target first.' : 'Choose an upgrade card first.'); + uiSystem.updatePanels(); + return; + } const issues = facilityConnectionIssues(game); if (issues.length) { build.fail(`Cannot start: ${issues[0]}`); @@ -71,6 +78,12 @@ function startNextTurn() { return; } const zundaBasisCash = game.cash; + const zunda = collectZundaTax(game, zundaBasisCash); + const tribute = collectFairiesTribute(game, game.turn); + game.lastStartFees = { zundaTax: zunda.tax || 0, fairiesTribute: tribute.amount || 0 }; + if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger); + if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger); + if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; } activateAcceptedContract(game); game.phase = 'running'; game.turn += 1; @@ -83,8 +96,6 @@ function startNextTurn() { game.truckCargo = []; game.view = { x: 0, y: 0 }; game.stats = newTurnStats(); - const zunda = collectZundaTax(game, zundaBasisCash); - if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger); for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; } for (const farm of game.eggFarms) { farm.nextSpawn = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; } game.buildTool = null; @@ -116,6 +127,8 @@ function completeTurn() { for (const scanner of game.scanners) scanner.queue = []; for (const farm of game.eggFarms) { farm.shutterProgress = 0; farm.shutterSparked = false; } game.contractOffer = rollContractOffer(game); + if (cardSystem) cardSystem.prepareDraft(); + game.cardTargetPick = null; game.buildTool = null; game.groupDrag = null; game.selectionBox = null; @@ -174,6 +187,7 @@ canvas.addEventListener('pointerdown', event => { const world = canvasPoint(event); if (event.button === 2) { startPan(event); return; } if (event.button !== 0) return; + if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; } if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; } if (game.buildTool) { const hit = build.equipmentAtPoint(world); @@ -211,9 +225,10 @@ document.addEventListener('pointerdown', event => { ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right')); ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase')); ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); }); -window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } }); +window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } }); build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() }); chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() }); -uiSystem = createUISystem({ game, ui, build, startGame, activeQueuedChick: chicks.activeQueuedChick }); +cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() }); +uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick }); initializeStaticText(); resetLayout(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); diff --git a/src/render/draw.js b/src/render/draw.js index e71abea..5917c25 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -2,6 +2,7 @@ import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js'; import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js'; import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js'; import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js'; +import { cardTargetBounds } from '../systems/cards.js'; const ASSET_PATHS = { chickMale: './assets/images/chick_male.png', @@ -44,6 +45,7 @@ export function drawAll(ctx, canvas, game, helpers) { drawEggFarms(ctx, game); drawScanners(ctx, game); drawChicks(ctx, game, helpers.activeQueuedChick); + drawCardTargetOverlay(ctx, canvas, game); drawSelectedTooltip(ctx, game, helpers.selectedObject, helpers.selectedTitle); drawSelectionBox(ctx, game); drawEffects(ctx, game); @@ -445,6 +447,58 @@ function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) { else if (obj.type === 'facility') { x = obj.x + obj.w / 2 - 76; y = obj.y - 48; } ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, x, y, 176, 40, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(selectedTitle(obj), x + 9, y + 16); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag to move / panel to edit', x + 9, y + 31); ctx.restore(); } + +function drawCardTargetOverlay(ctx, canvas, game) { + if (!game.cardTargetPick?.pending) return; + const items = cardTargetBounds(game); + ctx.save(); + // The canvas is already translated by the camera. Offset the dark layer back to screen space + // so every non-candidate object is reliably dimmed even after panning. + ctx.fillStyle = 'rgba(0,0,0,.66)'; + ctx.fillRect(-game.view.x, -game.view.y, canvas.width, canvas.height); + if (!items.length) { + ctx.fillStyle = 'rgba(255,255,255,.95)'; + ctx.strokeStyle = THEME.ink; + ctx.lineWidth = 3; + rect(ctx, 260 - game.view.x, 96 - game.view.y, 420, 42, true, true); + ctx.fillStyle = THEME.danger; + ctx.font = '900 12px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.fillText('NO VALID UPGRADE TARGET. PRESS ESC.', 470 - game.view.x, 122 - game.view.y); + ctx.restore(); + return; + } + for (const item of items) { + const b = item.bounds; + ctx.save(); + ctx.shadowColor = THEME.white; + ctx.shadowBlur = 20; + ctx.fillStyle = 'rgba(255,255,255,.32)'; + ctx.strokeStyle = THEME.white; + ctx.lineWidth = 8; + ctx.setLineDash([10, 6]); + rect(ctx, b.x - 10, b.y - 10, b.w + 20, b.h + 20, true, true); + ctx.restore(); + + ctx.save(); + ctx.strokeStyle = THEME.green; + ctx.lineWidth = 4; + ctx.strokeRect(b.x - 4, b.y - 4, b.w + 8, b.h + 8); + ctx.restore(); + + ctx.fillStyle = 'rgba(255,255,255,.96)'; + ctx.strokeStyle = THEME.ink; + ctx.lineWidth = 3; + const labelW = Math.min(300, Math.max(150, item.label.length * 7)); + rect(ctx, b.cx - labelW / 2, b.y - 34, labelW, 24, true, true); + ctx.fillStyle = THEME.ink; + ctx.font = '900 10px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.fillText(item.label, b.cx, b.y - 18); + } + ctx.restore(); +} + function drawSelectionBox(ctx, game) { if (game.phase !== 'build' || !game.selectionBox) return; const b = game.selectionBox; diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js index 6274660..32f6a5b 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -1,10 +1,11 @@ -import { AUTO_SCANNER_COOLDOWN, MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js'; -import { nextSpawnDelay, getSpawnRange } from '../core/state.js'; +import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js'; +import { getSpawnRange } from '../core/state.js'; import { createEggFarm, createScanner, createFacility } from '../core/entities.js'; import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js'; import { TEXT, equipmentName } from '../core/text.js'; import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js'; -import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, upgradeCostFor, resaleValueFor } from './economy.js'; +import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor } from './economy.js'; +import { autoScannerCooldownSeconds } from './cards.js'; import { record } from './history.js'; import { floating, eraseEffect } from './effects.js'; import { createSelectionSystem } from './selectionSystem.js'; @@ -198,23 +199,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel const selection = createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }); - function selectedUpgradeCost(obj) { - return upgradeCostFor(obj); - } - - function upgradeSelected() { - const obj = selectedObject(); - const cost = selectedUpgradeCost(obj); - if (!cost) return fail(TEXT.fail.noUpgrade); - if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); - record(game); - spendCash(game, cost); - obj.level += 1; - if (obj.type === 'eggFarm') { - obj.nextSpawn = nextSpawnDelay(obj); - obj.lastInterval = obj.nextSpawn; - } - updatePanels(); + function selectedUpgradeCost(_obj) { + return null; } function removeSelected() { @@ -243,7 +229,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel function showAutoScannerMenu(scanner) { ui.modalTitle.textContent = 'Auto Scanner'; - ui.modalBody.innerHTML = `

Standard auto scanner.

`; + ui.modalBody.innerHTML = `

Standard auto scanner.

`; 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(); }); @@ -288,13 +274,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
${lines.map(x => `

${x}

`).join('')}
${['mixer', 'truck'].includes(obj.id) ? '

Income/Fine = ceil(base × 1.05^upgrades)

' : ''}`; ui.modalActions.innerHTML = ''; - const cost = selectedUpgradeCost(obj); - if (cost) { - const upgrade = modalButton(`Upgrade ${yen(cost)}`, () => { upgradeSelected(); hideModal(); }, 'primary-button'); - upgrade.disabled = game.cash < cost; - if (upgrade.disabled) upgrade.title = TEXT.fail.notEnoughCash; - ui.modalActions.appendChild(upgrade); - } if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn')); const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj }; const resale = resaleValueFor(hit, game); @@ -339,7 +318,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`); lines.push(`Role: ${obj.role === 0 ? 'Male left / Others right' : 'Poop left / Others right'}`); lines.push(`Queue: ${obj.queue.length}`); - if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s`); + if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj).toFixed(1)}s`); } else if (obj.type === 'conveyor') { const meta = game.conveyorMeta.get(obj.id); @@ -377,7 +356,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel finishGroupDrag: selection.finishGroupDrag, selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost, selectedInfoLines, - upgradeSelected, removeSelected, switchScannerRole, + removeSelected, switchScannerRole, showAutoScannerMenu, showSelectedMenu, setBuildTool, fail, isEquipmentCell, equipmentHitBoxes, routeFromFarmToScanner diff --git a/src/systems/cards.js b/src/systems/cards.js new file mode 100644 index 0000000..8b08baf --- /dev/null +++ b/src/systems/cards.js @@ -0,0 +1,393 @@ +import { AUTO_SCANNER_COOLDOWN, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, GRID, THEME } from '../core/config.js'; +import { nextSpawnDelay } from '../core/state.js'; +import { cellCenter, yen } from '../core/utils.js'; +import { applyPenalty, upgradedMixerPrice, upgradedTruckPrice } from './economy.js'; +import { floating } from './effects.js'; +import { scannerCenter } from './routing.js'; + +const COMMON_WEIGHT = 8; +const RARE_WEIGHT = 2; +const AUTO_SCANNER_COOLDOWN_RATE = 0.95; +const AUTO_SCANNER_MIN_COOLDOWN = 1.0; +const BASE_DRAFT_SIZE = 3; + +export const CARD_DEFS = [ + { + id: 'upgradeEgg', + title: 'EGG Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'eggFarm', + description: 'Choose one EGG FARM on the map and raise it by 1 level.' + }, + { + id: 'upgradeAutoScanner', + title: 'AUTO SCANNER Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'autoScanner', + description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.' + }, + { + id: 'upgradeMixer', + title: 'MIXER Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'mixer', + description: 'Choose MIXER on the map and raise it by 1 level.' + }, + { + id: 'upgradeTruck', + title: 'TRUCK Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'truck', + description: 'Choose TRUCK on the map and raise it by 1 level.' + }, + { + id: 'bearing', + title: 'High-Quality Bearing', + rarity: 'common', + type: 'instant', + description: 'Conveyor speed +2%.' + }, + { + id: 'extraCards', + title: 'Extra Cards', + rarity: 'rare', + type: 'instant', + description: 'Draw 2 cards now and choose both.' + }, + { + id: 'legalWork', + title: 'Legal Work', + rarity: 'rare', + type: 'instant', + description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.' + }, + { + id: 'flattery', + title: 'Flattery', + rarity: 'rare', + type: 'instant', + description: 'Fairies tribute reduction card.' + } +]; + +export function ensureCardState(game) { + if (!game.cardEffects) game.cardEffects = { bearing: 0, legalWork: 0, flattery: 0 }; + 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; + return game.cardEffects; +} + +export function conveyorSpeedForGame(game) { + const effects = ensureCardState(game); + const speed = CONVEYOR_SPEED * Math.pow(1.02, Math.max(0, effects.bearing || 0)); + return Math.min(CONVEYOR_SPEED_MAX, speed); +} + +export function autoScannerCooldownSeconds(scanner) { + const upgrades = Math.max(0, (scanner?.level || 1) - 1); + return Math.max(AUTO_SCANNER_MIN_COOLDOWN, AUTO_SCANNER_COOLDOWN * Math.pow(AUTO_SCANNER_COOLDOWN_RATE, upgrades)); +} + +export function flatteryReductionForTurn(game, turn = game.turn) { + const effects = ensureCardState(game); + const day = Math.max(1, Math.floor(Number(turn) || 1)); + const perCard = Math.floor(10 * day / 10); + return Math.max(0, perCard * Math.max(0, effects.flattery || 0)); +} + +export function rerollCost(game) { + const draft = game.cardDraft || { rerolls: 0 }; + const nextRerollCount = Math.max(1, (draft.rerolls || 0) + 1); + return Math.ceil((Math.max(1, game.turn || 1) / 2) * nextRerollCount * 100); +} + +export function cardById(id) { + return CARD_DEFS.find(card => card.id === id) || null; +} + +function targetLabel(game, target) { + if (target.type === 'eggFarm') { + return `EGG #${target.id} L${target.level} -> L${target.level + 1}`; + } + if (target.type === 'scanner') { + return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }).toFixed(1)}s`; + } + if (target.type === 'facility' && target.id === 'mixer') { + const beforeLevel = target.level; + const before = upgradedMixerPrice(game); + target.level = beforeLevel + 1; + const after = upgradedMixerPrice(game); + target.level = beforeLevel; + return `MIXER L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`; + } + if (target.type === 'facility' && target.id === 'truck') { + const beforeLevel = target.level; + const before = upgradedTruckPrice(game); + target.level = beforeLevel + 1; + const after = upgradedTruckPrice(game); + target.level = beforeLevel; + return `TRUCK L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`; + } + return `${target.name || target.id} L${target.level || 1} -> L${(target.level || 1) + 1}`; +} + +export function targetsForCard(game, cardOrId) { + const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId; + ensureCardState(game); + if (!card) return []; + if (card.target === 'eggFarm') return game.eggFarms.filter(f => (f.level || 1) < 4); + 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] : []; + return []; +} + +function availableCards(game) { + return CARD_DEFS.filter(card => card.type !== 'equipmentUpgrade' || targetsForCard(game, card).length > 0); +} + +function weightedPick(pool) { + const total = pool.reduce((sum, card) => sum + (card.rarity === 'rare' ? RARE_WEIGHT : COMMON_WEIGHT), 0); + let roll = Math.random() * total; + for (const card of pool) { + roll -= card.rarity === 'rare' ? RARE_WEIGHT : COMMON_WEIGHT; + if (roll <= 0) return card; + } + return pool[pool.length - 1]; +} + +export function dealCards(game, count = BASE_DRAFT_SIZE) { + ensureCardState(game); + const source = availableCards(game); + if (!source.length) return []; + const choices = []; + let pool = [...source]; + while (choices.length < count && source.length) { + if (!pool.length) pool = [...source]; + const picked = weightedPick(pool); + choices.push(picked); + const i = pool.findIndex(card => card.id === picked.id); + if (i >= 0) pool.splice(i, 1); + } + return choices; +} + +function targetKey(target) { + if (!target) return ''; + if (target.type === 'eggFarm') return `eggFarm:${target.id}`; + if (target.type === 'scanner') return `scanner:${target.id}`; + if (target.type === 'facility') return `facility:${target.id}`; + return `${target.type}:${target.id}`; +} + +function boundsForTarget(target) { + if (!target) return null; + if (target.type === 'eggFarm') { + const c = cellCenter(target.col, target.row); + return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y }; + } + 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 }; + } + 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 }; + } + return null; +} + +export function cardTargetBounds(game) { + if (!game.cardTargetPick?.pending) return []; + const allowed = new Set(game.cardTargetPick.targetKeys || []); + return targetsForCard(game, game.cardTargetPick.cardId) + .filter(target => allowed.has(targetKey(target))) + .map(target => ({ key: targetKey(target), label: targetLabel(game, target), target, bounds: boundsForTarget(target) })) + .filter(item => item.bounds); +} + +function targetAtPoint(game, p) { + const items = cardTargetBounds(game); + for (const item of items) { + const b = item.bounds; + if (p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h) return item.target; + } + return null; +} + +function applyEquipmentUpgrade(game, target) { + target.level = (target.level || 1) + 1; + if (target.type === 'eggFarm') { + target.nextSpawn = nextSpawnDelay(target); + target.lastInterval = target.nextSpawn; + const c = cellCenter(target.col, target.row); + floating(game, c.x, c.y - 30, `LV ${target.level}`, THEME.green); + return; + } + if (target.type === 'scanner') { + const c = scannerCenter(target); + floating(game, c.x, c.y - 34, `LV ${target.level}`, THEME.green); + return; + } + if (target.type === 'facility') { + floating(game, target.x + target.w / 2, target.y + 18, `LV ${target.level}`, THEME.green); + } +} + +function applyInstantCard(game, card) { + const effects = ensureCardState(game); + if (card.id === 'bearing') effects.bearing += 1; + if (card.id === 'legalWork') effects.legalWork += 1; + if (card.id === 'flattery') effects.flattery += 1; +} + +function cardDescription(game, card) { + if (card.id === 'flattery') { + const reduction = flatteryReductionForTurn({ ...game, cardEffects: { ...ensureCardState(game), flattery: 1 } }, game.turn); + return `Fairies tribute reduction: ${yen(reduction)}`; + } + return card.description; +} + +function removeOneChoice(game, card) { + const choices = game.cardDraft?.choices || []; + const index = choices.findIndex(choice => choice.id === card.id); + if (index >= 0) choices.splice(index, 1); +} + +export function createCardSystem({ game, ui, onUpdatePanels }) { + function updatePanels() { if (onUpdatePanels) onUpdatePanels(); } + + function prepareDraft() { + ensureCardState(game); + game.cardTargetPick = null; + game.cardDraft = { pending: true, choices: dealCards(game, BASE_DRAFT_SIZE), rerolls: 0, picksRemaining: 1 }; + } + + function finishDraft() { + ensureCardState(game); + game.cardDraft.pending = false; + game.cardDraft.choices = []; + game.cardDraft.picksRemaining = 0; + game.cardTargetPick = null; + ui.modal.classList.remove('visible', 'equipment-popover'); + updatePanels(); + } + + function redrawChoices(count = BASE_DRAFT_SIZE) { + ensureCardState(game); + game.cardDraft.choices = dealCards(game, count); + } + + function refillChoicesIfNeeded() { + ensureCardState(game); + if (!game.cardDraft.choices.length && game.cardDraft.picksRemaining > 0) redrawChoices(BASE_DRAFT_SIZE); + } + + function completePickedCard(card) { + removeOneChoice(game, card); + game.cardDraft.picksRemaining = Math.max(0, (game.cardDraft.picksRemaining || 1) - 1); + game.cardTargetPick = null; + refillChoicesIfNeeded(); + if (game.cardDraft.picksRemaining <= 0) finishDraft(); + else showDraft(); + } + + function chooseExtraCards(card) { + removeOneChoice(game, card); + game.cardDraft.picksRemaining = Math.max(0, (game.cardDraft.picksRemaining || 1) - 1) + 2; + game.cardDraft.choices.push(...dealCards(game, 2)); + showDraft(); + } + + function chooseCard(card) { + if (card.id === 'extraCards') return chooseExtraCards(card); + if (card.type === 'equipmentUpgrade') return startMapTargetPicker(card); + applyInstantCard(game, card); + completePickedCard(card); + } + + function startMapTargetPicker(card) { + const targets = targetsForCard(game, card); + if (!targets.length) return; + game.cardTargetPick = { + pending: true, + cardId: card.id, + targetKeys: targets.map(targetKey) + }; + ui.modal.classList.remove('visible', 'equipment-popover'); + updatePanels(); + } + + function chooseTargetAtPoint(p) { + if (!game.cardTargetPick?.pending) return false; + const card = cardById(game.cardTargetPick.cardId); + const target = targetAtPoint(game, p); + if (!card || !target) { + floating(game, p.x, p.y - 18, 'SELECT WHITE TARGET', THEME.danger); + return true; + } + applyEquipmentUpgrade(game, target); + completePickedCard(card); + return true; + } + + function cancelTargetPick() { + if (!game.cardTargetPick?.pending) return; + game.cardTargetPick = null; + showDraft(); + } + + function doReroll() { + const cost = rerollCost(game); + if (game.cash < cost) return; + applyPenalty(game, cost); + game.stats.cardRerollCost = (game.stats.cardRerollCost || 0) + cost; + game.totals.cardRerollCost = (game.totals.cardRerollCost || 0) + cost; + game.cardDraft.rerolls += 1; + redrawChoices(BASE_DRAFT_SIZE); + showDraft(); + } + + function cardButton(card, index) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `card-choice ${card.rarity === 'rare' ? 'rare' : 'common'}`; + const rarity = card.rarity === 'rare' ? 'RARE' : 'COMMON'; + b.innerHTML = `${card.title}${rarity}${cardDescription(game, card)}`; + b.addEventListener('click', () => chooseCard(card)); + b.dataset.index = String(index); + return b; + } + + function showDraft() { + ensureCardState(game); + game.cardTargetPick = null; + if (!game.cardDraft?.pending) prepareDraft(); + if (!game.cardDraft.choices?.length) redrawChoices(BASE_DRAFT_SIZE); + const choices = game.cardDraft.choices || []; + const cost = rerollCost(game); + const remaining = Math.max(1, game.cardDraft.picksRemaining || 1); + ui.modalTitle.textContent = 'Choose Upgrade Card'; + ui.modalBody.innerHTML = `

Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Equipment cards are applied by selecting a white-highlighted machine on the map.

`; + const wrap = ui.modalBody.querySelector('#cardChoices'); + choices.forEach((card, index) => wrap.appendChild(cardButton(card, index))); + ui.modalActions.innerHTML = ''; + const reroll = document.createElement('button'); + reroll.type = 'button'; + reroll.className = 'facility-action warn'; + 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.addEventListener('click', doReroll); + ui.modalActions.appendChild(reroll); + ui.modal.classList.remove('equipment-popover'); + ui.modal.classList.add('visible'); + } + + return { prepareDraft, showDraft, finishDraft, chooseTargetAtPoint, cancelTargetPick }; +} diff --git a/src/systems/chickSystem.js b/src/systems/chickSystem.js index b732ac2..156de23 100644 --- a/src/systems/chickSystem.js +++ b/src/systems/chickSystem.js @@ -1,9 +1,10 @@ -import { AUTO_SCANNER_COOLDOWN, GRID, THEME } from '../core/config.js'; +import { GRID, THEME } from '../core/config.js'; import { createChick } from '../core/entities.js'; import { nextSpawnDelay } from '../core/state.js'; import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js'; import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice } from './economy.js'; +import { autoScannerCooldownSeconds } from './cards.js'; import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js'; import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js'; import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js'; @@ -184,7 +185,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck continue; } if (sortChickByIndex(index, autoSideFor(scanner, game.chicks[index]), true)) { - scanner.cooldown = AUTO_SCANNER_COOLDOWN; + scanner.cooldown = autoScannerCooldownSeconds(scanner); countAutoSorted(game); } } diff --git a/src/systems/economy.js b/src/systems/economy.js index 550cf1b..1b78f4a 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -44,19 +44,32 @@ export function maleTruckPenalty(game) { return Math.ceil(baseFine * incomeMultiplier(game, 'truck')); } -export function zundaTaxInfo(cash) { +export function zundaTaxInfo(cash, game = null) { const rules = ECONOMY.zundaTax; + const legalWork = Math.max(0, game?.cardEffects?.legalWork || 0); + const exemption = rules.exemption + legalWork * rules.legalWorkExemptionBonus; + const maxRateCash = rules.maxRateCash + legalWork * rules.legalWorkExemptionBonus; const profit = Math.max(0, Math.floor(Number(cash) || 0)); - const taxable = Math.max(0, profit - rules.exemption); - if (taxable <= 0) return { profit, taxable: 0, rate: 0, ratePercent: 0, tax: 0, step: 0 }; + const taxable = Math.max(0, profit - exemption); + if (taxable <= 0) return { profit, exemption, maxRateCash, taxable: 0, rate: 0, ratePercent: 0, tax: 0, step: 0, legalWork }; const step = Math.max(1, Math.ceil(taxable / rules.stepAmount)); const rate = Math.min(rules.maxRate, step * rules.stepRate); const tax = Math.ceil(taxable * rate); - return { profit, taxable, rate, ratePercent: Math.round(rate * 100), tax, step }; + return { profit, exemption, maxRateCash, taxable, rate, ratePercent: Math.round(rate * 100), tax, step, legalWork }; } -export function zundaTaxForCash(cash) { - return zundaTaxInfo(cash).tax; +export function zundaTaxForCash(cash, game = null) { + return zundaTaxInfo(cash, game).tax; +} + +export function fairiesTributeInfo(game, turn = game.turn) { + const day = Math.max(1, Math.floor(Number(turn) || 1)); + const base = Math.ceil(day * ECONOMY.fairiesTribute.perDay); + const flatteryCount = Math.max(0, game?.cardEffects?.flattery || 0); + const perCardReduction = Math.floor(10 * day / 10); + const reduction = Math.min(base, flatteryCount * perCardReduction); + const amount = Math.max(0, base - reduction); + return { day, base, flatteryCount, perCardReduction, reduction, amount }; } export function resaleValueFor(hit, game) { @@ -171,7 +184,7 @@ export function settleTruckRevenue(game) { } export function collectZundaTax(game, basisCash = game.cash) { - const info = zundaTaxInfo(basisCash); + const info = zundaTaxInfo(basisCash, game); if (info.tax <= 0) return info; game.stats.zundaTax += info.tax; game.totals.zundaTax += info.tax; @@ -179,6 +192,15 @@ export function collectZundaTax(game, basisCash = game.cash) { return info; } +export function collectFairiesTribute(game, turn = game.turn) { + const info = fairiesTributeInfo(game, turn); + if (info.amount <= 0) return info; + game.stats.fairiesTribute = (game.stats.fairiesTribute || 0) + info.amount; + game.totals.fairiesTribute = (game.totals.fairiesTribute || 0) + info.amount; + applyPenalty(game, info.amount); + return info; +} + export function finalScore(game) { const value = factoryValue(game); const days = Math.max(1, game.totals.turnsCompleted || game.turn || 1); diff --git a/src/systems/history.js b/src/systems/history.js index d071d2f..5ac6ccf 100644 --- a/src/systems/history.js +++ b/src/systems/history.js @@ -16,7 +16,10 @@ export function snapshot(game) { scanners: game.scanners.map(cleanScanner), conveyorTiles: [...game.conveyorTiles], conveyorMeta: [...game.conveyorMeta.entries()], - branchCounters: [...game.branchCounters.entries()] + branchCounters: [...game.branchCounters.entries()], + cardEffects: game.cardEffects, + cardDraft: game.cardDraft, + cardTargetPick: game.cardTargetPick }); } export function restore(game, text) { @@ -35,6 +38,11 @@ export function restore(game, text) { game.conveyorTiles = new Set(data.conveyorTiles || []); game.conveyorMeta = new Map(data.conveyorMeta || []); game.branchCounters = new Map(data.branchCounters || []); + game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, flattery: 0 }; + 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; + game.cardTargetPick = data.cardTargetPick || null; game.groupDrag = null; game.selectionBox = null; game.pan = null; diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js index 140ffbf..ed1c0ec 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -1,12 +1,13 @@ -import { BUILD_TOOL_IDS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js'; +import { BUILD_TOOL_IDS, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js'; import { yen } from '../core/utils.js'; import { TEXT } from '../core/text.js'; import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js'; -import { buildPrice, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, zundaTaxInfo } from './economy.js'; +import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, zundaTaxInfo } from './economy.js'; import { truckTarget } from './contracts.js'; +import { conveyorSpeedForGame } from './cards.js'; -export function createUISystem({ game, ui, build, startGame, activeQueuedChick }) { - function displaySpeed() { return Math.round(Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED)); } +export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) { + function displaySpeed() { return Math.round(conveyorSpeedForGame(game)); } function updatePanels() { updateToolButtons(); @@ -28,7 +29,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } const price = buildPrice(id); const uniqueAlreadyBuilt = MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id]; const unaffordable = game.cash < price; - btn.disabled = game.phase !== 'build' || unaffordable || uniqueAlreadyBuilt; + btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt; if (btn.disabled && game.buildTool === id) game.buildTool = null; btn.classList.toggle('unaffordable', unaffordable); btn.classList.toggle('already-built', uniqueAlreadyBuilt); @@ -38,12 +39,12 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } ? `${FACILITY_DEFS[id]?.name || id} already exists` : ''; } - if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build'; + if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending; } function updateBuildStatus() { ui.buildStatus.textContent = game.phase === 'build' - ? TEXT.status.build(game.buildTool) + ? (game.cardTargetPick?.pending ? 'Select a white-highlighted machine on the map.' : (game.cardDraft?.pending ? 'Choose upgrade card before building.' : TEXT.status.build(game.buildTool))) : TEXT.status.sorting; } @@ -55,12 +56,14 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } function updateTurnSummary() { const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)).length; const issues = facilityConnectionIssues(game); - const tax = zundaTaxInfo(game.cash); + const tax = zundaTaxInfo(game.cash, game); + const tribute = fairiesTributeInfo(game, game.turn); ui.turnSummary.innerHTML = [ `Truck target: ${truckTarget(game).toUpperCase()} | Male fine: -${yen(maleTruckPenalty(game))}`, `Poop fine: Mixer -${yen(mixerPoopPenalty(game))} / Shipment -${yen(truckPoopPenalty(game))}`, - `ZUNDA TAX on Next Day: -${yen(tax.tax)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}%`, - `Belt: ${displaySpeed()}px/s fixed | Farms connected: ${connected}/${game.eggFarms.length}`, + `ZUNDA TAX on Next Day: -${yen(tax.tax)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`, + `Fairies tribute on Next Day: -${yen(tribute.amount)}${tribute.reduction ? ` | flattery -${yen(tribute.reduction)}` : ''}`, + `Card draft: ${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'} | Belt: ${displaySpeed()}px/s | Farms connected: ${connected}/${game.eggFarms.length}`, issues.length ? `Blocked: ${issues[0]}` : `${TEXT.status.allPortsConnected}` ].join('
'); } @@ -104,7 +107,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } ui.facilityPanel.className = 'facility-card'; const lines = build.selectedInfoLines(obj); if (obj.type === 'facility' && obj.id === 'trash') lines.push('Items are shredded; no stored contents are visualized.'); - ui.facilityPanel.innerHTML = `

${build.selectedTitle(obj)}

${lines.map(x => `

${x}

`).join('')}

Upgrade choices appear in the clicked equipment menu.

`; + ui.facilityPanel.innerHTML = `

${build.selectedTitle(obj)}

${lines.map(x => `

${x}

`).join('')}

Upgrades come from pre-Build card choices.

`; } function updateUI() { @@ -134,9 +137,11 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } ui.buttons.s1Right.disabled = game.phase !== 'running' || !activeQueuedChick(0); ui.buttons.s2Left.disabled = game.phase !== 'running' || !activeQueuedChick(1); ui.buttons.s2Right.disabled = game.phase !== 'running' || !activeQueuedChick(1); - const nextTax = zundaTaxInfo(game.cash).tax; - ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA TAX ${yen(nextTax)}` : TEXT.actions.nextDay; - ui.buttons.nextTurn.disabled = game.phase !== 'build' || !factoryReady(game); + const nextTax = zundaTaxInfo(game.cash, game).tax; + const nextTribute = fairiesTributeInfo(game, game.turn).amount; + const nextFees = nextTax + nextTribute; + ui.buttons.nextTurn.textContent = game.phase === 'build' && nextFees > 0 ? `Next Day - ZUNDA ${yen(nextTax)} / Fairies ${yen(nextTribute)}` : TEXT.actions.nextDay; + ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game); } function updatePriorityStrip() { @@ -144,7 +149,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } const target = truckTarget(game).toUpperCase(); ui.targetBrief.textContent = `TRUCK TARGET: ${target}`; ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s`; - ui.speedBrief.title = `Conveyor speed is fixed by day. Cap: ${CONVEYOR_SPEED_MAX}px/s.`; + ui.speedBrief.title = `Conveyor speed does not increase by day. Cap: ${CONVEYOR_SPEED_MAX}px/s.`; const active = game.contractActive; const offer = game.contractOffer; if (active) { @@ -199,7 +204,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
Mixer income${yen(r.mixerRevenue)} / unit ${yen(upgradedMixerPrice(game))}
Poop fineMixer ${yen(r.mixerPoopFine)} / Shipment ${yen(r.truckPoopFine)}
Wrong truck fine${yen(r.maleTruckFine)}
-
ZUNDA TAXOpening tax paid on Next Day: -${yen(r.zundaTax || 0)}
+
Next Day feesZUNDA TAX and Fairies tribute are paid before the next run and are not included in this day's profit.
Explosion penalty${yen(r.explosionDamage)}
Daily earned formula(${yen(r.mixerRevenue)} + ${yen(r.ship.adjusted)} + ${yen(contractBonus)}) ÷ 1 day = ${yen(dailyEarned)}
Net profit formula${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}
@@ -207,7 +212,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } ${contractHtml}`; ui.modalActions.innerHTML = ''; - ui.modalActions.appendChild(button(TEXT.actions.buildPhase, hideModal, 'primary-button')); + ui.modalActions.appendChild(button('Choose Upgrade Card', beginCardDraft || hideModal, 'primary-button')); ui.modal.classList.remove('equipment-popover'); ui.modal.classList.add('visible'); } @@ -219,7 +224,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick } ui.modalTitle.textContent = TEXT.phases.gameover; ui.modalBody.innerHTML = `

Your cash went negative.

Final score formula
Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}
Daily earned = ceil(Base ÷ Days) = ceil(${yen(fs.base)} ÷ ${fs.days}) = ${yen(fs.dailyEarned)}
Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}
-
Final Score${fs.score}
Survival${game.totals.turnsCompleted} days
Final Cash${yen(game.cash)}
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Daily Earned${yen(fs.dailyEarned)} / day
Total Outflow${yen(game.totals.penalty)}
ZUNDA TAX${yen(game.totals.zundaTax)}
Correct Sorts${game.totals.correct}
Accuracy${accuracy}%
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer
Explosion Penalty${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Day Penalty-${fs.dayPenalty}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
`; +
Final Score${fs.score}
Survival${game.totals.turnsCompleted} days
Final Cash${yen(game.cash)}
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Daily Earned${yen(fs.dailyEarned)} / day
Total Outflow${yen(game.totals.penalty)}
ZUNDA TAX${yen(game.totals.zundaTax)}
Fairies Tribute${yen(game.totals.fairiesTribute || 0)}
Correct Sorts${game.totals.correct}
Accuracy${accuracy}%
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer
Explosion Penalty${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Day Penalty-${fs.dayPenalty}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
`; ui.modalActions.innerHTML = ''; ui.modalActions.appendChild(button(TEXT.actions.restart, startGame, 'primary-button')); ui.modal.classList.remove('equipment-popover'); diff --git a/styles.css b/styles.css index 381184e..d39b830 100644 --- a/styles.css +++ b/styles.css @@ -180,3 +180,31 @@ h1, h2, p { margin: 0; } margin: 6px 0; font-size: 9px; } + +/* v15 pre-build card draft */ +.card-choices { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; + margin-top: 14px; +} +.card-choice { + min-height: 132px; + border: 3px solid var(--line); + background: #f7fff5; + color: var(--ink); + box-shadow: 5px 5px 0 rgba(16,32,21,.16); + padding: 12px; + text-align: left; + cursor: pointer; + font-weight: 900; +} +.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); } +.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; } +.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; } +.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; } +.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); } +.muted-card-note { color: var(--muted); font-weight: 700; } +.card-targets { display: grid; gap: 8px; margin-top: 14px; } +.card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); } +.card-target-button:hover { background: var(--green-soft); } From 2c7e525c5396c54f9ccaee2c3c01cd1634ac9b75 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Mon, 8 Jun 2026 13:18:33 +0900 Subject: [PATCH 2/3] k --- README.md | 107 ++++++++++- index.html | 25 ++- src/core/balance.js | 130 +++++++++++++ src/core/config.js | 82 ++------ src/core/mapGen.js | 43 +++++ src/core/state.js | 157 ++++++++++++--- src/core/text.js | 6 +- src/game.js | 177 ++++++++++++++++- src/render/draw.js | 101 ++++++++-- src/systems/buildSystem.js | 85 +++++++- src/systems/cards.js | 98 +++++++--- src/systems/chickSystem.js | 89 ++++++++- src/systems/economy.js | 46 +++++ src/systems/effects.js | 6 +- src/systems/gameEvents.js | 39 ++++ src/systems/history.js | 4 + src/systems/maintenance.js | 276 ++++++++++++++++++++++++++ src/systems/routing.js | 342 +++++++++++++++++++++++++-------- src/systems/selectionSystem.js | 6 +- src/systems/uiSystem.js | 82 +++++--- styles.css | 114 +++++++++++ 21 files changed, 1730 insertions(+), 285 deletions(-) create mode 100644 src/core/balance.js create mode 100644 src/core/mapGen.js create mode 100644 src/systems/gameEvents.js create mode 100644 src/systems/maintenance.js diff --git a/README.md b/README.md index 7309df5..aded3c6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,57 @@ -# Chick Sorter v16.1 Card Target Overlay Fix +# Chick Sorter v23.0 Phase 2 Routefix Hotfix + + +This build changes the SHREDDER Improvement payout rule. + +## v18 changes +- `SHREDDER Improvement` now uses one payout roll per processed item. +- Probability after n cards: `((√n / 2) × n)%`. +- Maximum SHREDDER Improvement cards counted: 30. +- At 30 cards, the chance is about 82.16%, so the expected payout is about ¥0.8216 per processed item. + +## SHREDDER Improvement expected value + +```text +n = min(SHREDDER Improvement cards, 30) +p = ((√n / 2) × n)% = n√n / 200 +Per item payout X ~ Bernoulli(p) +E[X] = p yen = n√n / 200 yen +``` + +For k shredded items, expected total bonus is `k × n√n / 200` yen. + +--- + +# Previous build notes + +# Chick Sorter v17.0 Shredder Card and Cleanup + +This build adjusts selling constraints, timeout cleanup, settlement UI, and adds the SHREDDER improvement card. + +## v17 changes +- The last EGG FARM, MIXER, SHREDDER, and TRUCK can no longer be sold. +- Non-last equipment still follows the existing rule: same Build phase removal refunds 100%, older equipment sells for 50%. +- When the day timer and shutdown grace expire, remaining chicks/poop are blown off the conveyors with no equipment damage. +- Settlement appears 1 second after that cleanup. +- The day settlement modal is reduced to two lines: one simple formula and final profit. +- Added `SHREDDER Improvement` card. + - Each card adds one independent trial per shredded item. + - Each trial has a 1/3 chance to pay ¥1. + - Expected value per processed item after n cards: n / 3 yen. +- EGG FARM price remains ¥250. + +## SHREDDER Improvement expected value + +```text +Per item payout X ~ Binomial(n, 1/3) +E[X] = n × 1/3 = n/3 yen +``` + +For k shredded items, expected total bonus is `k × n / 3` yen. + +--- + +## Previous notes This build refines the v15 card and tax system. @@ -38,3 +91,55 @@ ZUNDA TAX = ceil(taxable × rate) ``` With no `Legal Work`, `exemption = ¥500`, so ¥501 starts at 5% and ¥10,000 reaches 95%. + +## v19 changes +- Added collapsed bottom-left debug panel: infinite cash, day override, free upgrade purchase. +- Added `src/core/balance.js` as the primary balance-tuning sheet for prices, income, penalties, timing, card rates, and caps. +- Increased UI text size. +- Auto Scanner base cooldown is now 2.25 seconds. +- High-Quality Bearing now adds +7.5% conveyor speed per card. +- Equipment click popover removed; hover now shows flavor text and equipment information. +- ERASE button renamed to SELL. +- Card drafts can contain up to 2 dud cards; each slot has a 30% dud chance. Dud cards can be clicked away without spending a pick. + +## v20 Phase 1 structural changes +- Added a canonical FactoryGraph in `src/systems/routing.js`. + - Build/edit operations invalidate the graph. + - Route planning, connection validation, congestion component lookup, and debug metrics now read from the same graph model. + - Scanner and facility ports are exact grid cells; visual adjacency is not accepted as connectivity. +- `Next Day` now commits the current FactoryGraph before fees and day start. + - The committed snapshot records conveyor cells, edges, components, port counts, farm outputs, and validation issues. +- Added `src/systems/gameEvents.js`. + - Major lifecycle, cash, build/sell, move, cleanup, and jam events are stored in a bounded run log. +- Expanded the debug panel from cheats only into an observability panel. + - Shows FactoryGraph size, components, port status, farm outputs, dead ends, active/queued items, max congestion, and recent events. +- This is a structural pass, not a content/balance pass. It is meant to make later map generation, market contracts, route diagnostics, replay, and deeper factory simulation safer to add. + +## v21 Phase 2 structural changes + +Implemented the Phase 2 foundation without the market board or programmable scanner logic. + +- Added seeded no-build terrain generation. Roughly 20% of grid cells become blocked each run while preserving the initial critical route. +- Added equipment degradation based on actual use. + - Truck and Manual Scanner are exempt and always operate normally. + - Conveyor tiles degrade from item pass counts and reduce local belt speed. + - Egg Farms degrade from egg production and gradually increase spawn interval. + - Mixer, Waste Shredder, and Auto Scanner degrade from processing counts and add delay. + - Equipment keeps full performance until 50% durability remains; at 0% durability it performs at half speed. +- Added dirty overlay rendering for degraded equipment. +- Added a one-day repairman hire button. The repairman costs ¥100 for the next production phase, walks freely across the map, and repairs 1% degradation every 3 seconds. +- Expanded debug/hover readouts with blocked-cell and degradation information. + +Market contracts and deeper scanner-programming logic are intentionally left for later Phase 2 work. + +## v22 Phase 2 starter-route protection + +No-build generation now protects the complete initial factory footprint before blocked cells are placed. The protected footprint includes the default Egg Farm, scanner bodies, scanner input/output ports, machine receiver cells, and every starter conveyor segment. After blocked-cell generation, the starter backbone is scrubbed from the blocked set and reinstalled as a final guard. + +Result: even with roughly 20% no-build cells, the initial line remains continuous from Egg Farm to S1, S1 to S2, and S2 to both Shredder and Truck exits. + + +## v23 hotfix + +- Fixed the startup ReferenceError caused by binding the repairman button to a missing `hireRepairman` wrapper. +- Disabled optional external art probing by default, preventing 404 console noise for placeholder image filenames. Canvas fallback art remains active. diff --git a/index.html b/index.html index f09f495..8bd2917 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Chick Sorter v15.0 Cards / Tribute + Chick Sorter v21.0 Phase 2 Map / Degradation @@ -35,7 +35,7 @@
-

CHICK SORTER v15.0

+

CHICK SORTER v21.0

Build, drag, connect all receiver ports, then start the next day.

@@ -47,12 +47,13 @@
- + - + +
Build tools unlock after each day.
@@ -81,6 +82,20 @@
+ + +
+ DBG +
+ + + + + +
+
+
+
@@ -92,6 +107,6 @@
- + diff --git a/src/core/balance.js b/src/core/balance.js new file mode 100644 index 0000000..c98abda --- /dev/null +++ b/src/core/balance.js @@ -0,0 +1,130 @@ +// Central balance sheet for tuning gameplay. +// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps. +export const BALANCE = { + version: 'v23.0 Phase 2 Routefix Hotfix', + time: { + daySeconds: 60, + farmShutdownGraceSeconds: 10 + }, + cash: { + starting: 250 + }, + grid: { + x: 190, + y: 142, + cols: 22, + rows: 13, + cell: 46 + }, + map: { + blockedRatio: 0.20 + }, + economy: { + income: { + mixer: 5, + truck: 10 + }, + poopFine: 10, + zundaTax: { + exemption: 500, + legalWorkExemptionBonus: 250, + maxRateCash: 10000, + stepAmount: 500, + stepRate: 0.05, + maxRate: 0.95 + }, + fairiesTribute: { + perDay: 10 + }, + incomeUpgradeRate: 1.05, + explosionDamageDivisor: 30, + maleTruckFinePerHalfDay: 30, + shredderBonus: { + maxCards: 30, + probabilityPercentDivisor: 2 + } + }, + production: { + poopRate: 0.08, + autoScannerCooldown: 2.25, + autoScannerUpgradeRate: 0.95, + autoScannerMinCooldown: 0.5, + eggSpawnRanges: [ + [1.95, 4.35], + [1.55, 3.45], + [1.25, 2.80], + [1.05, 2.35] + ] + }, + conveyor: { + baseSpeed: 52, + maxSpeed: 300, + bearingSpeedMultiplier: 1.075 + }, + cards: { + commonWeight: 8, + rareWeight: 2, + baseDraftSize: 3, + maxDudsPerDraft: 2, + dudChancePerCard: 0.30, + extraCardsAddedPicks: 2, + rerollCost: { + dayDivisor: 2, + multiplier: 100 + } + }, + contracts: { + eventChance: 0.70, + firstTurn: 8 + }, + maintenance: { + fullPerformanceUntilWear: 0.50, + minimumPerformance: 0.50, + durability: { + conveyor: 260, + eggFarm: 95, + autoScanner: 170, + mixer: 155, + trash: 190 + }, + processingDelayMaxSeconds: { + mixer: 1.20, + trash: 1.00 + }, + repairman: { + dailyCost: 100, + secondsPerOnePercent: 3, + walkSpeed: 140 + } + }, + facilities: { + conveyor: { + id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30, + buildable: true, upgradeable: false + }, + eggFarm: { + id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250, + buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600] + }, + manualScanner: { + id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260, + buildable: true, upgradeable: false + }, + autoScanner: { + id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360, + buildable: true, upgradeable: false + }, + mixer: { + id: 'mixer', type: 'facility', name: 'Mixer', shortName: 'MIXER', price: 450, + buildable: true, upgradeable: true, incomeKey: 'mixer', body: { w: 164, h: 126 } + }, + trash: { + id: 'trash', type: 'facility', name: 'Waste Shredder', shortName: 'SHREDDER', price: 240, + buildable: true, upgradeable: true, body: { w: 180, h: 100 } + }, + truck: { + id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450, + buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 } + } + } +}; diff --git a/src/core/config.js b/src/core/config.js index 5f825d1..23bcf64 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,70 +1,26 @@ -export const VERSION = 'v16.1 Card Target Overlay Fix'; +import { BALANCE } from './balance.js'; -export const TURN_SECONDS = 60; -export const STARTING_CASH = 250; +export const VERSION = BALANCE.version; -export const ECONOMY = { - income: { - mixer: 5, - truck: 10 - }, - poopFine: 10, - zundaTax: { - exemption: 500, - legalWorkExemptionBonus: 250, - maxRateCash: 10000, - stepAmount: 500, - stepRate: 0.05, - maxRate: 0.95 - }, - fairiesTribute: { - perDay: 10 - }, - incomeUpgradeRate: 1.05, - explosionDamageDivisor: 30, - maleTruckFinePerHalfDay: 30 -}; +export const TURN_SECONDS = BALANCE.time.daySeconds; +export const STARTING_CASH = BALANCE.cash.starting; +export const ECONOMY = BALANCE.economy; -export const FARM_SHUTDOWN_GRACE_SECONDS = 10; -export const AUTO_SCANNER_COOLDOWN = 4.5; -export const CONTRACT_EVENT_CHANCE = 0.70; -export const CONTRACT_EVENT_FIRST_TURN = 8; -export const POOP_RATE = 0.08; -export const CONVEYOR_SPEED = 52; -export const CONVEYOR_SPEED_MAX = 300; +export const FARM_SHUTDOWN_GRACE_SECONDS = BALANCE.time.farmShutdownGraceSeconds; +export const AUTO_SCANNER_COOLDOWN = BALANCE.production.autoScannerCooldown; +export const AUTO_SCANNER_UPGRADE_RATE = BALANCE.production.autoScannerUpgradeRate; +export const AUTO_SCANNER_MIN_COOLDOWN = BALANCE.production.autoScannerMinCooldown; +export const CONTRACT_EVENT_CHANCE = BALANCE.contracts.eventChance; +export const CONTRACT_EVENT_FIRST_TURN = BALANCE.contracts.firstTurn; +export const POOP_RATE = BALANCE.production.poopRate; +export const CONVEYOR_SPEED = BALANCE.conveyor.baseSpeed; +export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed; +export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier; -export const GRID = { x: 190, y: 142, cols: 22, rows: 13, cell: 46 }; - -export const FACILITY_DEFS = { - conveyor: { - id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30, - buildable: true, upgradeable: false - }, - eggFarm: { - id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250, - buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600] - }, - manualScanner: { - id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260, - buildable: true, upgradeable: false - }, - autoScanner: { - id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360, - buildable: true, upgradeable: false - }, - mixer: { - id: 'mixer', type: 'facility', name: 'Mixer', shortName: 'MIXER', price: 450, - buildable: true, upgradeable: true, incomeKey: 'mixer', body: { w: 164, h: 126 } - }, - trash: { - id: 'trash', type: 'facility', name: 'Waste Shredder', shortName: 'SHREDDER', price: 240, - buildable: true, upgradeable: false, body: { w: 180, h: 100 } - }, - truck: { - id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450, - buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 } - } -}; +export const CARD_BALANCE = BALANCE.cards; +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 MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck']; diff --git a/src/core/mapGen.js b/src/core/mapGen.js new file mode 100644 index 0000000..4b8b51f --- /dev/null +++ b/src/core/mapGen.js @@ -0,0 +1,43 @@ +import { GRID } from './config.js'; +import { key } from './utils.js'; + +function hashSeed(seedText) { + let h = 2166136261; + for (let i = 0; i < String(seedText).length; i += 1) { + h ^= String(seedText).charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function mulberry32(seed) { + let t = seed >>> 0; + return () => { + t += 0x6D2B79F5; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; +} + +export function createRunSeed() { + return `RUN-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`.toUpperCase(); +} + +export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] } = {}) { + const rng = mulberry32(hashSeed(seed || createRunSeed())); + const reservedSet = new Set(reserved.map(p => typeof p === 'string' ? p : key(p.col, p.row))); + const candidates = []; + for (let row = 0; row < GRID.rows; row += 1) { + for (let col = 0; col < GRID.cols; col += 1) { + const k = key(col, row); + if (!reservedSet.has(k)) candidates.push({ col, row, k }); + } + } + for (let i = candidates.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)); + [candidates[i], candidates[j]] = [candidates[j], candidates[i]]; + } + const target = Math.round(GRID.cols * GRID.rows * targetRatio); + return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k)); +} diff --git a/src/core/state.js b/src/core/state.js index 22d7190..b7b5ee3 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -1,5 +1,7 @@ -import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js'; -import { randomBetween } from './utils.js'; +import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_DEFS, GRID, EGG_SPAWN_RANGES } from './config.js'; +import { BALANCE } from './balance.js'; +import { createRunSeed, generateBlockedCells } from './mapGen.js'; +import { randomBetween, key, inGrid } from './utils.js'; export function newTurnStats() { return { @@ -25,10 +27,13 @@ export function newTurnStats() { truckPoopFine: 0, maleTruckFine: 0, explosionDamage: 0, + shredderBonus: 0, correct: 0, mistakes: 0, contractBonus: 0, - contractResult: null + contractResult: null, + conveyorPasses: 0, + eggProduced: 0 }; } @@ -47,6 +52,7 @@ export function newTotalStats() { poopTrash: 0, poopMixer: 0, explosionDamage: 0, + shredderBonus: 0, contractBonus: 0, zundaTax: 0, fairiesTribute: 0, @@ -55,7 +61,9 @@ export function newTotalStats() { truckPoopFine: 0, maleTruckFine: 0, contractSuccess: 0, - contractFailed: 0 + contractFailed: 0, + conveyorPasses: 0, + eggProduced: 0 }; } @@ -67,8 +75,13 @@ export function createGame() { timeLeft: TURN_SECONDS, shutdownTimeLeft: 0, shutdownGraceSeconds: FARM_SHUTDOWN_GRACE_SECONDS, + cleanupTimer: null, + cleanupBlown: false, lastTimestamp: 0, nextId: 10, + nextEventId: 1, + runSeed: createRunSeed(), + blockedCells: new Set(), buildTool: null, buildSession: 0, undoStack: [], @@ -80,6 +93,8 @@ export function createGame() { pan: null, view: { x: 0, y: 0 }, debug: false, + debugInfiniteCash: false, + hover: null, chicks: [], effects: [], floatingTexts: [], @@ -91,6 +106,10 @@ export function createGame() { scanners: [], facilities: {}, branchCounters: new Map(), + routingVersion: 0, + factoryGraph: null, + factoryGraphCommittedVersion: -1, + factoryGraphSnapshot: null, mixerHalfTimer: 0, congestion: new Map(), lastExplodedComponent: new Map(), @@ -99,15 +118,16 @@ export function createGame() { cardEffects: { bearing: 0, legalWork: 0, flattery: 0 }, cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 }, cardTargetPick: null, + repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + GRID.rows * GRID.cell + 84, target: null, repairedToday: 0 }, stats: newTurnStats(), lastResult: null, + eventLog: [], totals: newTotalStats() }; } export function getSpawnRange(farm) { - const ranges = [[1.95, 4.35], [1.55, 3.45], [1.25, 2.80], [1.05, 2.35]]; - return ranges[(farm?.level || 1) - 1] || ranges[0]; + return EGG_SPAWN_RANGES[(farm?.level || 1) - 1] || EGG_SPAWN_RANGES[EGG_SPAWN_RANGES.length - 1] || [2, 4]; } export function nextSpawnDelay(farm) { @@ -138,34 +158,107 @@ export function defaultFacilities() { }; } +const INITIAL_CONVEYOR_BACKBONE = Object.freeze([ + // Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it. + [4, 1], [4, 2], [4, 3], [4, 4], + // S1 left output -> Mixer receiver on the left grid edge. + [3, 5], [2, 5], [1, 5], [0, 5], + // S1 right output -> S2 top input. + [5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5], + [11, 6], [11, 7], + // S2 left output -> Waste Shredder receiver on bottom grid edge. + [10, 8], [10, 9], [10, 10], [10, 11], [10, 12], + // S2 right output -> Truck receiver on the bottom grid edge. + [12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12] +]); + +const INITIAL_SCANNERS = Object.freeze([ + { type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 5, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }, + { type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, row: 8, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' } +]); + +const INITIAL_EGG_FARMS = Object.freeze([ + { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null } +]); + +function addConveyorTile(game, col, row, options = {}) { + if (!inGrid(col, row)) return; + const k = key(col, row); + game.conveyorTiles.add(k); + if (!game.conveyorMeta.has(k) || options.forceMeta) { + game.conveyorMeta.set(k, { + price: BUILD_COSTS.conveyor, + builtSession: null, + uses: 0, + durability: BALANCE.maintenance.durability.conveyor, + maintenanceType: 'conveyor' + }); + } +} + +function scannerPortCells(scanner) { + return [ + { col: scanner.col, row: scanner.row - 1 }, + { col: scanner.col - 1, row: scanner.row }, + { col: scanner.col + 1, row: scanner.row } + ].filter(p => inGrid(p.col, p.row)); +} + +function farmOutputCells(farm) { + return [ + { col: farm.col, row: farm.row + 1 }, + { col: farm.col - 1, row: farm.row }, + { col: farm.col + 1, row: farm.row }, + { col: farm.col, row: farm.row - 1 } + ].filter(p => inGrid(p.col, p.row)); +} + +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 scannerPortCells(scanner)) add(p); + } + for (const farm of game.eggFarms) { + add(farm); + for (const p of farmOutputCells(farm)) add(p); + } + for (const f of Object.values(game.facilities)) add(f.entry); + return reserved; +} + +function installInitialConveyorBackbone(game) { + for (const [col, row] of INITIAL_CONVEYOR_BACKBONE) addConveyorTile(game, col, row); +} + +function scrubBlockedCellsFromInitialBackbone(game) { + const protectedCells = protectedInitialCells(game); + for (const k of protectedCells) game.blockedCells.delete(k); +} + export function resetLayout(game) { game.conveyorTiles.clear(); game.conveyorMeta.clear(); - const initial = [ - // Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it. - [4, 1], [4, 2], [4, 3], [4, 4], - // S1 left output -> Mixer receiver on the left grid edge. - [3, 5], [2, 5], [1, 5], [0, 5], - // S1 right output -> S2 top input. - [5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5], - [11, 6], [11, 7], - // S2 left output -> Waste Shredder receiver on bottom grid edge. - [10, 8], [10, 9], [10, 10], [10, 11], [10, 12], - // S2 right output -> Truck receiver on the bottom grid edge. - [12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12] - ]; - for (const [col, row] of initial) { - const k = `${col},${row}`; - game.conveyorTiles.add(k); - game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: null }); - } + game.blockedCells = new Set(); + installInitialConveyorBackbone(game); game.facilities = defaultFacilities(); - game.scanners = [ - { type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 5, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }, - { type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, row: 8, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' } - ]; - const farm = { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }; - farm.nextSpawn = nextSpawnDelay(farm); - farm.lastInterval = farm.nextSpawn; - game.eggFarms = [farm]; + game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, queue: [] })); + game.eggFarms = INITIAL_EGG_FARMS.map(farm => { + const nextSpawn = nextSpawnDelay(farm); + return { ...farm, nextSpawn, lastInterval: nextSpawn }; + }); + + // Generate no-build cells only after the complete initial factory footprint is + // installed. The protected footprint includes every default conveyor segment, + // scanner body/ports, farm output, and machine receiver. This guarantees that + // the starter line is always continuous from Egg Farm -> S1 -> S2 -> exits even + // when roughly 20% of the map is blocked. + const reserved = protectedInitialCells(game); + game.blockedCells = generateBlockedCells({ seed: game.runSeed, targetRatio: BALANCE.map.blockedRatio, reserved: [...reserved] }); + scrubBlockedCellsFromInitialBackbone(game); + installInitialConveyorBackbone(game); + game.routingVersion = (game.routingVersion || 0) + 1; + game.factoryGraph = null; + game.factoryGraphCommittedVersion = -1; } diff --git a/src/core/text.js b/src/core/text.js index b7e9358..82ec240 100644 --- a/src/core/text.js +++ b/src/core/text.js @@ -23,10 +23,10 @@ export const TEXT = { autoMenu: 'Auto Menu' }, status: { - build: tool => `BUILD: ${(tool || 'SELECT').toUpperCase()} | Right-drag pan | Click equipment for menu`, + build: tool => `BUILD: ${(tool === 'erase' ? 'SELL' : (tool || 'SELECT')).toUpperCase()} | Right-drag pan | Hover equipment for info`, sorting: 'Sorting: use scanner keys. Build after clearing the day.', allPortsConnected: 'All required ports connected.', - equipmentPanelEmpty: 'Click equipment in Build phase.', + equipmentPanelEmpty: 'Hover equipment in Build phase.', eventPanelRunning: 'Forced events appear in Build phase.' }, routeLabels: { @@ -43,7 +43,7 @@ export const TEXT = { notEnoughCash: 'Not enough cash', facilityExists: 'Facility already exists', facilityOverlap: 'Facility overlap', - nothingToErase: 'Nothing to erase', + nothingToErase: 'Nothing to sell', noUpgrade: 'No upgrade available' } }; diff --git a/src/game.js b/src/game.js index 80ce6fb..b8c4708 100644 --- a/src/game.js +++ b/src/game.js @@ -1,17 +1,19 @@ import { TURN_SECONDS, THEME } from './core/config.js'; import { buildToolButtonHtml } from './core/text.js'; -import { createGame, resetLayout, newTurnStats } from './core/state.js'; +import { createGame, resetLayout, newTurnStats, nextSpawnDelay } from './core/state.js'; import { clamp, pointToCell, cellCenter, yen } from './core/utils.js'; -import { facilityConnectionIssues } from './systems/routing.js'; +import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js'; import { collectFairiesTribute, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; import { undo, redo } from './systems/history.js'; import { drawAll } from './render/draw.js'; import { createBuildSystem } from './systems/buildSystem.js'; import { createUISystem } from './systems/uiSystem.js'; import { createChickSystem } from './systems/chickSystem.js'; -import { conveyorSpeedForGame, createCardSystem } from './systems/cards.js'; +import { conveyorSpeedForGame, createCardSystem, ensureCardState } from './systems/cards.js'; import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js'; import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js'; +import { clearGameEvents, emitGameEvent } from './systems/gameEvents.js'; +import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js'; const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); @@ -23,6 +25,16 @@ const ui = { turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'), buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'), facilityPanel: document.getElementById('facilityPanel'), modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'), + hoverTooltip: document.getElementById('hoverTooltip'), + debugPanel: document.getElementById('debugPanel'), + debugInfiniteCash: document.getElementById('debugInfiniteCash'), + debugDayInput: document.getElementById('debugDayInput'), + debugSetDayButton: document.getElementById('debugSetDayButton'), + debugUpgradeSelect: document.getElementById('debugUpgradeSelect'), + debugApplyUpgradeButton: document.getElementById('debugApplyUpgradeButton'), + debugObservations: document.getElementById('debugObservations'), + debugEventLog: document.getElementById('debugEventLog'), + hireRepairmanButton: document.getElementById('hireRepairmanButton'), 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') @@ -51,13 +63,21 @@ let uiSystem; let chicks; let cardSystem; -function currentConveyorSpeed() { return conveyorSpeedForGame(game); } +function currentConveyorSpeed(conveyorKey = null) { + const base = conveyorSpeedForGame(game, conveyorKey); + return conveyorKey ? base * conveyorSpeedFactorForKey(game, conveyorKey) : base; +} function startGame() { Object.assign(game, createGame()); game.phase = 'running'; game.view = { x: 0, y: 0 }; resetLayout(game); + ensureMaintenanceState(game); + clearGameEvents(game); + emitGameEvent(game, 'RunStarted', { cash: game.cash }); + game.debugInfiniteCash = !!ui.debugInfiniteCash?.checked; + refreshDebugUpgradeSelect(); uiSystem.hideModal(); chicks.updateCongestion(); uiSystem.updatePanels(); @@ -77,6 +97,10 @@ function startNextTurn() { uiSystem.updatePanels(); return; } + ensureMaintenanceState(game); + const graph = commitFactoryGraphForDay(game); + emitGameEvent(game, 'FactoryGraphCommitted', { cells: graph.metrics.conveyorCells, edges: graph.metrics.edges, components: graph.metrics.components }); + activateRepairmanForDay(game); const zundaBasisCash = game.cash; const zunda = collectZundaTax(game, zundaBasisCash); const tribute = collectFairiesTribute(game, game.turn); @@ -85,10 +109,13 @@ function startNextTurn() { if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger); if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; } activateAcceptedContract(game); + emitGameEvent(game, 'StartDay', { day: game.turn + 1, zundaTax: zunda.tax || 0, fairiesTribute: tribute.amount || 0 }); game.phase = 'running'; game.turn += 1; game.timeLeft = TURN_SECONDS; game.shutdownTimeLeft = 0; + game.cleanupTimer = null; + game.cleanupBlown = false; game.chicks = []; game.effects = []; game.floatingTexts = []; @@ -108,6 +135,20 @@ function startNextTurn() { uiSystem.updateUI(); } + +function hireRepairman() { + ensureMaintenanceState(game); + const result = hireRepairmanForNextDay(game); + if (!result.ok) { + floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, result.reason || 'Cannot hire repairman.', THEME.danger); + build?.fail?.(result.reason || 'Cannot hire repairman.'); + } else { + floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `Repairman hired -${yen(result.cost)}`, THEME.green); + } + uiSystem.updatePanels(); + uiSystem.updateUI(); +} + function completeTurn() { if (game.phase !== 'running') return; const ship = settleTruckRevenue(game); @@ -115,17 +156,21 @@ function completeTurn() { clearActiveContract(game); game.totals.turnsCompleted += 1; game.lastResult = { ...game.stats, ship, contract, turn: game.turn, cash: game.cash }; + emitGameEvent(game, 'EndDay', { day: game.turn, revenue: game.stats.revenue, penalty: game.stats.penalty, profit: game.stats.profit, cash: game.cash }); if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; } game.phase = 'build'; game.truckCargo = []; game.chicks = []; game.shutdownTimeLeft = 0; + game.cleanupTimer = null; + game.cleanupBlown = false; game.buildSession += 1; game.undoStack = []; game.redoStack = []; game.timeLeft = 0; for (const scanner of game.scanners) scanner.queue = []; for (const farm of game.eggFarms) { farm.shutterProgress = 0; farm.shutterSparked = false; } + deactivateRepairman(game); game.contractOffer = rollContractOffer(game); if (cardSystem) cardSystem.prepareDraft(); game.cardTargetPick = null; @@ -147,6 +192,7 @@ function closeFarmShutters() { shockwave(game, c.x, c.y, THEME.ink); sparkBurst(game, c.x, c.y + 10, 10); floating(game, c.x, c.y - 34, 'SHUT +10s', THEME.ink); + emitGameEvent(game, 'FarmShutterClosed', { farmId: farm.id }); } } @@ -154,6 +200,7 @@ function update(timestamp) { if (!game.lastTimestamp) game.lastTimestamp = timestamp; const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05); game.lastTimestamp = timestamp; + if (game.debugInfiniteCash && game.cash < 999999) { game.cash = 999999; game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); } if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn }); updateEffects(game, dt, canvas, build.equipmentHitBoxes); chicks.updateCongestion(); @@ -170,14 +217,49 @@ function canvasPoint(event) { const p = rawCanvasPoint(event); return { x: p.x - function startPan(event) { const p = rawCanvasPoint(event); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; } function updatePan(event) { if (!game.pan) return; const p = rawCanvasPoint(event); game.view.x = clamp(game.pan.viewX + p.x - game.pan.start.x, -620, 420); game.view.y = clamp(game.pan.viewY + p.y - game.pan.start.y, -340, 240); } function selectionForHit(hit) { if (!hit) return null; if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey }; return { type: hit.type, id: hit.ref.id }; } +function objectFromHit(hit) { + if (!hit) return null; + if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey }; + return hit.ref || null; +} + +function hideHoverTooltip() { + if (!ui.hoverTooltip) return; + ui.hoverTooltip.classList.remove('visible'); + ui.hoverTooltip.setAttribute('aria-hidden', 'true'); + game.hover = null; +} + +function showHoverTooltip(event, hit) { + if (!ui.hoverTooltip || game.phase !== 'build' || game.cardTargetPick?.pending || game.groupDrag || game.selectionBox || game.pan) return hideHoverTooltip(); + const obj = objectFromHit(hit); + if (!obj) return hideHoverTooltip(); + const title = build.selectedTitle(obj); + const flavor = build.flavorText ? build.flavorText(obj) : ''; + const lines = build.selectedInfoLines(obj).slice(0, 7); + ui.hoverTooltip.innerHTML = `${title}${flavor ? `${flavor}` : ''}${lines.map(line => `${line}`).join('')}`; + const x = Math.min(window.innerWidth - 330, event.clientX + 18); + const y = Math.min(window.innerHeight - 220, event.clientY + 18); + ui.hoverTooltip.style.left = `${Math.max(8, x)}px`; + ui.hoverTooltip.style.top = `${Math.max(8, y)}px`; + ui.hoverTooltip.classList.add('visible'); + ui.hoverTooltip.setAttribute('aria-hidden', 'false'); + game.hover = { type: hit.type, id: hit.oldKey || hit.ref?.id || null }; +} + +function updateHover(event) { + if (game.phase !== 'build') return hideHoverTooltip(); + showHoverTooltip(event, build.equipmentAtPoint(canvasPoint(event))); +} + function clickSelect(event) { + hideHoverTooltip(); const p = canvasPoint(event), hit = build.equipmentAtPoint(p); const sel = selectionForHit(hit); if (!sel) { game.selected = null; game.multiSelected = []; uiSystem.updatePanels(); return; } game.selected = sel; game.multiSelected = [sel]; uiSystem.updatePanels(); - build.showSelectedMenu(); } canvas.addEventListener('contextmenu', event => event.preventDefault()); @@ -199,7 +281,8 @@ canvas.addEventListener('pointerdown', event => { build.startSelectionBox(event); }); canvas.addEventListener('pointermove', event => { - if (game.phase !== 'build') return; + if (game.phase !== 'build') { hideHoverTooltip(); return; } + updateHover(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); @@ -215,7 +298,8 @@ canvas.addEventListener('pointerup', event => { game.groupDrag = null; game.pan = null; try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ } }); -canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; }); +canvas.addEventListener('pointerleave', hideHoverTooltip); +canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); }); document.addEventListener('pointerdown', event => { if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return; if (event.target.closest('#modal .modal-card')) return; @@ -224,11 +308,88 @@ document.addEventListener('pointerdown', event => { ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right')); ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase')); +ui.hireRepairmanButton?.addEventListener('click', hireRepairman); ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); }); window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } }); + +function debugUpgradeOptions() { + const options = [ + { value: 'effect:bearing', label: `Bearing +1 (${game.cardEffects?.bearing || 0})` }, + { value: 'effect:legalWork', label: `Legal Work +1 (${game.cardEffects?.legalWork || 0})` }, + { value: 'effect:flattery', label: `Flattery +1 (${game.cardEffects?.flattery || 0})` } + ]; + for (const farm of game.eggFarms) options.push({ value: `eggFarm:${farm.id}`, label: `EGG #${farm.id} L${farm.level}` }); + for (const scanner of game.scanners.filter(s => s.kind === 'auto')) options.push({ value: `scanner:${scanner.id}`, label: `AUTO #${scanner.id} L${scanner.level}` }); + for (const id of ['mixer', 'trash', 'truck']) { + const f = game.facilities[id]; + if (f) options.push({ value: `facility:${id}`, label: `${f.shortName || f.name} L${f.level}` }); + } + return options; +} + +function refreshDebugUpgradeSelect() { + if (!ui.debugUpgradeSelect) return; + const previous = ui.debugUpgradeSelect.value; + const options = debugUpgradeOptions(); + ui.debugUpgradeSelect.innerHTML = options.map(o => ``).join(''); + if (options.some(o => o.value === previous)) ui.debugUpgradeSelect.value = previous; +} + +function applyDebugUpgrade() { + ensureCardState(game); + const value = ui.debugUpgradeSelect?.value || ''; + const [kind, id] = value.split(':'); + if (kind === 'effect') { + game.cardEffects[id] = Math.max(0, game.cardEffects[id] || 0) + 1; + floating(game, canvas.width / 2 - game.view.x, 112 - game.view.y, `${id.toUpperCase()} +1`, THEME.green); + } else if (kind === 'eggFarm') { + const farm = game.eggFarms.find(f => String(f.id) === id); + if (farm) { + farm.level = Math.min(4, (farm.level || 1) + 1); + farm.nextSpawn = nextSpawnDelay(farm); + farm.lastInterval = farm.nextSpawn; + floating(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y - 34, `LV ${farm.level}`, THEME.green); + } + } else if (kind === 'scanner') { + const scanner = game.scanners.find(sc => String(sc.id) === id); + if (scanner) { + scanner.level = (scanner.level || 1) + 1; + const c = cellCenter(scanner.col, scanner.row); + floating(game, c.x, c.y - 42, `LV ${scanner.level}`, THEME.green); + } + } else if (kind === 'facility') { + const f = game.facilities[id]; + if (f) { + if (id === 'trash') f.level = Math.min(31, (f.level || 1) + 1); + else f.level = (f.level || 1) + 1; + floating(game, f.x + f.w / 2, f.y + 20, `LV ${f.level}`, THEME.green); + } + } + refreshDebugUpgradeSelect(); + uiSystem?.updatePanels(); +} + +function wireDebugControls() { + if (!ui.debugPanel) return; + refreshDebugUpgradeSelect(); + ui.debugPanel.addEventListener('toggle', refreshDebugUpgradeSelect); + ui.debugInfiniteCash?.addEventListener('change', () => { + game.debugInfiniteCash = !!ui.debugInfiniteCash.checked; + if (game.debugInfiniteCash) game.cash = Math.max(game.cash, 999999); + uiSystem?.updateUI(); + }); + ui.debugSetDayButton?.addEventListener('click', () => { + game.turn = Math.max(1, Math.floor(Number(ui.debugDayInput?.value) || 1)); + uiSystem?.updatePanels(); + uiSystem?.updateUI(); + }); + ui.debugApplyUpgradeButton?.addEventListener('click', applyDebugUpgrade); + ui.debugUpgradeSelect?.addEventListener('pointerdown', refreshDebugUpgradeSelect); +} + build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() }); chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() }); cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() }); uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick }); -initializeStaticText(); resetLayout(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); +wireDebugControls(); initializeStaticText(); resetLayout(game); ensureMaintenanceState(game); refreshDebugUpgradeSelect(); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); diff --git a/src/render/draw.js b/src/render/draw.js index 5917c25..be8fbd0 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -3,7 +3,12 @@ import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } fr import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js'; import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js'; import { cardTargetBounds } from '../systems/cards.js'; +import { wearRatio, remainingPercent } from '../systems/maintenance.js'; +// Optional art overlay hook. +// Keep external art probing disabled by default so a clean checkout does not emit 404s for missing PNGs. +// To use replacement art, set ENABLE_EXTERNAL_ART_ASSETS to true and place files under assets/images/. +const ENABLE_EXTERNAL_ART_ASSETS = false; const ASSET_PATHS = { chickMale: './assets/images/chick_male.png', chickFemale: './assets/images/chick_female.png', @@ -20,16 +25,16 @@ const ASSET_PATHS = { export const assets = {}; for (const [name, src] of Object.entries(ASSET_PATHS)) { const img = new Image(); - img.src = src; img.loaded = false; - img.onload = () => { img.loaded = true; }; + img.onerror = () => { img.loaded = false; }; + if (ENABLE_EXTERNAL_ART_ASSETS && src) { + img.onload = () => { img.loaded = true; }; + img.src = src; + } assets[name] = img; } -// ART OVERLAY HOOK: -// Drop replacement PNG/WebP files into assets/images/ using the names in ASSET_PATHS. -// Each draw* function first checks whether an image loaded; if not, it falls back to simple flat shapes. -// Keep these placeholder shapes simple so your later artwork can be layered over them without fighting the UI. +// Each draw* function first checks whether an image loaded; if not, it falls back to simple canvas shapes. export function drawAll(ctx, canvas, game, helpers) { ctx.clearRect(0, 0, canvas.width, canvas.height); @@ -38,13 +43,14 @@ export function drawAll(ctx, canvas, game, helpers) { const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0; const sy = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0; ctx.translate(game.view.x + sx, game.view.y + sy); - drawGrid(ctx); + drawGrid(ctx, game); drawConveyors(ctx, game); drawScannerConnectors(ctx, game); drawFacilities(ctx, game); drawEggFarms(ctx, game); drawScanners(ctx, game); drawChicks(ctx, game, helpers.activeQueuedChick); + drawRepairman(ctx, game); drawCardTargetOverlay(ctx, canvas, game); drawSelectedTooltip(ctx, game, helpers.selectedObject, helpers.selectedTitle); drawSelectionBox(ctx, game); @@ -76,7 +82,7 @@ function drawBackground(ctx, canvas) { for (let y = 0; y < canvas.height; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } ctx.restore(); } -function drawGrid(ctx) { +function drawGrid(ctx, game) { ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.46)'; ctx.strokeStyle = 'rgba(16,32,21,.055)'; @@ -90,8 +96,40 @@ function drawGrid(ctx) { const y = GRID.y + r * GRID.cell; ctx.beginPath(); ctx.moveTo(GRID.x, y); ctx.lineTo(GRID.x + GRID.cols * GRID.cell, y); ctx.stroke(); } + for (const k of game.blockedCells || []) { + const p = parseKey(k); + const c = cellCenter(p.col, p.row); + ctx.fillStyle = 'rgba(16,32,21,.30)'; + ctx.strokeStyle = 'rgba(16,32,21,.60)'; + ctx.lineWidth = 2; + rect(ctx, c.x - GRID.cell / 2 + 4, c.y - GRID.cell / 2 + 4, GRID.cell - 8, GRID.cell - 8, true, true); + ctx.fillStyle = 'rgba(255,255,255,.70)'; + ctx.font = '900 9px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.fillText('NO BUILD', c.x, c.y + 3); + } ctx.restore(); } + +function drawDirtOverlay(ctx, x, y, w, h, target, radius = 0) { + const wear = wearRatio(target); + if (wear <= 0.01) return; + ctx.save(); + ctx.globalAlpha = Math.min(0.62, 0.12 + wear * 0.54); + ctx.fillStyle = '#6b4a23'; + if (radius > 0) { + ctx.beginPath(); ctx.roundRect?.(x, y, w, h, radius); + if (ctx.roundRect) ctx.fill(); + else rect(ctx, x, y, w, h, true, false); + } else rect(ctx, x, y, w, h, true, false); + ctx.globalAlpha = Math.min(0.9, 0.25 + wear * 0.6); + ctx.strokeStyle = '#3a2a16'; + ctx.lineWidth = Math.max(2, Math.floor(2 + wear * 5)); + ctx.setLineDash([5, 6]); + rect(ctx, x + 3, y + 3, w - 6, h - 6, false, true); + ctx.restore(); +} + function componentRatio(game, k) { const id = game.componentLookup?.get(k); const data = id ? game.congestion.get(id) : null; @@ -135,6 +173,7 @@ function drawConveyors(ctx, game) { ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2; rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true); + drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta: game.conveyorMeta.get(k) }); drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []); if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger); } @@ -221,7 +260,11 @@ function drawEggFarms(ctx, game) { for (const farm of game.eggFarms) drawEggFarm function drawEggFarm(ctx, farm, game) { const c = cellCenter(farm.col, farm.row); ctx.save(); - if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { ctx.restore(); return; } + if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { + drawDirtOverlay(ctx, c.x - 29, c.y - 29, 58, 58, farm); + drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68); + ctx.restore(); return; + } ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, c.x - 28, c.y - 28, 56, 56, true, true); ctx.fillStyle = THEME.green; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4); @@ -235,6 +278,7 @@ function drawEggFarm(ctx, farm, game) { for (let yy = c.y - 24; yy < c.y - 28 + h; yy += 8) { ctx.beginPath(); ctx.moveTo(c.x - 24, yy); ctx.lineTo(c.x + 24, yy); ctx.stroke(); } if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); } } + drawDirtOverlay(ctx, c.x - 28, c.y - 28, 56, 56, farm); drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68); ctx.restore(); } @@ -243,7 +287,11 @@ function drawScanner(ctx, scanner, game) { const c = scannerCenter(scanner); ctx.save(); const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual; - if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) { ctx.restore(); return; } + 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); + drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84); + 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); @@ -255,6 +303,7 @@ function drawScanner(ctx, scanner, game) { const q = scanner.queue.length; ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; ctx.fillText(`Q:${q}${scanner.kind === 'auto' ? ` CD:${scanner.cooldown.toFixed(1)}` : ''}`, c.x, c.y + 25); + if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner); drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84); ctx.restore(); } @@ -339,13 +388,14 @@ function drawMixer(ctx, game) { const m = game.facilities.mixer; ctx.save(); drawExternalDuct(ctx, m); - if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { ctx.restore(); return; } + if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m); drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12); ctx.restore(); return; } ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 17px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28); ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48); drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route'); ctx.strokeStyle = THEME.green; ctx.lineWidth = 5; for (let i = 0; i < 3; i += 1) { ctx.beginPath(); ctx.arc(m.x + m.w / 2, m.y + 78, 16 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); } + 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(); } @@ -353,11 +403,12 @@ function drawTrash(ctx, game) { const t = game.facilities.trash; ctx.save(); drawExternalDuct(ctx, t); - if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { ctx.restore(); return; } + if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; } ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25); 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); ctx.restore(); } @@ -439,13 +490,8 @@ function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) { ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.96)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, 54 - game.view.x, 66 - game.view.y, 214, 42, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`${game.multiSelected.length} ITEMS SELECTED`, 65 - game.view.x, 83 - game.view.y); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore(); return; } - const obj = selectedObject(); - if (!obj) return; - let x = 80, y = 80; - if (obj.type === 'conveyor') { const p = parseKey(obj.id); const c = cellCenter(p.col, p.row); x = c.x + 22; y = c.y - 56; } - else if (obj.type === 'eggFarm' || obj.type === 'scanner') { const c = obj.type === 'eggFarm' ? cellCenter(obj.col, obj.row) : scannerCenter(obj); x = c.x + 40; y = c.y - 56; } - else if (obj.type === 'facility') { x = obj.x + obj.w / 2 - 76; y = obj.y - 48; } - ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, x, y, 176, 40, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(selectedTitle(obj), x + 9, y + 16); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag to move / panel to edit', x + 9, y + 31); ctx.restore(); + // Single equipment inspection is handled by the DOM hover tooltip. + // Click selection remains only for dragging/multi-select; no clicked equipment popover is drawn. } function drawCardTargetOverlay(ctx, canvas, game) { @@ -537,6 +583,23 @@ function drawFloatingTexts(ctx, game) { for (const t of game.floatingTexts) { const alpha = Math.max(0, t.life / t.maxLife); ctx.globalAlpha = alpha; ctx.lineWidth = 5; ctx.strokeStyle = THEME.white; ctx.fillStyle = t.color; ctx.strokeText(t.text, t.x, t.y); ctx.fillText(t.text, t.x, t.y); } ctx.restore(); } + +function drawRepairman(ctx, game) { + const r = game.repairman; + if (!r?.active) return; + ctx.save(); + ctx.fillStyle = '#fff0d1'; + ctx.strokeStyle = THEME.ink; + ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(r.x, r.y, 15, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = THEME.ink; + ctx.font = '900 10px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.fillText('R', r.x, r.y + 4); + if (r.target?.label) label(ctx, r.x, r.y - 22, r.target.label, THEME.green); + ctx.restore(); +} + function drawCanvasHints(ctx, game, canvas) { ctx.save(); ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink; diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js index 32f6a5b..5d45ce9 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -1,19 +1,23 @@ import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js'; +import { BALANCE } from '../core/balance.js'; import { getSpawnRange } from '../core/state.js'; import { createEggFarm, createScanner, createFacility } from '../core/entities.js'; import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js'; import { TEXT, equipmentName } from '../core/text.js'; import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js'; -import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor } from './economy.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'; import { floating, eraseEffect } from './effects.js'; import { createSelectionSystem } from './selectionSystem.js'; +import { emitGameEvent } from './gameEvents.js'; +import { ensureMaintenanceState, remainingPercent, degradationPercent, performanceFactor, autoScannerDelayMultiplier, eggSpawnDelayMultiplier, facilityProcessingDelay } from './maintenance.js'; export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) { function updatePanels() { if (onUpdatePanels) onUpdatePanels(); } function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; } + function isBlockedCell(col, row) { return game.blockedCells?.has?.(key(col, row)); } function rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; } function rectsOverlap(a, b, margin = 10) { @@ -26,6 +30,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel function isEquipmentCell(col, row, moving = null) { if (!pointInGrid(col, row)) return false; + if (isBlockedCell(col, row)) return true; const k = key(col, row); const existingFarm = farmAt(game, col, row); if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true; @@ -57,6 +62,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel return equipmentBasePrice(hit); } + function lastProtectedSaleReason(hit) { + const obj = hit?.ref || hit || {}; + 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 (count <= 1) return `Cannot sell the last ${obj.shortName || obj.name || obj.id}.`; + } + return ''; + } + + function canSellHit(hit) { + return !lastProtectedSaleReason(hit); + } + function fail(reason) { ui.buildStatus.textContent = reason; floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger); @@ -67,6 +88,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel const { col, row } = cell; if (game.buildTool === 'conveyor') { if (game.conveyorTiles.has(key(col, row))) return fail(TEXT.fail.cellOccupied); + if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.'); if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail(TEXT.fail.cellOccupied); const cost = buildPrice('conveyor'); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); @@ -74,12 +96,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel spendCash(game, cost); const k = key(col, row); game.conveyorTiles.add(k); - game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession }); + game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession, uses: 0, durability: BALANCE.maintenance.durability.conveyor, maintenanceType: 'conveyor' }); game.selected = { type: 'conveyor', id: k }; refreshRoutingAfterEdit(game); + emitGameEvent(game, 'BuildEquipment', { type: 'conveyor', cell: k, cost }); floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); return; } + if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.'); if (isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied); if (game.buildTool === 'eggFarm') buildFarm(col, row); else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual'); @@ -101,6 +125,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel game.eggFarms.push(farm); refreshRoutingAfterEdit(game); game.selected = { type: 'eggFarm', id: farm.id }; + emitGameEvent(game, 'BuildEquipment', { type: 'eggFarm', id: farm.id, col, row, cost }); floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); } @@ -113,6 +138,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel game.scanners.push(scanner); refreshRoutingAfterEdit(game); game.selected = { type: 'scanner', id: scanner.id }; + emitGameEvent(game, 'BuildEquipment', { type: kind + 'Scanner', id: scanner.id, col, row, cost }); floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); } @@ -121,12 +147,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel if (game.facilities[id]) return fail(TEXT.fail.facilityExists); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); const f = createFacility(game, id, p, cost); + 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; refreshRoutingAfterEdit(game); game.selected = { type: 'facility', id }; + emitGameEvent(game, 'BuildEquipment', { type: id, cost }); floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink); } @@ -141,7 +169,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel function eraseAtPoint(p) { const hit = equipmentAtPoint(p); if (!hit) return fail(TEXT.fail.nothingToErase); + const saleReason = lastProtectedSaleReason(hit); + if (saleReason) return fail(saleReason); record(game); + emitGameEvent(game, 'SellEquipment', { type: hit.type, id: hit.oldKey || hit.ref?.id || hit.ref?.type || 'unknown', value: resaleValueFor(hit, game).amount }); if (hit.type === 'conveyor') { const c = parseKey(hit.oldKey); const center = cellCenter(c.col, c.row); @@ -276,9 +307,16 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel ui.modalActions.innerHTML = ''; if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn')); const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj }; - const resale = resaleValueFor(hit, game); - const removeLabel = resale.sameBuild ? `Remove / Refund ${yen(resale.amount)}` : `Sell ${yen(resale.amount)}`; - ui.modalActions.appendChild(modalButton(removeLabel, () => { removeSelected(); hideModal(); }, 'facility-action danger')); + const saleReason = lastProtectedSaleReason(hit); + if (saleReason) { + const disabled = modalButton(saleReason, () => {}, 'facility-action danger disabled'); + disabled.disabled = true; + ui.modalActions.appendChild(disabled); + } else { + const resale = resaleValueFor(hit, game); + const removeLabel = resale.sameBuild ? `Remove / Refund ${yen(resale.amount)}` : `Sell ${yen(resale.amount)}`; + ui.modalActions.appendChild(modalButton(removeLabel, () => { removeSelected(); hideModal(); }, 'facility-action danger')); + } positionEquipmentPopover(obj); ui.modal.classList.add('visible', 'equipment-popover'); } @@ -305,20 +343,24 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel } function selectedInfoLines(obj) { + ensureMaintenanceState(game); const lines = []; if (obj.type === 'eggFarm') { const [min, max] = getSpawnRange(obj); lines.push(`Price: ${yen(equipmentPrice(obj))}`); - lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); + { 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(`Level: ${obj.level}/4`); lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`); + lines.push(`Durability: ${remainingPercent(obj)}% | Spawn delay x${eggSpawnDelayMultiplier(obj).toFixed(2)}`); } else if (obj.type === 'scanner') { 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'}`); lines.push(`Queue: ${obj.queue.length}`); - if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj).toFixed(1)}s`); + if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj, game).toFixed(1)}s`); + if (obj.kind === 'auto') lines.push(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`); + if (obj.kind === 'manual') lines.push('Durability: none | Always normal'); } else if (obj.type === 'conveyor') { const meta = game.conveyorMeta.get(obj.id); @@ -327,12 +369,15 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel lines.push(`Price: ${yen(buildPrice('conveyor'))}`); lines.push(`Cell: ${obj.id}`); lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`); + lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`); lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.'); lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`); } else if (obj.type === 'facility') { lines.push(`Price: ${yen(equipmentPrice(obj))}`); - lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); - lines.push(['mixer', 'truck'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`); + { 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') { lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`); lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`); @@ -341,11 +386,31 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel 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') { + const cards = shredderUpgradeCount(game); + const chance = shredderBonusChance(cards); + lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`); + lines.push(`Bonus chance: ${(chance * 100).toFixed(2)}% for ${yen(1)} per processed item.`); + lines.push(`Expected value: ${chance.toFixed(3)}円 per item.`); + } if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`); } return lines; } + + function flavorText(obj) { + if (!obj) return ''; + 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') 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.'; + return 'Factory equipment.'; + } + return { buildAtCell, buildAtPoint, eraseAtPoint, equipmentAtPoint, startSelectionBox: selection.startSelectionBox, @@ -355,7 +420,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel updateGroupDrag: selection.updateGroupDrag, finishGroupDrag: selection.finishGroupDrag, selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost, - selectedInfoLines, + selectedInfoLines, flavorText, removeSelected, switchScannerRole, showAutoScannerMenu, showSelectedMenu, setBuildTool, fail, isEquipmentCell, equipmentHitBoxes, diff --git a/src/systems/cards.js b/src/systems/cards.js index 8b08baf..f172c16 100644 --- a/src/systems/cards.js +++ b/src/systems/cards.js @@ -1,15 +1,14 @@ -import { AUTO_SCANNER_COOLDOWN, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, GRID, THEME } from '../core/config.js'; +import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, GRID, THEME } from '../core/config.js'; import { nextSpawnDelay } from '../core/state.js'; import { cellCenter, yen } from '../core/utils.js'; -import { applyPenalty, upgradedMixerPrice, upgradedTruckPrice } from './economy.js'; +import { applyPenalty, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js'; import { floating } from './effects.js'; +import { averageConveyorPerformance, autoScannerDelayMultiplier } from './maintenance.js'; import { scannerCenter } from './routing.js'; -const COMMON_WEIGHT = 8; -const RARE_WEIGHT = 2; -const AUTO_SCANNER_COOLDOWN_RATE = 0.95; -const AUTO_SCANNER_MIN_COOLDOWN = 1.0; -const BASE_DRAFT_SIZE = 3; +const COMMON_WEIGHT = CARD_BALANCE.commonWeight; +const RARE_WEIGHT = CARD_BALANCE.rareWeight; +const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize; export const CARD_DEFS = [ { @@ -44,12 +43,20 @@ export const CARD_DEFS = [ target: 'truck', description: 'Choose TRUCK on the map and raise it by 1 level.' }, + { + id: 'upgradeTrash', + title: 'SHREDDER Improvement', + rarity: 'common', + type: 'equipmentUpgrade', + target: 'trash', + description: 'Choose SHREDDER on the map and raise it by 1 level. Per processed item: ((√cards / 2) × cards)% chance to pay ¥1. Max 30 cards.' + }, { id: 'bearing', title: 'High-Quality Bearing', rarity: 'common', type: 'instant', - description: 'Conveyor speed +2%.' + description: 'Conveyor speed +7.5%.' }, { id: 'extraCards', @@ -82,15 +89,17 @@ export function ensureCardState(game) { return game.cardEffects; } -export function conveyorSpeedForGame(game) { +export function conveyorSpeedForGame(game, conveyorKey = null) { const effects = ensureCardState(game); - const speed = CONVEYOR_SPEED * Math.pow(1.02, Math.max(0, effects.bearing || 0)); - return Math.min(CONVEYOR_SPEED_MAX, speed); + const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0)); + const perf = conveyorKey ? 1 : averageConveyorPerformance(game); + return Math.min(CONVEYOR_SPEED_MAX, speed * perf); } -export function autoScannerCooldownSeconds(scanner) { +export function autoScannerCooldownSeconds(scanner, game = null) { const upgrades = Math.max(0, (scanner?.level || 1) - 1); - return Math.max(AUTO_SCANNER_MIN_COOLDOWN, AUTO_SCANNER_COOLDOWN * Math.pow(AUTO_SCANNER_COOLDOWN_RATE, upgrades)); + const base = Math.max(AUTO_SCANNER_MIN_COOLDOWN, AUTO_SCANNER_COOLDOWN * Math.pow(AUTO_SCANNER_UPGRADE_RATE, upgrades)); + return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner)) : base; } export function flatteryReductionForTurn(game, turn = game.turn) { @@ -103,7 +112,7 @@ export function flatteryReductionForTurn(game, turn = game.turn) { export function rerollCost(game) { const draft = game.cardDraft || { rerolls: 0 }; const nextRerollCount = Math.max(1, (draft.rerolls || 0) + 1); - return Math.ceil((Math.max(1, game.turn || 1) / 2) * nextRerollCount * 100); + return Math.ceil((Math.max(1, game.turn || 1) / CARD_BALANCE.rerollCost.dayDivisor) * nextRerollCount * CARD_BALANCE.rerollCost.multiplier); } export function cardById(id) { @@ -115,7 +124,7 @@ function targetLabel(game, target) { return `EGG #${target.id} L${target.level} -> L${target.level + 1}`; } if (target.type === 'scanner') { - return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }).toFixed(1)}s`; + return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`; } if (target.type === 'facility' && target.id === 'mixer') { const beforeLevel = target.level; @@ -133,6 +142,13 @@ function targetLabel(game, target) { target.level = beforeLevel; return `TRUCK L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`; } + if (target.type === 'facility' && target.id === 'trash') { + const before = shredderUpgradeCount(game); + const after = Math.min(shredderBonusMaxCards(), before + 1); + const beforePercent = (shredderBonusChance(before) * 100).toFixed(2); + const afterPercent = (shredderBonusChance(after) * 100).toFixed(2); + return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | ${beforePercent}% -> ${afterPercent}% per item`; + } return `${target.name || target.id} L${target.level || 1} -> L${(target.level || 1) + 1}`; } @@ -144,6 +160,7 @@ export function targetsForCard(game, cardOrId) { 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] : []; return []; } @@ -161,6 +178,32 @@ function weightedPick(pool) { return pool[pool.length - 1]; } +let dudSerial = 0; +function dudCard() { + dudSerial += 1; + return { + id: `dud:${Date.now()}:${dudSerial}`, + title: 'スカ', + rarity: 'dud', + type: 'dud', + description: 'Click to flick this dud away. It does not consume a pick.' + }; +} + +function applyDudsToChoices(choices) { + const result = [...choices]; + let duds = 0; + const maxDuds = Math.max(0, CARD_BALANCE.maxDudsPerDraft || 0); + const chance = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0)); + for (let i = 0; i < result.length && duds < maxDuds; i += 1) { + if (Math.random() < chance) { + result[i] = dudCard(); + duds += 1; + } + } + return result; +} + export function dealCards(game, count = BASE_DRAFT_SIZE) { ensureCardState(game); const source = availableCards(game); @@ -174,7 +217,7 @@ export function dealCards(game, count = BASE_DRAFT_SIZE) { const i = pool.findIndex(card => card.id === picked.id); if (i >= 0) pool.splice(i, 1); } - return choices; + return applyDudsToChoices(choices); } function targetKey(target) { @@ -299,12 +342,22 @@ export function createCardSystem({ game, ui, onUpdatePanels }) { function chooseExtraCards(card) { removeOneChoice(game, card); - game.cardDraft.picksRemaining = Math.max(0, (game.cardDraft.picksRemaining || 1) - 1) + 2; + game.cardDraft.picksRemaining = Math.max(0, (game.cardDraft.picksRemaining || 1) - 1) + CARD_BALANCE.extraCardsAddedPicks; game.cardDraft.choices.push(...dealCards(game, 2)); showDraft(); } - function chooseCard(card) { + function chooseDud(card, buttonEl) { + if (buttonEl) buttonEl.classList.add('flung'); + window.setTimeout(() => { + removeOneChoice(game, card); + refillChoicesIfNeeded(); + showDraft(); + }, 190); + } + + function chooseCard(card, buttonEl = null) { + if (card.type === 'dud') return chooseDud(card, buttonEl); if (card.id === 'extraCards') return chooseExtraCards(card); if (card.type === 'equipmentUpgrade') return startMapTargetPicker(card); applyInstantCard(game, card); @@ -356,10 +409,11 @@ export function createCardSystem({ game, ui, onUpdatePanels }) { function cardButton(card, index) { const b = document.createElement('button'); b.type = 'button'; - b.className = `card-choice ${card.rarity === 'rare' ? 'rare' : 'common'}`; - const rarity = card.rarity === 'rare' ? 'RARE' : 'COMMON'; + const kind = card.type === 'dud' ? 'dud' : (card.rarity === 'rare' ? 'rare' : 'common'); + b.className = `card-choice ${kind}`; + const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'rare' ? 'RARE' : 'COMMON'); b.innerHTML = `${card.title}${rarity}${cardDescription(game, card)}`; - b.addEventListener('click', () => chooseCard(card)); + b.addEventListener('click', () => chooseCard(card, b)); b.dataset.index = String(index); return b; } @@ -373,7 +427,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) { const cost = rerollCost(game); const remaining = Math.max(1, game.cardDraft.picksRemaining || 1); ui.modalTitle.textContent = 'Choose Upgrade Card'; - ui.modalBody.innerHTML = `

Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Equipment cards are applied by selecting a white-highlighted machine on the map.

`; + ui.modalBody.innerHTML = `

Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Each shown card has a 30% dud chance, capped at 2 duds per draft. Click dud cards to flick them away. Equipment cards are applied by selecting a white-highlighted machine on the map.

`; const wrap = ui.modalBody.querySelector('#cardChoices'); choices.forEach((card, index) => wrap.appendChild(cardButton(card, index))); ui.modalActions.innerHTML = ''; diff --git a/src/systems/chickSystem.js b/src/systems/chickSystem.js index 156de23..10adc90 100644 --- a/src/systems/chickSystem.js +++ b/src/systems/chickSystem.js @@ -3,15 +3,17 @@ import { createChick } from '../core/entities.js'; import { nextSpawnDelay } from '../core/state.js'; import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js'; -import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice } from './economy.js'; +import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus } from './economy.js'; import { autoScannerCooldownSeconds } from './cards.js'; import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js'; import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js'; import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js'; +import { emitGameEvent } from './gameEvents.js'; +import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js'; export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) { function currentSpawnDelay(farm) { - return nextSpawnDelay(farm) / productionMultiplier(game); + return nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) / productionMultiplier(game); } function spawnChick(farm) { @@ -30,6 +32,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck const chick = createChick(game, data); if (chick.sex === 'poop') countPoopSpawned(game); game.chicks.push(chick); + recordFarmProduction(game, farm); spawnPulse(game, farmCenter.x, farmCenter.y); return true; } @@ -39,6 +42,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck } function updateRunning(dt, { closeFarmShutters, completeTurn }) { + updateRepairman(game, dt); + updateProcessingCooldowns(game, dt); + if (game.cleanupTimer !== null) { + game.cleanupTimer = Math.max(0, game.cleanupTimer - dt); + if (game.cleanupTimer <= 0) completeTurn(); + return; + } + const producing = game.timeLeft > 0; game.timeLeft = Math.max(0, game.timeLeft - dt); if (producing && game.timeLeft <= 0) closeFarmShutters(); @@ -65,11 +76,32 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck } updateScannerQueues(dt); updateCongestion(); - checkCongestionExplosions(); - if (game.timeLeft <= 0 && (game.chicks.length === 0 || game.shutdownTimeLeft <= 0)) completeTurn(); + if (game.timeLeft > 0 || game.shutdownTimeLeft > 0) checkCongestionExplosions(); + if (game.timeLeft <= 0) { + if (game.chicks.length === 0) completeTurn(); + else if (game.shutdownTimeLeft <= 0) blowOffRemainingForCleanup(); + } onGameOverCheck(); } + function blowOffRemainingForCleanup() { + if (game.cleanupBlown) return; + game.cleanupBlown = true; + game.cleanupTimer = 1; + const victims = [...game.chicks]; + for (const chick of victims) flyingDebris(game, chick.sex, chick.x, chick.y, -Math.PI / 2, true); + game.chicks = []; + for (const scanner of game.scanners) scanner.queue = []; + updateCongestion(); + if (victims.length) { + emitGameEvent(game, 'CleanupBlowoff', { count: victims.length }); + const cx = victims.reduce((sum, chick) => sum + chick.x, 0) / victims.length; + const cy = victims.reduce((sum, chick) => sum + chick.y, 0) / victims.length; + shockwave(game, cx, cy, THEME.warn, 58); + floating(game, cx, cy - 24, 'CLEANUP', THEME.warn); + } + } + function updateRoutedChick(chick, index, dt) { if (blockedByFrontChick(chick)) { chick.stoppedTimer = 0.35; @@ -105,19 +137,40 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck return chick.targetIndex || 0; } + function conveyorKeyFromPoint(p) { + const cell = pointToCell(p.x, p.y); + if (!cell) return null; + const k = key(cell.col, cell.row); + return game.conveyorTiles.has(k) ? k : null; + } + + function speedForChick(chick) { + const k = conveyorKeyFromPoint(chick) || conveyorKeyFromPoint(chick.route?.[chick.targetIndex] || chick); + return currentConveyorSpeed(k); + } + + function recordPassAtPoint(chick, p) { + const k = conveyorKeyFromPoint(p); + if (!k || chick.lastWearKey === k) return; + chick.lastWearKey = k; + recordConveyorPass(game, k); + } + function moveAlongRoute(chick, dt) { - let remaining = currentConveyorSpeed() * dt; + let remaining = speedForChick(chick) * dt; while (remaining > 0 && chick.targetIndex < chick.route.length) { const target = chick.route[chick.targetIndex]; const dx = target.x - chick.x, dy = target.y - chick.y; const dist = Math.hypot(dx, dy); if (dist <= 0.001) { + recordPassAtPoint(chick, target); chick.targetIndex += 1; continue; } if (remaining >= dist) { chick.x = target.x; chick.y = target.y; + recordPassAtPoint(chick, target); chick.targetIndex += 1; remaining -= dist; } else { @@ -185,7 +238,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck continue; } if (sortChickByIndex(index, autoSideFor(scanner, game.chicks[index]), true)) { - scanner.cooldown = autoScannerCooldownSeconds(scanner); + recordAutoScan(game, scanner); + scanner.cooldown = autoScannerCooldownSeconds(scanner, game); countAutoSorted(game); } } @@ -235,10 +289,18 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck } function resolveMixer(index) { + const mixer = game.facilities.mixer; + if (mixer?.processingCooldown > 0) { + const chick = game.chicks[index]; + if (chick) chick.stoppedTimer = 0.25; + return; + } const chick = takeChick(index); if (!chick) return; const { x, y } = chick; countMixer(game); + recordFacilityProcess(game, 'mixer'); + setFacilityCooldownAfterProcess(game, 'mixer'); if (chick.sex === 'poop') { const penalty = applyMixerPoopFine(game); countPoopDestination(game, 'mixer'); @@ -301,18 +363,28 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck } function resolveTrash(index) { + const trash = game.facilities.trash; + if (trash?.processingCooldown > 0) { + const chick = game.chicks[index]; + if (chick) chick.stoppedTimer = 0.25; + return; + } const chick = takeChick(index); if (!chick) return; const { x, y } = chick; shredEffect(game, x, y, chick.sex); countTrash(game); + 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); - floating(game, x, y - 18, 'CLEAN', THEME.green); + floating(game, x, y - 18, `CLEAN${bonusText}`, bonus.amount > 0 ? THEME.green : THEME.green); } else { countMistake(game); - floating(game, x, y - 18, 'WASTE', THEME.ink); + floating(game, x, y - 18, bonus.amount > 0 ? `WASTE${bonusText}` : 'WASTE', bonus.amount > 0 ? THEME.green : THEME.ink); } } @@ -399,6 +471,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck const first = parseKey(comp.cells[0]); const fc = cellCenter(first.col, first.row); floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger); + emitGameEvent(game, 'JamExplosion', { component: comp.id, victims: victims.length }); } function explodeRoute(route, extras, label) { diff --git a/src/systems/economy.js b/src/systems/economy.js index 1b78f4a..44fc95b 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -1,5 +1,6 @@ import { ECONOMY, FACILITY_DEFS, INCOME_FACILITY_IDS } from '../core/config.js'; import { factoryValue, targetTruckCount, truckTarget } from './contracts.js'; +import { emitGameEvent } from './gameEvents.js'; export function equipmentBasePrice(objOrHit) { const obj = objOrHit?.ref || objOrHit || {}; @@ -44,6 +45,47 @@ export function maleTruckPenalty(game) { return Math.ceil(baseFine * incomeMultiplier(game, 'truck')); } +export function shredderUpgradeCount(game) { + const raw = Math.max(0, (game.facilities?.trash?.level || 1) - 1); + return Math.min(ECONOMY.shredderBonus.maxCards, raw); +} + +export function rawShredderUpgradeCount(game) { + return Math.max(0, (game.facilities?.trash?.level || 1) - 1); +} + +export function shredderBonusMaxCards() { + return ECONOMY.shredderBonus.maxCards; +} + +export function shredderBonusChance(gameOrCount) { + const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount); + const capped = Math.min(ECONOMY.shredderBonus.maxCards, Math.max(0, count)); + const percent = (Math.sqrt(capped) / ECONOMY.shredderBonus.probabilityPercentDivisor) * capped; + return Math.min(1, percent / 100); +} + +export function shredderBonusExpectedPerItem(gameOrCount) { + return shredderBonusChance(gameOrCount); +} + +export function rollShredderBonus(game) { + const cards = shredderUpgradeCount(game); + const chance = shredderBonusChance(cards); + const amount = cards > 0 && Math.random() < chance ? 1 : 0; + return { cards, chance, amount, expected: chance }; +} + +export function applyShredderBonus(game) { + const result = rollShredderBonus(game); + if (result.amount > 0) { + game.stats.shredderBonus = (game.stats.shredderBonus || 0) + result.amount; + game.totals.shredderBonus = (game.totals.shredderBonus || 0) + result.amount; + applyRevenue(game, result.amount); + } + return result; +} + export function zundaTaxInfo(cash, game = null) { const rules = ECONOMY.zundaTax; const legalWork = Math.max(0, game?.cardEffects?.legalWork || 0); @@ -100,12 +142,14 @@ export function positivePayout(_game, amount) { } export function applyCashDelta(game, delta) { + const before = game.cash; game.cash += delta; game.stats.profit += delta; game.totals.profit += delta; if (delta >= 0) { game.stats.revenue += delta; game.totals.revenue += delta; } else { game.stats.penalty += Math.abs(delta); game.totals.penalty += Math.abs(delta); } game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); + emitGameEvent(game, 'CashDelta', { delta, before, after: game.cash }); } export function applyRevenue(game, amount) { @@ -126,10 +170,12 @@ export function spendCash(game, amount) { export function refundCash(game, amount) { if (amount <= 0) return; + const before = game.cash; game.cash += amount; game.stats.profit += amount; game.totals.profit += amount; game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); + emitGameEvent(game, 'CashRefund', { amount, before, after: game.cash }); } export function applyMixerIncome(game) { diff --git a/src/systems/effects.js b/src/systems/effects.js index db38f9e..ebd444f 100644 --- a/src/systems/effects.js +++ b/src/systems/effects.js @@ -43,10 +43,10 @@ export function smokeBurst(game, x, y, count = 10) { game.effects.push({ type: 'smoke', priority: EFFECT_PRIORITY.ambient, x: x + randomBetween(-10, 10), y: y + randomBetween(-10, 10), vx: randomBetween(-26, 26), vy: randomBetween(-60, -12), life: randomBetween(.6, 1.2), maxLife: 1.2, size: randomBetween(8, 18) }); } } -export function flyingDebris(game, sex, x, y, baseAngle = null) { +export function flyingDebris(game, sex, x, y, baseAngle = null, noDamage = false) { const angle = baseAngle === null ? randomBetween(-Math.PI, Math.PI) : baseAngle + randomBetween(-0.9, 0.9); const speed = randomBetween(260, 620); - game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8) }); + game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8), noDamage }); } export function eraseEffect(game, x, y) { game.effects.push({ type: 'erase', priority: EFFECT_PRIORITY.important, x, y, life: .45, maxLife: .45, size: 54 }); @@ -66,7 +66,7 @@ export function updateEffects(game, dt, canvas, equipmentHitBoxes) { e.x += (e.vx || 0) * dt; e.y += (e.vy || 0) * dt; e.vy = (e.vy || 0) + 150 * dt; } else if (e.type === 'flyingChick') { e.x += e.vx * dt; e.y += e.vy * dt; e.vy += 360 * dt; - handleFlyingChickDamage(game, e, equipmentHitBoxes); + if (!e.noDamage) handleFlyingChickDamage(game, e, equipmentHitBoxes); const sx = e.x + game.view.x, sy = e.y + game.view.y; if (sx < -100 || sy < -100 || sx > canvas.width + 100 || sy > canvas.height + 100) e.life = 0; } diff --git a/src/systems/gameEvents.js b/src/systems/gameEvents.js new file mode 100644 index 0000000..65dd54e --- /dev/null +++ b/src/systems/gameEvents.js @@ -0,0 +1,39 @@ +const MAX_EVENTS = 160; + +export function emitGameEvent(game, type, payload = {}) { + if (!game) return null; + if (!Array.isArray(game.eventLog)) game.eventLog = []; + const id = game.nextEventId || 1; + game.nextEventId = id + 1; + const event = { + id, + at: Date.now(), + day: game.turn || 1, + phase: game.phase || 'unknown', + type, + payload: sanitizePayload(payload) + }; + game.eventLog.push(event); + if (game.eventLog.length > MAX_EVENTS) game.eventLog.splice(0, game.eventLog.length - MAX_EVENTS); + return event; +} + +export function recentGameEvents(game, limit = 8) { + const events = Array.isArray(game?.eventLog) ? game.eventLog : []; + return events.slice(Math.max(0, events.length - limit)).reverse(); +} + +export function clearGameEvents(game) { + if (game) game.eventLog = []; +} + +function sanitizePayload(payload) { + if (!payload || typeof payload !== 'object') return payload; + const out = {}; + for (const [k, v] of Object.entries(payload)) { + if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean' || v == null) out[k] = v; + else if (Array.isArray(v)) out[k] = v.slice(0, 12).map(x => typeof x === 'object' ? JSON.stringify(x).slice(0, 80) : x); + else out[k] = JSON.stringify(v).slice(0, 120); + } + return out; +} diff --git a/src/systems/history.js b/src/systems/history.js index 5ac6ccf..d71d435 100644 --- a/src/systems/history.js +++ b/src/systems/history.js @@ -46,7 +46,11 @@ export function restore(game, text) { game.groupDrag = null; game.selectionBox = null; game.pan = null; + game.routingVersion = (game.routingVersion || 0) + 1; + game.factoryGraph = null; + game.factoryGraphCommittedVersion = -1; } + export function record(game) { if (game.phase !== 'build') return; game.undoStack.push(snapshot(game)); diff --git a/src/systems/maintenance.js b/src/systems/maintenance.js new file mode 100644 index 0000000..9ad8af6 --- /dev/null +++ b/src/systems/maintenance.js @@ -0,0 +1,276 @@ +import { BALANCE } from '../core/balance.js'; +import { GRID, THEME } from '../core/config.js'; +import { cellCenter, parseKey, key, yen } from '../core/utils.js'; +import { spendCash } from './economy.js'; +import { floating } from './effects.js'; +import { emitGameEvent } from './gameEvents.js'; + +const RULES = BALANCE.maintenance; + +export function ensureMaintenanceState(game) { + if (!game.repairman) { + game.repairman = { + hiredForNextDay: false, + active: false, + x: GRID.x + GRID.cell * 0.5, + y: GRID.y + GRID.rows * GRID.cell + 84, + target: null, + repairedToday: 0 + }; + } + for (const [k, meta] of game.conveyorMeta || []) { + if (meta.uses == null) meta.uses = 0; + if (meta.durability == null) meta.durability = RULES.durability.conveyor; + if (meta.maintenanceType == null) meta.maintenanceType = 'conveyor'; + } + for (const farm of game.eggFarms || []) attachMaintenance(farm, 'eggFarm'); + for (const scanner of game.scanners || []) { + if (scanner.kind === 'auto') attachMaintenance(scanner, 'autoScanner'); + } + for (const f of Object.values(game.facilities || {})) { + if (f.id === 'mixer') attachMaintenance(f, 'mixer'); + if (f.id === 'trash') attachMaintenance(f, 'trash'); + } +} + +function attachMaintenance(obj, type) { + if (!obj) return; + if (!obj.maintenance) obj.maintenance = {}; + obj.maintenance.type = type; + if (obj.maintenance.uses == null) obj.maintenance.uses = 0; + if (obj.maintenance.durability == null) obj.maintenance.durability = RULES.durability[type] || 1000; +} + +function usesOf(target) { + if (!target) return 0; + if (target.meta) return Math.max(0, target.meta.uses || 0); + return Math.max(0, target.maintenance?.uses || 0); +} +function durabilityOf(target) { + if (!target) return 1; + if (target.meta) return Math.max(1, target.meta.durability || RULES.durability.conveyor); + return Math.max(1, target.maintenance?.durability || RULES.durability[target.maintenance?.type] || 1000); +} +function setUses(target, uses) { + if (!target) return; + if (target.meta) target.meta.uses = Math.max(0, uses); + else if (target.maintenance) target.maintenance.uses = Math.max(0, uses); +} + +export function wearRatio(target) { + return Math.max(0, Math.min(1, usesOf(target) / durabilityOf(target))); +} + +export function remainingPercent(target) { + return Math.max(0, Math.round((1 - wearRatio(target)) * 100)); +} + +export function performanceFactor(target) { + const wear = wearRatio(target); + if (wear <= RULES.fullPerformanceUntilWear) return 1; + const t = (wear - RULES.fullPerformanceUntilWear) / (1 - RULES.fullPerformanceUntilWear); + return Math.max(RULES.minimumPerformance, 1 - t * (1 - RULES.minimumPerformance)); +} + +export function degradationPercent(target) { + return Math.round(wearRatio(target) * 100); +} + +export function conveyorTarget(game, conveyorKey) { + const meta = game.conveyorMeta?.get(conveyorKey); + return meta ? { type: 'conveyor', key: conveyorKey, meta } : null; +} + +export function conveyorSpeedFactorForKey(game, conveyorKey) { + ensureMaintenanceState(game); + const target = conveyorTarget(game, conveyorKey); + return target ? performanceFactor(target) : 1; +} + +export function averageConveyorPerformance(game) { + ensureMaintenanceState(game); + let sum = 0; + let count = 0; + for (const k of game.conveyorTiles || []) { + sum += conveyorSpeedFactorForKey(game, k); + count += 1; + } + return count ? sum / count : 1; +} + +export function delayMultiplier(target) { + return 1 / performanceFactor(target); +} + +export function autoScannerDelayMultiplier(scanner) { + if (!scanner || scanner.kind !== 'auto') return 1; + attachMaintenance(scanner, 'autoScanner'); + return delayMultiplier(scanner); +} + +export function eggSpawnDelayMultiplier(farm) { + attachMaintenance(farm, 'eggFarm'); + return delayMultiplier(farm); +} + +export function facilityProcessingDelay(game, id) { + ensureMaintenanceState(game); + const f = game.facilities?.[id]; + if (!f || !['mixer', 'trash'].includes(id)) return 0; + attachMaintenance(f, id); + const t = Math.max(0, delayMultiplier(f) - 1); + return t * (RULES.processingDelayMaxSeconds?.[id] || 1); +} + +export function recordConveyorPass(game, conveyorKey) { + ensureMaintenanceState(game); + const meta = game.conveyorMeta?.get(conveyorKey); + if (!meta) return; + meta.uses = Math.min(meta.durability || RULES.durability.conveyor, (meta.uses || 0) + 1); + game.stats.conveyorPasses = (game.stats.conveyorPasses || 0) + 1; +} + +export function recordFarmProduction(game, farm) { + ensureMaintenanceState(game); + attachMaintenance(farm, 'eggFarm'); + farm.maintenance.uses = Math.min(farm.maintenance.durability, (farm.maintenance.uses || 0) + 1); + game.stats.eggProduced = (game.stats.eggProduced || 0) + 1; +} + +export function recordAutoScan(game, scanner) { + ensureMaintenanceState(game); + if (!scanner || scanner.kind !== 'auto') return; + attachMaintenance(scanner, 'autoScanner'); + scanner.maintenance.uses = Math.min(scanner.maintenance.durability, (scanner.maintenance.uses || 0) + 1); +} + +export function recordFacilityProcess(game, id) { + ensureMaintenanceState(game); + const f = game.facilities?.[id]; + if (!f || !['mixer', 'trash'].includes(id)) return; + attachMaintenance(f, id); + f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + 1); +} + +export function setFacilityCooldownAfterProcess(game, id) { + const f = game.facilities?.[id]; + if (!f) return 0; + const delay = facilityProcessingDelay(game, id); + 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]; + if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - 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 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']) { + const f = game.facilities?.[id]; + if (f) targets.push({ type: id, ref: f, label: f.shortName || f.name || id, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } }); + } + return targets; +} + +function targetWear(t) { return t.meta ? wearRatio(t) : wearRatio(t.ref); } +function targetUses(t) { return t.meta ? usesOf(t) : usesOf(t.ref); } +function targetDurability(t) { return t.meta ? durabilityOf(t) : durabilityOf(t.ref); } +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 hireRepairmanForNextDay(game) { + ensureMaintenanceState(game); + if (game.repairman.hiredForNextDay) return { ok: false, reason: 'Repairman already hired.' }; + const cost = RULES.repairman.dailyCost; + if (game.cash < cost) return { ok: false, reason: `Need ${yen(cost - game.cash)} more.` }; + spendCash(game, cost); + game.repairman.hiredForNextDay = true; + emitGameEvent(game, 'HireRepairman', { cost }); + return { ok: true, cost }; +} + +export function activateRepairmanForDay(game) { + ensureMaintenanceState(game); + game.repairman.active = !!game.repairman.hiredForNextDay; + game.repairman.hiredForNextDay = false; + game.repairman.target = null; + game.repairman.repairedToday = 0; + if (game.repairman.active) emitGameEvent(game, 'RepairmanStarted', { day: game.turn }); +} + +export function deactivateRepairman(game) { + ensureMaintenanceState(game); + if (game.repairman.active) emitGameEvent(game, 'RepairmanStopped', { repairedPercent: Math.round(game.repairman.repairedToday || 0) }); + game.repairman.active = false; + game.repairman.target = null; +} + +function resolveStoredTarget(game, stored) { + if (!stored) return null; + const targets = equipmentMaintenanceTargets(game); + return targets.find(t => targetKey(t) === stored.key) || null; +} + +function chooseRepairTarget(game) { + let best = null; + for (const t of equipmentMaintenanceTargets(game)) { + const w = targetWear(t); + if (w <= 0.001) continue; + if (!best || w > targetWear(best)) best = t; + } + return best; +} + +export function updateRepairman(game, dt) { + ensureMaintenanceState(game); + const r = game.repairman; + if (!r.active || game.phase !== 'running') return; + let target = resolveStoredTarget(game, r.target) || chooseRepairTarget(game); + if (!target) { + r.target = null; + return; + } + r.target = { key: targetKey(target), label: target.label }; + const speed = RULES.repairman.walkSpeed; + const dx = target.center.x - r.x; + const dy = target.center.y - r.y; + const dist = Math.hypot(dx, dy); + if (dist > 8) { + const step = Math.min(dist, speed * dt); + r.x += dx / dist * step; + r.y += dy / dist * step; + return; + } + const repairWear = dt / (RULES.repairman.secondsPerOnePercent * 100); + const usesBefore = targetUses(target); + const usesDelta = repairWear * targetDurability(target); + reduceTargetUses(target, usesDelta); + const repairedPct = Math.max(0, (usesBefore - targetUses(target)) / targetDurability(target) * 100); + r.repairedToday = (r.repairedToday || 0) + repairedPct; + if (repairedPct > 0 && Math.random() < 0.02) floating(game, r.x, r.y - 28, 'REPAIR', THEME.green); + if (targetWear(target) <= 0.001) r.target = null; +} + +export function worstDegradation(game) { + let worst = 0; + let label = 'none'; + for (const t of equipmentMaintenanceTargets(game)) { + const w = targetWear(t); + if (w > worst) { worst = w; label = t.label; } + } + return { ratio: worst, percent: Math.round(worst * 100), label }; +} + +export function maintenanceSummary(game) { + const worst = worstDegradation(game); + const avgBeltPerformance = averageConveyorPerformance(game); + return { worst, avgBeltPerformance, repairman: game.repairman || null }; +} diff --git a/src/systems/routing.js b/src/systems/routing.js index 0431d6e..fcb1503 100644 --- a/src/systems/routing.js +++ b/src/systems/routing.js @@ -12,7 +12,8 @@ export function scannerById(game, id) { return game.scanners.find(s => s.id === export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; } // ----------------------------------------------------------------------------- -// Port definitions +// Port definitions: only exact receiver/output cells are valid connections. +// Visual adjacency is deliberately not treated as a connection. // ----------------------------------------------------------------------------- export function scannerConnector(scanner, type) { return { @@ -104,22 +105,191 @@ export function inputCellsForScanner(game, scanner) { return scannerInputCells(g export function refreshRoutingAfterEdit(game) { game.branchCounters?.clear?.(); game.routeCache = null; + game.routingVersion = (game.routingVersion || 0) + 1; + game.factoryGraph = null; + game.factoryGraphCommittedVersion = -1; +} + +// ----------------------------------------------------------------------------- +// FactoryGraph: build once after edits, then route and validate against this +// graph. The graph contains conveyor cells, exact ports, component membership, +// and cached path results. This makes the simulation read a single canonical +// connectivity model instead of re-interpreting the map in several systems. +// ----------------------------------------------------------------------------- +function graphNodeKey(col, row) { return key(col, row); } +function graphCell(k) { return parseKey(k); } + +function emptyFactoryGraph(game) { + return { + version: game.routingVersion || 0, + cells: new Set(), + adjacency: new Map(), + components: new Map(), + cellToComponent: new Map(), + scannerInputs: new Map(), + scannerPorts: new Map(), + facilityPorts: new Map(), + farmOutputs: new Map(), + pathCache: new Map(), + metrics: { + conveyorCells: 0, + edges: 0, + components: 0, + deadEnds: 0, + crosses: 0, + scannerPorts: 0, + connectedScannerPorts: 0, + facilityPorts: 0, + connectedFacilityPorts: 0, + farmOutputs: 0 + }, + issues: [] + }; +} + +export function buildFactoryGraph(game) { + const graph = emptyFactoryGraph(game); + + for (const k of game.conveyorTiles) { + graph.cells.add(k); + graph.adjacency.set(k, []); + } + + for (const k of graph.cells) { + const p = graphCell(k); + 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 }); + } + } + + let edgePairs = 0; + for (const [k, ns] of graph.adjacency.entries()) { + edgePairs += ns.length; + const names = new Set(ns.map(n => n.dir)); + if (ns.length === 1) graph.metrics.deadEnds += 1; + if (ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down')) graph.metrics.crosses += 1; + } + graph.metrics.conveyorCells = graph.cells.size; + graph.metrics.edges = Math.floor(edgePairs / 2); + + buildGraphComponents(graph); + indexGraphPorts(game, graph); + graph.issues = validateFactoryGraph(game, graph, { includeRoutes: false }); + return graph; +} + +function buildGraphComponents(graph) { + let next = 1; + for (const k of graph.cells) { + if (graph.cellToComponent.has(k)) continue; + const id = next++; + const queue = [k]; + const cells = []; + graph.cellToComponent.set(k, id); + while (queue.length) { + const cur = queue.shift(); + cells.push(cur); + for (const n of graph.adjacency.get(cur) || []) { + if (graph.cellToComponent.has(n.key)) continue; + graph.cellToComponent.set(n.key, id); + queue.push(n.key); + } + } + graph.components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 }); + } + graph.metrics.components = graph.components.size; +} + +function indexGraphPorts(game, graph) { + for (const scanner of game.scanners) { + const ports = {}; + for (const type of ['inputA', 'left', 'right']) { + const cell = scannerConnector(scanner, type); + const cellKey = cell ? graphNodeKey(cell.col, cell.row) : null; + const connected = !!cellKey && graph.cells.has(cellKey); + ports[type] = { type, cell, key: connected ? cellKey : null, connected }; + graph.metrics.scannerPorts += 1; + if (connected) graph.metrics.connectedScannerPorts += 1; + if (type === 'inputA' && connected) graph.scannerInputs.set(cellKey, scanner.id); + } + graph.scannerPorts.set(scanner.id, ports); + } + + for (const [id, f] of Object.entries(game.facilities)) { + if (!f.entry) continue; + const cellKey = graphNodeKey(f.entry.col, f.entry.row); + const connected = graph.cells.has(cellKey); + graph.facilityPorts.set(id, { id, cell: { ...f.entry }, key: connected ? cellKey : null, connected }); + graph.metrics.facilityPorts += 1; + if (connected) graph.metrics.connectedFacilityPorts += 1; + } + + for (const farm of game.eggFarms) { + const outputs = adjacentCells({ col: farm.col, row: farm.row }) + .map(p => graphNodeKey(p.col, p.row)) + .filter(k => graph.cells.has(k)); + graph.farmOutputs.set(farm.id, outputs); + graph.metrics.farmOutputs += outputs.length; + } +} + +export function ensureFactoryGraph(game) { + const version = game.routingVersion || 0; + if (!game.factoryGraph || game.factoryGraph.version !== version) game.factoryGraph = buildFactoryGraph(game); + return game.factoryGraph; +} + +export function commitFactoryGraphForDay(game) { + const graph = ensureFactoryGraph(game); + game.factoryGraphCommittedVersion = graph.version; + game.factoryGraphSnapshot = graphSummary(graph); + return graph; +} + +export function graphSummary(graph) { + if (!graph) return null; + return { + version: graph.version, + conveyorCells: graph.metrics.conveyorCells, + edges: graph.metrics.edges, + components: graph.metrics.components, + deadEnds: graph.metrics.deadEnds, + crosses: graph.metrics.crosses, + scannerPorts: `${graph.metrics.connectedScannerPorts}/${graph.metrics.scannerPorts}`, + facilityPorts: `${graph.metrics.connectedFacilityPorts}/${graph.metrics.facilityPorts}`, + farmOutputs: graph.metrics.farmOutputs, + issues: [...(graph.issues || [])] + }; +} + +export function factoryGraphMetrics(game) { + const graph = ensureFactoryGraph(game); + const routeIssues = validateFactoryGraph(game, graph, { includeRoutes: true }); + const maxCongestion = game.congestion?.size ? Math.max(...[...game.congestion.values()].map(c => c.ratio || 0), 0) : 0; + const queued = game.scanners.reduce((sum, scanner) => sum + (scanner.queue?.length || 0), 0); + return { + ...graphSummary(graph), + issues: routeIssues, + activeItems: game.chicks.length, + queuedItems: queued, + movingItems: Math.max(0, game.chicks.length - queued), + maxCongestion + }; } // ----------------------------------------------------------------------------- // Conveyor graph and pathfinding // ----------------------------------------------------------------------------- export function getConveyorNeighbors(game, col, row) { - const result = []; - for (const d of DIRS) { - const nc = col + d.dc, nr = row + d.dr; - if (inGrid(nc, nr) && game.conveyorTiles.has(key(nc, nr))) result.push({ col: nc, row: nr, dir: d }); - } - return result; + const graph = ensureFactoryGraph(game); + const k = graphNodeKey(col, row); + return (graph.adjacency.get(k) || []).map(n => ({ ...graphCell(n.key), dir: DIRS.find(d => d.name === n.dir) || { name: n.dir, dc: 0, dr: 0 } })); } export function getAdjacentConveyors(game, col, row) { - return adjacentCells({ col, row }).filter(p => game.conveyorTiles.has(key(p.col, p.row))); + const graph = ensureFactoryGraph(game); + return adjacentCells({ col, row }).filter(p => graph.cells.has(graphNodeKey(p.col, p.row))); } function dirBetween(a, b) { @@ -127,79 +297,67 @@ function dirBetween(a, b) { return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null; } -function isCross(game, col, row) { - const ns = getConveyorNeighbors(game, col, row); - const names = new Set(ns.map(n => n.dir.name)); +function isCrossInGraph(graph, cellKey) { + 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 startK = key(start.col, start.row); - if (!game.conveyorTiles.has(startK)) return []; + const graph = ensureFactoryGraph(game); + const startK = graphNodeKey(start.col, start.row); + if (!graph.cells.has(startK)) return []; + const cacheKey = `${startK}|${isGoal.cacheKey || 'dynamic'}`; + if (isGoal.cacheKey && graph.pathCache.has(cacheKey)) return graph.pathCache.get(cacheKey).map(path => path.map(p => ({ ...p }))); + const startState = `${startK}|none`; - const queue = [{ col: start.col, row: start.row, incoming: 'none' }]; + const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none' }]; const visited = new Set([startState]); const parent = new Map(); const found = []; while (queue.length) { const cur = queue.shift(); - const curK = key(cur.col, cur.row); - const stateK = `${curK}|${cur.incoming}`; - if (isGoal(cur)) found.push(stateK); - for (const n of getConveyorNeighbors(game, cur.col, cur.row)) { - const outDir = dirBetween(cur, n); - if (cur.incoming !== 'none' && isCross(game, cur.col, cur.row) && outDir !== cur.incoming) continue; - const nextStateK = `${key(n.col, n.row)}|${outDir}`; + const curStateK = `${cur.key}|${cur.incoming}`; + if (isGoal(cur)) found.push(curStateK); + for (const n of graph.adjacency.get(cur.key) || []) { + const next = graphCell(n.key); + const outDir = dirBetween(cur, next); + if (cur.incoming !== 'none' && isCrossInGraph(graph, cur.key) && outDir !== cur.incoming) continue; + const nextStateK = `${n.key}|${outDir}`; if (visited.has(nextStateK)) continue; visited.add(nextStateK); - parent.set(nextStateK, stateK); - queue.push({ col: n.col, row: n.row, incoming: outDir }); + parent.set(nextStateK, curStateK); + queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir }); } } - return found.map(goalState => { + const paths = found.map(goalState => { const reversed = []; let cursor = goalState; while (cursor) { const [cellK] = cursor.split('|'); - reversed.push(parseKey(cellK)); + reversed.push(graphCell(cellK)); if (cellK === startK) break; cursor = parent.get(cursor); } return reversed.reverse(); }); + if (isGoal.cacheKey) graph.pathCache.set(cacheKey, paths.map(path => path.map(p => ({ ...p })))); + return paths; } export function buildConveyorComponents(game) { + const graph = ensureFactoryGraph(game); const components = new Map(); - const cellToComponent = new Map(); - let next = 1; - for (const k of game.conveyorTiles) { - if (cellToComponent.has(k)) continue; - const id = next++; - const queue = [parseKey(k)]; - const cells = []; - cellToComponent.set(k, id); - while (queue.length) { - const cur = queue.shift(); - const ck = key(cur.col, cur.row); - cells.push(ck); - for (const n of getConveyorNeighbors(game, cur.col, cur.row)) { - const nk = key(n.col, n.row); - if (cellToComponent.has(nk)) continue; - cellToComponent.set(nk, id); - queue.push({ col: n.col, row: n.row }); - } - } - components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 }); - } - return { components, cellToComponent }; + for (const [id, comp] of graph.components.entries()) components.set(id, { id, cells: [...comp.cells], capacity: comp.capacity, count: 0, ratio: 0 }); + return { components, cellToComponent: new Map(graph.cellToComponent) }; } export function nearestConveyorKey(game, x, y) { + const graph = ensureFactoryGraph(game); let best = null; let bestD = Infinity; - for (const k of game.conveyorTiles) { + for (const k of graph.cells) { const p = parseKey(k); const c = cellCenter(p.col, p.row); const d = Math.hypot(c.x - x, c.y - y); @@ -254,8 +412,6 @@ export function scannerOutputs(scanner) { export function autoSideFor(scanner, chick) { const rules = scannerRules(scanner); if (rules.left.match === chick.sex) return 'left'; - if (rules.right.match === chick.sex) return 'right'; - if (rules.left.match === 'default') return 'left'; return 'right'; } @@ -266,22 +422,23 @@ export function destinationColor(dest) { export function isFacilityEndpoint(game, p, dest, connector) { if (sameCell(p, connector)) return false; - const f = game.facilities[dest]; - if (f?.entry) return facilityEndpointCells(game, dest).some(e => sameCell(p, e)); - const degree = getConveyorNeighbors(game, p.col, p.row).length; - if (degree > 1) return false; - const entry = facilityEntryPoint(game, dest); - return !!entry && distance(cellCenter(p.col, p.row), entry) <= GRID.cell * 2.2; + const graph = ensureFactoryGraph(game); + const port = graph.facilityPorts.get(dest); + return !!port?.connected && graphNodeKey(p.col, p.row) === port.key; } export function routeFromFarmToScanner(game, farm, advance = false) { - const starts = getAdjacentConveyors(game, farm.col, farm.row); + const graph = ensureFactoryGraph(game); + const starts = (graph.farmOutputs.get(farm.id) || []).map(graphCell); const candidates = []; for (const start of starts) { - const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row)); + const isGoal = p => graph.scannerInputs.has(graphNodeKey(p.col, p.row)); + isGoal.cacheKey = `farmInput:${start.col},${start.row}:v${graph.version}`; + const routes = bfsAllRoutes(game, start, isGoal); for (const cells of routes) { const last = cells[cells.length - 1]; - const scanner = getInputScanner(game, last.col, last.row); + const scannerId = graph.scannerInputs.get(graphNodeKey(last.col, last.row)); + const scanner = scannerById(game, scannerId); if (!scanner) continue; candidates.push({ key: `${scanner.id}:${last.col},${last.row}:${cells.length}`, scanner, cells }); } @@ -295,8 +452,10 @@ export function routeFromFarmToScanner(game, farm, advance = false) { export function outputRoute(game, side, fromPoint, scannerId, advance = false) { const scanner = scannerById(game, scannerId); if (!scanner) return null; + const graph = ensureFactoryGraph(game); + const port = graph.scannerPorts.get(scanner.id)?.[side]; const connector = scannerConnector(scanner, side); - const starts = outputStartCells(game, scanner, side); + const starts = port?.connected ? [graphCell(port.key)] : []; if (!connector || !starts.length) return null; const dest = scannerOutputs(scanner)[side]; if (dest === 'scanner-role-1') return routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance); @@ -304,15 +463,17 @@ export function outputRoute(game, side, fromPoint, scannerId, advance = false) { } function routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance) { - const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && inputCellsForScanner(game, s).length); + const graph = ensureFactoryGraph(game); + const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && graph.scannerPorts.get(s.id)?.inputA?.connected); const candidates = []; for (const start of starts) { for (const target of targets) { - const inputKeys = new Set(inputCellsForScanner(game, target).map(p => key(p.col, p.row))); - const routes = bfsAllRoutes(game, start, p => inputKeys.has(key(p.col, p.row))); - for (const cells of routes) { - candidates.push({ key: `start${key(start.col, start.row)}:s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells }); - } + const targetKey = graph.scannerPorts.get(target.id)?.inputA?.key; + if (!targetKey) continue; + const isGoal = p => graphNodeKey(p.col, p.row) === targetKey; + isGoal.cacheKey = `toScanner:${start.col},${start.row}:${target.id}:v${graph.version}`; + const routes = bfsAllRoutes(game, start, isGoal); + for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells }); } } const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:toRole1`, candidates, advance); @@ -321,12 +482,15 @@ 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 candidates = []; for (const start of starts) { - const routes = bfsAllRoutes(game, start, p => isFacilityEndpoint(game, p, dest, connector)); - 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 }); - } + const isGoal = p => graphNodeKey(p.col, p.row) === port.key; + 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 }); } const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance); if (!chosen) return null; @@ -334,40 +498,56 @@ function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest } // ----------------------------------------------------------------------------- -// Connection validation +// Connection validation: uses FactoryGraph as the single source of truth. // ----------------------------------------------------------------------------- function scannerName(scanner) { if (scanner.kind === 'manual') return `S${(scanner.slot ?? 0) + 1}`; return `Auto Scanner #${scanner.id}`; } -export function facilityConnectionIssues(game) { +export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), options = {}) { + const includeRoutes = options.includeRoutes !== false; const issues = []; const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' }; + for (const id of MACHINE_FACILITY_IDS) { const f = game.facilities[id]; if (!f) { issues.push(`${labels[id] || id} is missing`); continue; } - if (!facilityEndpointCells(game, id).length) issues.push(`${labels[id] || id} receiver has no conveyor`); + if (!graph.facilityPorts.get(id)?.connected) issues.push(`${labels[id] || id} receiver has no conveyor`); } + for (const scanner of game.scanners) { - if (!scannerPortHasConveyor(game, scanner, 'inputA')) issues.push(`${scannerName(scanner)} input has no conveyor`); - if (!scannerPortHasConveyor(game, scanner, 'left')) issues.push(`${scannerName(scanner)} left output has no conveyor`); - if (!scannerPortHasConveyor(game, scanner, 'right')) issues.push(`${scannerName(scanner)} right output has no conveyor`); + const ports = graph.scannerPorts.get(scanner.id) || {}; + if (!ports.inputA?.connected) issues.push(`${scannerName(scanner)} input has no conveyor`); + if (!ports.left?.connected) issues.push(`${scannerName(scanner)} left output has no conveyor`); + if (!ports.right?.connected) issues.push(`${scannerName(scanner)} right output has no conveyor`); + if (!includeRoutes) continue; for (const side of ['left', 'right']) { - if (scannerPortHasConveyor(game, scanner, side) && !outputRoute(game, side, scannerCenter(scanner), scanner.id)) { - issues.push(`${scannerName(scanner)} ${side} route is incomplete`); - } + if (ports[side]?.connected && !outputRoute(game, side, scannerCenter(scanner), scanner.id)) issues.push(`${scannerName(scanner)} ${side} route is incomplete`); } } - for (const farm of game.eggFarms) { - if (!routeFromFarmToScanner(game, farm)) issues.push(`Egg Farm #${farm.id} has no scanner route`); + + if (includeRoutes) { + for (const farm of game.eggFarms) { + const route = routeFromFarmToScanner(game, farm); + if (!route?.scannerId) issues.push(`Egg Farm #${farm.id} has no scanner route`); + } + } else { + for (const farm of game.eggFarms) { + if (!(graph.farmOutputs.get(farm.id) || []).length) issues.push(`Egg Farm #${farm.id} has no output conveyor`); + } } + return [...new Set(issues)]; } +export function facilityConnectionIssues(game) { + return validateFactoryGraph(game, ensureFactoryGraph(game), { includeRoutes: true }); +} + export function factoryReady(game) { return facilityConnectionIssues(game).length === 0; } diff --git a/src/systems/selectionSystem.js b/src/systems/selectionSystem.js index d42d113..85e18cd 100644 --- a/src/systems/selectionSystem.js +++ b/src/systems/selectionSystem.js @@ -4,6 +4,7 @@ import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js'; import { farmAt, scannerAt, scannerCenter, refreshRoutingAfterEdit } from './routing.js'; import { snapshot } from './history.js'; import { buildPrice } from './economy.js'; +import { emitGameEvent } from './gameEvents.js'; export function createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }) { function selectionToken(item) { @@ -159,6 +160,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme if (origin.type !== 'facility') continue; const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy }; const { entry, side } = nearestGridEdge(nextCenter); + if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true; const draft = { ...origin.obj, entry: { ...entry }, side }; layoutFacilityOnEdge(draft, entry, side); candidateRects.push({ id: origin.id, rect: rectOfFacility(draft) }); @@ -191,6 +193,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme 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'); @@ -208,7 +211,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme for (const o of conveyorOrigins) { const newK = key(o.col + dcol, o.row + drow); game.conveyorTiles.add(newK); - game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null }); + game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null, uses: 0 }); o.currentKey = newK; } for (const origin of game.groupDrag.origins) { @@ -233,6 +236,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme function finishGroupDrag() { if (!game.groupDrag) return; + if (game.groupDrag.committed) emitGameEvent(game, 'MoveEquipment', { count: game.groupDrag.selections?.length || 0 }); refreshRoutingAfterEdit(game); game.groupDrag = null; updatePanels(); diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js index ed1c0ec..7b96ebb 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -1,12 +1,19 @@ import { BUILD_TOOL_IDS, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js'; import { yen } from '../core/utils.js'; import { TEXT } from '../core/text.js'; -import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js'; +import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues, factoryGraphMetrics } from './routing.js'; import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, zundaTaxInfo } from './economy.js'; import { truckTarget } from './contracts.js'; import { conveyorSpeedForGame } from './cards.js'; +import { recentGameEvents } from './gameEvents.js'; +import { maintenanceSummary } from './maintenance.js'; +import { BALANCE } from '../core/balance.js'; export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) { + function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); + } + function displaySpeed() { return Math.round(conveyorSpeedForGame(game)); } function updatePanels() { @@ -16,6 +23,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act updateContractPanel(); updateTurnSummary(); updateHistoryButtons(); + updateDebugPanel(); } function updateToolButtons() { @@ -40,6 +48,15 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act : ''; } if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending; + if (ui.hireRepairmanButton) { + const hired = !!game.repairman?.hiredForNextDay; + const cost = BALANCE.maintenance.repairman.dailyCost; + const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || hired || game.cash < cost; + ui.hireRepairmanButton.disabled = blocked; + ui.hireRepairmanButton.classList.toggle('active', hired); + ui.hireRepairmanButton.classList.toggle('unaffordable', game.cash < cost); + ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day.`; + } } function updateBuildStatus() { @@ -54,20 +71,48 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act } function updateTurnSummary() { - const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)).length; + const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length; const issues = facilityConnectionIssues(game); const tax = zundaTaxInfo(game.cash, game); const tribute = fairiesTributeInfo(game, game.turn); ui.turnSummary.innerHTML = [ + `Seed: ${escapeHtml(game.runSeed || 'none')} | Blocked: ${game.blockedCells?.size || 0} cells`, `Truck target: ${truckTarget(game).toUpperCase()} | Male fine: -${yen(maleTruckPenalty(game))}`, `Poop fine: Mixer -${yen(mixerPoopPenalty(game))} / Shipment -${yen(truckPoopPenalty(game))}`, `ZUNDA TAX on Next Day: -${yen(tax.tax)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`, `Fairies tribute on Next Day: -${yen(tribute.amount)}${tribute.reduction ? ` | flattery -${yen(tribute.reduction)}` : ''}`, + `Maintenance: worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label}) | Repairman: ${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}`, `Card draft: ${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'} | Belt: ${displaySpeed()}px/s | Farms connected: ${connected}/${game.eggFarms.length}`, issues.length ? `Blocked: ${issues[0]}` : `${TEXT.status.allPortsConnected}` ].join('
'); } + + function updateDebugPanel() { + if (!ui.debugObservations && !ui.debugEventLog) return; + let m = null; + try { m = factoryGraphMetrics(game); } catch (_err) { m = null; } + if (ui.debugObservations) { + if (!m) ui.debugObservations.innerHTML = 'FactoryGraphnot available'; + else ui.debugObservations.innerHTML = [ + 'FactoryGraph', + `v${m.version} | cells ${m.conveyorCells} | edges ${m.edges} | components ${m.components}`, + `ports scanner ${m.scannerPorts} / facility ${m.facilityPorts} | farm outputs ${m.farmOutputs}`, + `dead ends ${m.deadEnds} | crosses ${m.crosses} | max jam ${Math.round((m.maxCongestion || 0) * 100)}%`, + `items moving ${m.movingItems} / queued ${m.queuedItems} / total ${m.activeItems}`, + `blocked cells ${(game.blockedCells?.size || 0)} | worst dirt ${maintenanceSummary(game).worst.percent}% ${escapeHtml(maintenanceSummary(game).worst.label)}`, + (m.issues?.length ? `issue: ${m.issues[0]}` : 'graph valid') + ].join(''); + } + if (ui.debugEventLog) { + const events = recentGameEvents(game, 8); + ui.debugEventLog.innerHTML = ['Event Log', ...events.map(ev => { + const payload = Object.entries(ev.payload || {}).map(([k, v]) => `${escapeHtml(k)}:${escapeHtml(v)}`).join(' '); + return `D${ev.day} ${escapeHtml(ev.type)}${payload ? ` | ${payload}` : ''}`; + })].join(''); + } + } + function updateContractPanel() { if (!ui.contractPanel) return; const offer = game.contractOffer; @@ -126,6 +171,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act 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(); updatePriorityStrip(); + updateDebugPanel(); ui.turnProfit.textContent = yen(game.stats.profit); ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0); ui.turnProfit.classList.toggle('cash-positive', game.stats.profit > 0); @@ -149,7 +195,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act const target = truckTarget(game).toUpperCase(); ui.targetBrief.textContent = `TRUCK TARGET: ${target}`; ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s`; - ui.speedBrief.title = `Conveyor speed does not increase by day. Cap: ${CONVEYOR_SPEED_MAX}px/s.`; + ui.speedBrief.title = `Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${CONVEYOR_SPEED_MAX}px/s.`; const active = game.contractActive; const offer = game.contractOffer; if (active) { @@ -184,39 +230,17 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act } function showTurnResult(r) { - ui.modalTitle.textContent = `Day ${r.turn} Result`; - const contractHtml = r.contract ? ` -
Limited Contract
-
-
Contract${r.contract.title}
-
Rule${r.contract.route}
-
Progress${r.contract.count} / ${r.contract.targetAmount}
-
Bonus${r.contract.success ? '+' + yen(r.contract.bonus) : 'Failed'}
-
` : '
No limited contract active this day.
'; - const contractBonus = r.contract?.bonus || 0; - const dailyEarned = r.revenue; + ui.modalTitle.textContent = `Day ${r.turn} Settlement`; ui.modalBody.innerHTML = ` -
Shipment / Daily Revenue
-
-
Truck target${r.ship.target.toUpperCase()}
-
Truck formula${r.ship.targetCount} × ${yen(r.ship.unitPrice)} = ${yen(r.ship.base)}
-
Final truck revenue${yen(r.ship.adjusted)}
-
Mixer income${yen(r.mixerRevenue)} / unit ${yen(upgradedMixerPrice(game))}
-
Poop fineMixer ${yen(r.mixerPoopFine)} / Shipment ${yen(r.truckPoopFine)}
-
Wrong truck fine${yen(r.maleTruckFine)}
-
Next Day feesZUNDA TAX and Fairies tribute are paid before the next run and are not included in this day's profit.
-
Explosion penalty${yen(r.explosionDamage)}
-
Daily earned formula(${yen(r.mixerRevenue)} + ${yen(r.ship.adjusted)} + ${yen(contractBonus)}) ÷ 1 day = ${yen(dailyEarned)}
-
Net profit formula${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}
-
Cash${yen(r.cash)}
-
- ${contractHtml}`; +

${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}

+

Final profit: ${yen(r.profit)}

`; ui.modalActions.innerHTML = ''; ui.modalActions.appendChild(button('Choose Upgrade Card', beginCardDraft || hideModal, 'primary-button')); ui.modal.classList.remove('equipment-popover'); ui.modal.classList.add('visible'); } + function showGameOver() { const processed = game.totals.processed; const accuracy = processed ? Math.round(game.totals.correct / processed * 100) : 0; diff --git a/styles.css b/styles.css index d39b830..015d25e 100644 --- a/styles.css +++ b/styles.css @@ -208,3 +208,117 @@ h1, h2, p { margin: 0; } .card-targets { display: grid; gap: 8px; margin-top: 14px; } .card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); } .card-target-button:hover { background: var(--green-soft); } + +/* v19 readability pass */ +body { font-size: 16px; } +.hud-card span { font-size: 11px; } +.hud-card strong { font-size: clamp(18px, 1.5vw, 25px); } +.priority-strip > div { font-size: 14px; } +.panel-head h1 { font-size: 18px; } +.panel-head p { font-size: 11px; } +.build-panel h2 { font-size: 13px; } +.tool-button strong { font-size: 14px; } +.tool-button span { font-size: 11px; } +.mini-box, .facility-panel-empty, .facility-card, .contract-card { font-size: 12px; } +.facility-card h3 { font-size: 14px; } +.sort-button { font-size: 14px; } +.modal-card h2 { font-size: 29px; } +.card-choice strong { font-size: 16px; } +.card-choice small { font-size: 13px; } + +.tool-button.unaffordable, .tool-button.already-built { opacity: .42; filter: grayscale(.7); } + +.hover-tooltip { + position: fixed; + z-index: 30; + width: min(320px, calc(100vw - 20px)); + display: none; + border: 3px solid var(--line); + background: rgba(255,255,255,.96); + color: var(--ink); + box-shadow: 6px 6px 0 rgba(16,32,21,.18); + padding: 10px; + pointer-events: none; + line-height: 1.35; +} +.hover-tooltip.visible { display: block; } +.hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; } +.hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; } +.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; } + +.debug-panel { + position: absolute; + left: 10px; + bottom: 10px; + z-index: 12; + border: 3px solid var(--line); + background: rgba(255,255,255,.94); + box-shadow: 4px 4px 0 rgba(16,32,21,.16); + max-width: 330px; +} +.debug-panel summary { + cursor: pointer; + padding: 5px 8px; + font-weight: 900; + font-size: 12px; + user-select: none; +} +.debug-body { + display: grid; + gap: 7px; + padding: 8px; + border-top: 3px solid var(--line); + font-size: 12px; +} +.debug-body label { display: grid; gap: 3px; font-weight: 900; color: var(--muted); } +.debug-body input, .debug-body select, .debug-body button { + border: 2px solid var(--line); + background: var(--white); + color: var(--ink); + font: inherit; + font-weight: 900; + padding: 5px 6px; +} +.debug-body button { cursor: pointer; background: var(--green-soft); } + +.card-choice.dud { + background: #eeeeee; + color: #777; + filter: grayscale(1); + border-style: dashed; + box-shadow: 5px 5px 0 rgba(16,32,21,.10); +} +.card-choice.dud span { background: #f8f8f8; } +.card-choice.flung { + pointer-events: none; + transform: translate(180px, -60px) rotate(22deg); + opacity: 0; + transition: transform .18s ease-in, opacity .18s ease-in; +} + +/* v20 Phase 1 observability */ +.debug-readout { + border: 2px solid var(--line); + background: rgba(247,255,245,.92); + padding: 6px; + display: grid; + gap: 3px; + line-height: 1.25; +} +.debug-readout strong { + display: block; + color: var(--ink); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .08em; +} +.debug-readout span { + display: block; + color: var(--muted); + font-size: 11px; + overflow-wrap: anywhere; +} +.debug-readout.event-log { + max-height: 160px; + overflow: auto; +} From 33cad83ccbb232894fa419103c2145ac34559f55 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Mon, 8 Jun 2026 18:01:20 +0900 Subject: [PATCH 3/3] hmm --- README.md | 159 +----- assets/images/README.md | 17 +- assets/images/poop.png | Bin 0 -> 1211 bytes index.html | 29 +- src/core/balance.js | 17 +- src/core/entities.js | 6 +- src/core/state.js | 12 +- src/core/text.js | 18 +- src/game.js | 126 +---- src/index.html | 51 +- src/render/draw.js | 168 +++--- src/styles.css | 910 ++++++++++++++++++++------------- src/systems/buildSystem.js | 50 +- src/systems/cards.js | 388 ++++++++++++-- src/systems/chickSystem.js | 80 ++- src/systems/contracts.js | 4 +- src/systems/economy.js | 101 +++- src/systems/maintenance.js | 58 ++- src/systems/routing.js | 147 ++++-- src/systems/selectionSystem.js | 2 - src/systems/uiSystem.js | 123 ++--- styles.css | 262 +++++++--- 22 files changed, 1716 insertions(+), 1012 deletions(-) create mode 100644 assets/images/poop.png diff --git a/README.md b/README.md index aded3c6..346f012 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,28 @@ -# Chick Sorter v23.0 Phase 2 Routefix Hotfix +# Chick Sorter v27.0 Cleanup Build +Open `index.html` in a browser. -This build changes the SHREDDER Improvement payout rule. +## Current baseline -## v18 changes -- `SHREDDER Improvement` now uses one payout roll per processed item. -- Probability after n cards: `((√n / 2) × n)%`. -- Maximum SHREDDER Improvement cards counted: 30. -- At 30 cards, the chance is about 82.16%, so the expected payout is about ¥0.8216 per processed item. +- Build, move, box-select, sell, and repair factory equipment during Build phase. +- Start the next day only when at least one EGG route reaches a scanner and then any valid exit. +- Fairies tribute is paid after day-end settlement and before the next Build phase. +- Bankruptcy is checked only at that day-end transition. Temporary negative cash during production does not immediately end the game. +- Cards use English names and descriptions with numeric effect summaries. +- `poop.png` is the only bundled image asset currently used by default; other art falls back to canvas drawing. -## SHREDDER Improvement expected value +## Controls -```text -n = min(SHREDDER Improvement cards, 30) -p = ((√n / 2) × n)% = n√n / 200 -Per item payout X ~ Bernoulli(p) -E[X] = p yen = n√n / 200 yen -``` +- S1 manual scanner: `A` = left / `D` = right. +- S2 manual scanner: `←` = left / `→` = right. +- Build phase: left-drag empty map for box selection; drag selected equipment to move it. +- Right-drag: pan. +- `Ctrl+Z` / `Ctrl+Y`: undo / redo. +- `Esc`: cancel upgrade-card target selection. -For k shredded items, expected total bonus is `k × n√n / 200` yen. +## Cleanup in this build ---- - -# Previous build notes - -# Chick Sorter v17.0 Shredder Card and Cleanup - -This build adjusts selling constraints, timeout cleanup, settlement UI, and adds the SHREDDER improvement card. - -## v17 changes -- The last EGG FARM, MIXER, SHREDDER, and TRUCK can no longer be sold. -- Non-last equipment still follows the existing rule: same Build phase removal refunds 100%, older equipment sells for 50%. -- When the day timer and shutdown grace expire, remaining chicks/poop are blown off the conveyors with no equipment damage. -- Settlement appears 1 second after that cleanup. -- The day settlement modal is reduced to two lines: one simple formula and final profit. -- Added `SHREDDER Improvement` card. - - Each card adds one independent trial per shredded item. - - Each trial has a 1/3 chance to pay ¥1. - - Expected value per processed item after n cards: n / 3 yen. -- EGG FARM price remains ¥250. - -## SHREDDER Improvement expected value - -```text -Per item payout X ~ Binomial(n, 1/3) -E[X] = n × 1/3 = n/3 yen -``` - -For k shredded items, expected total bonus is `k × n / 3` yen. - ---- - -## Previous notes - -This build refines the v15 card and tax system. - -## v16.1 changes -- Fixed equipment improvement target-picking overlay by importing the target bounds helper into the renderer. -- Non-candidate equipment now receives a full dark overlay, and valid upgrade candidates are explicitly white-highlighted. -- Added a fallback message when a target picker has no valid target, so the game no longer appears frozen. - -## v16 changes - -- `Extra Cards` is now immediate. - - Selecting it consumes the current card pick, then grants 2 more card selections in the same draft. - - It no longer increases future draft size. -- Equipment improvement cards now target machines on the map. - - Valid targets are highlighted in white. - - Non-target areas are darkened. - - Click a highlighted machine to apply the upgrade. - - `Esc` cancels target selection and returns to the card draft. -- ZUNDA TAX and Fairies tribute no longer contaminate the next day’s profit display. - - They are still paid when `Next Day` is pressed. - - They still affect cash and total outflow. - - They are not counted inside the new day’s `Turn Profit`. -- ZUNDA TAX exemption / Legal Work behavior was rechecked and kept explicit. - - Base exemption: ¥500. - - `Legal Work`: exemption +¥250 and 95% cap point +¥250. - - Tax rate: 5% from the first yen above the exemption. - - +5% per ¥500 taxable band. - - Maximum rate: 95%. -- EGG FARM price remains ¥250. - -## Active ZUNDA TAX formula - -```text -taxable = max(0, cash - exemption) -rate = min(95%, ceil(taxable / 500) × 5%) -ZUNDA TAX = ceil(taxable × rate) -``` - -With no `Legal Work`, `exemption = ¥500`, so ¥501 starts at 5% and ¥10,000 reaches 95%. - -## v19 changes -- Added collapsed bottom-left debug panel: infinite cash, day override, free upgrade purchase. -- Added `src/core/balance.js` as the primary balance-tuning sheet for prices, income, penalties, timing, card rates, and caps. -- Increased UI text size. -- Auto Scanner base cooldown is now 2.25 seconds. -- High-Quality Bearing now adds +7.5% conveyor speed per card. -- Equipment click popover removed; hover now shows flavor text and equipment information. -- ERASE button renamed to SELL. -- Card drafts can contain up to 2 dud cards; each slot has a 30% dud chance. Dud cards can be clicked away without spending a pick. - -## v20 Phase 1 structural changes -- Added a canonical FactoryGraph in `src/systems/routing.js`. - - Build/edit operations invalidate the graph. - - Route planning, connection validation, congestion component lookup, and debug metrics now read from the same graph model. - - Scanner and facility ports are exact grid cells; visual adjacency is not accepted as connectivity. -- `Next Day` now commits the current FactoryGraph before fees and day start. - - The committed snapshot records conveyor cells, edges, components, port counts, farm outputs, and validation issues. -- Added `src/systems/gameEvents.js`. - - Major lifecycle, cash, build/sell, move, cleanup, and jam events are stored in a bounded run log. -- Expanded the debug panel from cheats only into an observability panel. - - Shows FactoryGraph size, components, port status, farm outputs, dead ends, active/queued items, max congestion, and recent events. -- This is a structural pass, not a content/balance pass. It is meant to make later map generation, market contracts, route diagnostics, replay, and deeper factory simulation safer to add. - -## v21 Phase 2 structural changes - -Implemented the Phase 2 foundation without the market board or programmable scanner logic. - -- Added seeded no-build terrain generation. Roughly 20% of grid cells become blocked each run while preserving the initial critical route. -- Added equipment degradation based on actual use. - - Truck and Manual Scanner are exempt and always operate normally. - - Conveyor tiles degrade from item pass counts and reduce local belt speed. - - Egg Farms degrade from egg production and gradually increase spawn interval. - - Mixer, Waste Shredder, and Auto Scanner degrade from processing counts and add delay. - - Equipment keeps full performance until 50% durability remains; at 0% durability it performs at half speed. -- Added dirty overlay rendering for degraded equipment. -- Added a one-day repairman hire button. The repairman costs ¥100 for the next production phase, walks freely across the map, and repairs 1% degradation every 3 seconds. -- Expanded debug/hover readouts with blocked-cell and degradation information. - -Market contracts and deeper scanner-programming logic are intentionally left for later Phase 2 work. - -## v22 Phase 2 starter-route protection - -No-build generation now protects the complete initial factory footprint before blocked cells are placed. The protected footprint includes the default Egg Farm, scanner bodies, scanner input/output ports, machine receiver cells, and every starter conveyor segment. After blocked-cell generation, the starter backbone is scrubbed from the blocked set and reinstalled as a final guard. - -Result: even with roughly 20% no-build cells, the initial line remains continuous from Egg Farm to S1, S1 to S2, and S2 to both Shredder and Truck exits. - - -## v23 hotfix - -- Fixed the startup ReferenceError caused by binding the repairman button to a missing `hireRepairman` wrapper. -- Disabled optional external art probing by default, preventing 404 console noise for placeholder image filenames. Canvas fallback art remains active. +- Removed duplicate `src/index.html` and `src/styles.css`; root `index.html` and `styles.css` are the single launch surface. +- Removed the old debug panel, debug hotkey, debug event log, and free-upgrade/debug-cash code. +- Removed the obsolete right-side selected-facility panel code left behind after the UI removal. +- Removed stale historical README files and old changelog text that no longer matched the current specification. diff --git a/assets/images/README.md b/assets/images/README.md index 1bd6e5b..0e29b23 100644 --- a/assets/images/README.md +++ b/assets/images/README.md @@ -1,16 +1,3 @@ -# Image asset folder +# Image assets -The game currently uses simple canvas placeholders. You can override them by adding images with these filenames: - -- `chick_male.png` -- `chick_female.png` -- `tarinai.png` -- `conveyor.png` -- `scanner_manual.png` -- `scanner_auto.png` -- `egg_farm.png` -- `mixer.png` -- `shredder.png` -- `truck.png` - -The render code checks whether each image loads and falls back to simple shapes when it does not. +`poop.png` is bundled and loaded by default. Other equipment/chick art is optional and currently falls back to canvas drawing unless external art loading is enabled in `src/render/draw.js`. diff --git a/assets/images/poop.png b/assets/images/poop.png new file mode 100644 index 0000000000000000000000000000000000000000..75fde9d97f6e0e8e3cec842c7b2d3c52709ed5f9 GIT binary patch literal 1211 zcmV;s1VsCZP) zO>Wdc5Jn5c8ihr&P9#=bg`;oH5A%qY@2qAR;mVN1n^Eu6$wg| zvfgBT`tbJb&%S>D({=3Z!$Ioc7g_|^OZqulWXLW+`XRt7`K_PJJ`|)Dz%uzgpU*5o zY5^`Wntbhc5J@Y*rK?UmX_wl|S_RSyu!q$ZTd@+96o9moQUKI)iyGG+DFxWWx=dFa z2FeLQ`k(-lLjfj-0!$7Cm>dc)IpqZC{XQS9eQ#0Z#$X36iOq&(UUqV!O_oWkpR9_X`M)`RlzMF z4+*e=iP&95n)?D;L6rb9D3SijUc?N&_qd1>K+XY<%=En%Y8*K(;=BOPNRhGi{vMTJ z;Qwy#VV;*yH`fi=RE-KMYR-t=o^L)NP>1|4GW$zBkaffxWY5niPS{^jZL5Ev;R|Q^vPNR^ErUlj^7OT|7*1RWIZPce-hY} z*enuc>XT`Cv0UtdI3!s1$7(1D6Tox3w>2Jpu@WHtqR{op_mm;JbZcQIK*aNHYZ)Sw zesz2OWKjjoh5)ob_%QPD#uM*hApH=~N|lnWP55mfiG>184h5K;5CIVG-4Tg6FJ=ND zEP1Yk)%oNQFgalYa2R+=)cG(K;8NM51pkQKJsds+%q~H6|IGzxu{Zi8L@~vRAQ4J% z3WVf~fUgR!$h5117ouMM8V%~(#Zv4H$}9r - Chick Sorter v21.0 Phase 2 Map / Degradation + Chick Sorter v27.0 @@ -35,8 +35,8 @@
-

CHICK SORTER v21.0

-

Build, drag, connect all receiver ports, then start the next day.

+

CHICK SORTER v27.0

+

Build, drag, connect at least one valid EGG route, then start the next day.

@@ -63,13 +63,7 @@

Irregular One-Day Event

Irregular forced events appear during Build phase.
- -
-

Selected Facility

-
Click equipment in Build phase.
-
- -
+

Status

@@ -83,19 +77,6 @@
- -
- DBG -
- - - - - -
-
-
-
@@ -107,6 +88,6 @@ - + diff --git a/src/core/balance.js b/src/core/balance.js index c98abda..647d0a1 100644 --- a/src/core/balance.js +++ b/src/core/balance.js @@ -1,7 +1,7 @@ // Central balance sheet for tuning gameplay. // Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps. export const BALANCE = { - version: 'v23.0 Phase 2 Routefix Hotfix', + version: 'v27.0 cleanup build', time: { daySeconds: 60, farmShutdownGraceSeconds: 10 @@ -17,7 +17,7 @@ export const BALANCE = { cell: 46 }, map: { - blockedRatio: 0.20 + blockedRatio: 0.075 }, economy: { income: { @@ -64,6 +64,7 @@ export const BALANCE = { cards: { commonWeight: 8, rareWeight: 2, + ultraRareWeight: 0.45, baseDraftSize: 3, maxDudsPerDraft: 2, dudChancePerCard: 0.30, @@ -81,11 +82,11 @@ export const BALANCE = { fullPerformanceUntilWear: 0.50, minimumPerformance: 0.50, durability: { - conveyor: 260, - eggFarm: 95, - autoScanner: 170, - mixer: 155, - trash: 190 + conveyor: 1300, + eggFarm: 475, + autoScanner: 850, + mixer: 775, + trash: 950 }, processingDelayMaxSeconds: { mixer: 1.20, @@ -93,7 +94,7 @@ export const BALANCE = { }, repairman: { dailyCost: 100, - secondsPerOnePercent: 3, + secondsPerOnePercent: 1 / 3, walkSpeed: 140 } }, diff --git a/src/core/entities.js b/src/core/entities.js index 080a343..7e9db82 100644 --- a/src/core/entities.js +++ b/src/core/entities.js @@ -74,8 +74,12 @@ export function createFacility(game, id, p, price) { return layoutFacilityOnEdge(f, entry, side); } +export function rollChickSex(game) { + return Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female'); +} + export function createChick(game, routeData) { - const sex = Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female'); + const sex = rollChickSex(game); const start = routeData.route[0]; return { id: game.nextId++, sex, diff --git a/src/core/state.js b/src/core/state.js index b7b5ee3..9e7e66e 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -22,6 +22,9 @@ export function newTurnStats() { truckRevenue: 0, zundaTax: 0, fairiesTribute: 0, + loanRepayment: 0, + chemicalWeaponSubsidy: 0, + rescueLoan: 0, cardRerollCost: 0, mixerPoopFine: 0, truckPoopFine: 0, @@ -56,6 +59,9 @@ export function newTotalStats() { contractBonus: 0, zundaTax: 0, fairiesTribute: 0, + loanRepayment: 0, + chemicalWeaponSubsidy: 0, + rescueLoan: 0, cardRerollCost: 0, mixerPoopFine: 0, truckPoopFine: 0, @@ -79,7 +85,6 @@ export function createGame() { cleanupBlown: false, lastTimestamp: 0, nextId: 10, - nextEventId: 1, runSeed: createRunSeed(), blockedCells: new Set(), buildTool: null, @@ -92,8 +97,6 @@ export function createGame() { groupDrag: null, pan: null, view: { x: 0, y: 0 }, - debug: false, - debugInfiniteCash: false, hover: null, chicks: [], effects: [], @@ -115,13 +118,12 @@ export function createGame() { lastExplodedComponent: new Map(), contractOffer: null, contractActive: null, - cardEffects: { bearing: 0, legalWork: 0, flattery: 0 }, + cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] }, cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 }, cardTargetPick: null, repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + GRID.rows * GRID.cell + 84, target: null, repairedToday: 0 }, stats: newTurnStats(), lastResult: null, - eventLog: [], totals: newTotalStats() }; } diff --git a/src/core/text.js b/src/core/text.js index 82ec240..f854201 100644 --- a/src/core/text.js +++ b/src/core/text.js @@ -25,7 +25,7 @@ export const TEXT = { status: { build: tool => `BUILD: ${(tool === 'erase' ? 'SELL' : (tool || 'SELECT')).toUpperCase()} | Right-drag pan | Hover equipment for info`, sorting: 'Sorting: use scanner keys. Build after clearing the day.', - allPortsConnected: 'All required ports connected.', + allPortsConnected: 'Start condition met: one EGG route reaches an exit.', equipmentPanelEmpty: 'Hover equipment in Build phase.', eventPanelRunning: 'Forced events appear in Build phase.' }, @@ -65,8 +65,22 @@ export function buildToolPriceText(id) { return yen(def.price); } +export function buildToolFlavorText(id) { + const flavors = { + conveyor: 'A narrow green belt. Routes decide whether chicks live, ship, or become invoices.', + eggFarm: 'A tiny gatehouse producing questionable eggs on schedule.', + autoScanner: 'An automated judge. Faster than hands, still very sure of itself.', + 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.', + truck: 'Ships the target cargo. Wrong cargo still leaves a paper trail.' + }; + return flavors[id] || ''; +} + export function buildToolButtonHtml(id) { - return `${equipmentName(id)}${buildToolPriceText(id)}`; + const flavor = buildToolFlavorText(id); + return `${equipmentName(id)}${buildToolPriceText(id)}${flavor ? `${flavor}` : ''}`; } export function buildToolButtonIds() { diff --git a/src/game.js b/src/game.js index b8c4708..d4a35fa 100644 --- a/src/game.js +++ b/src/game.js @@ -1,18 +1,17 @@ import { TURN_SECONDS, THEME } from './core/config.js'; import { buildToolButtonHtml } from './core/text.js'; -import { createGame, resetLayout, newTurnStats, nextSpawnDelay } from './core/state.js'; +import { createGame, resetLayout, newTurnStats } from './core/state.js'; import { clamp, pointToCell, cellCenter, yen } from './core/utils.js'; import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js'; -import { collectFairiesTribute, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; +import { applyRescueLoanIfNeeded, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js'; import { undo, redo } from './systems/history.js'; import { drawAll } from './render/draw.js'; import { createBuildSystem } from './systems/buildSystem.js'; import { createUISystem } from './systems/uiSystem.js'; import { createChickSystem } from './systems/chickSystem.js'; -import { conveyorSpeedForGame, createCardSystem, ensureCardState } from './systems/cards.js'; +import { conveyorSpeedForGame, createCardSystem } from './systems/cards.js'; import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js'; import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js'; -import { clearGameEvents, emitGameEvent } from './systems/gameEvents.js'; import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js'; const canvas = document.getElementById('gameCanvas'); @@ -23,17 +22,9 @@ const ui = { money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'), turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'), - buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'), facilityPanel: document.getElementById('facilityPanel'), + buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'), modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'), hoverTooltip: document.getElementById('hoverTooltip'), - debugPanel: document.getElementById('debugPanel'), - debugInfiniteCash: document.getElementById('debugInfiniteCash'), - debugDayInput: document.getElementById('debugDayInput'), - debugSetDayButton: document.getElementById('debugSetDayButton'), - debugUpgradeSelect: document.getElementById('debugUpgradeSelect'), - debugApplyUpgradeButton: document.getElementById('debugApplyUpgradeButton'), - debugObservations: document.getElementById('debugObservations'), - debugEventLog: document.getElementById('debugEventLog'), hireRepairmanButton: document.getElementById('hireRepairmanButton'), buttons: { s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'), @@ -74,10 +65,6 @@ function startGame() { game.view = { x: 0, y: 0 }; resetLayout(game); ensureMaintenanceState(game); - clearGameEvents(game); - emitGameEvent(game, 'RunStarted', { cash: game.cash }); - game.debugInfiniteCash = !!ui.debugInfiniteCash?.checked; - refreshDebugUpgradeSelect(); uiSystem.hideModal(); chicks.updateCongestion(); uiSystem.updatePanels(); @@ -87,7 +74,7 @@ function startGame() { function startNextTurn() { if (game.phase !== 'build') return; if (game.cardDraft?.pending || game.cardTargetPick?.pending) { - build.fail(game.cardTargetPick?.pending ? 'Choose highlighted upgrade target first.' : 'Choose an upgrade card first.'); + build.fail(game.cardTargetPick?.pending ? 'Choose upgrade target first.' : 'Choose an upgrade card first.'); uiSystem.updatePanels(); return; } @@ -98,18 +85,16 @@ function startNextTurn() { return; } ensureMaintenanceState(game); - const graph = commitFactoryGraphForDay(game); - emitGameEvent(game, 'FactoryGraphCommitted', { cells: graph.metrics.conveyorCells, edges: graph.metrics.edges, components: graph.metrics.components }); + commitFactoryGraphForDay(game); activateRepairmanForDay(game); const zundaBasisCash = game.cash; const zunda = collectZundaTax(game, zundaBasisCash); - const tribute = collectFairiesTribute(game, game.turn); - game.lastStartFees = { zundaTax: zunda.tax || 0, fairiesTribute: tribute.amount || 0 }; + const loanRepayment = collectLoanRepayments(game); + game.lastStartFees = { zundaTax: zunda.tax || 0, loanRepayment: loanRepayment.amount || 0, fairiesTribute: 0 }; if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger); - if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger); - if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; } + if (loanRepayment.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `LOAN -${yen(loanRepayment.amount)}`, THEME.danger); + // Negative cash during the next production day is allowed; bankruptcy is checked only at day-end settlement before Build phase. activateAcceptedContract(game); - emitGameEvent(game, 'StartDay', { day: game.turn + 1, zundaTax: zunda.tax || 0, fairiesTribute: tribute.amount || 0 }); game.phase = 'running'; game.turn += 1; game.timeLeft = TURN_SECONDS; @@ -153,10 +138,17 @@ function completeTurn() { if (game.phase !== 'running') return; const ship = settleTruckRevenue(game); const contract = resolveContract(game); + const chemical = collectChemicalWeaponSubsidy(game); + if (chemical.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `CHEM SUBSIDY +${yen(chemical.amount)}`, THEME.green); clearActiveContract(game); + const settlementDay = Math.max(1, (game.totals.turnsCompleted || 0) + 1); + const tribute = collectFairiesTribute(game, settlementDay); + game.lastBuildFees = { fairiesTribute: tribute.amount || 0, reduction: tribute.reduction || 0, turn: settlementDay }; + if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger); + const rescue = applyRescueLoanIfNeeded(game); + if (rescue.amount > 0) floating(game, canvas.width / 2 - game.view.x, 176 - game.view.y, `RESCUE +${yen(rescue.amount)}`, THEME.green); game.totals.turnsCompleted += 1; - game.lastResult = { ...game.stats, ship, contract, turn: game.turn, cash: game.cash }; - emitGameEvent(game, 'EndDay', { day: game.turn, revenue: game.stats.revenue, penalty: game.stats.penalty, profit: game.stats.profit, cash: game.cash }); + game.lastResult = { ...game.stats, ship, contract, chemical, rescue, fairiesTribute: tribute, turn: game.turn, cash: game.cash }; if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; } game.phase = 'build'; game.truckCargo = []; @@ -192,7 +184,6 @@ function closeFarmShutters() { shockwave(game, c.x, c.y, THEME.ink); sparkBurst(game, c.x, c.y + 10, 10); floating(game, c.x, c.y - 34, 'SHUT +10s', THEME.ink); - emitGameEvent(game, 'FarmShutterClosed', { farmId: farm.id }); } } @@ -200,7 +191,6 @@ function update(timestamp) { if (!game.lastTimestamp) game.lastTimestamp = timestamp; const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05); game.lastTimestamp = timestamp; - if (game.debugInfiniteCash && game.cash < 999999) { game.cash = 999999; game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); } if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn }); updateEffects(game, dt, canvas, build.equipmentHitBoxes); chicks.updateCongestion(); @@ -310,86 +300,12 @@ ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); u ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase')); ui.hireRepairmanButton?.addEventListener('click', hireRepairman); ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); }); -window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } }); +window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } }); -function debugUpgradeOptions() { - const options = [ - { value: 'effect:bearing', label: `Bearing +1 (${game.cardEffects?.bearing || 0})` }, - { value: 'effect:legalWork', label: `Legal Work +1 (${game.cardEffects?.legalWork || 0})` }, - { value: 'effect:flattery', label: `Flattery +1 (${game.cardEffects?.flattery || 0})` } - ]; - for (const farm of game.eggFarms) options.push({ value: `eggFarm:${farm.id}`, label: `EGG #${farm.id} L${farm.level}` }); - for (const scanner of game.scanners.filter(s => s.kind === 'auto')) options.push({ value: `scanner:${scanner.id}`, label: `AUTO #${scanner.id} L${scanner.level}` }); - for (const id of ['mixer', 'trash', 'truck']) { - const f = game.facilities[id]; - if (f) options.push({ value: `facility:${id}`, label: `${f.shortName || f.name} L${f.level}` }); - } - return options; -} - -function refreshDebugUpgradeSelect() { - if (!ui.debugUpgradeSelect) return; - const previous = ui.debugUpgradeSelect.value; - const options = debugUpgradeOptions(); - ui.debugUpgradeSelect.innerHTML = options.map(o => ``).join(''); - if (options.some(o => o.value === previous)) ui.debugUpgradeSelect.value = previous; -} - -function applyDebugUpgrade() { - ensureCardState(game); - const value = ui.debugUpgradeSelect?.value || ''; - const [kind, id] = value.split(':'); - if (kind === 'effect') { - game.cardEffects[id] = Math.max(0, game.cardEffects[id] || 0) + 1; - floating(game, canvas.width / 2 - game.view.x, 112 - game.view.y, `${id.toUpperCase()} +1`, THEME.green); - } else if (kind === 'eggFarm') { - const farm = game.eggFarms.find(f => String(f.id) === id); - if (farm) { - farm.level = Math.min(4, (farm.level || 1) + 1); - farm.nextSpawn = nextSpawnDelay(farm); - farm.lastInterval = farm.nextSpawn; - floating(game, cellCenter(farm.col, farm.row).x, cellCenter(farm.col, farm.row).y - 34, `LV ${farm.level}`, THEME.green); - } - } else if (kind === 'scanner') { - const scanner = game.scanners.find(sc => String(sc.id) === id); - if (scanner) { - scanner.level = (scanner.level || 1) + 1; - const c = cellCenter(scanner.col, scanner.row); - floating(game, c.x, c.y - 42, `LV ${scanner.level}`, THEME.green); - } - } else if (kind === 'facility') { - const f = game.facilities[id]; - if (f) { - if (id === 'trash') f.level = Math.min(31, (f.level || 1) + 1); - else f.level = (f.level || 1) + 1; - floating(game, f.x + f.w / 2, f.y + 20, `LV ${f.level}`, THEME.green); - } - } - refreshDebugUpgradeSelect(); - uiSystem?.updatePanels(); -} - -function wireDebugControls() { - if (!ui.debugPanel) return; - refreshDebugUpgradeSelect(); - ui.debugPanel.addEventListener('toggle', refreshDebugUpgradeSelect); - ui.debugInfiniteCash?.addEventListener('change', () => { - game.debugInfiniteCash = !!ui.debugInfiniteCash.checked; - if (game.debugInfiniteCash) game.cash = Math.max(game.cash, 999999); - uiSystem?.updateUI(); - }); - ui.debugSetDayButton?.addEventListener('click', () => { - game.turn = Math.max(1, Math.floor(Number(ui.debugDayInput?.value) || 1)); - uiSystem?.updatePanels(); - uiSystem?.updateUI(); - }); - ui.debugApplyUpgradeButton?.addEventListener('click', applyDebugUpgrade); - ui.debugUpgradeSelect?.addEventListener('pointerdown', refreshDebugUpgradeSelect); -} build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() }); chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() }); cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() }); uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick }); -wireDebugControls(); initializeStaticText(); resetLayout(game); ensureMaintenanceState(game); refreshDebugUpgradeSelect(); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); +initializeStaticText(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update); diff --git a/src/index.html b/src/index.html index f6bcf63..a84c5b6 100644 --- a/src/index.html +++ b/src/index.html @@ -3,7 +3,7 @@ - Chick Sorter v11.0 Routing / Compact Upgrades + Chick Sorter v27.0 @@ -12,9 +12,9 @@
-
CASHJPY 250
+
CASH¥250
TIME60.0s
-
NETJPY 0
+
NET¥0
DAY1
PHASETitle
@@ -35,8 +35,8 @@
-

CHICK SORTER v11.0

-

Build, drag, connect all receiver ports, then start the next day.

+

CHICK SORTER v27.0

+

Build, drag, connect at least one valid EGG route, then start the next day.

@@ -45,14 +45,15 @@

Add / Edit Equipment

- - - - - - - - + + + + + + + + +
Build tools unlock after each day.
@@ -62,13 +63,7 @@

Irregular One-Day Event

Irregular forced events appear during Build phase.
- -
-

Selected Facility

-
Click equipment in Build phase.
-
- -
+

Status

@@ -81,6 +76,20 @@
+ + +
+ DBG +
+ + + + + +
+
+
+
@@ -92,6 +101,6 @@ - + diff --git a/src/render/draw.js b/src/render/draw.js index be8fbd0..a8cebaa 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -3,7 +3,7 @@ import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } fr import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js'; import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js'; import { cardTargetBounds } from '../systems/cards.js'; -import { wearRatio, remainingPercent } from '../systems/maintenance.js'; +import { wearRatio } from '../systems/maintenance.js'; // Optional art overlay hook. // Keep external art probing disabled by default so a clean checkout does not emit 404s for missing PNGs. @@ -12,7 +12,7 @@ const ENABLE_EXTERNAL_ART_ASSETS = false; const ASSET_PATHS = { chickMale: './assets/images/chick_male.png', chickFemale: './assets/images/chick_female.png', - poop: './assets/images/tarinai.png', + poop: new URL('../../assets/images/poop.png', import.meta.url).href, conveyor: './assets/images/conveyor.png', scannerManual: './assets/images/scanner_manual.png', scannerAuto: './assets/images/scanner_auto.png', @@ -27,7 +27,7 @@ for (const [name, src] of Object.entries(ASSET_PATHS)) { const img = new Image(); img.loaded = false; img.onerror = () => { img.loaded = false; }; - if (ENABLE_EXTERNAL_ART_ASSETS && src) { + if ((ENABLE_EXTERNAL_ART_ASSETS || name === 'poop') && src) { img.onload = () => { img.loaded = true; }; img.src = src; } @@ -56,7 +56,6 @@ export function drawAll(ctx, canvas, game, helpers) { drawSelectionBox(ctx, game); drawEffects(ctx, game); drawFloatingTexts(ctx, game); - if (game.debug) drawDebug(ctx, game); ctx.restore(); drawCanvasHints(ctx, game, canvas); } @@ -103,17 +102,13 @@ function drawGrid(ctx, game) { ctx.strokeStyle = 'rgba(16,32,21,.60)'; ctx.lineWidth = 2; rect(ctx, c.x - GRID.cell / 2 + 4, c.y - GRID.cell / 2 + 4, GRID.cell - 8, GRID.cell - 8, true, true); - ctx.fillStyle = 'rgba(255,255,255,.70)'; - ctx.font = '900 9px ui-monospace, monospace'; - ctx.textAlign = 'center'; - ctx.fillText('NO BUILD', c.x, c.y + 3); } ctx.restore(); } function drawDirtOverlay(ctx, x, y, w, h, target, radius = 0) { const wear = wearRatio(target); - if (wear <= 0.01) return; + if (wear < 0.50) return; ctx.save(); ctx.globalAlpha = Math.min(0.62, 0.12 + wear * 0.54); ctx.fillStyle = '#6b4a23'; @@ -174,6 +169,7 @@ function drawConveyors(ctx, game) { 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) }); + drawSelection(ctx, game, 'conveyor', k, c.x, c.y, 38, 38); drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []); if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger); } @@ -289,6 +285,7 @@ function drawScanner(ctx, scanner, game) { 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); + else drawManualKeyboardIcon(ctx, scanner, c); drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84); ctx.restore(); return; } @@ -297,17 +294,63 @@ function drawScanner(ctx, scanner, game) { rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true); ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink; ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'center'; - ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 12); - ctx.font = '900 10px ui-monospace, monospace'; - ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 7); + ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 14); + ctx.font = '900 9px ui-monospace, monospace'; + ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 3); const q = scanner.queue.length; - ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; - ctx.fillText(`Q:${q}${scanner.kind === 'auto' ? ` CD:${scanner.cooldown.toFixed(1)}` : ''}`, c.x, c.y + 25); - if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner); + if (scanner.kind === 'manual') { + drawManualKeyboardIcon(ctx, scanner, c); + ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; + ctx.font = '900 8px ui-monospace, monospace'; + ctx.fillText(`Q:${q}`, c.x, c.y + 32); + } else { + ctx.fillStyle = q > 0 ? THEME.green : THEME.muted; + ctx.font = '900 10px ui-monospace, monospace'; + ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 25); + drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner); + } drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84); ctx.restore(); } +function drawManualKeyboardIcon(ctx, scanner, c) { + if (scanner.kind !== 'manual') return; + const slot = scanner.slot ?? 0; + const labels = slot === 0 + ? [{ side: 'left', text: 'A' }, { side: 'right', text: 'D' }] + : [{ side: 'left', text: '←' }, { side: 'right', text: '→' }]; + const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null; + const baseX = c.x - 30; + const baseY = c.y + 8; + ctx.save(); + ctx.lineWidth = 3; + ctx.fillStyle = '#f7fff5'; + ctx.strokeStyle = THEME.ink; + rect(ctx, baseX - 4, baseY - 4, 68, 25, true, true); + ctx.font = '900 13px ui-monospace, monospace'; + ctx.textAlign = 'center'; + for (let i = 0; i < labels.length; i += 1) { + const keyDef = labels[i]; + const pressed = pressedSide === keyDef.side; + const x = baseX + i * 32; + const y = baseY + (pressed ? 3 : 0); + ctx.fillStyle = pressed ? THEME.greenSoft : THEME.white; + ctx.strokeStyle = THEME.ink; + ctx.lineWidth = pressed ? 4 : 3; + rect(ctx, x, y, 28, 17, true, true); + if (!pressed) { + ctx.fillStyle = 'rgba(16,32,21,.18)'; + rect(ctx, x + 3, y + 17, 28, 3, true, false); + } + ctx.fillStyle = THEME.ink; + ctx.fillText(keyDef.text, x + 14, y + 13); + } + ctx.fillStyle = THEME.muted; + ctx.font = '900 6px ui-monospace, monospace'; + ctx.fillText('KEYBOARD', c.x, baseY - 7); + ctx.restore(); +} + function targetForTruck(game) { return game.contractActive?.target || game.contractOffer?.target || 'female'; } @@ -416,7 +459,7 @@ function drawTruck(ctx, game) { const t = game.facilities.truck; ctx.save(); drawExternalDuct(ctx, t); - if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { ctx.restore(); return; } + if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; } ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24); ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + t.w / 2, t.y + 42); @@ -482,7 +525,13 @@ function isSelected(game, type, id) { } function drawSelection(ctx, game, type, id, x, y, w, h) { if (!isSelected(game, type, id)) return; - ctx.save(); ctx.strokeStyle = THEME.green; ctx.lineWidth = 4; ctx.setLineDash([8, 5]); ctx.strokeRect(x - w / 2, y - h / 2, w, h); ctx.restore(); + ctx.save(); + ctx.fillStyle = 'rgba(34,185,79,.18)'; + ctx.strokeStyle = THEME.green; + ctx.lineWidth = (game.multiSelected || []).length > 1 ? 6 : 4; + ctx.setLineDash((game.multiSelected || []).length > 1 ? [] : [8, 5]); + rect(ctx, x - w / 2, y - h / 2, w, h, true, true); + ctx.restore(); } function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) { if (game.phase !== 'build') return; @@ -501,7 +550,15 @@ function drawCardTargetOverlay(ctx, canvas, game) { // The canvas is already translated by the camera. Offset the dark layer back to screen space // so every non-candidate object is reliably dimmed even after panning. ctx.fillStyle = 'rgba(0,0,0,.66)'; - ctx.fillRect(-game.view.x, -game.view.y, canvas.width, canvas.height); + ctx.beginPath(); + ctx.rect(-game.view.x, -game.view.y, canvas.width, canvas.height); + for (const item of items) { + const b = item.bounds; + const pad = 8; + ctx.rect(b.x - pad, b.y - pad, b.w + pad * 2, b.h + pad * 2); + } + ctx.fill('evenodd'); + const isBlockedCellMode = game.cardTargetPick?.mode === 'blockedCell'; if (!items.length) { ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; @@ -510,38 +567,20 @@ function drawCardTargetOverlay(ctx, canvas, game) { ctx.fillStyle = THEME.danger; ctx.font = '900 12px ui-monospace, monospace'; ctx.textAlign = 'center'; - ctx.fillText('NO VALID UPGRADE TARGET. PRESS ESC.', 470 - game.view.x, 122 - game.view.y); + ctx.fillText(isBlockedCellMode ? 'NO BLOCKED CELL. PRESS ESC.' : 'NO VALID UPGRADE TARGET. PRESS ESC.', 470 - game.view.x, 122 - game.view.y); ctx.restore(); return; } - for (const item of items) { - const b = item.bounds; - ctx.save(); - ctx.shadowColor = THEME.white; - ctx.shadowBlur = 20; - ctx.fillStyle = 'rgba(255,255,255,.32)'; - ctx.strokeStyle = THEME.white; - ctx.lineWidth = 8; - ctx.setLineDash([10, 6]); - rect(ctx, b.x - 10, b.y - 10, b.w + 20, b.h + 20, true, true); - ctx.restore(); - - ctx.save(); - ctx.strokeStyle = THEME.green; - ctx.lineWidth = 4; - ctx.strokeRect(b.x - 4, b.y - 4, b.w + 8, b.h + 8); - ctx.restore(); - - ctx.fillStyle = 'rgba(255,255,255,.96)'; - ctx.strokeStyle = THEME.ink; - ctx.lineWidth = 3; - const labelW = Math.min(300, Math.max(150, item.label.length * 7)); - rect(ctx, b.cx - labelW / 2, b.y - 34, labelW, 24, true, true); - ctx.fillStyle = THEME.ink; - ctx.font = '900 10px ui-monospace, monospace'; - ctx.textAlign = 'center'; - ctx.fillText(item.label, b.cx, b.y - 18); - } + ctx.fillStyle = 'rgba(255,255,255,.95)'; + 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.'; + rect(ctx, 216 - game.view.x, 96 - game.view.y, 560, 42, true, true); + ctx.fillStyle = THEME.ink; + ctx.font = '900 12px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.fillText(msg, 496 - game.view.x, 122 - game.view.y); ctx.restore(); } @@ -586,17 +625,35 @@ function drawFloatingTexts(ctx, game) { function drawRepairman(ctx, game) { const r = game.repairman; - if (!r?.active) return; + const visible = !!r?.active || (game.phase === 'build' && !!r?.hiredForNextDay); + if (!visible) return; ctx.save(); + ctx.lineWidth = 3; ctx.fillStyle = '#fff0d1'; ctx.strokeStyle = THEME.ink; + ctx.beginPath(); ctx.arc(r.x, r.y, 17, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#ffd6e7'; + ctx.beginPath(); ctx.arc(r.x - 6, r.y - 2, 3.2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(r.x + 6, r.y - 2, 3.2, 0, Math.PI * 2); ctx.fill(); + const exploited = !!game.cardEffects?.laborExploitation; + ctx.fillStyle = exploited ? THEME.danger : THEME.ink; + ctx.beginPath(); ctx.arc(r.x - 5, r.y - 5, exploited ? 2.8 : 2.2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(r.x + 5, r.y - 5, exploited ? 2.8 : 2.2, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = THEME.ink; + ctx.lineWidth = exploited ? 3 : 2; + ctx.beginPath(); ctx.arc(r.x, r.y + (exploited ? 0 : 1), exploited ? 8 : 7, 0.10 * Math.PI, 0.90 * Math.PI); ctx.stroke(); + ctx.fillStyle = THEME.green; + rect(ctx, r.x - 9, r.y + 14, 18, 8, true, true); + ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; - ctx.beginPath(); ctx.arc(r.x, r.y, 15, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(r.x - 11, r.y + 18); ctx.lineTo(r.x - 20, r.y + 24); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(r.x + 11, r.y + 18); ctx.lineTo(r.x + 20, r.y + 24); ctx.stroke(); ctx.fillStyle = THEME.ink; - ctx.font = '900 10px ui-monospace, monospace'; + ctx.font = '900 9px ui-monospace, monospace'; ctx.textAlign = 'center'; - ctx.fillText('R', r.x, r.y + 4); - if (r.target?.label) label(ctx, r.x, r.y - 22, r.target.label, THEME.green); + ctx.fillText(r.active ? 'FIX' : 'HIRED', r.x, r.y + 33); + if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green); + else if (!r.active && r.hiredForNextDay) label(ctx, r.x, r.y - 24, 'NEXT DAY', THEME.green); ctx.restore(); } @@ -610,13 +667,8 @@ function drawCanvasHints(ctx, game, canvas) { ctx.font = '900 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; const lines = []; if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2'); - if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect all receiver ports`); + if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect 1 EGG route to any exit`); let y = 76; for (const line of lines) { ctx.strokeStyle = THEME.white; ctx.lineWidth = 4; ctx.strokeText(line, 24, y); ctx.fillText(line, 24, y); y += 20; } ctx.restore(); } -function drawDebug(ctx, game) { - ctx.save(); ctx.font = '900 9px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillStyle = THEME.danger; - for (const k of game.conveyorTiles) { const { col, row } = parseKey(k); const c = cellCenter(col, row); const comp = game.componentLookup?.get(k) || '?'; ctx.fillText(`${col},${row}/C${comp}`, c.x, c.y + 24); } - ctx.restore(); -} diff --git a/src/styles.css b/src/styles.css index dd3ec8c..29cafcf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,367 +1,135 @@ :root { - --bg: #020303; - --screen: #050607; - --panel: rgba(10, 12, 14, 0.94); - --panel-solid: #0b0d0f; - --panel-2: #111417; - --line: rgba(132, 140, 148, 0.18); - --line-strong: rgba(177, 185, 193, 0.62); - --text: #d7dadd; - --muted: #8d949b; - --accent: #b4bac0; - --accent-2: #b99561; - --danger: #d66d6d; - --good: #9fd27d; - --shadow: 0 22px 70px rgba(0, 0, 0, 0.70); + --bg: #f2fff0; + --screen: #eefbea; + --panel: rgba(245, 255, 242, 0.96); + --ink: #102015; + --muted: #526456; + --line: #102015; + --green: #22b94f; + --green-soft: #b7f5c6; + --white: #ffffff; + --danger: #c42424; + --shadow: 8px 8px 0 rgba(16, 32, 21, 0.22); } - * { box-sizing: border-box; } html, body { min-height: 100%; } -body { - margin: 0; - background: - linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px), - linear-gradient(180deg, rgba(255,255,255,0.010) 1px, transparent 1px), - #020303; - background-size: 24px 24px; - color: var(--text); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - overflow-x: hidden; -} - +body { margin: 0; background: var(--bg); color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; overflow-x: hidden; } button { font: inherit; } h1, h2, p { margin: 0; } - -#app { - width: min(100vw, 1680px); - margin: 0 auto; - padding: 0 8px 12px; -} - -.game-shell { - position: relative; - width: 100%; - margin-top: 0; - min-height: min(100vh, 920px); - border: 1px solid #30363d; - border-top: 0; - border-radius: 0; - background: var(--screen); - box-shadow: var(--shadow); - overflow: hidden; -} - -.game-shell::before { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - background: linear-gradient(180deg, rgba(255,255,255,0.035), transparent 90px); - z-index: 1; -} - -#gameCanvas { - position: relative; - z-index: 0; - display: block; - width: 100%; - height: min(calc(100vh - 10px), 900px); - min-height: 660px; - background: #050607; - touch-action: none; -} - -.hud { - position: absolute; - z-index: 4; - display: grid; - gap: 6px; - pointer-events: none; -} -.hud-top-left { - top: 10px; - left: 10px; - grid-template-columns: repeat(5, minmax(78px, auto)); -} -.hud-top-right { - top: 10px; - right: 10px; - grid-template-columns: repeat(4, minmax(78px, auto)); -} -.hud-card { - min-width: 78px; - padding: 6px 8px; - border: 1px solid rgba(82, 91, 100, 0.76); - border-radius: 0; - background: rgba(9, 11, 13, 0.92); - box-shadow: inset 0 0 0 1px rgba(255,255,255,0.018), 0 8px 20px rgba(0, 0, 0, 0.26); - backdrop-filter: blur(3px); -} -.hud-card span { - display: block; - font-size: 9px; - line-height: 1; - letter-spacing: 0.09em; - color: #7c858e; - margin-bottom: 4px; -} -.hud-card strong { - font-size: clamp(15px, 1.35vw, 21px); - line-height: 1; -} -.hud-card.cash strong { color: #d7b27d; } -.hud-card.compact strong { font-size: clamp(13px, 1.1vw, 18px); } -.hud-card.profit strong { color: var(--good); } - +#app { width: min(100vw, 1680px); margin: 0 auto; padding: 0 8px 12px; } +.game-shell { position: relative; width: 100%; min-height: min(100vh, 920px); border: 4px solid var(--line); border-top: 0; background: var(--screen); overflow: hidden; box-shadow: var(--shadow); } +#gameCanvas { display: block; width: 100%; height: min(calc(100vh - 10px), 900px); min-height: 720px; background: var(--screen); touch-action: none; } +.hud { position: absolute; z-index: 4; display: grid; gap: 6px; pointer-events: none; } +.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(5, minmax(86px, auto)); } +.hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); } +.hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); } +.hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); } +.hud-card.primary span { font-size: 9px; } +.hud-card.primary strong { font-size: clamp(22px, 2vw, 32px); } +.hud-card.time-critical { background: #fff0d1; box-shadow: inset 0 0 0 4px var(--danger), 4px 4px 0 rgba(16,32,21,.16); } +.hud-card span { display: block; font-size: 9px; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; } +.hud-card strong { font-size: clamp(15px, 1.3vw, 22px); line-height: 1; color: var(--ink); } +.hud-card.cash strong, .hud-card.profit strong, .cash-positive { color: var(--green) !important; } .cash-negative { color: var(--danger) !important; } -.cash-positive { color: var(--good) !important; } -.upgrade-panel { - position: absolute; - z-index: 5; - left: 10px; - right: 10px; - bottom: 64px; - display: grid; - gap: 9px; - padding: 10px; - border: 1px solid #343a40; - border-radius: 0; - background: rgba(8, 10, 12, 0.94); - box-shadow: 0 20px 55px rgba(0, 0, 0, 0.56); - backdrop-filter: blur(4px); -} -.panel-head { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 14px; -} -.panel-head h1 { - font-size: 18px; - letter-spacing: 0.08em; -} -.panel-head h1 span { - color: #050607; - font-size: 11px; - background: #b4bac0; - border-radius: 0; - padding: 2px 7px; - vertical-align: middle; -} -.panel-head p, -.mini-box, -.upgrade-desc { color: var(--muted); } -.panel-grid { - display: grid; - grid-template-columns: 260px minmax(420px, 1fr) 340px; - gap: 10px; -} -.upgrade-panel h2 { - color: var(--accent); - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.10em; - margin-bottom: 7px; -} -.tool-list, -.upgrade-list { - display: grid; - gap: 7px; -} -.tool-list { grid-template-columns: 1fr 1fr; } -.tool-button, -.primary-button, -.sort-button, -.buy-button { - border-radius: 0; - cursor: pointer; - font-weight: 850; - text-transform: uppercase; - letter-spacing: 0.035em; - transition: transform 0.06s ease, filter 0.12s ease, opacity 0.12s ease, border-color 0.12s ease; -} -.tool-button:active, -.primary-button:active, -.sort-button:active, -.buy-button:active { transform: translateY(1px); } -.tool-button:disabled, -.primary-button:disabled, -.sort-button:disabled, -.buy-button:disabled { - cursor: not-allowed; - opacity: 0.38; -} -.tool-button { - color: var(--text); - background: #111417; - border: 1px solid rgba(82, 91, 100, 0.76); - padding: 8px 9px; - text-align: left; - font-size: 11px; -} -.tool-button.active { - border-color: #b4bac0; - box-shadow: inset 0 0 0 1px rgba(180, 186, 192, 0.35); -} -.tool-button.danger.active { - border-color: var(--danger); - box-shadow: inset 0 0 0 1px rgba(214, 109, 109, 0.30); -} -.primary-button { - border: 1px solid #b4bac0; - padding: 9px 16px; - background: #b4bac0; - color: #050607; - white-space: nowrap; -} -.upgrade-list { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} -.upgrade-item { - border: 1px solid #30363d; - border-radius: 0; - padding: 8px; - background: #0f1215; -} -.upgrade-head { - display: flex; - justify-content: space-between; - gap: 8px; - margin-bottom: 4px; -} -.upgrade-head strong { font-size: 12px; } -.upgrade-level { color: var(--muted); font-size: 11px; white-space: nowrap; } -.upgrade-desc { font-size: 11px; line-height: 1.28; margin-bottom: 7px; } -.buy-button { - color: var(--text); - background: #1a1f24; - border: 1px solid #4b535b; - padding: 6px 8px; - font-size: 11px; -} -.buy-button:not(:disabled):hover, -.tool-button:not(:disabled):hover, -.sort-button:not(:disabled):hover { filter: brightness(1.14); } -.mini-box { - padding: 8px; - border: 1px solid #30363d; - border-radius: 0; - background: #080a0c; - font-size: 11px; - line-height: 1.45; -} -.mini-box strong { color: var(--text); } +.priority-strip { position: absolute; z-index: 5; top: 76px; left: 10px; display: grid; grid-template-columns: minmax(280px, 1.2fr) minmax(170px, .75fr) minmax(130px, .55fr); gap: 8px; max-width: calc(100% - 420px); pointer-events: none; } +.priority-strip > div { border: 3px solid var(--line); background: rgba(255,255,255,.94); padding: 8px 10px; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 rgba(16,32,21,.14); font-size: 12px; letter-spacing: .03em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.event-brief.active { background: #fff0d1; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.14); } +.event-brief.next { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); } +.event-brief.locked { background: #edf3ec; color: var(--muted); } +.target-brief { color: var(--green); } +.speed-brief { color: var(--ink); } -.control-dock { - position: absolute; - z-index: 7; - left: 50%; - bottom: 10px; - transform: translateX(-50%); - display: grid; - grid-template-columns: repeat(4, 150px); - gap: 8px; - padding: 6px; - border: 1px solid #343a40; - background: rgba(8, 10, 12, 0.92); -} -.sort-button { - border: 1px solid #4b535b; - min-height: 46px; - font-size: 12px; - color: #f0f1f2; - box-shadow: inset 0 0 0 1px rgba(255,255,255,0.025); -} -.sort-button.mixer { - background: #1b2025; -} -.sort-button.truck { - background: #2a2118; - border-color: #705a3c; -} - -.modal { - position: fixed; - inset: 0; - z-index: 20; - display: none; - align-items: center; - justify-content: center; - padding: 22px; - background: rgba(0, 0, 0, 0.72); -} +.build-panel { position: absolute; z-index: 6; top: 118px; right: 12px; bottom: 12px; width: clamp(278px, 23vw, 360px); min-height: 0; padding: 10px; border: 4px solid var(--line); background: var(--panel); box-shadow: var(--shadow); overflow: auto; } +.panel-head { display: grid; gap: 8px; margin-bottom: 10px; } +.panel-head h1 { font-size: 15px; letter-spacing: .06em; } +.panel-head h1 span { font-size: 9px; color: var(--white); background: var(--green); border: 2px solid var(--line); padding: 1px 6px; } +.panel-head p { color: var(--muted); margin-top: 4px; font-size: 9px; line-height: 1.25; } +.panel-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } +.build-panel h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; } +.large-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.history-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; grid-column: 1 / -1; } +.tool-button.small { min-height: 40px; padding: 6px; } +.tool-button, .primary-button, .sort-button, .facility-action { border: 3px solid var(--line); background: var(--white); color: var(--ink); font-weight: 900; text-transform: uppercase; cursor: pointer; box-shadow: 4px 4px 0 rgba(16,32,21,.18); } +.tool-button { min-height: 50px; text-align: left; padding: 8px; } +.tool-button strong { display: block; font-size: 12px; } +.tool-button span { display: block; margin-top: 4px; font-size: 9px; color: var(--muted); } +.tool-button.active { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.18); } +.tool-button.danger.active { background: #ffdada; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); } +.primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; } +.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; } +.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; } +.facility-card h3 { color: var(--ink); font-size: 12px; margin: 0 0 6px; } +.facility-card p { margin: 4px 0; } +.facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.facility-action { padding: 7px 9px; font-size: 9px; } +.facility-action.good { background: var(--green-soft); } +.facility-action.warn { background: #fff0d1; } +.facility-action.danger { background: #ffdada; } +.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); } +.sort-button { min-height: 46px; font-size: 12px; } +.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); } .modal.visible { display: flex; } -.modal-card { - width: min(720px, 96vw); - border: 1px solid #4b535b; - border-radius: 0; - padding: 22px; - background: #090b0d; - box-shadow: 0 34px 110px rgba(0, 0, 0, 0.78); -} -.modal-card h2 { - font-size: 24px; - margin-bottom: 12px; - letter-spacing: 0.06em; - text-transform: uppercase; -} -.modal-card p { color: var(--text); line-height: 1.55; } -.modal-card ul { - margin: 12px 0 0; - padding-left: 22px; - color: var(--muted); - line-height: 1.55; -} -.modal-card strong { color: var(--text); } -.modal-actions { - margin-top: 18px; - display: flex; - justify-content: flex-end; - gap: 10px; -} - -@media (max-width: 1100px) { +.modal-card { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); } +.modal-card h2 { font-size: 26px; text-transform: uppercase; margin-bottom: 12px; } +.modal-card p { line-height: 1.55; } +.modal-card ul { margin: 12px 0 0; padding-left: 22px; line-height: 1.55; color: var(--muted); } +.modal-actions { margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; } +.route-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 14px 0; } +.route-card { border: 3px solid var(--line); background: #f7fff5; padding: 14px; display: grid; grid-template-columns: 44px 1fr 28px 1fr; align-items: center; gap: 4px 8px; } +.route-card .route-icon { grid-row: 1 / span 2; font-size: 30px; text-align: center; } +.route-card strong, .route-card em { font-style: normal; font-weight: 900; text-transform: uppercase; } +.route-card b { font-size: 24px; color: var(--green); text-align: center; } +.route-card small { grid-column: 2 / span 3; color: var(--muted); font-weight: 900; } +.result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; } +.result-grid div { border: 3px solid var(--line); padding: 10px; background: #f7fff5; } +.result-grid strong { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 6px; } +.result-grid span { font-size: 16px; font-weight: 900; color: var(--ink); } +.game-shell:not(.phase-build) .build-panel { display: none; } +.game-shell.phase-running .hud-top-right { display: none; } +.game-shell:not(.phase-running) .control-dock { display: none; } +@media (max-width: 1180px) { #app { padding: 0; } .game-shell { min-height: 100vh; } - #gameCanvas { height: 100vh; min-height: 680px; } - .hud-top-left, - .hud-top-right { grid-template-columns: repeat(2, minmax(74px, auto)); } - .panel-grid { grid-template-columns: 1fr; } - .upgrade-list { grid-template-columns: 1fr; } - .upgrade-panel { max-height: 44vh; overflow: auto; } + #gameCanvas { height: 100vh; } + .hud-top-left, .hud-top-right { grid-template-columns: repeat(2, minmax(76px, auto)); } + .priority-strip { top: 150px; left: 8px; right: 8px; max-width: none; grid-template-columns: 1fr; } + .build-panel { top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 42vh; } + .large-tools { grid-template-columns: 1fr 1fr; } .control-dock { grid-template-columns: repeat(2, 150px); } + .route-cards, .result-grid { grid-template-columns: 1fr; } } -/* v6 visibility pass: play-first layering */ -.game-shell:not(.phase-build) .upgrade-panel { - display: none; -} -.game-shell.phase-running .hud-top-right { - display: none; -} -.game-shell:not(.phase-running) .control-dock { - display: none; -} -.game-shell.phase-build .upgrade-panel { - bottom: 10px; -} -.game-shell.phase-running .hud-card { - background: rgba(5, 7, 8, 0.84); - border-color: rgba(124, 133, 142, 0.42); -} +.contract-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); } +.contract-card.empty { color: var(--muted); background: rgba(255,255,255,.72); } +.contract-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; } +.contract-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; } +.contract-card p { margin: 5px 0; } +.contract-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); } +.contract-card.declined { background: #f1f1f1; color: var(--muted); } +.contract-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; } +.contract-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; } +.contract-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); } +.contract-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.result-section-title { margin: 16px 0 8px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .12em; color: var(--green); } +.result-section-title:first-child { margin-top: 0; } +.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; } + +.compact-rules .mini-box { padding: 8px; font-size: 9px; } +.primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; } .formula-box { - border: 1px solid #30363d; - background: #101418; + border: 3px solid var(--line); + background: #f7fff5; padding: 10px; margin: 10px 0; line-height: 1.5; - color: var(--text); + color: var(--ink); font-size: 12px; + font-weight: 700; } .equipment-menu-lines { - border: 1px solid #30363d; - background: #080a0c; + border: 3px solid var(--line); + background: #ffffff; padding: 10px; margin: 8px 0; color: var(--muted); @@ -381,12 +149,13 @@ h1, h2, p { margin: 0; } position: absolute; left: var(--popover-x, 16px); top: var(--popover-y, 16px); - width: min(320px, calc(100vw - 24px)); - max-height: min(360px, calc(100vh - 24px)); + width: min(250px, calc(100vw - 24px)); + max-height: min(280px, calc(100vh - 24px)); overflow: auto; - padding: 10px; + padding: 8px; + border-width: 3px; pointer-events: auto; - box-shadow: 6px 6px 0 rgba(0,0,0,.28); + box-shadow: 6px 6px 0 rgba(16,32,21,.18); } .modal.equipment-popover .modal-card h2 { font-size: 14px; @@ -398,16 +167,457 @@ h1, h2, p { margin: 0; } justify-content: flex-start; } .modal.equipment-popover .modal-actions button { - padding: 7px 9px; - font-size: 10px; + padding: 5px 7px; + font-size: 9px; } .equipment-menu-lines.compact { padding: 7px; margin: 6px 0; - font-size: 10px; + font-size: 9px; } .formula-box.compact { padding: 7px; margin: 6px 0; - font-size: 10px; + font-size: 9px; +} + +/* v15 pre-build card draft */ +.card-choices { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 220px)); + justify-content: center; + align-items: stretch; + gap: 10px; + margin-top: 14px; +} +.card-choice { + min-height: 132px; + border: 3px solid var(--line); + background: #f7fff5; + color: var(--ink); + box-shadow: 5px 5px 0 rgba(16,32,21,.16); + padding: 12px; + text-align: left; + cursor: pointer; + font-weight: 900; +} +.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); } +.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; } +.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; } +.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; } +.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); } +.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); } +.card-choice.ultra-rare span { background: #c42424; color: #fff; } +.muted-card-note { color: var(--muted); font-weight: 700; } +.card-targets { display: grid; gap: 8px; margin-top: 14px; } +.card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); } +.card-target-button:hover { background: var(--green-soft); } + +/* v19 readability pass */ +body { font-size: 16px; } +.hud-card span { font-size: 11px; } +.hud-card strong { font-size: clamp(18px, 1.5vw, 25px); } +.priority-strip > div { font-size: 14px; } +.panel-head h1 { font-size: 18px; } +.panel-head p { font-size: 11px; } +.build-panel h2 { font-size: 13px; } +.tool-button strong { font-size: 14px; } +.tool-button span { font-size: 11px; } +.mini-box, .facility-panel-empty, .facility-card, .contract-card { font-size: 12px; } +.facility-card h3 { font-size: 14px; } +.sort-button { font-size: 14px; } +.modal-card h2 { font-size: 29px; } +.card-choice strong { font-size: 16px; } +.card-choice small { font-size: 13px; } + +.tool-button.unaffordable, .tool-button.already-built { opacity: .42; filter: grayscale(.7); } + +.hover-tooltip { + position: fixed; + z-index: 30; + width: min(320px, calc(100vw - 20px)); + display: none; + border: 3px solid var(--line); + background: rgba(255,255,255,.96); + color: var(--ink); + box-shadow: 6px 6px 0 rgba(16,32,21,.18); + padding: 10px; + pointer-events: none; + line-height: 1.35; +} +.hover-tooltip.visible { display: block; } +.hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; } +.hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; } +.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; } + +.debug-panel { + position: absolute; + left: 10px; + bottom: 10px; + z-index: 12; + border: 3px solid var(--line); + background: rgba(255,255,255,.94); + box-shadow: 4px 4px 0 rgba(16,32,21,.16); + max-width: 330px; +} +.debug-panel summary { + cursor: pointer; + padding: 5px 8px; + font-weight: 900; + font-size: 12px; + user-select: none; +} +.debug-body { + display: grid; + gap: 7px; + padding: 8px; + border-top: 3px solid var(--line); + font-size: 12px; +} +.debug-body label { display: grid; gap: 3px; font-weight: 900; color: var(--muted); } +.debug-body input, .debug-body select, .debug-body button { + border: 2px solid var(--line); + background: var(--white); + color: var(--ink); + font: inherit; + font-weight: 900; + padding: 5px 6px; +} +.debug-body button { cursor: pointer; background: var(--green-soft); } + +.card-choice.dud { + background: #eeeeee; + color: #777; + filter: grayscale(1); + border-style: dashed; + box-shadow: 5px 5px 0 rgba(16,32,21,.10); +} +.card-choice.dud span { background: #f8f8f8; } +.card-choice.flung { + pointer-events: none; + transform: translate(var(--fling-x, 480px), var(--fling-y, -320px)) rotate(var(--fling-r, 68deg)) scale(.68); + opacity: 0; + transition: transform .34s cubic-bezier(.18,.84,.26,.99), opacity .28s ease-in; +} + +/* v20 Phase 1 observability */ +.debug-readout { + border: 2px solid var(--line); + background: rgba(247,255,245,.92); + padding: 6px; + display: grid; + gap: 3px; + line-height: 1.25; +} +.debug-readout strong { + display: block; + color: var(--ink); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .08em; +} +.debug-readout span { + display: block; + color: var(--muted); + font-size: 11px; + overflow-wrap: anywhere; +} +.debug-readout.event-log { + max-height: 160px; + overflow: auto; +} + + +.route-card .route-icon.male, +.route-card .route-icon.female, +.route-card .route-icon.poop { + display: inline-grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 999px; + border: 3px solid var(--line, #102015); + background: var(--white, #fff); + line-height: 1; +} +.route-card .route-icon.male { color: #2477ff; background: #d9ecff; } +.route-card .route-icon.female { color: #d62a85; background: #ffe0f0; } +.route-card .route-icon.poop { color: #7b4a23; background: #fff0d1; font-size: 24px; } +.restart-button { min-width: 160px; } +.restart-fallback-note { margin-top: 12px; color: var(--muted, #526456); font-weight: 700; } + +/* v24.4 hover-only build-button flavor / cashout guidance / compact game-over */ +.tool-button .tool-flavor { + display: block; + max-height: 0; + opacity: 0; + overflow: hidden; + margin-top: 0; + color: var(--muted); + font-size: 8px; + line-height: 1.22; + text-transform: none; + letter-spacing: 0; + transition: max-height .12s ease, opacity .12s ease, margin-top .12s ease; +} +.tool-button:hover .tool-flavor { + max-height: 42px; + opacity: 1; + margin-top: 5px; +} +.large-tools .tool-button { min-height: 58px; } +.large-tools .tool-button:hover { min-height: 78px; } +.history-tools .tool-button.small { min-height: 40px; } +.primary-button.cashout-emphasis, +.tool-button.cashout-emphasis { + opacity: 1 !important; + filter: none !important; + animation: cashoutPulse 0.9s ease-in-out infinite alternate; +} +.primary-button.cashout-emphasis { + box-shadow: inset 0 0 0 4px #fff0d1, 6px 6px 0 rgba(16,32,21,.22); +} +.tool-button.cashout-emphasis { + background: #fff0d1; + box-shadow: inset 0 0 0 3px var(--danger), 6px 6px 0 rgba(16,32,21,.20); +} +@keyframes cashoutPulse { + from { transform: translate(0, 0); } + to { transform: translate(-1px, -1px); } +} +.modal.gameover-modal { padding: 12px; } +.modal.gameover-modal .modal-card { + width: min(640px, 92vw); + max-height: min(82vh, 720px); + overflow: auto; + padding: 16px; +} +.modal.gameover-modal .modal-card h2 { + font-size: 22px; + margin-bottom: 8px; +} +.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; } +.gameover-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.gameover-breakdown { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.modal.gameover-modal .result-grid div { padding: 7px; } +.modal.gameover-modal .result-grid strong { font-size: 9px; margin-bottom: 3px; } +.modal.gameover-modal .result-grid span { font-size: 12px; } +.gameover-details { + margin-top: 10px; + border: 3px solid var(--line); + background: #f7fff5; + padding: 8px; +} +.gameover-details summary { + cursor: pointer; + font-weight: 900; + text-transform: uppercase; +} +.modal.gameover-modal .formula-box.compact { font-size: 9px; line-height: 1.35; } +@media (max-width: 720px) { + .gameover-summary, .gameover-breakdown { grid-template-columns: 1fr 1fr; } +} + +/* v26.0 readability and stable build-menu tooltip pass */ +body { font-size: 18px; } +.build-panel { width: clamp(330px, 26vw, 420px); } +.panel-head h1 { font-size: 20px; } +.panel-head h1 span { font-size: 12px; } +.panel-head p { font-size: 13px; line-height: 1.35; } +.build-panel h2 { font-size: 14px; } +.priority-strip > div { font-size: 15px; } +.hud-card span { font-size: 12px; } +.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); } +.mini-box, .contract-card, .facility-panel-empty, .facility-card { font-size: 14px; line-height: 1.45; } +.compact-rules .mini-box { font-size: 13px; } +.tool-button { + position: relative; + overflow: visible; + min-height: 62px; + padding: 10px; +} +.large-tools .tool-button { min-height: 62px; } +.large-tools .tool-button:hover { min-height: 62px; } +.tool-button strong { font-size: 15px; line-height: 1.15; } +.tool-button span { font-size: 12px; } +.tool-button .tool-flavor { + display: none; + position: absolute; + z-index: 60; + left: 0; + top: calc(100% + 7px); + width: min(300px, calc(100vw - 40px)); + max-height: none; + opacity: 1; + overflow: visible; + margin: 0; + padding: 10px 12px; + border: 3px solid var(--line); + background: rgba(255,255,255,.98); + color: var(--ink); + box-shadow: 5px 5px 0 rgba(16,32,21,.18); + font-size: 13px; + line-height: 1.35; + text-transform: none; + letter-spacing: 0; + pointer-events: none; + transition: none; +} +.tool-button .tool-flavor::before { + content: ''; + position: absolute; + left: 16px; + top: -10px; + width: 16px; + height: 16px; + border-left: 3px solid var(--line); + border-top: 3px solid var(--line); + background: rgba(255,255,255,.98); + transform: rotate(45deg); +} +.tool-button:hover .tool-flavor { + display: block; + max-height: none; + opacity: 1; + margin: 0; +} +.primary-button { font-size: 16px; padding: 13px 16px; } +#nextTurnButton { min-height: 56px; font-size: 18px; } +@keyframes cashoutPulse { + from { box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); filter: brightness(1); } + to { box-shadow: inset 0 0 0 5px #fff0d1, 4px 4px 0 rgba(16,32,21,.20); filter: brightness(1.08); } +} +.card-choices { grid-template-columns: repeat(auto-fill, minmax(230px, 270px)); gap: 14px; } +.card-choice { min-height: 164px; padding: 15px; } +.card-choice strong { font-size: 18px; } +.card-choice span { font-size: 11px; } +.card-choice small { font-size: 15px; line-height: 1.4; } +.muted-card-note { font-size: 15px; line-height: 1.45; } +.modal-card { width: min(980px, 96vw); } +.modal-card p, .modal-card li { font-size: 16px; } +.modal-actions .reroll-button { + order: 99; + flex: 1 1 100%; + min-height: 64px; + margin-top: 8px; + font-size: 20px; + text-align: center; + justify-content: center; + background: #fff0d1; + box-shadow: inset 0 0 0 4px var(--green), 6px 6px 0 rgba(16,32,21,.20); +} +.modal-actions .reroll-button:disabled { box-shadow: 4px 4px 0 rgba(16,32,21,.12); } +.hover-tooltip strong { font-size: 17px; } +.hover-tooltip span { font-size: 14px; } +.sort-button { font-size: 16px; min-height: 56px; } +.control-dock { grid-template-columns: repeat(4, 172px); } +@media (max-width: 1180px) { + .build-panel { width: auto; } + .control-dock { grid-template-columns: repeat(2, 172px); } +} + +/* v27 combo / tags / settlement clarity */ +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0 0 8px; +} +.card-tags i { + display: inline-block; + border: 2px solid var(--line); + background: rgba(255,255,255,.86); + color: var(--ink); + font-style: normal; + font-size: 10px; + font-weight: 900; + padding: 1px 6px; + letter-spacing: .05em; +} +.settlement-hero { + border: 4px solid var(--line); + background: #f7fff5; + padding: 14px; + margin-bottom: 12px; + box-shadow: 5px 5px 0 rgba(16,32,21,.14); +} +.settlement-hero.positive { box-shadow: inset 0 0 0 4px var(--green), 5px 5px 0 rgba(16,32,21,.14); } +.settlement-hero.negative { box-shadow: inset 0 0 0 4px var(--danger), 5px 5px 0 rgba(16,32,21,.14); } +.settlement-hero strong { display: block; font-size: 34px; line-height: 1; color: var(--green); } +.settlement-hero.negative strong { color: var(--danger); } +.settlement-hero span { display: block; margin-top: 8px; font-weight: 900; color: var(--muted); } +.settlement-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} +.settlement-grid div { + border: 3px solid var(--line); + background: rgba(255,255,255,.9); + padding: 9px; +} +.settlement-grid strong { display: block; font-size: 11px; text-transform: uppercase; color: var(--muted); } +.settlement-grid span { display: block; margin-top: 5px; font-size: 20px; font-weight: 900; color: var(--ink); } +.settlement-table { + width: 100%; + border-collapse: collapse; + border: 3px solid var(--line); + background: #fff; + margin: 10px 0; +} +.settlement-table td { + border-bottom: 2px solid rgba(16,32,21,.18); + padding: 7px 9px; + font-size: 15px; + font-weight: 900; +} +.settlement-table td:last-child { text-align: right; } +.settlement-table tr.income td:last-child { color: var(--green); } +.settlement-table tr.cost td:last-child { color: var(--danger); } +.settlement-notes { + display: grid; + gap: 5px; + border: 3px solid var(--line); + background: #f7fff5; + padding: 10px; + color: var(--muted); + font-weight: 800; +} +.key-config-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin: 10px 0; +} +.key-config-grid label { + display: grid; + gap: 4px; + font-weight: 900; + color: var(--muted); +} +.key-config-grid input, +.key-config-grid select { + border: 3px solid var(--line); + background: var(--white); + color: var(--ink); + padding: 7px 8px; + font: inherit; + font-weight: 900; +} +.modal.rescue-modal .modal-card { + width: min(560px, 92vw); +} +.hover-tooltip .cash-negative { color: var(--danger) !important; font-weight: 900; } +@media (max-width: 720px) { + .settlement-grid { grid-template-columns: 1fr 1fr; } + .settlement-hero strong { font-size: 26px; } } diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js index 5d45ce9..3d5804b 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -1,5 +1,4 @@ import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js'; -import { BALANCE } from '../core/balance.js'; import { getSpawnRange } from '../core/state.js'; import { createEggFarm, createScanner, createFacility } from '../core/entities.js'; import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js'; @@ -10,8 +9,7 @@ import { autoScannerCooldownSeconds } from './cards.js'; import { record } from './history.js'; import { floating, eraseEffect } from './effects.js'; import { createSelectionSystem } from './selectionSystem.js'; -import { emitGameEvent } from './gameEvents.js'; -import { ensureMaintenanceState, remainingPercent, degradationPercent, performanceFactor, autoScannerDelayMultiplier, eggSpawnDelayMultiplier, facilityProcessingDelay } from './maintenance.js'; +import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScannerDelayMultiplier, eggSpawnDelayMultiplier, facilityProcessingDelay, durabilityCapFor } from './maintenance.js'; export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) { function updatePanels() { if (onUpdatePanels) onUpdatePanels(); } @@ -62,6 +60,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel return equipmentBasePrice(hit); } + function usedMachineMode() { + return !!game.cardEffects?.usedMachineActive; + } + + function buildQualityPatch() { + return usedMachineMode() + ? { usedMachine: true, noRefund: true, durabilityBaseMultiplier: 0.6 } + : { usedMachine: false, noRefund: false, durabilityBaseMultiplier: 1 }; + } + + function applyBuildQuality(obj) { + const patch = buildQualityPatch(); + Object.assign(obj, patch); + return obj; + } + function lastProtectedSaleReason(hit) { const obj = hit?.ref || hit || {}; if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') { @@ -90,16 +104,16 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel if (game.conveyorTiles.has(key(col, row))) return fail(TEXT.fail.cellOccupied); if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.'); if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail(TEXT.fail.cellOccupied); - const cost = buildPrice('conveyor'); + const cost = buildPrice('conveyor', game); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); record(game); spendCash(game, cost); const k = key(col, row); game.conveyorTiles.add(k); - game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession, uses: 0, durability: BALANCE.maintenance.durability.conveyor, maintenanceType: 'conveyor' }); + const quality = buildQualityPatch(); + game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession, uses: 0, durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier), maintenanceType: 'conveyor', ...quality }); game.selected = { type: 'conveyor', id: k }; refreshRoutingAfterEdit(game); - emitGameEvent(game, 'BuildEquipment', { type: 'conveyor', cell: k, cost }); floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); return; } @@ -117,36 +131,36 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel } function buildFarm(col, row) { - const cost = buildPrice('eggFarm'); + const cost = buildPrice('eggFarm', game); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); record(game); spendCash(game, cost); - const farm = createEggFarm(game, col, row); + const farm = applyBuildQuality(createEggFarm(game, col, row)); + farm.price = cost; game.eggFarms.push(farm); refreshRoutingAfterEdit(game); game.selected = { type: 'eggFarm', id: farm.id }; - emitGameEvent(game, 'BuildEquipment', { type: 'eggFarm', id: farm.id, col, row, cost }); floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); } function buildScanner(col, row, kind) { - const cost = kind === 'auto' ? buildPrice('autoScanner') : buildPrice('manualScanner'); + const cost = kind === 'auto' ? buildPrice('autoScanner', game) : buildPrice('manualScanner', game); if (game.cash < cost) return fail(TEXT.fail.notEnoughCash); record(game); spendCash(game, cost); - const scanner = createScanner(game, col, row, kind); + const scanner = applyBuildQuality(createScanner(game, col, row, kind)); + scanner.price = cost; game.scanners.push(scanner); refreshRoutingAfterEdit(game); game.selected = { type: 'scanner', id: scanner.id }; - emitGameEvent(game, 'BuildEquipment', { type: kind + 'Scanner', id: scanner.id, col, row, cost }); floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink); } function buildFacility(p, id) { - const cost = buildPrice(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 = createFacility(game, id, p, cost); + const f = applyBuildQuality(createFacility(game, id, p, cost)); 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); @@ -154,7 +168,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel game.facilities[id] = f; refreshRoutingAfterEdit(game); game.selected = { type: 'facility', id }; - emitGameEvent(game, 'BuildEquipment', { type: id, cost }); floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink); } @@ -172,7 +185,6 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel const saleReason = lastProtectedSaleReason(hit); if (saleReason) return fail(saleReason); record(game); - emitGameEvent(game, 'SellEquipment', { type: hit.type, id: hit.oldKey || hit.ref?.id || hit.ref?.type || 'unknown', value: resaleValueFor(hit, game).amount }); if (hit.type === 'conveyor') { const c = parseKey(hit.oldKey); const center = cellCenter(c.col, c.row); @@ -338,7 +350,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel } 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'), x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); } + 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 }); } return boxes; } @@ -366,7 +378,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel const meta = game.conveyorMeta.get(obj.id); const comp = game.componentLookup?.get(obj.id); const c = comp ? game.congestion.get(comp) : null; - lines.push(`Price: ${yen(buildPrice('conveyor'))}`); + lines.push(`Price: ${yen(equipmentPrice({ type: 'conveyor', oldKey: obj.id, ref: obj }))}`); lines.push(`Cell: ${obj.id}`); lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`); lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`); @@ -391,7 +403,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel const chance = shredderBonusChance(cards); lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`); lines.push(`Bonus chance: ${(chance * 100).toFixed(2)}% for ${yen(1)} per processed item.`); - lines.push(`Expected value: ${chance.toFixed(3)}円 per item.`); + lines.push(`Expected value: ${chance.toFixed(3)} JPY per item.`); } if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`); } diff --git a/src/systems/cards.js b/src/systems/cards.js index f172c16..e897fb3 100644 --- a/src/systems/cards.js +++ b/src/systems/cards.js @@ -1,15 +1,34 @@ -import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, GRID, THEME } from '../core/config.js'; +import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js'; import { nextSpawnDelay } from '../core/state.js'; -import { cellCenter, yen } from '../core/utils.js'; -import { applyPenalty, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js'; -import { floating } from './effects.js'; -import { averageConveyorPerformance, autoScannerDelayMultiplier } from './maintenance.js'; -import { scannerCenter } from './routing.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 { scannerCenter, refreshRoutingAfterEdit } from './routing.js'; const COMMON_WEIGHT = CARD_BALANCE.commonWeight; const RARE_WEIGHT = CARD_BALANCE.rareWeight; +const ULTRA_RARE_WEIGHT = CARD_BALANCE.ultraRareWeight || 0.45; const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize; +const DEFAULT_EFFECTS = Object.freeze({ + bearing: 0, + legalWork: 0, + fairiesFlatteryNext: 0, + crowdedFarming: 0, + extraEggOutlet: 0, + hatchingFeed: 0, + safetyCover: 0, + recyclingSubsidy: 0, + chemicalWeaponSubsidy: 0, + preventiveMaintenance: 0, + durabilityCoating: 0, + laborExploitation: 0, + usedMachineActive: false, + rescueLoanCharges: 0, + loans: [] +}); + export const CARD_DEFS = [ { id: 'upgradeEgg', @@ -49,7 +68,42 @@ export const CARD_DEFS = [ rarity: 'common', type: 'equipmentUpgrade', target: 'trash', - description: 'Choose SHREDDER on the map and raise it by 1 level. Per processed item: ((√cards / 2) × cards)% chance to pay ¥1. Max 30 cards.' + description: 'Choose SHREDDER and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.' + }, + { + id: 'crowdedFarming', + title: 'Crowded Farming Contract', + rarity: 'common', + type: 'instant', + description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.' + }, + { + id: 'hatchingFeed', + title: 'Hatching Feed', + rarity: 'common', + type: 'instant', + description: 'EGG production interval -4%. Poop rate +3%. No card limit.' + }, + { + id: 'safetyCover', + title: 'Safety Cover', + rarity: 'common', + type: 'instant', + description: 'Explosion damage -5%. No card limit.' + }, + { + id: 'preventiveMaintenance', + title: 'Preventive Maintenance Manual', + rarity: 'common', + type: 'instant', + description: 'All equipment deterioration speed -5%. No card limit.' + }, + { + id: 'durabilityCoating', + title: 'Durability Coating', + rarity: 'common', + type: 'instant', + description: 'All equipment maximum durability +3%. No card limit.' }, { id: 'bearing', @@ -58,6 +112,56 @@ export const CARD_DEFS = [ type: 'instant', description: 'Conveyor speed +7.5%.' }, + { + id: 'extraEggOutlet', + title: 'Extra Egg Outlet', + rarity: 'rare', + type: 'instant', + description: 'EGG output ports +1. Each connected belt can receive eggs. Max 3 cards.' + }, + { + id: 'recyclingSubsidy', + title: 'Recycling Subsidy', + rarity: 'rare', + type: 'instant', + description: 'SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.' + }, + { + id: 'dynamite', + title: 'Dynamite', + rarity: 'rare', + type: 'cellAction', + target: 'blockedCell', + description: 'Remove any 3 blocked cells with a burst effect.' + }, + { + id: 'usedMachine', + title: 'Used Machines', + rarity: 'rare', + type: 'instant', + description: 'Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.' + }, + { + id: 'newMachine', + title: 'New Machines', + rarity: 'common', + type: 'instant', + description: 'Cancel Used Machines and restore normal prices, refunds, and durability.' + }, + { + id: 'flattery', + title: 'Flattery', + rarity: 'rare', + type: 'instant', + description: 'Next Fairies tribute -50%.' + }, + { + id: 'loan', + title: 'Loan', + rarity: 'rare', + type: 'instant', + description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.' + }, { id: 'extraCards', title: 'Extra Cards', @@ -73,22 +177,63 @@ export const CARD_DEFS = [ description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.' }, { - id: 'flattery', - title: 'Flattery', - rarity: 'rare', + id: 'chemicalWeaponSubsidy', + title: 'Chemical Weapons Subsidy', + rarity: 'ultraRare', type: 'instant', - description: 'Fairies tribute reduction card.' + description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.' + }, + { + id: 'laborExploitation', + title: 'Motivational Exploitation', + rarity: 'ultraRare', + type: 'instant', + description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.' + }, + { + id: 'rescueLoan', + title: 'Emergency Loan', + rarity: 'ultraRare', + type: 'instant', + description: 'If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.' } ]; +function effectCount(game, id) { + const effects = ensureCardState(game); + return Math.max(0, Number(effects[id]) || 0); +} + export function ensureCardState(game) { - if (!game.cardEffects) game.cardEffects = { bearing: 0, legalWork: 0, flattery: 0 }; + if (!game.cardEffects) game.cardEffects = { ...DEFAULT_EFFECTS, loans: [] }; + for (const [k, v] of Object.entries(DEFAULT_EFFECTS)) { + if (game.cardEffects[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v; + } + if (game.cardEffects.flattery != null) { + game.cardEffects.fairiesFlatteryNext = Math.max(0, game.cardEffects.fairiesFlatteryNext || 0) + Math.max(0, game.cardEffects.flattery || 0); + delete game.cardEffects.flattery; + } + if (!Array.isArray(game.cardEffects.loans)) game.cardEffects.loans = []; 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; return game.cardEffects; } +export function eggProductionDelayMultiplier(game) { + const crowded = Math.max(0.5, 1 - 0.05 * Math.min(10, effectCount(game, 'crowdedFarming'))); + const feed = Math.pow(0.96, effectCount(game, 'hatchingFeed')); + return crowded * feed; +} + +export function poopRateCardMultiplier(game) { + return Math.pow(1.03, effectCount(game, 'hatchingFeed')); +} + +export function extraEggOutletCount(game) { + return Math.min(3, effectCount(game, 'extraEggOutlet')); +} + export function conveyorSpeedForGame(game, conveyorKey = null) { const effects = ensureCardState(game); const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0)); @@ -103,10 +248,10 @@ export function autoScannerCooldownSeconds(scanner, game = null) { } export function flatteryReductionForTurn(game, turn = game.turn) { - const effects = ensureCardState(game); const day = Math.max(1, Math.floor(Number(turn) || 1)); - const perCard = Math.floor(10 * day / 10); - return Math.max(0, perCard * Math.max(0, effects.flattery || 0)); + const base = Math.ceil(day * 10); + const charges = Math.max(0, ensureCardState(game).fairiesFlatteryNext || 0); + return charges > 0 ? Math.ceil(base * (1 - Math.pow(0.5, charges))) : 0; } export function rerollCost(game) { @@ -120,6 +265,7 @@ export function cardById(id) { } function targetLabel(game, target) { + if (target.type === 'blockedCell') return `Blocked cell ${target.col},${target.row}`; if (target.type === 'eggFarm') { return `EGG #${target.id} L${target.level} -> L${target.level + 1}`; } @@ -152,10 +298,18 @@ function targetLabel(game, target) { return `${target.name || target.id} L${target.level || 1} -> L${(target.level || 1) + 1}`; } +function blockedCellTargets(game) { + return [...(game.blockedCells || [])].map(k => { + const [col, row] = String(k).split(',').map(Number); + return { type: 'blockedCell', id: k, col, row }; + }).filter(t => Number.isFinite(t.col) && Number.isFinite(t.row)); +} + export function targetsForCard(game, cardOrId) { const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId; ensureCardState(game); if (!card) return []; + if (card.target === 'blockedCell') return blockedCellTargets(game); if (card.target === 'eggFarm') return game.eggFarms.filter(f => (f.level || 1) < 4); if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto'); if (card.target === 'mixer') return game.facilities.mixer ? [game.facilities.mixer] : []; @@ -164,15 +318,36 @@ export function targetsForCard(game, cardOrId) { return []; } +function cardAtCap(game, card) { + const effects = ensureCardState(game); + if (card.id === 'crowdedFarming') return (effects.crowdedFarming || 0) >= 10; + if (card.id === 'extraEggOutlet') return (effects.extraEggOutlet || 0) >= 3; + if (card.id === 'recyclingSubsidy') return (effects.recyclingSubsidy || 0) >= 3; + if (card.id === 'laborExploitation') return !!effects.laborExploitation; + if (card.id === 'usedMachine') return !!effects.usedMachineActive; + if (card.id === 'newMachine') return !effects.usedMachineActive; + return false; +} + function availableCards(game) { - return CARD_DEFS.filter(card => card.type !== 'equipmentUpgrade' || targetsForCard(game, card).length > 0); + return CARD_DEFS.filter(card => { + if (cardAtCap(game, card)) return false; + if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false; + return true; + }); +} + +function rarityWeight(card) { + 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 + (card.rarity === 'rare' ? RARE_WEIGHT : COMMON_WEIGHT), 0); + const total = pool.reduce((sum, card) => sum + rarityWeight(card), 0); let roll = Math.random() * total; for (const card of pool) { - roll -= card.rarity === 'rare' ? RARE_WEIGHT : COMMON_WEIGHT; + roll -= rarityWeight(card); if (roll <= 0) return card; } return pool[pool.length - 1]; @@ -183,7 +358,7 @@ function dudCard() { dudSerial += 1; return { id: `dud:${Date.now()}:${dudSerial}`, - title: 'スカ', + title: 'Dud', rarity: 'dud', type: 'dud', description: 'Click to flick this dud away. It does not consume a pick.' @@ -222,6 +397,7 @@ export function dealCards(game, count = BASE_DRAFT_SIZE) { function targetKey(target) { if (!target) return ''; + if (target.type === 'blockedCell') return `blockedCell:${target.id}`; if (target.type === 'eggFarm') return `eggFarm:${target.id}`; if (target.type === 'scanner') return `scanner:${target.id}`; if (target.type === 'facility') return `facility:${target.id}`; @@ -230,6 +406,10 @@ function targetKey(target) { function boundsForTarget(target) { if (!target) return null; + if (target.type === 'blockedCell') { + const c = cellCenter(target.col, target.row); + return { x: c.x - GRID.cell * 0.48, y: c.y - GRID.cell * 0.48, w: GRID.cell * 0.96, h: GRID.cell * 0.96, cx: c.x, cy: c.y }; + } if (target.type === 'eggFarm') { const c = cellCenter(target.col, target.row); return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y }; @@ -283,15 +463,112 @@ function applyEquipmentUpgrade(game, target) { function applyInstantCard(game, card) { const effects = ensureCardState(game); - if (card.id === 'bearing') effects.bearing += 1; - if (card.id === 'legalWork') effects.legalWork += 1; - if (card.id === 'flattery') effects.flattery += 1; + const inc = id => { effects[id] = Math.max(0, effects[id] || 0) + 1; }; + if (card.id === 'bearing') inc('bearing'); + if (card.id === 'legalWork') inc('legalWork'); + if (card.id === 'crowdedFarming') effects.crowdedFarming = Math.min(10, (effects.crowdedFarming || 0) + 1); + if (card.id === 'extraEggOutlet') effects.extraEggOutlet = Math.min(3, (effects.extraEggOutlet || 0) + 1); + if (card.id === 'hatchingFeed') inc('hatchingFeed'); + if (card.id === 'safetyCover') inc('safetyCover'); + if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1); + if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy'); + if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance'); + if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); } + if (card.id === 'flattery') inc('fairiesFlatteryNext'); + if (card.id === 'usedMachine') effects.usedMachineActive = true; + if (card.id === 'newMachine') effects.usedMachineActive = false; + if (card.id === 'laborExploitation') effects.laborExploitation = 1; + if (card.id === 'rescueLoan') { + inc('rescueLoanCharges'); + if (game.cash < 0) { + effects.rescueLoanCharges = Math.max(0, (effects.rescueLoanCharges || 0) - 1); + applyRevenue(game, 500); + floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, '+¥500 RESCUE', THEME.green); + } else { + floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green); + } + } + if (card.id === 'loan') { + const day = Math.max(1, Math.floor(Number(game.turn) || 1)); + const amount = 300 * day; + applyRevenue(game, amount); + effects.loans.push({ daysRemaining: day, dailyPayment: 400, borrowedOnTurn: game.turn || 1 }); + floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, `LOAN +${yen(amount)}`, THEME.green); + } +} + +function formatMultiplier(value) { + const rounded = Math.round(Number(value || 0) * 100) / 100; + return `x${rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')}`; +} + +function formatPercent(value) { + return `${Math.round(Number(value || 0) * 100)}%`; +} + +function eggRangeLabel(level) { + const range = EGG_SPAWN_RANGES[Math.max(0, Math.min(EGG_SPAWN_RANGES.length - 1, level - 1))] || EGG_SPAWN_RANGES[0] || [0, 0]; + return `${range[0].toFixed(2)}-${range[1].toFixed(2)}s`; +} + +function incomeAtLevel(base, level) { + return Math.ceil(base * Math.pow(ECONOMY.incomeUpgradeRate, Math.max(0, level - 1))); } function cardDescription(game, card) { - if (card.id === 'flattery') { - const reduction = flatteryReductionForTurn({ ...game, cardEffects: { ...ensureCardState(game), flattery: 1 } }, game.turn); - return `Fairies tribute reduction: ${yen(reduction)}`; + const e = ensureCardState(game); + if (card.id === 'upgradeEgg') { + const targets = targetsForCard(game, card); + const level = Math.min(3, Math.max(1, targets[0]?.level || 1)); + return `Choose one EGG. Level +1. Production interval ${eggRangeLabel(level)} -> ${eggRangeLabel(level + 1)}.`; + } + if (card.id === 'upgradeAutoScanner') { + const scanner = targetsForCard(game, card)[0] || { level: 1 }; + const before = autoScannerCooldownSeconds(scanner, game); + const after = autoScannerCooldownSeconds({ ...scanner, level: (scanner.level || 1) + 1 }, game); + return `Choose one AUTO SCANNER. Level +1. Cooldown ${before.toFixed(2)}s -> ${after.toFixed(2)}s.`; + } + if (card.id === 'upgradeMixer') { + const level = game.facilities?.mixer?.level || 1; + return `MIXER level +1. Male-chick income ${yen(incomeAtLevel(ECONOMY.income.mixer, level))} -> ${yen(incomeAtLevel(ECONOMY.income.mixer, level + 1))}.`; + } + if (card.id === 'upgradeTruck') { + const level = game.facilities?.truck?.level || 1; + return `TRUCK level +1. Correct shipment income ${yen(incomeAtLevel(ECONOMY.income.truck, level))} -> ${yen(incomeAtLevel(ECONOMY.income.truck, level + 1))}.`; + } + if (card.id === 'upgradeTrash') { + const before = shredderUpgradeCount(game); + const after = Math.min(shredderBonusMaxCards(), before + 1); + return `SHREDDER level +1. Bonus chance per item ${formatPercent(shredderBonusChance(before))} -> ${formatPercent(shredderBonusChance(after))}.`; + } + if (card.id === 'bearing') { + const before = Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, e.bearing || 0))); + const after = Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, (e.bearing || 0) + 1))); + return `Conveyor speed +7.5%. After pick: ${before.toFixed(1)} -> ${after.toFixed(1)} px/s.`; + } + if (card.id === 'legalWork') { + const before = Math.max(0, e.legalWork || 0); + return `ZUNDA TAX exemption +${yen(250)}. After pick: exemption bonus ${yen(250 * (before + 1))}.`; + } + if (card.id === 'crowdedFarming') { + const next = Math.min(10, (e.crowdedFarming || 0) + 1); + return `Held ${e.crowdedFarming || 0}/10. After pick: EGG interval ${formatPercent(Math.max(0.5, 1 - 0.05 * next))}, deterioration ${formatPercent(Math.min(1.4, 1 + 0.04 * next))}.`; + } + if (card.id === 'extraEggOutlet') return `Held ${e.extraEggOutlet || 0}/3. After pick: up to ${1 + Math.min(3, (e.extraEggOutlet || 0) + 1)} EGG output ports.`; + if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`; + if (card.id === 'safetyCover') return `Held ${e.safetyCover || 0}. After pick: explosion damage ${formatPercent(Math.pow(0.95, (e.safetyCover || 0) + 1))}.`; + if (card.id === 'recyclingSubsidy') return `Held ${e.recyclingSubsidy || 0}/3. After pick: normal SELL refund ${Math.round(Math.min(0.8, 0.5 + 0.1 * ((e.recyclingSubsidy || 0) + 1)) * 100)}%.`; + if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(2000 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 100 truck-shipped poop each day.`; + if (card.id === 'preventiveMaintenance') return `Held ${e.preventiveMaintenance || 0}. After pick: deterioration speed ${formatPercent(Math.pow(0.95, (e.preventiveMaintenance || 0) + 1))}.`; + if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`; + if (card.id === 'flattery') return `Next Fairies tribute -50%. Held for next tribute: ${e.fairiesFlatteryNext || 0}.`; + if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.'; + if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.'; + if (card.id === 'laborExploitation') return 'Repairman labor cost -30% permanently. One time only.'; + if (card.id === 'rescueLoan') return `At day-end bankruptcy check, gain ${yen(500)} if cash is below ${yen(0)}. Held: ${e.rescueLoanCharges || 0}.`; + if (card.id === 'loan') { + const day = Math.max(1, Math.floor(Number(game.turn) || 1)); + return `Gain ${yen(300 * day)} now. Starting tomorrow, pay ${yen(400)} per day for ${day} days.`; } return card.description; } @@ -348,18 +625,34 @@ export function createCardSystem({ game, ui, onUpdatePanels }) { } function chooseDud(card, buttonEl) { - if (buttonEl) buttonEl.classList.add('flung'); + shake(game, 34, 0.62); + if (document.body?.animate) { + document.body.animate([ + { transform: 'translate(0, 0)' }, + { transform: 'translate(-10px, 7px)' }, + { transform: 'translate(12px, -8px)' }, + { transform: 'translate(-8px, -5px)' }, + { transform: 'translate(9px, 6px)' }, + { transform: 'translate(0, 0)' } + ], { duration: 260, easing: 'steps(5, end)' }); + } + if (buttonEl) { + buttonEl.style.setProperty('--fling-x', `${260 + Math.random() * 260}px`); + buttonEl.style.setProperty('--fling-y', `${-260 - Math.random() * 170}px`); + buttonEl.style.setProperty('--fling-r', `${(Math.random() < 0.5 ? -1 : 1) * (52 + Math.random() * 38)}deg`); + buttonEl.classList.add('flung'); + } window.setTimeout(() => { removeOneChoice(game, card); refillChoicesIfNeeded(); showDraft(); - }, 190); + }, 340); } function chooseCard(card, buttonEl = null) { if (card.type === 'dud') return chooseDud(card, buttonEl); if (card.id === 'extraCards') return chooseExtraCards(card); - if (card.type === 'equipmentUpgrade') return startMapTargetPicker(card); + if (card.type === 'equipmentUpgrade' || card.type === 'cellAction') return startMapTargetPicker(card); applyInstantCard(game, card); completePickedCard(card); } @@ -370,18 +663,45 @@ export function createCardSystem({ game, ui, onUpdatePanels }) { game.cardTargetPick = { pending: true, cardId: card.id, - targetKeys: targets.map(targetKey) + mode: card.target === 'blockedCell' ? 'blockedCell' : 'equipment', + targetKeys: targets.map(targetKey), + remaining: card.id === 'dynamite' ? Math.min(3, targets.length) : 1 }; ui.modal.classList.remove('visible', 'equipment-popover'); updatePanels(); } + function applyDynamiteTarget(target) { + if (!target || target.type !== 'blockedCell') return false; + const k = key(target.col, target.row); + if (!game.blockedCells?.has(k)) return false; + game.blockedCells.delete(k); + const c = cellCenter(target.col, target.row); + eraseEffect(game, c.x, c.y); + shockwave(game, c.x, c.y, THEME.danger, 62); + sparkBurst(game, c.x, c.y, 18, THEME.warn); + smokeBurst(game, c.x, c.y, 8); + shake(game, 16, 0.35); + floating(game, c.x, c.y - 18, 'BOOM', THEME.danger); + refreshRoutingAfterEdit(game); + game.cardTargetPick.remaining = Math.max(0, (game.cardTargetPick.remaining || 1) - 1); + game.cardTargetPick.targetKeys = targetsForCard(game, 'dynamite').map(targetKey); + updatePanels(); + return true; + } + function chooseTargetAtPoint(p) { if (!game.cardTargetPick?.pending) return false; const card = cardById(game.cardTargetPick.cardId); const target = targetAtPoint(game, p); if (!card || !target) { - floating(game, p.x, p.y - 18, 'SELECT WHITE TARGET', THEME.danger); + const label = game.cardTargetPick.mode === 'blockedCell' ? 'SELECT BLOCKED CELL' : 'SELECT UPGRADE TARGET'; + floating(game, p.x, p.y - 18, label, THEME.danger); + return true; + } + if (card.id === 'dynamite') { + if (!applyDynamiteTarget(target)) return true; + if ((game.cardTargetPick.remaining || 0) <= 0 || !targetsForCard(game, 'dynamite').length) completePickedCard(card); return true; } applyEquipmentUpgrade(game, target); @@ -409,9 +729,9 @@ export function createCardSystem({ game, ui, onUpdatePanels }) { function cardButton(card, index) { const b = document.createElement('button'); b.type = 'button'; - const kind = card.type === 'dud' ? 'dud' : (card.rarity === 'rare' ? 'rare' : 'common'); + const kind = card.type === 'dud' ? 'dud' : (card.rarity === 'ultraRare' ? 'ultra-rare' : (card.rarity === 'rare' ? 'rare' : 'common')); b.className = `card-choice ${kind}`; - const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'rare' ? 'RARE' : 'COMMON'); + const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'ultraRare' ? 'ULTRA RARE' : (card.rarity === 'rare' ? 'RARE' : 'COMMON')); b.innerHTML = `${card.title}${rarity}${cardDescription(game, card)}`; b.addEventListener('click', () => chooseCard(card, b)); b.dataset.index = String(index); @@ -427,13 +747,13 @@ export function createCardSystem({ game, ui, onUpdatePanels }) { const cost = rerollCost(game); const remaining = Math.max(1, game.cardDraft.picksRemaining || 1); ui.modalTitle.textContent = 'Choose Upgrade Card'; - ui.modalBody.innerHTML = `

Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Each shown card has a 30% dud chance, capped at 2 duds per draft. Click dud cards to flick them away. Equipment cards are applied by selecting a white-highlighted machine on the map.

`; + ui.modalBody.innerHTML = `

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.

`; const wrap = ui.modalBody.querySelector('#cardChoices'); choices.forEach((card, index) => wrap.appendChild(cardButton(card, index))); ui.modalActions.innerHTML = ''; const reroll = document.createElement('button'); reroll.type = 'button'; - reroll.className = 'facility-action warn'; + 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}`; diff --git a/src/systems/chickSystem.js b/src/systems/chickSystem.js index 10adc90..5849327 100644 --- a/src/systems/chickSystem.js +++ b/src/systems/chickSystem.js @@ -4,39 +4,69 @@ import { nextSpawnDelay } from '../core/state.js'; import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js'; import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js'; import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus } from './economy.js'; -import { autoScannerCooldownSeconds } from './cards.js'; +import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.js'; import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js'; import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js'; import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js'; -import { emitGameEvent } from './gameEvents.js'; import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js'; export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) { function currentSpawnDelay(farm) { - return nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) / productionMultiplier(game); + return nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) * eggProductionDelayMultiplier(game) / productionMultiplier(game); } function spawnChick(farm) { - const data = routeFromFarmToScanner(game, farm, true); const farmCenter = cellCenter(farm.col, farm.row); - if (!data) { - floating(game, farmCenter.x, farmCenter.y - 18, 'NO BELT', THEME.danger); - return false; + const maxOutputs = 1 + extraEggOutletCount(game); + const routes = []; + const usedStarts = new Set(); + const maxAttempts = Math.max(maxOutputs * 5, 5); + for (let attempt = 0; attempt < maxAttempts && routes.length < maxOutputs; attempt += 1) { + const data = routeFromFarmToScanner(game, farm, true); + if (!data) break; + const start = data.route?.[0]; + if (!start) continue; + const startKey = `${Math.round(start.x)}:${Math.round(start.y)}`; + if (usedStarts.has(startKey) && routes.length > 0) continue; + usedStarts.add(startKey); + routes.push(data); } - const start = data.route[0]; - const blocker = spawnTileBlocker(start); - if (blocker) { - blocker.stoppedTimer = 0.85; - return false; + if (!routes.length) { + suppressUnconnectedFarmSpawn(farm, farmCenter); + return true; } - const chick = createChick(game, data); - if (chick.sex === 'poop') countPoopSpawned(game); - game.chicks.push(chick); - recordFarmProduction(game, farm); - spawnPulse(game, farmCenter.x, farmCenter.y); - return true; + let spawned = 0; + let blocked = 0; + for (const data of routes) { + const start = data.route[0]; + const blocker = spawnTileBlocker(start); + if (blocker) { + blocker.stoppedTimer = 0.85; + blocked += 1; + continue; + } + const chick = createChick(game, data); + if (chick.sex === 'poop') countPoopSpawned(game); + game.chicks.push(chick); + recordFarmProduction(game, farm); + spawned += 1; + } + if (spawned > 0) { + spawnPulse(game, farmCenter.x, farmCenter.y); + if (spawned > 1) floating(game, farmCenter.x, farmCenter.y - 28, `x${spawned}`, THEME.green); + return true; + } + return blocked === 0; } + function suppressUnconnectedFarmSpawn(farm, farmCenter) { + if ((farm.noRouteNoticeCooldown || 0) <= 0) { + floating(game, farmCenter.x, farmCenter.y - 24, 'NO BELT: PAUSED', THEME.warn); + farm.noRouteNoticeCooldown = 2.5; + } + } + + function spawnTileBlocker(start) { return game.chicks.find(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null; } @@ -59,6 +89,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck } if (producing && game.timeLeft > 0) { for (const farm of game.eggFarms) { + if (farm.noRouteNoticeCooldown > 0) farm.noRouteNoticeCooldown = Math.max(0, farm.noRouteNoticeCooldown - dt); farm.nextSpawn -= dt; if (farm.nextSpawn <= 0) { const spawned = spawnChick(farm); @@ -81,7 +112,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck if (game.chicks.length === 0) completeTurn(); else if (game.shutdownTimeLeft <= 0) blowOffRemainingForCleanup(); } - onGameOverCheck(); } function blowOffRemainingForCleanup() { @@ -94,7 +124,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck for (const scanner of game.scanners) scanner.queue = []; updateCongestion(); if (victims.length) { - emitGameEvent(game, 'CleanupBlowoff', { count: victims.length }); const cx = victims.reduce((sum, chick) => sum + chick.x, 0) / victims.length; const cy = victims.reduce((sum, chick) => sum + chick.y, 0) / victims.length; shockwave(game, cx, cy, THEME.warn, 58); @@ -229,6 +258,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck function updateScannerQueues(dt) { for (const scanner of game.scanners) { if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt); + if (scanner.keyPressTime > 0) scanner.keyPressTime = Math.max(0, scanner.keyPressTime - dt); positionScannerQueue(scanner); if (scanner.kind !== 'auto' || scanner.cooldown > 0 || !scanner.queue.length) continue; const id = scanner.queue[0]; @@ -254,7 +284,10 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck function sortSlot(slot, side) { if (game.phase !== 'running') return; const scanner = scannerBySlot(game, slot); - if (!scanner || !scanner.queue.length) return; + if (!scanner) return; + scanner.keyPressTime = 0.18; + scanner.keyPressSide = side; + if (!scanner.queue.length) return; const index = game.chicks.findIndex(c => c.id === scanner.queue[0]); if (index >= 0) sortChickByIndex(index, side, false); } @@ -307,7 +340,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck countMistake(game); sludgeEffect(game, x, y); floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); - onGameOverCheck(); return; } const amount = applyMixerIncome(game); @@ -315,7 +347,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck else countMistake(game); meatEffect(game, x, y); floating(game, x, y - 18, `+${yen(amount)}`, THEME.green); - onGameOverCheck(); } function resolveTruck(index) { @@ -333,7 +364,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck if (isTarget) countCorrect(game); else countMistake(game); floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); - onGameOverCheck(); return; } if (chick.sex === 'female') { @@ -355,7 +385,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck const penalty = applyWrongTruckFine(game); countMistake(game); floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger); - onGameOverCheck(); } else { countMistake(game); floating(game, x, y - 18, 'REJECT', THEME.warn); @@ -471,7 +500,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck const first = parseKey(comp.cells[0]); const fc = cellCenter(first.col, first.row); floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger); - emitGameEvent(game, 'JamExplosion', { component: comp.id, victims: victims.length }); } function explodeRoute(route, extras, label) { diff --git a/src/systems/contracts.js b/src/systems/contracts.js index 570eb4f..8f208d4 100644 --- a/src/systems/contracts.js +++ b/src/systems/contracts.js @@ -88,7 +88,9 @@ export function productionMultiplier(game) { } export function currentPoopRate(game) { - return Math.min(0.9, POOP_RATE * (game.contractActive?.poopMultiplier || 1)); + const feedCards = Math.max(0, Number(game?.cardEffects?.hatchingFeed) || 0); + const feedMultiplier = Math.pow(1.03, feedCards); + return Math.min(0.95, POOP_RATE * (game.contractActive?.poopMultiplier || 1) * feedMultiplier); } export function truckTarget(game) { diff --git a/src/systems/economy.js b/src/systems/economy.js index 44fc95b..d79f639 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -1,6 +1,22 @@ import { ECONOMY, FACILITY_DEFS, INCOME_FACILITY_IDS } from '../core/config.js'; import { factoryValue, targetTruckCount, truckTarget } from './contracts.js'; -import { emitGameEvent } from './gameEvents.js'; + +function effectCount(game, id) { + return Math.max(0, Number(game?.cardEffects?.[id]) || 0); +} + +function usedMachineActive(game) { + return !!game?.cardEffects?.usedMachineActive; +} + +function resaleRate(game, sameBuild) { + if (sameBuild) return 1; + return Math.min(0.8, 0.5 + 0.1 * Math.min(3, effectCount(game, 'recyclingSubsidy'))); +} + +function explosionDamageMultiplier(game) { + return Math.pow(0.95, effectCount(game, 'safetyCover')); +} export function equipmentBasePrice(objOrHit) { const obj = objOrHit?.ref || objOrHit || {}; @@ -12,8 +28,9 @@ export function equipmentBasePrice(objOrHit) { return FACILITY_DEFS[obj.id]?.price || 0; } -export function buildPrice(id) { - return FACILITY_DEFS[id]?.price || 0; +export function buildPrice(id, game = null) { + const base = FACILITY_DEFS[id]?.price || 0; + return usedMachineActive(game) ? Math.ceil(base * 0.5) : base; } export function facilityUpgradeCount(game, id) { @@ -107,11 +124,11 @@ export function zundaTaxForCash(cash, game = null) { export function fairiesTributeInfo(game, turn = game.turn) { const day = Math.max(1, Math.floor(Number(turn) || 1)); const base = Math.ceil(day * ECONOMY.fairiesTribute.perDay); - const flatteryCount = Math.max(0, game?.cardEffects?.flattery || 0); - const perCardReduction = Math.floor(10 * day / 10); - const reduction = Math.min(base, flatteryCount * perCardReduction); - const amount = Math.max(0, base - reduction); - return { day, base, flatteryCount, perCardReduction, reduction, amount }; + const flatteryCount = Math.max(0, game?.cardEffects?.fairiesFlatteryNext || game?.cardEffects?.flattery || 0); + const multiplier = flatteryCount > 0 ? Math.pow(0.5, flatteryCount) : 1; + const amount = Math.max(0, Math.ceil(base * multiplier)); + const reduction = Math.max(0, base - amount); + return { day, base, flatteryCount, perCardReduction: Math.ceil(base * 0.5), reduction, amount }; } export function resaleValueFor(hit, game) { @@ -119,11 +136,14 @@ export function resaleValueFor(hit, game) { const meta = hit?.type === 'conveyor' ? game?.conveyorMeta?.get(hit.oldKey || obj.id) : obj; const price = meta?.price || equipmentBasePrice(hit || obj); const sameBuild = meta?.builtSession === game?.buildSession; - return { amount: Math.max(0, Math.ceil(price * (sameBuild ? 1 : 0.5))), sameBuild, price }; + if (meta?.noRefund || obj?.noRefund) return { amount: 0, sameBuild, price, rate: 0, noRefund: true }; + const rate = resaleRate(game, sameBuild); + return { amount: Math.max(0, Math.ceil(price * rate)), sameBuild, price, rate, noRefund: false }; } -export function explosionDamageForPrice(price) { - return Math.max(1, Math.ceil((Number(price) || 0) / ECONOMY.explosionDamageDivisor)); +export function explosionDamageForPrice(price, game = null) { + const base = Math.max(1, Math.ceil((Number(price) || 0) / ECONOMY.explosionDamageDivisor)); + return Math.max(1, Math.ceil(base * explosionDamageMultiplier(game))); } export function upgradeCostFor(obj) { @@ -149,7 +169,6 @@ export function applyCashDelta(game, delta) { if (delta >= 0) { game.stats.revenue += delta; game.totals.revenue += delta; } else { game.stats.penalty += Math.abs(delta); game.totals.penalty += Math.abs(delta); } game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); - emitGameEvent(game, 'CashDelta', { delta, before, after: game.cash }); } export function applyRevenue(game, amount) { @@ -175,7 +194,6 @@ export function refundCash(game, amount) { game.stats.profit += amount; game.totals.profit += amount; game.totals.maxCash = Math.max(game.totals.maxCash, game.cash); - emitGameEvent(game, 'CashRefund', { amount, before, after: game.cash }); } export function applyMixerIncome(game) { @@ -210,7 +228,7 @@ export function applyWrongTruckFine(game) { } export function applyExplosionDamage(game, price) { - const amount = explosionDamageForPrice(price); + const amount = explosionDamageForPrice(price, game); game.stats.explosionDamage += amount; game.totals.explosionDamage += amount; applyPenalty(game, amount); @@ -240,6 +258,10 @@ export function collectZundaTax(game, basisCash = game.cash) { export function collectFairiesTribute(game, turn = game.turn) { const info = fairiesTributeInfo(game, turn); + if (game.cardEffects) { + game.cardEffects.fairiesFlatteryNext = 0; + if (game.cardEffects.flattery != null) delete game.cardEffects.flattery; + } if (info.amount <= 0) return info; game.stats.fairiesTribute = (game.stats.fairiesTribute || 0) + info.amount; game.totals.fairiesTribute = (game.totals.fairiesTribute || 0) + info.amount; @@ -247,6 +269,57 @@ export function collectFairiesTribute(game, turn = game.turn) { return info; } +export function applyRescueLoanIfNeeded(game) { + if (!game?.cardEffects || game.cash >= 0) return { amount: 0, used: 0, remaining: Math.max(0, game?.cardEffects?.rescueLoanCharges || 0) }; + let used = 0; + let amount = 0; + while (game.cash < 0 && (game.cardEffects.rescueLoanCharges || 0) > 0) { + game.cardEffects.rescueLoanCharges -= 1; + used += 1; + amount += 500; + game.stats.rescueLoan = (game.stats.rescueLoan || 0) + 500; + game.totals.rescueLoan = (game.totals.rescueLoan || 0) + 500; + applyRevenue(game, 500); + } + return { amount, used, remaining: Math.max(0, game.cardEffects.rescueLoanCharges || 0) }; +} + +export function collectChemicalWeaponSubsidy(game) { + const cards = effectCount(game, 'chemicalWeaponSubsidy'); + const poopTruck = Math.max(0, game?.stats?.poopTruck || 0); + const blocks = Math.floor(poopTruck / 100); + const amount = cards > 0 && blocks > 0 ? blocks * 2000 * cards : 0; + if (amount > 0) { + game.stats.chemicalWeaponSubsidy = (game.stats.chemicalWeaponSubsidy || 0) + amount; + game.totals.chemicalWeaponSubsidy = (game.totals.chemicalWeaponSubsidy || 0) + amount; + applyRevenue(game, amount); + } + return { cards, poopTruck, blocks, amount }; +} + +export function collectLoanRepayments(game) { + const effects = game?.cardEffects; + if (!effects || !Array.isArray(effects.loans) || !effects.loans.length) return { amount: 0, paidLoans: 0, remainingLoans: 0 }; + let amount = 0; + let paidLoans = 0; + const nextLoans = []; + for (const loan of effects.loans) { + if ((loan.daysRemaining || 0) <= 0) continue; + const payment = Math.max(0, Math.ceil(loan.dailyPayment || 400)); + amount += payment; + paidLoans += 1; + const daysRemaining = Math.max(0, (loan.daysRemaining || 0) - 1); + if (daysRemaining > 0) nextLoans.push({ ...loan, daysRemaining }); + } + effects.loans = nextLoans; + if (amount > 0) { + game.stats.loanRepayment = (game.stats.loanRepayment || 0) + amount; + game.totals.loanRepayment = (game.totals.loanRepayment || 0) + amount; + applyPenalty(game, amount); + } + return { amount, paidLoans, remainingLoans: nextLoans.length }; +} + export function finalScore(game) { const value = factoryValue(game); const days = Math.max(1, game.totals.turnsCompleted || game.turn || 1); diff --git a/src/systems/maintenance.js b/src/systems/maintenance.js index 9ad8af6..32ebbf2 100644 --- a/src/systems/maintenance.js +++ b/src/systems/maintenance.js @@ -3,10 +3,37 @@ import { GRID, THEME } from '../core/config.js'; import { cellCenter, parseKey, key, yen } from '../core/utils.js'; import { spendCash } from './economy.js'; import { floating } from './effects.js'; -import { emitGameEvent } from './gameEvents.js'; const RULES = BALANCE.maintenance; +function effectCount(game, id) { + return Math.max(0, Number(game?.cardEffects?.[id]) || 0); +} + +export function degradationUseMultiplier(game) { + const crowded = Math.min(1.4, 1 + 0.04 * Math.min(10, effectCount(game, 'crowdedFarming'))); + const preventive = Math.pow(0.95, effectCount(game, 'preventiveMaintenance')); + return crowded * preventive; +} + +export function durabilityMultiplier(game) { + return Math.pow(1.03, effectCount(game, 'durabilityCoating')); +} + +export function durabilityCapFor(game, type, qualityMultiplier = 1) { + const base = RULES.durability[type] || 1000; + return Math.max(1, Math.ceil(base * Math.max(0.1, qualityMultiplier || 1) * durabilityMultiplier(game))); +} + +export function repairmanDailyCost(game) { + const base = RULES.repairman.dailyCost; + return game?.cardEffects?.laborExploitation ? Math.ceil(base * 0.7) : base; +} + +function qualityMultiplierOf(objOrMeta) { + return Math.max(0.1, Number(objOrMeta?.durabilityBaseMultiplier) || 1); +} + export function ensureMaintenanceState(game) { if (!game.repairman) { game.repairman = { @@ -20,25 +47,27 @@ export function ensureMaintenanceState(game) { } for (const [k, meta] of game.conveyorMeta || []) { if (meta.uses == null) meta.uses = 0; - if (meta.durability == null) meta.durability = RULES.durability.conveyor; if (meta.maintenanceType == null) meta.maintenanceType = 'conveyor'; + const desired = durabilityCapFor(game, 'conveyor', qualityMultiplierOf(meta)); + if (meta.durability == null || meta.durability < desired) meta.durability = desired; } - for (const farm of game.eggFarms || []) attachMaintenance(farm, 'eggFarm'); + for (const farm of game.eggFarms || []) attachMaintenance(farm, 'eggFarm', game); for (const scanner of game.scanners || []) { - if (scanner.kind === 'auto') attachMaintenance(scanner, 'autoScanner'); + if (scanner.kind === 'auto') attachMaintenance(scanner, 'autoScanner', game); } for (const f of Object.values(game.facilities || {})) { - if (f.id === 'mixer') attachMaintenance(f, 'mixer'); - if (f.id === 'trash') attachMaintenance(f, 'trash'); + if (f.id === 'mixer') attachMaintenance(f, 'mixer', game); + if (f.id === 'trash') attachMaintenance(f, 'trash', game); } } -function attachMaintenance(obj, type) { +function attachMaintenance(obj, type, game = null) { if (!obj) return; if (!obj.maintenance) obj.maintenance = {}; obj.maintenance.type = type; if (obj.maintenance.uses == null) obj.maintenance.uses = 0; - if (obj.maintenance.durability == null) obj.maintenance.durability = RULES.durability[type] || 1000; + const desired = game ? durabilityCapFor(game, type, qualityMultiplierOf(obj)) : (RULES.durability[type] || 1000); + if (obj.maintenance.durability == null || obj.maintenance.durability < desired) obj.maintenance.durability = desired; } function usesOf(target) { @@ -126,14 +155,14 @@ export function recordConveyorPass(game, conveyorKey) { ensureMaintenanceState(game); const meta = game.conveyorMeta?.get(conveyorKey); if (!meta) return; - meta.uses = Math.min(meta.durability || RULES.durability.conveyor, (meta.uses || 0) + 1); + meta.uses = Math.min(meta.durability || RULES.durability.conveyor, (meta.uses || 0) + degradationUseMultiplier(game)); game.stats.conveyorPasses = (game.stats.conveyorPasses || 0) + 1; } export function recordFarmProduction(game, farm) { ensureMaintenanceState(game); attachMaintenance(farm, 'eggFarm'); - farm.maintenance.uses = Math.min(farm.maintenance.durability, (farm.maintenance.uses || 0) + 1); + farm.maintenance.uses = Math.min(farm.maintenance.durability, (farm.maintenance.uses || 0) + degradationUseMultiplier(game)); game.stats.eggProduced = (game.stats.eggProduced || 0) + 1; } @@ -141,7 +170,7 @@ export function recordAutoScan(game, scanner) { ensureMaintenanceState(game); if (!scanner || scanner.kind !== 'auto') return; attachMaintenance(scanner, 'autoScanner'); - scanner.maintenance.uses = Math.min(scanner.maintenance.durability, (scanner.maintenance.uses || 0) + 1); + scanner.maintenance.uses = Math.min(scanner.maintenance.durability, (scanner.maintenance.uses || 0) + degradationUseMultiplier(game)); } export function recordFacilityProcess(game, id) { @@ -149,7 +178,7 @@ export function recordFacilityProcess(game, id) { const f = game.facilities?.[id]; if (!f || !['mixer', 'trash'].includes(id)) return; attachMaintenance(f, id); - f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + 1); + f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game)); } export function setFacilityCooldownAfterProcess(game, id) { @@ -189,11 +218,10 @@ function targetKey(t) { return t.type === 'conveyor' ? `conveyor:${t.key}` : `${ export function hireRepairmanForNextDay(game) { ensureMaintenanceState(game); if (game.repairman.hiredForNextDay) return { ok: false, reason: 'Repairman already hired.' }; - const cost = RULES.repairman.dailyCost; + const cost = repairmanDailyCost(game); if (game.cash < cost) return { ok: false, reason: `Need ${yen(cost - game.cash)} more.` }; spendCash(game, cost); game.repairman.hiredForNextDay = true; - emitGameEvent(game, 'HireRepairman', { cost }); return { ok: true, cost }; } @@ -203,12 +231,10 @@ export function activateRepairmanForDay(game) { game.repairman.hiredForNextDay = false; game.repairman.target = null; game.repairman.repairedToday = 0; - if (game.repairman.active) emitGameEvent(game, 'RepairmanStarted', { day: game.turn }); } export function deactivateRepairman(game) { ensureMaintenanceState(game); - if (game.repairman.active) emitGameEvent(game, 'RepairmanStopped', { repairedPercent: Math.round(game.repairman.repairedToday || 0) }); game.repairman.active = false; game.repairman.target = null; } diff --git a/src/systems/routing.js b/src/systems/routing.js index fcb1503..08c8289 100644 --- a/src/systems/routing.js +++ b/src/systems/routing.js @@ -12,8 +12,8 @@ export function scannerById(game, id) { return game.scanners.find(s => s.id === export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; } // ----------------------------------------------------------------------------- -// Port definitions: only exact receiver/output cells are valid connections. -// Visual adjacency is deliberately not treated as a connection. +// Port definitions. Ports prefer the exact connector cell, but also accept +// visually touching adjacent conveyor cells so drawn connections and routing match. // ----------------------------------------------------------------------------- export function scannerConnector(scanner, type) { return { @@ -37,8 +37,27 @@ function exactConveyorCell(game, point) { return conveyorAt(game, point) ? [{ ...point }] : []; } -// Kept for explicit non-port use. Ports and validation intentionally use exact -// cells only; visual adjacency is not treated as a connection. +function visualPortConveyorCells(game, point, blocked = [], preferred = []) { + if (!point || !inGrid(point.col, point.row)) return []; + const result = []; + const add = p => { + if (!p || !inGrid(p.col, p.row) || !game.conveyorTiles.has(key(p.col, p.row))) return; + if (blocked.some(b => b && sameCell(p, b))) return; + if (!result.some(x => sameCell(x, p))) result.push({ ...p, viaTolerance: !sameCell(p, point) }); + }; + add(point); + for (const p of preferred) add(p); + for (const p of adjacentCells(point)) add(p); + return result; +} + +function graphPortConveyorCells(game, point, blocked = [], preferred = []) { + const cells = visualPortConveyorCells(game, point, blocked, preferred); + const exact = cells.filter(p => !p.viaTolerance); + return exact.length ? exact : cells; +} + +// Kept for explicit non-port use. export function exactOrAdjacentConveyorCells(game, point, blocked = [], preferred = []) { if (!point || !inGrid(point.col, point.row)) return []; const result = []; @@ -57,11 +76,13 @@ export function exactOrAdjacentConveyorCells(game, point, blocked = [], preferre // Strict connection tests // ----------------------------------------------------------------------------- export function scannerInputCells(game, scanner) { - return exactConveyorCell(game, scannerConnector(scanner, 'inputA')); + const connector = scannerConnector(scanner, 'inputA'); + return visualPortConveyorCells(game, connector, [ { col: scanner.col, row: scanner.row } ]); } function outputStartCells(game, scanner, side) { - return exactConveyorCell(game, scannerConnector(scanner, side)); + const connector = scannerConnector(scanner, side); + return visualPortConveyorCells(game, connector, [ { col: scanner.col, row: scanner.row } ]); } export function facilityEntryPoint(game, dest) { @@ -81,11 +102,11 @@ export function facilityEntryCell(game, dest) { export function facilityEndpointCells(game, dest) { const f = game.facilities[dest]; if (!f?.entry) return []; - return exactConveyorCell(game, f.entry); + return visualPortConveyorCells(game, f.entry); } export function portHasConveyor(game, port) { - return exactConveyorCell(game, port).length > 0; + return visualPortConveyorCells(game, port).length > 0; } export function scannerPortHasConveyor(game, scanner, type) { @@ -206,21 +227,24 @@ function indexGraphPorts(game, graph) { const ports = {}; for (const type of ['inputA', 'left', 'right']) { const cell = scannerConnector(scanner, type); - const cellKey = cell ? graphNodeKey(cell.col, cell.row) : null; - const connected = !!cellKey && graph.cells.has(cellKey); - ports[type] = { type, cell, key: connected ? cellKey : null, connected }; + const cells = graphPortConveyorCells(game, cell, [ { col: scanner.col, row: scanner.row } ]) + .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; + ports[type] = { type, cell, cells, keys: cellKeys, key: cellKeys[0] || null, connected }; graph.metrics.scannerPorts += 1; if (connected) graph.metrics.connectedScannerPorts += 1; - if (type === 'inputA' && connected) graph.scannerInputs.set(cellKey, scanner.id); + if (type === 'inputA') for (const cellKey of cellKeys) graph.scannerInputs.set(cellKey, scanner.id); } graph.scannerPorts.set(scanner.id, ports); } for (const [id, f] of Object.entries(game.facilities)) { if (!f.entry) continue; - const cellKey = graphNodeKey(f.entry.col, f.entry.row); - const connected = graph.cells.has(cellKey); - graph.facilityPorts.set(id, { id, cell: { ...f.entry }, key: connected ? cellKey : null, connected }); + const cells = graphPortConveyorCells(game, f.entry).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; + graph.facilityPorts.set(id, { id, cell: { ...f.entry }, cells, keys: cellKeys, key: cellKeys[0] || null, connected }); graph.metrics.facilityPorts += 1; if (connected) graph.metrics.connectedFacilityPorts += 1; } @@ -263,21 +287,6 @@ export function graphSummary(graph) { }; } -export function factoryGraphMetrics(game) { - const graph = ensureFactoryGraph(game); - const routeIssues = validateFactoryGraph(game, graph, { includeRoutes: true }); - const maxCongestion = game.congestion?.size ? Math.max(...[...game.congestion.values()].map(c => c.ratio || 0), 0) : 0; - const queued = game.scanners.reduce((sum, scanner) => sum + (scanner.queue?.length || 0), 0); - return { - ...graphSummary(graph), - issues: routeIssues, - activeItems: game.chicks.length, - queuedItems: queued, - movingItems: Math.max(0, game.chicks.length - queued), - maxCongestion - }; -} - // ----------------------------------------------------------------------------- // Conveyor graph and pathfinding // ----------------------------------------------------------------------------- @@ -387,9 +396,33 @@ function routeWithConnector(fromPoint, connector, cells, extraEnd = null) { return points; } +function routeToScannerCenter(cells, scanner) { + const points = routePoints(cells); + const input = scannerConnector(scanner, 'inputA'); + if (input) appendPoint(points, cellCenter(input.col, input.row)); + appendPoint(points, scannerCenter(scanner)); + return points; +} + +function routeWithScannerTarget(fromPoint, connector, cells, targetScanner) { + const points = [{ x: fromPoint.x, y: fromPoint.y }]; + appendPoint(points, cellCenter(connector.col, connector.row)); + for (const p of routePoints(cells)) appendPoint(points, p); + const input = scannerConnector(targetScanner, 'inputA'); + if (input) appendPoint(points, cellCenter(input.col, input.row)); + appendPoint(points, scannerCenter(targetScanner)); + return points; +} + export function chooseRoundRobin(game, id, candidates, advance = false) { if (!candidates.length) return null; const sorted = [...candidates].sort((a, b) => { + const ba = a.roleBias ?? 0; + const bb = b.roleBias ?? 0; + if (ba !== bb) return ba - bb; + const la = a.cells?.length ?? a.route?.length ?? Number.POSITIVE_INFINITY; + const lb = b.cells?.length ?? b.route?.length ?? Number.POSITIVE_INFINITY; + if (la !== lb) return la - lb; const ka = a.key || JSON.stringify(a.route || a.cells || a); const kb = b.key || JSON.stringify(b.route || b.cells || b); return ka.localeCompare(kb); @@ -424,7 +457,7 @@ export function isFacilityEndpoint(game, p, dest, connector) { if (sameCell(p, connector)) return false; const graph = ensureFactoryGraph(game); const port = graph.facilityPorts.get(dest); - return !!port?.connected && graphNodeKey(p.col, p.row) === port.key; + return !!port?.connected && (port.keys || [port.key]).includes(graphNodeKey(p.col, p.row)); } export function routeFromFarmToScanner(game, farm, advance = false) { @@ -444,7 +477,7 @@ export function routeFromFarmToScanner(game, farm, advance = false) { } } const chosen = chooseRoundRobin(game, `farm:${farm.id}`, candidates, advance); - if (chosen) return { scannerId: chosen.scanner.id, route: routePoints(chosen.cells, scannerCenter(chosen.scanner)) }; + if (chosen) return { scannerId: chosen.scanner.id, route: routeToScannerCenter(chosen.cells, chosen.scanner) }; const fallback = chooseRoundRobin(game, `farm:${farm.id}:output`, starts.map(p => ({ key: key(p.col, p.row), cells: [p] })), advance); return fallback ? { scannerId: null, route: routePoints(fallback.cells) } : null; } @@ -455,7 +488,7 @@ export function outputRoute(game, side, fromPoint, scannerId, advance = false) { const graph = ensureFactoryGraph(game); const port = graph.scannerPorts.get(scanner.id)?.[side]; const connector = scannerConnector(scanner, side); - const starts = port?.connected ? [graphCell(port.key)] : []; + const starts = port?.connected ? (port.keys || [port.key]).filter(Boolean).map(graphCell) : []; if (!connector || !starts.length) return null; const dest = scannerOutputs(scanner)[side]; if (dest === 'scanner-role-1') return routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance); @@ -464,21 +497,23 @@ export function outputRoute(game, side, fromPoint, scannerId, advance = false) { function routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance) { const graph = ensureFactoryGraph(game); - const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && graph.scannerPorts.get(s.id)?.inputA?.connected); + const targets = game.scanners.filter(s => s.id !== scanner.id && graph.scannerPorts.get(s.id)?.inputA?.connected); const candidates = []; for (const start of starts) { for (const target of targets) { const targetKey = graph.scannerPorts.get(target.id)?.inputA?.key; if (!targetKey) continue; - const isGoal = p => graphNodeKey(p.col, p.row) === targetKey; + const targetKeys = (graph.scannerPorts.get(target.id)?.inputA?.keys || [targetKey]).filter(Boolean); + const isGoal = p => targetKeys.includes(graphNodeKey(p.col, p.row)); isGoal.cacheKey = `toScanner:${start.col},${start.row}:${target.id}:v${graph.version}`; const routes = bfsAllRoutes(game, start, isGoal); - for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells }); + const roleBias = target.role === 1 ? 0 : 1; + for (const cells of routes) candidates.push({ key: `${roleBias}:start${key(start.col, start.row)}:s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells, roleBias }); } } const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:toRole1`, candidates, advance); if (!chosen) return null; - return { destination: 'scanner', nextScannerId: chosen.target.id, route: routeWithConnector(fromPoint, connector, chosen.cells, scannerCenter(chosen.target)) }; + return { destination: 'scanner', nextScannerId: chosen.target.id, route: routeWithScannerTarget(fromPoint, connector, chosen.cells, chosen.target) }; } function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest, advance) { @@ -487,7 +522,9 @@ function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest if (!port?.connected) return null; const candidates = []; for (const start of starts) { - const isGoal = p => graphNodeKey(p.col, p.row) === port.key; + 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 }); @@ -500,6 +537,38 @@ function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest // ----------------------------------------------------------------------------- // Connection validation: uses FactoryGraph as the single source of truth. // ----------------------------------------------------------------------------- +function outputRouteReachesFacility(game, scanner, visited = new Set()) { + if (!scanner || visited.has(scanner.id)) return false; + visited.add(scanner.id); + for (const side of ['left', 'right']) { + const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id, false); + if (!plan) continue; + if (['mixer', 'trash', 'truck'].includes(plan.destination)) return true; + if (plan.destination === 'scanner') { + const next = scannerById(game, plan.nextScannerId); + if (outputRouteReachesFacility(game, next, visited)) return true; + } + } + return false; +} + +export function minimumStartConnectionIssues(game) { + const graph = ensureFactoryGraph(game); + for (const farm of game.eggFarms || []) { + const route = routeFromFarmToScanner(game, farm, false); + if (!route?.scannerId) continue; + const scanner = scannerById(game, route.scannerId); + if (outputRouteReachesFacility(game, scanner)) return []; + } + const farmCount = game.eggFarms?.length || 0; + const exitCount = ['mixer', 'trash', 'truck'].filter(id => graph.facilityPorts.get(id)?.connected).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.']; + if (!exitCount) return ['Connect at least one receiver exit: Mixer, Shredder, or Truck.']; + return ['Connect at least one EGG through a scanner to any valid exit.']; +} + function scannerName(scanner) { if (scanner.kind === 'manual') return `S${(scanner.slot ?? 0) + 1}`; return `Auto Scanner #${scanner.id}`; @@ -545,7 +614,7 @@ export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), opt } export function facilityConnectionIssues(game) { - return validateFactoryGraph(game, ensureFactoryGraph(game), { includeRoutes: true }); + return minimumStartConnectionIssues(game); } export function factoryReady(game) { diff --git a/src/systems/selectionSystem.js b/src/systems/selectionSystem.js index 85e18cd..02348e5 100644 --- a/src/systems/selectionSystem.js +++ b/src/systems/selectionSystem.js @@ -4,7 +4,6 @@ import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js'; import { farmAt, scannerAt, scannerCenter, refreshRoutingAfterEdit } from './routing.js'; import { snapshot } from './history.js'; import { buildPrice } from './economy.js'; -import { emitGameEvent } from './gameEvents.js'; export function createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }) { function selectionToken(item) { @@ -236,7 +235,6 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme function finishGroupDrag() { if (!game.groupDrag) return; - if (game.groupDrag.committed) emitGameEvent(game, 'MoveEquipment', { count: game.groupDrag.selections?.length || 0 }); refreshRoutingAfterEdit(game); game.groupDrag = null; updatePanels(); diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js index 7b96ebb..ee6bfe6 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -1,13 +1,11 @@ import { BUILD_TOOL_IDS, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js'; import { yen } from '../core/utils.js'; import { TEXT } from '../core/text.js'; -import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues, factoryGraphMetrics } from './routing.js'; -import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, zundaTaxInfo } from './economy.js'; +import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js'; +import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, zundaTaxInfo } from './economy.js'; import { truckTarget } from './contracts.js'; import { conveyorSpeedForGame } from './cards.js'; -import { recentGameEvents } from './gameEvents.js'; -import { maintenanceSummary } from './maintenance.js'; -import { BALANCE } from '../core/balance.js'; +import { maintenanceSummary, repairmanDailyCost } from './maintenance.js'; export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) { function escapeHtml(value) { @@ -16,14 +14,23 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act function displaySpeed() { return Math.round(conveyorSpeedForGame(game)); } + function noAffordableBuildTools() { + 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; + } + return hasBuildableOption; + } + function updatePanels() { updateToolButtons(); updateBuildStatus(); - updateFacilityPanel(); updateContractPanel(); updateTurnSummary(); updateHistoryButtons(); - updateDebugPanel(); } function updateToolButtons() { @@ -34,7 +41,9 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act for (const id of BUILD_TOOL_IDS) { const btn = ui.buttons[id]; if (!btn) continue; - const price = buildPrice(id); + 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]; const unaffordable = game.cash < price; btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt; @@ -50,18 +59,26 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending; if (ui.hireRepairmanButton) { const hired = !!game.repairman?.hiredForNextDay; - const cost = BALANCE.maintenance.repairman.dailyCost; + const cost = repairmanDailyCost(game); const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || hired || game.cash < cost; ui.hireRepairmanButton.disabled = blocked; ui.hireRepairmanButton.classList.toggle('active', hired); ui.hireRepairmanButton.classList.toggle('unaffordable', game.cash < cost); - ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day.`; + ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 3% durability per second.`; } + const cashout = noAffordableBuildTools(); + ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout); + ui.buttons.erase?.classList.toggle('cashout-emphasis', cashout); } function updateBuildStatus() { + const cashout = noAffordableBuildTools(); ui.buildStatus.textContent = game.phase === 'build' - ? (game.cardTargetPick?.pending ? 'Select a white-highlighted machine on the map.' : (game.cardDraft?.pending ? 'Choose upgrade card before building.' : TEXT.status.build(game.buildTool))) + ? (game.cardTargetPick?.pending + ? 'Select an upgrade target on the map.' + : (game.cardDraft?.pending + ? 'Choose upgrade card before building.' + : (cashout ? 'No affordable equipment. Start the next day or sell equipment to recover cash.' : TEXT.status.build(game.buildTool)))) : TEXT.status.sorting; } @@ -74,45 +91,24 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length; const issues = facilityConnectionIssues(game); const tax = zundaTaxInfo(game.cash, game); - const tribute = fairiesTributeInfo(game, game.turn); + const tributeDay = game.phase === 'running' ? Math.max(1, (game.totals.turnsCompleted || 0) + 1) : Math.max(1, Number(game.turn) || 1); + const tribute = fairiesTributeInfo(game, tributeDay); + const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null; + const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0; ui.turnSummary.innerHTML = [ `Seed: ${escapeHtml(game.runSeed || 'none')} | Blocked: ${game.blockedCells?.size || 0} cells`, `Truck target: ${truckTarget(game).toUpperCase()} | Male fine: -${yen(maleTruckPenalty(game))}`, `Poop fine: Mixer -${yen(mixerPoopPenalty(game))} / Shipment -${yen(truckPoopPenalty(game))}`, `ZUNDA TAX on Next Day: -${yen(tax.tax)} | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`, - `Fairies tribute on Next Day: -${yen(tribute.amount)}${tribute.reduction ? ` | flattery -${yen(tribute.reduction)}` : ''}`, + lastTribute != null + ? `Fairies tribute paid before Build: -${yen(lastTribute)}${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}` + : `Fairies tribute before Build: -${yen(tribute.amount)}${tribute.reduction ? ` | Flattery -${yen(tribute.reduction)}` : ''}`, `Maintenance: worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label}) | Repairman: ${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}`, - `Card draft: ${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'} | Belt: ${displaySpeed()}px/s | Farms connected: ${connected}/${game.eggFarms.length}`, + `Card draft: ${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'} | Belt: ${displaySpeed()}px/s | Farms with scanner route: ${connected}/${game.eggFarms.length}`, + game.cardEffects?.usedMachineActive ? 'Procurement: USED MACHINE MODE / no refunds / 60% durability' : '', issues.length ? `Blocked: ${issues[0]}` : `${TEXT.status.allPortsConnected}` ].join('
'); } - - - function updateDebugPanel() { - if (!ui.debugObservations && !ui.debugEventLog) return; - let m = null; - try { m = factoryGraphMetrics(game); } catch (_err) { m = null; } - if (ui.debugObservations) { - if (!m) ui.debugObservations.innerHTML = 'FactoryGraphnot available'; - else ui.debugObservations.innerHTML = [ - 'FactoryGraph', - `v${m.version} | cells ${m.conveyorCells} | edges ${m.edges} | components ${m.components}`, - `ports scanner ${m.scannerPorts} / facility ${m.facilityPorts} | farm outputs ${m.farmOutputs}`, - `dead ends ${m.deadEnds} | crosses ${m.crosses} | max jam ${Math.round((m.maxCongestion || 0) * 100)}%`, - `items moving ${m.movingItems} / queued ${m.queuedItems} / total ${m.activeItems}`, - `blocked cells ${(game.blockedCells?.size || 0)} | worst dirt ${maintenanceSummary(game).worst.percent}% ${escapeHtml(maintenanceSummary(game).worst.label)}`, - (m.issues?.length ? `issue: ${m.issues[0]}` : 'graph valid') - ].join(''); - } - if (ui.debugEventLog) { - const events = recentGameEvents(game, 8); - ui.debugEventLog.innerHTML = ['Event Log', ...events.map(ev => { - const payload = Object.entries(ev.payload || {}).map(([k, v]) => `${escapeHtml(k)}:${escapeHtml(v)}`).join(' '); - return `D${ev.day} ${escapeHtml(ev.type)}${payload ? ` | ${payload}` : ''}`; - })].join(''); - } - } - function updateContractPanel() { if (!ui.contractPanel) return; const offer = game.contractOffer; @@ -141,20 +137,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act Output x${offer.productionMultiplier} / Poop x${offer.poopMultiplier} `; } - - function updateFacilityPanel() { - const obj = build.selectedObject(); - if (!obj) { - ui.facilityPanel.className = 'facility-panel-empty'; - ui.facilityPanel.innerHTML = TEXT.status.equipmentPanelEmpty; - return; - } - ui.facilityPanel.className = 'facility-card'; - const lines = build.selectedInfoLines(obj); - if (obj.type === 'facility' && obj.id === 'trash') lines.push('Items are shredded; no stored contents are visualized.'); - ui.facilityPanel.innerHTML = `

${build.selectedTitle(obj)}

${lines.map(x => `

${x}

`).join('')}

Upgrades come from pre-Build card choices.

`; - } - function updateUI() { updateToolButtons(); ui.shell.classList.toggle('phase-running', game.phase === 'running'); @@ -171,7 +153,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act 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(); updatePriorityStrip(); - updateDebugPanel(); ui.turnProfit.textContent = yen(game.stats.profit); ui.turnProfit.classList.toggle('cash-negative', game.stats.profit < 0); ui.turnProfit.classList.toggle('cash-positive', game.stats.profit > 0); @@ -184,9 +165,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act ui.buttons.s2Left.disabled = game.phase !== 'running' || !activeQueuedChick(1); ui.buttons.s2Right.disabled = game.phase !== 'running' || !activeQueuedChick(1); const nextTax = zundaTaxInfo(game.cash, game).tax; - const nextTribute = fairiesTributeInfo(game, game.turn).amount; - const nextFees = nextTax + nextTribute; - ui.buttons.nextTurn.textContent = game.phase === 'build' && nextFees > 0 ? `Next Day - ZUNDA ${yen(nextTax)} / Fairies ${yen(nextTribute)}` : TEXT.actions.nextDay; + ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA ${yen(nextTax)}` : TEXT.actions.nextDay; ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game); } @@ -222,10 +201,10 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act function showTitle() { ui.modalTitle.textContent = TEXT.versionTitle; - ui.modalBody.innerHTML = `
MMale->MixerS1: A
FFemale->TruckS1: D, then S2: right
PPoop->ShredderS1: D, then S2: left

Build phase: connect all receiver ports before Next Day. One day is one run.

`; + ui.modalBody.innerHTML = `
♂Male->MixerS1: A
♀Female->TruckS1: D, then S2: right
💩Poop->ShredderS1: D, then S2: left

Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.

`; ui.modalActions.innerHTML = ''; ui.modalActions.appendChild(button(TEXT.actions.startGame, startGame, 'primary-button')); - ui.modal.classList.remove('equipment-popover'); + ui.modal.classList.remove('equipment-popover', 'gameover-modal'); ui.modal.classList.add('visible'); } @@ -236,7 +215,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act

Final profit: ${yen(r.profit)}

`; ui.modalActions.innerHTML = ''; ui.modalActions.appendChild(button('Choose Upgrade Card', beginCardDraft || hideModal, 'primary-button')); - ui.modal.classList.remove('equipment-popover'); + ui.modal.classList.remove('equipment-popover', 'gameover-modal'); ui.modal.classList.add('visible'); } @@ -246,13 +225,16 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act const accuracy = processed ? Math.round(game.totals.correct / processed * 100) : 0; const fs = finalScore(game); ui.modalTitle.textContent = TEXT.phases.gameover; - ui.modalBody.innerHTML = `

Your cash went negative.

-
Final score formula
Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}
Daily earned = ceil(Base ÷ Days) = ceil(${yen(fs.base)} ÷ ${fs.days}) = ${yen(fs.dailyEarned)}
Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}
-
Final Score${fs.score}
Survival${game.totals.turnsCompleted} days
Final Cash${yen(game.cash)}
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Daily Earned${yen(fs.dailyEarned)} / day
Total Outflow${yen(game.totals.penalty)}
ZUNDA TAX${yen(game.totals.zundaTax)}
Fairies Tribute${yen(game.totals.fairiesTribute || 0)}
Correct Sorts${game.totals.correct}
Accuracy${accuracy}%
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer
Explosion Penalty${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Day Penalty-${fs.dayPenalty}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
`; + ui.modalBody.innerHTML = `

Your cash went negative.

+
Final Score${fs.score}
Survival${game.totals.turnsCompleted} days
Final Cash${yen(game.cash)}
Accuracy${accuracy}%
+
Score breakdown +
Formula
Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}
Daily earned = ceil(Base ÷ Days) = ${yen(fs.dailyEarned)}
Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}
+
Factory Value${yen(fs.factoryValue)}
Total Revenue${yen(game.totals.revenue)}
Daily Earned${yen(fs.dailyEarned)} / day
Total Outflow${yen(game.totals.penalty)}
ZUNDA TAX${yen(game.totals.zundaTax)}
Fairies Tribute${yen(game.totals.fairiesTribute || 0)}
Correct Sorts${game.totals.correct}
Auto Sorted${game.totals.autoSorted}
Poop Control${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer
Explosion Penalty${yen(game.totals.explosionDamage)}
Contract Bonus${yen(game.totals.contractBonus)}
Day Penalty-${fs.dayPenalty}
Contracts${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed
+
`; ui.modalActions.innerHTML = ''; - ui.modalActions.appendChild(button(TEXT.actions.restart, startGame, 'primary-button')); + ui.modalActions.appendChild(button(TEXT.actions.restart || 'Restart', startGame, 'primary-button restart-button')); ui.modal.classList.remove('equipment-popover'); - ui.modal.classList.add('visible'); + ui.modal.classList.add('visible', 'gameover-modal'); } function button(text, onClick, className = 'primary-button') { @@ -265,16 +247,13 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act } function hideModal() { - ui.modal.classList.remove('visible', 'equipment-popover'); + ui.modal.classList.remove('visible', 'equipment-popover', 'gameover-modal'); ui.modal.style.removeProperty('--popover-x'); ui.modal.style.removeProperty('--popover-y'); } function checkGameOver() { - if (game.phase !== 'gameover' && game.cash < 0) { - game.phase = 'gameover'; - showGameOver(); - } + // Bankruptcy is intentionally checked only in completeTurn(), after all day-end revenue, tribute, and rescue-loan effects are settled. } return { updatePanels, updateUI, showTitle, showTurnResult, showGameOver, hideModal, checkGameOver }; diff --git a/styles.css b/styles.css index 015d25e..dae2754 100644 --- a/styles.css +++ b/styles.css @@ -58,9 +58,7 @@ h1, h2, p { margin: 0; } .tool-button.danger.active { background: #ffdada; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); } .primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; } .primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; } -.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; } -.facility-card h3 { color: var(--ink); font-size: 12px; margin: 0 0 6px; } -.facility-card p { margin: 4px 0; } +.mini-box { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; } .facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .facility-action { padding: 7px 9px; font-size: 9px; } .facility-action.good { background: var(--green-soft); } @@ -184,7 +182,9 @@ h1, h2, p { margin: 0; } /* v15 pre-build card draft */ .card-choices { display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(190px, 220px)); + justify-content: center; + align-items: stretch; gap: 10px; margin-top: 14px; } @@ -204,6 +204,8 @@ h1, h2, p { margin: 0; } .card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; } .card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; } .card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); } +.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); } +.card-choice.ultra-rare span { background: #c42424; color: #fff; } .muted-card-note { color: var(--muted); font-weight: 700; } .card-targets { display: grid; gap: 8px; margin-top: 14px; } .card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); } @@ -219,8 +221,7 @@ body { font-size: 16px; } .build-panel h2 { font-size: 13px; } .tool-button strong { font-size: 14px; } .tool-button span { font-size: 11px; } -.mini-box, .facility-panel-empty, .facility-card, .contract-card { font-size: 12px; } -.facility-card h3 { font-size: 14px; } +.mini-box, .contract-card { font-size: 12px; } .sort-button { font-size: 14px; } .modal-card h2 { font-size: 29px; } .card-choice strong { font-size: 16px; } @@ -246,40 +247,6 @@ body { font-size: 16px; } .hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; } .hover-tooltip span { display: block; color: var(--muted); font-size: 12px; } -.debug-panel { - position: absolute; - left: 10px; - bottom: 10px; - z-index: 12; - border: 3px solid var(--line); - background: rgba(255,255,255,.94); - box-shadow: 4px 4px 0 rgba(16,32,21,.16); - max-width: 330px; -} -.debug-panel summary { - cursor: pointer; - padding: 5px 8px; - font-weight: 900; - font-size: 12px; - user-select: none; -} -.debug-body { - display: grid; - gap: 7px; - padding: 8px; - border-top: 3px solid var(--line); - font-size: 12px; -} -.debug-body label { display: grid; gap: 3px; font-weight: 900; color: var(--muted); } -.debug-body input, .debug-body select, .debug-body button { - border: 2px solid var(--line); - background: var(--white); - color: var(--ink); - font: inherit; - font-weight: 900; - padding: 5px 6px; -} -.debug-body button { cursor: pointer; background: var(--green-soft); } .card-choice.dud { background: #eeeeee; @@ -291,34 +258,205 @@ body { font-size: 16px; } .card-choice.dud span { background: #f8f8f8; } .card-choice.flung { pointer-events: none; - transform: translate(180px, -60px) rotate(22deg); + transform: translate(var(--fling-x, 480px), var(--fling-y, -320px)) rotate(var(--fling-r, 68deg)) scale(.68); opacity: 0; - transition: transform .18s ease-in, opacity .18s ease-in; + transition: transform .34s cubic-bezier(.18,.84,.26,.99), opacity .28s ease-in; } -/* v20 Phase 1 observability */ -.debug-readout { - border: 2px solid var(--line); - background: rgba(247,255,245,.92); - padding: 6px; - display: grid; - gap: 3px; - line-height: 1.25; + + +.route-card .route-icon.male, +.route-card .route-icon.female, +.route-card .route-icon.poop { + display: inline-grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 999px; + border: 3px solid var(--line, #102015); + background: var(--white, #fff); + line-height: 1; } -.debug-readout strong { - display: block; - color: var(--ink); - font-size: 12px; - text-transform: uppercase; - letter-spacing: .08em; -} -.debug-readout span { +.route-card .route-icon.male { color: #2477ff; background: #d9ecff; } +.route-card .route-icon.female { color: #d62a85; background: #ffe0f0; } +.route-card .route-icon.poop { color: #7b4a23; background: #fff0d1; font-size: 24px; } +.restart-button { min-width: 160px; } +.restart-fallback-note { margin-top: 12px; color: var(--muted, #526456); font-weight: 700; } + +/* v24.4 hover-only build-button flavor / cashout guidance / compact game-over */ +.tool-button .tool-flavor { display: block; + max-height: 0; + opacity: 0; + overflow: hidden; + margin-top: 0; color: var(--muted); - font-size: 11px; - overflow-wrap: anywhere; + font-size: 8px; + line-height: 1.22; + text-transform: none; + letter-spacing: 0; + transition: max-height .12s ease, opacity .12s ease, margin-top .12s ease; } -.debug-readout.event-log { - max-height: 160px; +.tool-button:hover .tool-flavor { + max-height: 42px; + opacity: 1; + margin-top: 5px; +} +.large-tools .tool-button { min-height: 58px; } +.large-tools .tool-button:hover { min-height: 78px; } +.history-tools .tool-button.small { min-height: 40px; } +.primary-button.cashout-emphasis, +.tool-button.cashout-emphasis { + opacity: 1 !important; + filter: none !important; + animation: cashoutPulse 0.9s ease-in-out infinite alternate; +} +.primary-button.cashout-emphasis { + box-shadow: inset 0 0 0 4px #fff0d1, 6px 6px 0 rgba(16,32,21,.22); +} +.tool-button.cashout-emphasis { + background: #fff0d1; + box-shadow: inset 0 0 0 3px var(--danger), 6px 6px 0 rgba(16,32,21,.20); +} +@keyframes cashoutPulse { + from { transform: translate(0, 0); } + to { transform: translate(-1px, -1px); } +} +.modal.gameover-modal { padding: 12px; } +.modal.gameover-modal .modal-card { + width: min(640px, 92vw); + max-height: min(82vh, 720px); overflow: auto; + padding: 16px; +} +.modal.gameover-modal .modal-card h2 { + font-size: 22px; + margin-bottom: 8px; +} +.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; } +.gameover-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.gameover-breakdown { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.modal.gameover-modal .result-grid div { padding: 7px; } +.modal.gameover-modal .result-grid strong { font-size: 9px; margin-bottom: 3px; } +.modal.gameover-modal .result-grid span { font-size: 12px; } +.gameover-details { + margin-top: 10px; + border: 3px solid var(--line); + background: #f7fff5; + padding: 8px; +} +.gameover-details summary { + cursor: pointer; + font-weight: 900; + text-transform: uppercase; +} +.modal.gameover-modal .formula-box.compact { font-size: 9px; line-height: 1.35; } +@media (max-width: 720px) { + .gameover-summary, .gameover-breakdown { grid-template-columns: 1fr 1fr; } +} + +/* v27.0 cleanup and stable build-menu tooltip pass */ +body { font-size: 18px; } +.build-panel { width: clamp(330px, 26vw, 420px); } +.panel-head h1 { font-size: 20px; } +.panel-head h1 span { font-size: 12px; } +.panel-head p { font-size: 13px; line-height: 1.35; } +.build-panel h2 { font-size: 14px; } +.priority-strip > div { font-size: 15px; } +.hud-card span { font-size: 12px; } +.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); } +.mini-box, .contract-card { font-size: 14px; line-height: 1.45; } +.compact-rules .mini-box { font-size: 13px; } +.tool-button { + position: relative; + overflow: visible; + min-height: 62px; + padding: 10px; +} +.large-tools .tool-button { min-height: 62px; } +.large-tools .tool-button:hover { min-height: 62px; } +.tool-button strong { font-size: 15px; line-height: 1.15; } +.tool-button span { font-size: 12px; } +.tool-button .tool-flavor { + display: none; + position: absolute; + z-index: 60; + left: 0; + top: calc(100% + 7px); + width: min(300px, calc(100vw - 40px)); + max-height: none; + opacity: 1; + overflow: visible; + margin: 0; + padding: 10px 12px; + border: 3px solid var(--line); + background: rgba(255,255,255,.98); + color: var(--ink); + box-shadow: 5px 5px 0 rgba(16,32,21,.18); + font-size: 13px; + line-height: 1.35; + text-transform: none; + letter-spacing: 0; + pointer-events: none; + transition: none; +} +.tool-button .tool-flavor::before { + content: ''; + position: absolute; + left: 16px; + top: -10px; + width: 16px; + height: 16px; + border-left: 3px solid var(--line); + border-top: 3px solid var(--line); + background: rgba(255,255,255,.98); + transform: rotate(45deg); +} +.tool-button:hover .tool-flavor { + display: block; + max-height: none; + opacity: 1; + margin: 0; +} +.primary-button { font-size: 16px; padding: 13px 16px; } +#nextTurnButton { min-height: 56px; font-size: 18px; } +@keyframes cashoutPulse { + from { box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); filter: brightness(1); } + to { box-shadow: inset 0 0 0 5px #fff0d1, 4px 4px 0 rgba(16,32,21,.20); filter: brightness(1.08); } +} +.card-choices { grid-template-columns: repeat(auto-fill, minmax(230px, 270px)); gap: 14px; } +.card-choice { min-height: 164px; padding: 15px; } +.card-choice strong { font-size: 18px; } +.card-choice span { font-size: 11px; } +.card-choice small { font-size: 15px; line-height: 1.4; } +.muted-card-note { font-size: 15px; line-height: 1.45; } +.modal-card { width: min(980px, 96vw); } +.modal-card p, .modal-card li { font-size: 16px; } +.modal-actions .reroll-button { + order: 99; + flex: 1 1 100%; + min-height: 64px; + margin-top: 8px; + font-size: 20px; + text-align: center; + justify-content: center; + background: #fff0d1; + box-shadow: inset 0 0 0 4px var(--green), 6px 6px 0 rgba(16,32,21,.20); +} +.modal-actions .reroll-button:disabled { box-shadow: 4px 4px 0 rgba(16,32,21,.12); } +.hover-tooltip strong { font-size: 17px; } +.hover-tooltip span { font-size: 14px; } +.sort-button { font-size: 16px; min-height: 56px; } +.control-dock { grid-template-columns: repeat(4, 172px); } +@media (max-width: 1180px) { + .build-panel { width: auto; } + .control-dock { grid-template-columns: repeat(2, 172px); } }