cards and lot of

This commit is contained in:
33333-33333 2026-06-07 22:04:17 +09:00
commit ca40a0cb73
13 changed files with 624 additions and 92 deletions

View file

@ -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 days 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 days `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%.

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v14.0 Zunda Tax / Resale</title>
<title>Chick Sorter v15.0 Cards / Tribute</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
@ -35,7 +35,7 @@
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v14.0</span></h1>
<h1>CHICK SORTER <span>v15.0</span></h1>
<p>Build, drag, connect all receiver ports, then start the next day.</p>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
@ -92,6 +92,6 @@
</div>
</div>
<script type="module" src="./src/game.js?v=14.0-zunda-tax-resale"></script>
<script type="module" src="./src/game.js?v=15.0-cards-tribute"></script>
</body>
</html>

View file

@ -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

View file

@ -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()

View file

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

View file

@ -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;

View file

@ -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 = `<p>Standard auto scanner.</p><ul><li>Role 0: male left, others right.</li><li>Role 1: poop left, others right.</li><li>Cooldown: ${AUTO_SCANNER_COOLDOWN}s.</li></ul>`;
ui.modalBody.innerHTML = `<p>Standard auto scanner.</p><ul><li>Role 0: male left, others right.</li><li>Role 1: poop left, others right.</li><li>Cooldown: ${autoScannerCooldownSeconds(scanner).toFixed(1)}s.</li></ul>`;
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
<div class="equipment-menu-lines compact">${lines.map(x => `<p>${x}</p>`).join('')}</div>
${['mixer', 'truck'].includes(obj.id) ? '<p class="formula-box compact">Income/Fine = ceil(base × 1.05^upgrades)</p>' : ''}`;
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

393
src/systems/cards.js Normal file
View file

@ -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 = `<strong>${card.title}</strong><span>${rarity}</span><small>${cardDescription(game, card)}</small>`;
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 = `<p class="muted-card-note">Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Equipment cards are applied by selecting a white-highlighted machine on the map.</p><div id="cardChoices" class="card-choices"></div>`;
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 };
}

View file

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

View file

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

View file

@ -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;

View file

@ -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: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`,
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`,
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | taxable ${yen(tax.taxable)} × ${tax.ratePercent}%`,
`Belt: <b>${displaySpeed()}px/s fixed</b> | Farms connected: <b>${connected}/${game.eggFarms.length}</b>`,
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
`Fairies tribute on Next Day: <b>-${yen(tribute.amount)}</b>${tribute.reduction ? ` | flattery -${yen(tribute.reduction)}` : ''}`,
`Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Belt: <b>${displaySpeed()}px/s</b> | Farms connected: <b>${connected}/${game.eggFarms.length}</b>`,
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">${TEXT.status.allPortsConnected}</span>`
].join('<br>');
}
@ -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 = `<h3>${build.selectedTitle(obj)}</h3>${lines.map(x => `<p>${x}</p>`).join('')}<p><b>Upgrade choices appear in the clicked equipment menu.</b></p>`;
ui.facilityPanel.innerHTML = `<h3>${build.selectedTitle(obj)}</h3>${lines.map(x => `<p>${x}</p>`).join('')}<p><b>Upgrades come from pre-Build card choices.</b></p>`;
}
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 }
<div><strong>Mixer income</strong><span>${yen(r.mixerRevenue)} / unit ${yen(upgradedMixerPrice(game))}</span></div>
<div><strong>Poop fine</strong><span>Mixer ${yen(r.mixerPoopFine)} / Shipment ${yen(r.truckPoopFine)}</span></div>
<div><strong>Wrong truck fine</strong><span>${yen(r.maleTruckFine)}</span></div>
<div><strong>ZUNDA TAX</strong><span>Opening tax paid on Next Day: -${yen(r.zundaTax || 0)}</span></div>
<div><strong>Next Day fees</strong><span>ZUNDA TAX and Fairies tribute are paid before the next run and are not included in this day's profit.</span></div>
<div><strong>Explosion penalty</strong><span>${yen(r.explosionDamage)}</span></div>
<div><strong>Daily earned formula</strong><span>(${yen(r.mixerRevenue)} + ${yen(r.ship.adjusted)} + ${yen(contractBonus)}) ÷ 1 day = ${yen(dailyEarned)}</span></div>
<div><strong>Net profit formula</strong><span>${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}</span></div>
@ -207,7 +212,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
</div>
${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 = `<p>Your cash went negative.</p>
<div class="formula-box"><strong>Final score formula</strong><br>Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base ÷ Days) = ceil(${yen(fs.base)} ÷ ${fs.days}) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>`;
<div class="result-grid"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button(TEXT.actions.restart, startGame, 'primary-button'));
ui.modal.classList.remove('equipment-popover');

View file

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