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); }