before visual update

This commit is contained in:
33333-33333 2026-06-11 01:09:49 +09:00
commit ced8f251d4
20 changed files with 873 additions and 388 deletions

78
BALANCE_PATCH_NOTES.md Normal file
View file

@ -0,0 +1,78 @@
# Balance Patch Notes v27.14
## Event system
The old shipment-goal terminology has been replaced by one-day Events in player-facing UI and balance notes. Internal names are mostly event-based; a few generic helper concepts are preserved only where they are not player-facing.
Events start appearing from Day 8 with a 10% chance. An offered event is forced and applies only to the next running day.
Implemented events:
1. Premium Female Shipment
- Female -> Truck event.
- Target = estimated total production x 0.3.
- Success pays an event bonus.
2. Contaminant Sample Export
- Poop is valid Truck cargo.
- All poop income is doubled.
- Target = estimated total production x 0.3.
- Success pays an event bonus.
3. Health Inspection
- Poop mistake fines are doubled.
- Shredding enough poop pays an event bonus.
4. Conveyor Jam Warning
- Jam explosion threshold becomes 85% congestion.
- Finishing with zero explosions pays an event bonus.
5. Power Saving Order
- Conveyor speed x0.8.
- Auto Scanner cooldown x1.2.
- Settlement pays a flat subsidy.
6. Overtime Day
- Day length becomes 80 seconds.
- No extra effect.
7. Short Shift
- Day length becomes 40 seconds.
- Next Fairies tribute is reduced by 10%.
8. Chick Boom
- Egg interval x0.65.
- Poop rate +5 percentage points.
9. Rotten Feed
- Poop rate +20 percentage points.
- Shredder poop payout x1.5.
10. Maintenance Audit
- No penalty.
- If no maintained machine is below 50% remaining durability, settlement pays a bonus.
## Related balance behavior
- Shredder Upgrade payout is affected by Event poop/shredder multipliers.
- COMPOSTER payout is also affected by Event shredder payout multipliers.
- Poop target Truck events no longer fine poop when poop is the valid cargo.
- Chemical Weapons Subsidy remains 70 poop / 1200 yen.
- Fairies tribute is paid on Next Day press.
- Normal Loan card and repayment state are removed. Emergency Loan remains.
- The old tax system is removed. Fairies tribute is the only recurring day-start levy.
## v27.14 consistency/debug changes
- Debug UI restored intentionally for balancing builds. HTML/CSS/JS include comments stating it should not be removed during cleanup unless explicitly requested.
- Debug card selector is grouped by gameplay genre.
- Debug single-card grant supports selecting the target for target-consuming build-phase cards.
- Debug All Cards still uses automatic valid targets.
- Removed unused special weight logic left over from old SPARE PARTS BIN behavior.
- Removed unused Egg Farm cash upgrade cost data; Egg upgrades are card-based.
- Shredder Upgrade now sums upgrades across all Shredder facilities, not only the original primary Shredder.
- Maintenance and repair target collection now includes additional Mixer/Shredder facilities.
- Maintenance Audit low-durability checks now include additional Mixer/Shredder facilities.
- Fixed duplicate Emergency Loan button in the bankruptcy decision modal.
- Fixed stale debug-removal CSS fragments left from the previous audit pass.
- Fixed corrupted score-breakdown formula text.

View file

@ -1,4 +1,4 @@
# Chick Sorter v27.0 Cleanup Build
# Chick Sorter v27.14 Balancing Build
Open `index.html` in a browser.
@ -24,8 +24,8 @@ After deploying, purge any CDN or reverse-proxy cache once.
- Build, move, box-select, sell, and repair factory equipment during Build phase.
- Start the next day only when at least one EGG route reaches a scanner and then any valid exit.
- Fairies tribute is paid after day-end settlement and before the next Build phase.
- Bankruptcy is checked only at that day-end transition. Temporary negative cash during production does not immediately end the game.
- Fairies tribute is paid when pressing Next Day, using the previous day's net profit as part of the calculation.
- Bankruptcy is checked at day-end settlement after shipment/event/subsidy revenue. Temporary negative cash during production does not immediately end the game.
- Cards use English names and descriptions with numeric effect summaries.
- `poop.png` is the only bundled image asset currently used by default; other art falls back to canvas drawing.
@ -41,6 +41,6 @@ After deploying, purge any CDN or reverse-proxy cache once.
## Cleanup in this build
- Removed duplicate `src/index.html` and `src/styles.css`; root `index.html` and `styles.css` are the single launch surface.
- Removed the old debug panel, debug hotkey, debug event log, and free-upgrade/debug-cash code.
- Debug panel is intentionally present for balancing builds. Do not remove it during cleanup unless explicitly requested. Its card menu is grouped and target-consuming cards can be applied to selected targets.
- Removed the obsolete right-side selected-facility panel code left behind after the UI removal.
- Removed stale historical README files and old changelog text that no longer matched the current specification.

85
dist/game.js vendored

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v27.6</title>
<title>Chick Sorter v27.14</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
@ -12,9 +12,9 @@
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
<div class="hud hud-top-left" aria-label="Run status">
<div class="hud-card cash"><span>CASH</span><strong id="money">・・250</strong></div>
<div class="hud-card cash"><span>CASH</span><strong id="money">¥250</strong></div>
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">・・0</strong></div>
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">¥0</strong></div>
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
<div class="hud-card combo"><span>COMBO</span><strong id="comboCount">0</strong></div>
@ -36,7 +36,7 @@
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v27.6</span></h1>
<h1>CHICK SORTER <span>v27.14</span></h1>
<p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
@ -46,24 +46,28 @@
<section class="equipment-section">
<h2>Add / Edit Equipment</h2>
<div class="large-tools">
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>・・30 / tile</span></button>
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>・・50 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>・・250</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 3.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>・・260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>・・450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>・・240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>・・450</span></button>
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>¥75 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥180 / 3.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</span></button>
<button id="hireRepairmanButton" class="tool-button repair" type="button"><strong>Hire Repairman</strong><span>・・100 / next day</span></button>
<button id="hireRepairmanButton" class="tool-button repair" type="button"><strong>Hire Repairman</strong><span>¥100 / next day</span></button>
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
</div>
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
</section>
<section class="rules-section compact-rules">
<section class="rules-section compact-rules">
<h2>Status</h2>
<div id="turnSummary" class="mini-box"></div>
</section>
<section class="rules-section compact-rules">
<h2>Next Event</h2>
<div id="eventPanel" class="event-card empty"></div>
</section>
</div>
</div>
@ -77,19 +81,24 @@
</div>
<div id="hoverTooltip" class="hover-tooltip" aria-hidden="true"></div>
<!-- DEBUG UI is intentionally kept for this active balancing build. Do not remove during cleanup/audit passes unless explicitly requested. -->
<details id="debugPanel" class="debug-panel">
<summary>DBG</summary>
<div class="debug-body">
<label class="debug-check"><input id="debugInfiniteCash" type="checkbox" /> Infinite money</label>
<label class="debug-day">Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
<button id="debugSetDayButton" type="button">Set Day</button>
<div class="debug-row">
<label class="debug-day">Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
<button id="debugSetDayButton" type="button">Set Day</button>
</div>
<button id="debugZeroTimerButton" type="button">Timer 0</button>
<label class="debug-card">Card <select id="debugCardSelect"></select></label>
<button id="debugGrantCardButton" type="button">Grant Card</button>
<button id="debugGrantAllCardsButton" type="button">All Cards</button>
<label class="debug-card">Card <select id="debugCardSelect"></select></label>
<label class="debug-target">Target <select id="debugTargetSelect"></select></label>
<button id="debugGrantCardButton" type="button">Grant Selected</button>
<div id="debugReadout" class="debug-readout"></div>
</div>
</details>
</section>
</main>

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "zunda-shiwake",
"version": "27.10.0",
"version": "27.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zunda-shiwake",
"version": "27.10.0",
"version": "27.13.0",
"devDependencies": {
"esbuild": "^0.25.0"
}

View file

@ -1,6 +1,6 @@
{
"name": "zunda-shiwake",
"version": "27.6.0",
"version": "27.14.0",
"private": true,
"type": "module",
"scripts": {

View file

@ -1,7 +1,7 @@
// Central balance sheet for tuning gameplay.
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
export const BALANCE = {
version: 'v27.6 direct conveyor chick movement',
version: 'v27.14 debug restore consistency patch',
time: {
daySeconds: 60,
farmShutdownGraceSeconds: 10
@ -32,30 +32,31 @@ export const BALANCE = {
truck: 10
},
poopFine: 10,
zundaTax: {
exemption: 500,
legalWorkExemptionBonus: 250,
maxRateCash: 10000,
stepAmount: 500,
stepRate: 0.05,
maxRate: 0.95
},
fairiesTribute: {
perDay: 10
perDay: 10,
profitExemption: 300,
legalWorkExemptionBonus: 250,
profitRate: 0.12
},
incomeUpgradeRate: 1.10,
explosionDamageDivisor: 30,
maleTruckFinePerHalfDay: 30,
shredderBonus: {
maxCards: 30,
probabilityPercentDivisor: 2
incomePerUpgrade: 1.25
},
chemicalWeaponSubsidy: {
poopPerBlock: 70,
yenPerBlock: 1200
}
},
production: {
poopRate: 0.08,
autoScannerCooldown: 3.5,
autoScannerUpgradeRate: 0.95,
autoScannerCardUpgradeRate: 0.95,
autoScannerMinCooldown: 0.5,
poopRateCardBonus: 0.04,
eggSpawnRanges: [
[1.95, 4.35],
[1.55, 3.45],
@ -88,6 +89,14 @@ export const BALANCE = {
description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.',
tags: ['SCANNER', 'ACTIVE']
},
{
id: 'autoScannerTuning',
title: 'AUTO SCANNER Tuning',
rarity: 'common',
type: 'instant',
description: 'All AUTO SCANNER cooldowns use default cooldown x0.95 per copy.',
tags: ['SCANNER', 'PASSIVE']
},
{
id: 'upgradeMixer',
title: 'MIXER Improvement',
@ -117,7 +126,7 @@ export const BALANCE = {
},
{
id: 'crowdedFarming',
title: 'Crowded Farming Contract',
title: 'Crowded Farming',
rarity: 'common',
type: 'instant',
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.',
@ -131,6 +140,14 @@ export const BALANCE = {
description: 'EGG production interval -4%. Poop rate +3%. No card limit.',
tags: ['EGG', 'POOP', 'PASSIVE']
},
{
id: 'poopRateBooster',
title: 'Poop Culture',
rarity: 'common',
type: 'instant',
description: 'Base poop rate +4 percentage points. No card limit.',
tags: ['POOP', 'PASSIVE']
},
{
id: 'safetyCover',
title: 'Safety Cover',
@ -177,9 +194,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'Restores 2% durability to every machine. A box of almost-compatible parts.',
tags: ['MAINTENANCE', 'ACTIVE'],
weightBase: 100,
weightLossPerCopy: 2
tags: ['MAINTENANCE', 'ACTIVE']
},
{
id: 'dudRefund',
@ -263,14 +278,6 @@ export const BALANCE = {
description: 'Next Fairies tribute -50%.',
tags: ['ECONOMY', 'RARE', 'ONE-SHOT']
},
{
id: 'loan',
title: 'Loan',
rarity: 'rare',
type: 'instant',
description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.',
tags: ['ECONOMY', 'RISK', 'RARE']
},
{
id: 'extraCards',
title: 'Extra Cards',
@ -284,7 +291,7 @@ export const BALANCE = {
title: 'Legal Work',
rarity: 'rare',
type: 'instant',
description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.',
description: 'Fairies tribute profit exemption +JPY 250.',
tags: ['ECONOMY', 'RARE', 'PASSIVE']
},
{
@ -292,7 +299,7 @@ export const BALANCE = {
title: 'Chemical Weapons Subsidy',
rarity: 'ultraRare',
type: 'instant',
description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.',
description: 'Each day, gain JPY 1200 per 70 poop shipped by truck. No card limit.',
tags: ['POOP', 'ULTRA RARE', 'PASSIVE']
},
{
@ -316,8 +323,9 @@ export const BALANCE = {
bearing: 0,
legalWork: 0,
fairiesFlatteryNext: 0,
autoScannerTuning: 0,
poopRateBooster: 0,
crowdedFarming: 0,
extraEggOutlet: 0,
hatchingFeed: 0,
safetyCover: 0,
recyclingSubsidy: 0,
@ -332,8 +340,7 @@ export const BALANCE = {
scannerQueueSpacing: 0,
laborExploitation: 0,
usedMachineActive: false,
rescueLoanCharges: 0,
loans: []
rescueLoanCharges: 0
},
commonWeight: 8,
rareWeight: 2,
@ -348,8 +355,8 @@ export const BALANCE = {
multiplier: 100
}
},
contracts: {
eventChance: 0.70,
events: {
eventChance: 0.10,
firstTurn: 8
},
maintenance: {
@ -383,14 +390,14 @@ export const BALANCE = {
},
eggFarm: {
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]
buildable: true, upgradeable: true, maxLevel: 4
},
manualScanner: {
id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260,
buildable: true, upgradeable: false
},
autoScanner: {
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360,
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 180,
buildable: true, upgradeable: false
},
mixer: {

View file

@ -9,10 +9,12 @@ export const ECONOMY = BALANCE.economy;
export const FARM_SHUTDOWN_GRACE_SECONDS = BALANCE.time.farmShutdownGraceSeconds;
export const AUTO_SCANNER_COOLDOWN = BALANCE.production.autoScannerCooldown;
export const AUTO_SCANNER_UPGRADE_RATE = BALANCE.production.autoScannerUpgradeRate;
export const AUTO_SCANNER_CARD_UPGRADE_RATE = BALANCE.production.autoScannerCardUpgradeRate || BALANCE.production.autoScannerUpgradeRate;
export const AUTO_SCANNER_MIN_COOLDOWN = BALANCE.production.autoScannerMinCooldown;
export const CONTRACT_EVENT_CHANCE = BALANCE.contracts.eventChance;
export const CONTRACT_EVENT_FIRST_TURN = BALANCE.contracts.firstTurn;
export const DAILY_EVENT_CHANCE = BALANCE.events.eventChance;
export const DAILY_EVENT_FIRST_TURN = BALANCE.events.firstTurn;
export const POOP_RATE = BALANCE.production.poopRate;
export const POOP_RATE_CARD_BONUS = BALANCE.production.poopRateCardBonus || 0;
export const CONVEYOR_SPEED = BALANCE.conveyor.baseSpeed;
export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed;
export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier;

View file

@ -1,6 +1,6 @@
import { BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
import { nextSpawnDelay } from './state.js';
import { currentPoopRate } from '../systems/contracts.js';
import { currentPoopRate } from '../systems/events.js';
const DEFAULT_MANUAL_KEYS = [
[{ key: 'a', code: 'KeyA', label: 'A' }, { key: 'd', code: 'KeyD', label: 'D' }],

View file

@ -27,9 +27,7 @@ export function newTurnStats() {
manualComboSuccess: 0,
manualComboFailure: 0,
repairWorkerWages: 0,
zundaTax: 0,
fairiesTribute: 0,
loanRepayment: 0,
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
cardRerollCost: 0,
@ -43,8 +41,9 @@ export function newTurnStats() {
shredderBonus: 0,
correct: 0,
mistakes: 0,
contractBonus: 0,
contractResult: null,
eventBonus: 0,
explosions: 0,
eventResult: null,
conveyorPasses: 0,
eggProduced: 0
};
@ -66,10 +65,9 @@ export function newTotalStats() {
poopMixer: 0,
explosionDamage: 0,
shredderBonus: 0,
contractBonus: 0,
zundaTax: 0,
eventBonus: 0,
explosions: 0,
fairiesTribute: 0,
loanRepayment: 0,
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
manualComboBonus: 0,
@ -83,8 +81,8 @@ export function newTotalStats() {
mixerPoopFine: 0,
truckPoopFine: 0,
maleTruckFine: 0,
contractSuccess: 0,
contractFailed: 0,
eventSuccess: 0,
eventFailed: 0,
conveyorPasses: 0,
eggProduced: 0
};
@ -139,9 +137,10 @@ export function createGame() {
mixerHalfTimer: 0,
congestion: new Map(),
lastExplodedComponent: new Map(),
contractOffer: null,
contractActive: null,
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, sparePartsBin: 0, dudRefund: 0, freeReroll: 0, composter: 0, scannerQueueSpacing: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
eventOffer: null,
eventActive: null,
pendingFairiesTributeMultiplier: 1,
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, autoScannerTuning: 0, poopRateBooster: 0, crowdedFarming: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, sparePartsBin: 0, dudRefund: 0, freeReroll: 0, composter: 0, scannerQueueSpacing: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0 },
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null,
manualCombo: { count: 0, lastBonus: 0 },
@ -205,7 +204,7 @@ const INITIAL_SCANNERS = Object.freeze([
]);
const INITIAL_EGG_FARMS = Object.freeze([
{ type: 'eggFarm', id: 1, col: 1, row: 0, level: 1, extraEggOutlet: 0, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
{ type: 'eggFarm', id: 1, col: 1, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
]);
function addConveyorTile(game, col, row, options = {}) {

View file

@ -4,15 +4,15 @@ import { createGame, resetLayout, newTurnStats } from './core/state.js';
import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js';
import { expansionLots, lotBounds } from './core/gridExpansion.js';
import { commitFactoryGraphForDay, facilityConnectionIssues, scannerConnector } from './systems/routing.js';
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, 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 { CARD_DEFS, conveyorSpeedForGame, createCardSystem, debugGrantCard } from './systems/cards.js';
import { CARD_DEFS, cardById, conveyorSpeedForGame, createCardSystem, debugCardTargets, debugGrantCard } from './systems/cards.js';
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js';
import { rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
const canvas = document.getElementById('gameCanvas');
@ -23,7 +23,7 @@ const ui = {
money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'),
eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'),
turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'),
buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'),
buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), eventPanel: document.getElementById('eventPanel'),
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
hoverTooltip: document.getElementById('hoverTooltip'),
manualScannerMonitor: document.getElementById('manualScannerMonitor'),
@ -35,6 +35,7 @@ const ui = {
setDay: document.getElementById('debugSetDayButton'),
zeroTimer: document.getElementById('debugZeroTimerButton'),
cardSelect: document.getElementById('debugCardSelect'),
targetSelect: document.getElementById('debugTargetSelect'),
grantCard: document.getElementById('debugGrantCardButton'),
grantAllCards: document.getElementById('debugGrantAllCardsButton'),
readout: document.getElementById('debugReadout')
@ -62,13 +63,139 @@ function initializeStaticText() {
}
}
// DEBUG UI is intentionally part of this non-production balancing build.
// Do not remove it during consistency cleanup/audit passes unless explicitly requested.
function debugCategoryForCard(card) {
const tags = new Set(card.tags || []);
if (['upgradeEgg', 'crowdedFarming', 'hatchingFeed', 'extraEggOutlet'].includes(card.id) || tags.has('EGG')) return 'Production / EGG';
if (['upgradeTrash', 'poopRateBooster', 'composter', 'chemicalWeaponSubsidy'].includes(card.id) || tags.has('POOP')) return 'Poop / Shredder';
if (['upgradeAutoScanner', 'autoScannerTuning', 'scannerQueueSpacing'].includes(card.id) || tags.has('SCANNER')) return 'Scanner';
if (['bearing', 'dynamite'].includes(card.id) || tags.has('CONVEYOR')) return 'Conveyor / Map';
if (['safetyCover', 'preventiveMaintenance', 'durabilityCoating', 'sparePartsBin', 'laborExploitation'].includes(card.id) || tags.has('MAINTENANCE')) return 'Maintenance / Risk';
if (['dudFilter', 'dudRefund', 'freeReroll', 'extraCards'].includes(card.id) || tags.has('CARD')) return 'Card Engine';
if (tags.has('ULTRA RARE')) return 'Ultra Rare';
if (tags.has('ECONOMY') || ['upgradeMixer', 'upgradeTruck', 'recyclingSubsidy', 'usedMachine', 'newMachine', 'flattery', 'legalWork', 'rescueLoan'].includes(card.id)) return 'Economy / Shipment';
return 'Other';
}
function initializeDebugPanel() {
if (!ui.debug?.panel) return;
if (ui.debug.cardSelect) {
ui.debug.cardSelect.innerHTML = CARD_DEFS
.map(card => `<option value="${card.id}">${card.title}</option>`)
.join('');
if (!ui.debug?.panel || !ui.debug.cardSelect) return;
const order = ['Production / EGG', 'Poop / Shredder', 'Scanner', 'Conveyor / Map', 'Maintenance / Risk', 'Card Engine', 'Economy / Shipment', 'Ultra Rare', 'Other'];
const groups = new Map(order.map(label => [label, []]));
for (const card of CARD_DEFS) {
const label = debugCategoryForCard(card);
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(card);
}
ui.debug.cardSelect.innerHTML = '';
for (const label of order) {
const cards = groups.get(label) || [];
if (!cards.length) continue;
const optGroup = document.createElement('optgroup');
optGroup.label = label;
for (const card of cards) {
const option = document.createElement('option');
option.value = card.id;
const rarity = card.rarity === 'ultraRare' ? 'UR' : card.rarity === 'rare' ? 'R' : 'C';
option.textContent = `[${rarity}] ${card.title}`;
optGroup.appendChild(option);
}
ui.debug.cardSelect.appendChild(optGroup);
}
ui.debug.cardSelect.addEventListener('change', refreshDebugTargetSelect);
refreshDebugTargetSelect();
}
function refreshDebugTargetSelect() {
if (!ui.debug?.targetSelect) return;
const id = ui.debug.cardSelect?.value;
const card = cardById(id);
const targets = card ? debugCardTargets(game, card) : [];
ui.debug.targetSelect.innerHTML = '';
if (!targets.length) {
const option = document.createElement('option');
option.value = '__none__';
option.textContent = card ? 'No target required' : 'No card selected';
ui.debug.targetSelect.appendChild(option);
ui.debug.targetSelect.disabled = true;
return;
}
const auto = document.createElement('option');
auto.value = '__auto__';
auto.textContent = card?.id === 'dynamite' ? 'Auto: first 3 blocked cells' : 'Auto: first valid target';
ui.debug.targetSelect.appendChild(auto);
for (const target of targets) {
const option = document.createElement('option');
option.value = target.key;
option.textContent = target.label;
ui.debug.targetSelect.appendChild(option);
}
ui.debug.targetSelect.disabled = false;
}
function debugSetDay() {
const day = Math.max(1, Math.floor(Number(ui.debug.dayInput?.value) || 1));
game.turn = day;
if (ui.debug.dayInput) ui.debug.dayInput.value = String(day);
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `DAY ${day}`, THEME.green);
uiSystem.updatePanels();
uiSystem.updateUI();
refreshDebugTargetSelect();
updateDebugReadout(`Day set to ${day}.`);
}
function debugZeroTimer() {
game.timeLeft = 0;
if (game.phase === 'running') closeFarmShutters();
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, 'TIMER 0', THEME.warn);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout('Timer set to 0.');
}
function debugGrantSelectedCard() {
const id = ui.debug.cardSelect?.value;
const targetKey = ui.debug.targetSelect?.disabled ? undefined : ui.debug.targetSelect?.value;
const result = debugGrantCard(game, id, { targetKey });
if (ui.debug.grantCard) {
ui.debug.grantCard.textContent = result.ok ? 'Granted' : 'Failed';
window.setTimeout(() => { if (ui.debug.grantCard) ui.debug.grantCard.textContent = 'Grant Selected'; }, 900);
}
chicks?.updateCongestion?.();
uiSystem.updatePanels();
uiSystem.updateUI();
refreshDebugTargetSelect();
updateDebugReadout(result.reason);
}
function debugGrantAllCards() {
const results = CARD_DEFS.map(card => debugGrantCard(game, card, { targetKey: '__auto__' }));
const ok = results.filter(result => result.ok).length;
chicks?.updateCongestion?.();
uiSystem.updatePanels();
uiSystem.updateUI();
refreshDebugTargetSelect();
updateDebugReadout(`Granted ${ok}/${CARD_DEFS.length} cards.`);
}
function applyDebugState() {
if (ui.debug?.infiniteCash?.checked && game.cash < 999999) {
game.cash = 999999;
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
}
}
function updateDebugReadout(message = '') {
if (!ui.debug?.readout) return;
ui.debug.readout.textContent = [
message,
`D${game.turn}`,
yen(game.cash),
`Combo ${game.manualCombo?.count || 0}`,
`Event ${game.eventActive?.title || game.eventOffer?.title || 'none'}`,
`Rescue ${game.cardEffects?.rescueLoanCharges || 0}`,
`Chem ${game.cardEffects?.chemicalWeaponSubsidy || 0}`
].filter(Boolean).join(' | ');
}
const game = createGame();
@ -96,64 +223,6 @@ function startGame() {
updateDebugReadout();
}
function debugSetDay() {
const day = Math.max(1, Math.floor(Number(ui.debug.dayInput?.value) || 1));
game.turn = day;
if (ui.debug.dayInput) ui.debug.dayInput.value = String(day);
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `DAY ${day}`, THEME.green);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(`Day set to ${day}.`);
}
function debugZeroTimer() {
game.timeLeft = 0;
if (game.phase === 'running') closeFarmShutters();
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, 'TIMER 0', THEME.warn);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout('Timer set to 0.');
}
function debugGrantSelectedCard() {
const id = ui.debug.cardSelect?.value;
const result = debugGrantCard(game, id);
if (ui.debug.grantCard) {
ui.debug.grantCard.textContent = result.ok ? 'Granted' : 'Grant failed';
window.setTimeout(() => { if (ui.debug.grantCard) ui.debug.grantCard.textContent = 'Grant Card'; }, 900);
}
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(result.reason);
}
function debugGrantAllCards() {
const results = CARD_DEFS.map(card => debugGrantCard(game, card));
const ok = results.filter(result => result.ok).length;
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(`Granted ${ok}/${CARD_DEFS.length} cards.`);
}
function applyDebugState() {
if (ui.debug?.infiniteCash?.checked && game.cash < 999999) {
game.cash = 999999;
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
}
}
function updateDebugReadout(message = '') {
if (!ui.debug?.readout) return;
ui.debug.readout.textContent = [
message,
`D${game.turn}`,
yen(game.cash),
`Combo ${game.manualCombo?.count || 0}`,
`Rescue ${game.cardEffects?.rescueLoanCharges || 0}`,
`Chem ${game.cardEffects?.chemicalWeaponSubsidy || 0}`
].filter(Boolean).join(' | ');
}
function startNextTurn() {
if (game.phase !== 'build') return;
if (game.cardDraft?.pending || game.cardTargetPick?.pending) {
@ -170,17 +239,15 @@ function startNextTurn() {
ensureMaintenanceState(game);
commitFactoryGraphForDay(game);
activateRepairmanForDay(game);
const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const zunda = collectZundaTax(game, zundaBasisProfit);
const loanRepayment = collectLoanRepayments(game);
game.lastStartFees = { zundaTax: zunda.tax || 0, loanRepayment: loanRepayment.amount || 0, fairiesTribute: 0 };
if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger);
if (loanRepayment.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `LOAN -${yen(loanRepayment.amount)}`, THEME.danger);
const priorProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const tribute = collectFairiesTribute(game, Math.max(1, game.turn || 1), priorProfit);
game.lastStartFees = { fairiesTribute: tribute.amount || 0, tribute };
if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger);
// Negative cash during the next production day is allowed; bankruptcy is checked only at day-end settlement before Build phase.
activateAcceptedContract(game);
activateEventOffer(game);
game.phase = 'running';
game.turn += 1;
game.timeLeft = TURN_SECONDS;
game.timeLeft = eventDaySeconds(game);
game.shutdownTimeLeft = 0;
game.cleanupTimer = null;
game.cleanupBlown = false;
@ -191,10 +258,9 @@ function startNextTurn() {
game.truckCargo = [];
resetCameraToFactoryStart();
game.stats = newTurnStats();
game.stats.zundaTax = 0;
game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0;
game.stats.fairiesTribute = game.lastStartFees?.fairiesTribute || 0;
game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0;
const carriedStartCosts = game.stats.loanRepayment + game.stats.repairWorkerWages;
const carriedStartCosts = game.stats.fairiesTribute + game.stats.repairWorkerWages;
if (carriedStartCosts > 0) {
game.stats.penalty += carriedStartCosts;
game.stats.profit -= carriedStartCosts;
@ -229,7 +295,8 @@ function hireRepairman() {
function completeTurn() {
if (game.phase !== 'running') return;
const ship = settleTruckRevenue(game);
const contract = resolveContract(game);
const activeEvent = game.eventActive ? { ...game.eventActive } : null;
const eventResult = resolveEvent(game);
const chemical = collectChemicalWeaponSubsidy(game);
if (ship.adjusted > 0) {
const truck = game.facilities.truck;
@ -237,15 +304,13 @@ function completeTurn() {
const y = truck ? truck.y + 26 : 96 - game.view.y;
floating(game, x, y, `${ship.target === 'poop' ? 'POOP SHIP' : 'SHIP'} +${yen(ship.adjusted)}`, THEME.green);
}
if (contract?.bonus > 0) floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `CONTRACT +${yen(contract.bonus)}`, THEME.green);
if (eventResult?.bonus > 0) floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `EVENT +${yen(eventResult.bonus)}`, THEME.green);
if (chemical.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `CHEM SUBSIDY +${yen(chemical.amount)}`, THEME.green);
clearActiveContract(game);
const settlementDay = Math.max(1, (game.totals.turnsCompleted || 0) + 1);
const tribute = collectFairiesTribute(game, settlementDay);
game.lastBuildFees = { fairiesTribute: tribute.amount || 0, reduction: tribute.reduction || 0, turn: settlementDay };
if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger);
clearActiveEvent(game);
game.totals.turnsCompleted += 1;
game.lastResult = { ...game.stats, ship, contract, chemical, rescue: { amount: 0, used: 0, remaining: game.cardEffects?.rescueLoanCharges || 0 }, fairiesTribute: tribute, turn: game.turn, cash: game.cash };
const tributeMultiplier = fairiesTributeMultiplierFromEvent(activeEvent);
if (tributeMultiplier !== 1) game.pendingFairiesTributeMultiplier = tributeMultiplier;
game.lastResult = { ...game.stats, ship, event: eventResult, chemical, rescue: { amount: 0, used: 0, remaining: game.cardEffects?.rescueLoanCharges || 0 }, fairiesTribute: { amount: game.stats.fairiesTribute || 0 }, tributeMultiplier, turn: game.turn, cash: game.cash };
if (game.cash < 0) {
if ((game.cardEffects?.rescueLoanCharges || 0) > 0) {
game.phase = 'loanDecision';
@ -271,7 +336,7 @@ function enterBuildPhase() {
for (const scanner of game.scanners) scanner.queue = [];
for (const farm of game.eggFarms) { farm.shutterProgress = 0; farm.shutterSparked = false; }
deactivateRepairman(game);
game.contractOffer = rollContractOffer(game);
game.eventOffer = rollEventOffer(game);
if (cardSystem) cardSystem.prepareDraft();
game.cardTargetPick = null;
game.buildTool = null;
@ -317,11 +382,11 @@ function update(timestamp) {
if (!game.lastTimestamp) game.lastTimestamp = timestamp;
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
game.lastTimestamp = timestamp;
applyDebugState();
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
chicks.updateCongestion();
drawAll(ctx, canvas, game, { activeQueuedChick: chicks.activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle });
applyDebugState();
uiSystem.updateUI();
updateManualScannerMonitor();
updateDebugReadout();
@ -654,7 +719,7 @@ ui.debug.setDay?.addEventListener('click', debugSetDay);
ui.debug.zeroTimer?.addEventListener('click', debugZeroTimer);
ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard);
ui.debug.grantAllCards?.addEventListener('click', debugGrantAllCards);
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } });
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); refreshDebugTargetSelect(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); refreshDebugTargetSelect(); } });
function scannerKeyMatches(scanner, side, event) {
const binding = scanner.keys?.[side];
if (!binding) return false;

View file

@ -558,7 +558,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
}
function targetForTruck(game) {
return game.contractActive?.target || game.contractOffer?.target || 'female';
return game.eventActive?.target || game.eventOffer?.target || 'female';
}
function drawItemIcon(ctx, x, y, type) {
if (type === 'poop') drawTinyPoop(ctx, x, y);
@ -672,7 +672,7 @@ function drawTruck(ctx, game, t = game.facilities.truck) {
ctx.fillStyle = THEME.ink; ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + t.w / 2, t.y + 42);
const truckTargetType = targetForTruck(game);
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.contractOffer && !game.contractActive ? 'next event' : 'truck cargo');
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.eventOffer && !game.eventActive ? 'next event' : 'truck cargo');
ctx.fillStyle = THEME.greenSoft; rect(ctx, t.x + 13, t.y + 88, t.w - 26, t.h - 103, true, false);
for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);

View file

@ -7,7 +7,7 @@ import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lo
import { key, parseKey, pointToCell, cellCenter, yen, directionNameBetweenCells } from '../core/utils.js';
import { TEXT, equipmentName } from '../core/text.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js';
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusIncome, shredderBonusMaxCards } from './economy.js';
import { autoScannerCooldownSeconds } from './cards.js';
import { record } from './history.js';
import { floating, eraseEffect } from './effects.js';
@ -941,10 +941,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
if (facilityKind === 'trash') {
const cards = shredderUpgradeCount(game);
const chance = shredderBonusChance(cards);
const income = shredderBonusIncome(cards);
lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`);
lines.push(`Bonus chance: ${(chance * 100).toFixed(2)}% for ${yen(1)} per processed item.`);
lines.push(`Expected value: ${chance.toFixed(3)} JPY per item.`);
lines.push(`Poop income bonus: ${yen(income)} per processed poop.`);
lines.push(`With enough upgrades, poop can out-earn male/female routes.`);
}
if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
}

View file

@ -1,7 +1,7 @@
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_CARD_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
import { nextSpawnDelay } from '../core/state.js';
import { cellCenter, key, yen } from '../core/utils.js';
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusIncome, shredderBonusMaxCards } from './economy.js';
import { floating, shake, eraseEffect, shockwave, sparkBurst, smokeBurst } from './effects.js';
import { averageConveyorPerformance, autoScannerDelayMultiplier, ensureMaintenanceState, repairAllEquipment } from './maintenance.js';
import { scannerCenter, refreshRoutingAfterEdit } from './routing.js';
@ -19,7 +19,7 @@ function effectCount(game, id) {
}
export function ensureCardState(game) {
if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS, loans: [] };
if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS };
for (const [k, v] of Object.entries(CARD_DEFAULT_EFFECTS)) {
if (game.cardEffects[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v;
}
@ -27,7 +27,6 @@ export function ensureCardState(game) {
game.cardEffects.fairiesFlatteryNext = Math.max(0, game.cardEffects.fairiesFlatteryNext || 0) + Math.max(0, game.cardEffects.flattery || 0);
delete game.cardEffects.flattery;
}
if (!Array.isArray(game.cardEffects.loans)) game.cardEffects.loans = [];
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
if (!game.cardDraft) game.cardDraft = { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;
@ -54,13 +53,19 @@ export function conveyorSpeedForGame(game, conveyorKey = null) {
const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0));
const beltMultiplier = conveyorKey ? Math.max(0.1, Number(game?.conveyorMeta?.get?.(conveyorKey)?.speedMultiplier) || 1) : 1;
const perf = conveyorKey ? 1 : averageConveyorPerformance(game);
return Math.min(CONVEYOR_SPEED_MAX, speed * perf) * beltMultiplier;
const eventMult = Math.max(0.01, Number(game?.eventActive?.conveyorSpeedMultiplier) || 1);
return Math.min(CONVEYOR_SPEED_MAX, speed * perf) * beltMultiplier * eventMult;
}
export function autoScannerCooldownSeconds(scanner, game = null) {
const upgrades = Math.max(0, (scanner?.level || 1) - 1);
const base = Math.max(AUTO_SCANNER_MIN_COOLDOWN, AUTO_SCANNER_COOLDOWN * Math.pow(AUTO_SCANNER_UPGRADE_RATE, upgrades));
return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner)) : base;
const localUpgrades = Math.max(0, (scanner?.level || 1) - 1);
const globalUpgrades = game ? effectCount(game, 'autoScannerTuning') : 0;
const tunedBase = AUTO_SCANNER_COOLDOWN
* Math.pow(AUTO_SCANNER_UPGRADE_RATE, localUpgrades)
* Math.pow(AUTO_SCANNER_CARD_UPGRADE_RATE, globalUpgrades);
const base = Math.max(AUTO_SCANNER_MIN_COOLDOWN, tunedBase);
const eventMult = game ? Math.max(0.01, Number(game?.eventActive?.autoScannerCooldownMultiplier) || 1) : 1;
return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner) * eventMult) : base;
}
export function rerollCost(game) {
@ -73,35 +78,58 @@ export function cardById(id) {
return CARD_DEFS.find(card => card.id === id) || null;
}
export function debugGrantCard(game, cardOrId) {
export function debugCardTargets(game, cardOrId) {
const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId;
ensureCardState(game);
if (!card || !(card.type === 'equipmentUpgrade' || card.type === 'cellAction')) return [];
return targetsForCard(game, card).map(target => ({
key: targetKey(target),
label: targetLabel(game, target),
type: target.type
}));
}
export function debugGrantCard(game, cardOrId, options = {}) {
const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId;
ensureCardState(game);
if (!card || card.type === 'dud') return { ok: false, reason: 'Card not found.' };
if (card.id === 'extraCards') {
game.cardDraft = game.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
game.cardDraft.choices.push(...dealCards(game, 2));
return { ok: true, reason: `${card.title} added draft cards.` };
return { ok: true, reason: `${card.title} added 2 draft cards.` };
}
if (card.type === 'instant') {
applyInstantCard(game, card);
return { ok: true, reason: `${card.title} granted.` };
}
if (card.type === 'equipmentUpgrade') {
const target = targetsForCard(game, card)[0];
if (!target) return { ok: false, reason: `No valid target for ${card.title}.` };
const targets = targetsForCard(game, card);
if (!targets.length) return { ok: false, reason: `No valid target for ${card.title}.` };
const requestedKey = options.targetKey && options.targetKey !== '__auto__' ? String(options.targetKey) : '';
const target = requestedKey ? targets.find(t => targetKey(t) === requestedKey) : targets[0];
if (!target) return { ok: false, reason: `Selected target is no longer valid for ${card.title}.` };
applyEquipmentUpgrade(game, target);
return { ok: true, reason: `${card.title} applied.` };
return { ok: true, reason: `${card.title} applied to ${targetLabel(game, target)}.` };
}
if (card.type === 'cellAction' && card.target === 'blockedCell') {
const targets = targetsForCard(game, card).slice(0, 3);
const targets = targetsForCard(game, card);
if (!targets.length) return { ok: false, reason: 'No blocked cells to remove.' };
for (const target of targets) {
const requestedKeys = new Set((Array.isArray(options.targetKeys) ? options.targetKeys : [options.targetKey])
.filter(k => k && k !== '__auto__')
.map(String));
const selected = requestedKeys.size
? targets.filter(t => requestedKeys.has(targetKey(t)))
: targets.slice(0, 3);
if (!selected.length) return { ok: false, reason: `Selected target is no longer valid for ${card.title}.` };
for (const target of selected.slice(0, 3)) {
game.blockedCells.delete(key(target.col, target.row));
const c = cellCenter(target.col, target.row);
eraseEffect(game, c.x, c.y);
shockwave(game, c.x, c.y, THEME.danger, 42);
sparkBurst(game, c.x, c.y, 8, THEME.warn);
}
refreshRoutingAfterEdit(game);
return { ok: true, reason: `${card.title} removed ${targets.length} blocked cells.` };
return { ok: true, reason: `${card.title} removed ${Math.min(3, selected.length)} blocked cell(s).` };
}
return { ok: false, reason: `${card.title} cannot be debug-granted.` };
}
@ -137,9 +165,9 @@ function targetLabel(game, target) {
if (target.type === 'facility' && target.id === 'trash') {
const before = shredderUpgradeCount(game);
const after = Math.min(shredderBonusMaxCards(), before + 1);
const beforePercent = (shredderBonusChance(before) * 100).toFixed(2);
const afterPercent = (shredderBonusChance(after) * 100).toFixed(2);
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | ${beforePercent}% -> ${afterPercent}% per item`;
const beforeIncome = shredderBonusIncome(before);
const afterIncome = shredderBonusIncome(after);
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | poop income ${yen(beforeIncome)} -> ${yen(afterIncome)} per item`;
}
return `${target.name || target.id} L${target.level || 1} -> L${(target.level || 1) + 1}`;
}
@ -198,11 +226,7 @@ function availableCards(game) {
});
}
function cardWeight(game, card) {
if (card.weightBase != null) {
const held = effectCount(game, card.id);
return Math.max(0, Number(card.weightBase) - Math.max(0, Number(card.weightLossPerCopy) || 0) * held);
}
function cardWeight(_game, card) {
if (card.rarity === 'ultraRare') return ULTRA_RARE_WEIGHT;
if (card.rarity === 'rare') return RARE_WEIGHT;
return COMMON_WEIGHT;
@ -355,9 +379,11 @@ function applyInstantCard(game, card) {
const effects = ensureCardState(game);
const inc = id => { effects[id] = Math.max(0, effects[id] || 0) + 1; };
if (card.id === 'bearing') inc('bearing');
if (card.id === 'autoScannerTuning') inc('autoScannerTuning');
if (card.id === 'legalWork') inc('legalWork');
if (card.id === 'crowdedFarming') effects.crowdedFarming = Math.min(10, (effects.crowdedFarming || 0) + 1);
if (card.id === 'hatchingFeed') inc('hatchingFeed');
if (card.id === 'poopRateBooster') inc('poopRateBooster');
if (card.id === 'safetyCover') inc('safetyCover');
if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1);
if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy');
@ -383,13 +409,6 @@ function applyInstantCard(game, card) {
inc('rescueLoanCharges');
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green);
}
if (card.id === 'loan') {
const day = Math.max(1, Math.floor(Number(game.turn) || 1));
const amount = 300 * day;
applyRevenue(game, amount);
effects.loans.push({ daysRemaining: day, dailyPayment: 400, borrowedOnTurn: game.turn || 1 });
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, `LOAN +${yen(amount)}`, THEME.green);
}
}
function formatPercent(value) {
@ -441,7 +460,9 @@ function cardDescription(game, card) {
if (card.id === 'upgradeTrash') {
const before = shredderUpgradeCount(game);
const after = Math.min(shredderBonusMaxCards(), before + 1);
return `SHREDDER level +1. Bonus chance per item ${formatPercent(shredderBonusChance(before))} -> ${formatPercent(shredderBonusChance(after))}.`;
const beforeIncome = shredderBonusIncome(before);
const afterIncome = shredderBonusIncome(after);
return `SHREDDER level +1. Poop income ${yen(beforeIncome)} -> ${yen(afterIncome)} per item.`;
}
if (card.id === 'bearing') {
const before = Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, e.bearing || 0)));
@ -450,7 +471,7 @@ function cardDescription(game, card) {
}
if (card.id === 'legalWork') {
const before = Math.max(0, e.legalWork || 0);
return `ZUNDA TAX exemption +${yen(250)}. After pick: exemption bonus ${yen(250 * (before + 1))}.`;
return `Fairies tribute profit exemption +${yen(250)}. After pick: exemption bonus ${yen(250 * (before + 1))}.`;
}
if (card.id === 'crowdedFarming') {
const next = Math.min(10, (e.crowdedFarming || 0) + 1);
@ -461,10 +482,12 @@ function cardDescription(game, card) {
const held = Math.max(0, Number(targets[0]?.farm?.extraEggOutlet) || 0);
return `Choose one EGG. Output ports ${1 + held} -> ${2 + held}. Max 4 ports per EGG.`;
}
if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`;
if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate multiplier ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`;
if (card.id === 'poopRateBooster') return `Held ${e.poopRateBooster || 0}. After pick: base poop rate +${Math.round((e.poopRateBooster || 0) + 1) * 4} percentage points.`;
if (card.id === 'autoScannerTuning') return `Held ${e.autoScannerTuning || 0}. After pick: all AUTO SCANNER default cooldown ${formatPercent(Math.pow(AUTO_SCANNER_CARD_UPGRADE_RATE, (e.autoScannerTuning || 0) + 1))}.`;
if (card.id === 'safetyCover') return `Held ${e.safetyCover || 0}. After pick: explosion damage ${formatPercent(Math.pow(0.95, (e.safetyCover || 0) + 1))}.`;
if (card.id === 'recyclingSubsidy') return `Held ${e.recyclingSubsidy || 0}/3. After pick: normal SELL refund ${Math.round(Math.min(0.8, 0.5 + 0.1 * ((e.recyclingSubsidy || 0) + 1)) * 100)}%.`;
if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(2000 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 100 truck-shipped poop each day.`;
if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(1200 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 70 truck-shipped poop each day.`;
if (card.id === 'preventiveMaintenance') return `Held ${e.preventiveMaintenance || 0}. After pick: deterioration speed ${formatPercent(Math.pow(0.95, (e.preventiveMaintenance || 0) + 1))}.`;
if (card.id === 'dudFilter') {
const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
@ -475,7 +498,7 @@ function cardDescription(game, card) {
return `Held ${held}. DUD chance per card ${formatPercent(before)} -> ${formatPercent(after)}.`;
}
if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`;
if (card.id === 'sparePartsBin') return `Held ${e.sparePartsBin || 0}. Immediately restores 2% durability to every maintained machine. Offer weight: ${Math.max(0, 100 - 2 * Math.max(0, e.sparePartsBin || 0))}.`;
if (card.id === 'sparePartsBin') return `Held ${e.sparePartsBin || 0}. Immediately restores 2% durability to every maintained machine. Common card weight.`;
if (card.id === 'dudRefund') return `Held ${e.dudRefund || 0}. After pick: DUD clicks award ${yen(50 * ((e.dudRefund || 0) + 1))}.`;
if (card.id === 'freeReroll') return `Held ${e.freeReroll || 0}. After pick: each card screen starts with ${(e.freeReroll || 0) + 1} free reroll${(e.freeReroll || 0) + 1 === 1 ? '' : 's'}.`;
if (card.id === 'composter') return `Held ${e.composter || 0}. After pick: SHREDDER poop grants ${yen(3 * ((e.composter || 0) + 1))}.`;
@ -485,10 +508,6 @@ function cardDescription(game, card) {
if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.';
if (card.id === 'laborExploitation') return 'Repairman labor cost -30% permanently. One time only.';
if (card.id === 'rescueLoan') return `At day-end bankruptcy check, gain ${yen(500)} if cash is below ${yen(0)}. Held: ${e.rescueLoanCharges || 0}.`;
if (card.id === 'loan') {
const day = Math.max(1, Math.floor(Number(game.turn) || 1));
return `Gain ${yen(300 * day)} now. Starting tomorrow, pay ${yen(400)} per day for ${day} days.`;
}
return card.description;
}

View file

@ -5,7 +5,7 @@ import { key, parseKey, pointToCell, cellCenter, randomBetween, yen, inGrid } fr
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js';
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount, scannerQueueSpacingMultiplier } from './cards.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck, shredderPayoutMultiplier, congestionExplosionThreshold, congestionWarningThreshold } from './events.js';
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js';
import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js';
@ -582,11 +582,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const target = truckTarget(game);
const isTarget = isTargetTruckCargo(game, chick.sex);
if (chick.sex === 'poop') {
const penalty = applyTruckPoopFine(game);
countPoopDestination(game, 'truck');
if (isTarget) countCorrect(game);
else countMistake(game);
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
if (isTarget) {
countCorrect(game);
floating(game, x, y - 18, `+${yen(upgradedTruckPrice(game))}`, THEME.green);
} else {
const penalty = applyTruckPoopFine(game);
countMistake(game);
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
}
return;
}
if (chick.sex === 'female') {
@ -627,12 +631,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
countTrash(game);
recordFacilityProcess(game, trash);
setFacilityCooldownAfterProcess(game, trash);
const bonus = applyShredderBonus(game);
const bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
let bonus = { amount: 0 };
let bonusText = '';
if (chick.sex === 'poop') {
bonus = applyShredderBonus(game);
bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
countPoopDestination(game, 'trash');
countCorrect(game);
const compost = Math.max(0, Number(game.cardEffects?.composter) || 0) * 3;
const compost = Math.ceil(Math.max(0, Number(game.cardEffects?.composter) || 0) * 3 * shredderPayoutMultiplier(game));
if (compost > 0) {
const compostAmount = applyRevenue(game, compost);
game.stats.composterIncome = (game.stats.composterIncome || 0) + compostAmount;
@ -689,12 +695,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function checkCongestionExplosions() {
for (const comp of game.congestion.values()) {
if (comp.ratio > 0.8 && comp.ratio < 1) {
const explodeAt = congestionExplosionThreshold(game);
const warnAt = congestionWarningThreshold(game);
if (comp.ratio > warnAt && comp.ratio < explodeAt) {
for (const chick of chicksInComponent(comp.id).slice(0, 3)) {
if (Math.random() < 0.06) rageEffect(game, chick.x, chick.y - chick.radius - 8);
}
}
if (comp.ratio >= 1) {
if (comp.ratio >= explodeAt) {
const victims = chicksInComponent(comp.id);
if (!victims.length || victims.some(chick => chick.stage !== 'queued' && chick.stoppedTimer <= 0.2)) continue;
const last = game.lastExplodedComponent.get(comp.id) || 0;
@ -730,7 +738,9 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
shake(game, 18, .75);
const first = parseKey(comp.cells[0]);
const fc = cellCenter(first.col, first.row);
floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger);
game.stats.explosions = (game.stats.explosions || 0) + 1;
game.totals.explosions = (game.totals.explosions || 0) + 1;
floating(game, fc.x, fc.y - 22, `${Math.round(congestionExplosionThreshold(game) * 100)}% JAM EXPLOSION`, THEME.danger);
}
return {

View file

@ -1,5 +1,5 @@
import { ECONOMY, FACILITY_DEFS, INCOME_FACILITY_IDS } from '../core/config.js';
import { factoryValue, targetTruckCount, truckTarget } from './contracts.js';
import { factoryValue, targetTruckCount, truckTarget, poopFineMultiplier, poopValueMultiplier, shredderPayoutMultiplier } from './events.js';
function effectCount(game, id) {
return Math.max(0, Number(game?.cardEffects?.[id]) || 0);
@ -53,11 +53,11 @@ export function upgradedTruckPrice(game) {
}
export function mixerPoopPenalty(game) {
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'mixer'));
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'mixer') * poopFineMultiplier(game));
}
export function truckPoopPenalty(game) {
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'truck'));
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'truck') * poopFineMultiplier(game));
}
export function maleTruckPenalty(game) {
@ -65,31 +65,36 @@ export function maleTruckPenalty(game) {
return Math.ceil(baseFine * incomeMultiplier(game, 'truck'));
}
export function shredderUpgradeCount(game) {
const raw = Math.max(0, (game.facilities?.trash?.level || 1) - 1);
return Math.min(ECONOMY.shredderBonus.maxCards, raw);
export function rawShredderUpgradeCount(game) {
if (!game?.facilities) return 0;
return Object.values(game.facilities)
.filter(f => (f?.baseId || f?.id) === 'trash')
.reduce((sum, f) => sum + Math.max(0, (f.level || 1) - 1), 0);
}
export function rawShredderUpgradeCount(game) {
return Math.max(0, (game.facilities?.trash?.level || 1) - 1);
export function shredderUpgradeCount(game) {
return Math.min(ECONOMY.shredderBonus.maxCards, rawShredderUpgradeCount(game));
}
export function shredderBonusMaxCards() {
return ECONOMY.shredderBonus.maxCards;
}
export function shredderBonusChance(gameOrCount) {
export function shredderBonusIncome(gameOrCount) {
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
const capped = Math.min(ECONOMY.shredderBonus.maxCards, Math.max(0, count));
const percent = (Math.sqrt(capped) / ECONOMY.shredderBonus.probabilityPercentDivisor) * capped;
return Math.min(1, percent / 100);
return capped > 0 ? Math.ceil(capped * (ECONOMY.shredderBonus.incomePerUpgrade || 1)) : 0;
}
export function shredderBonusChance(gameOrCount) {
return shredderBonusIncome(gameOrCount) > 0 ? 1 : 0;
}
export function rollShredderBonus(game) {
const cards = shredderUpgradeCount(game);
const chance = shredderBonusChance(cards);
const amount = cards > 0 && Math.random() < chance ? 1 : 0;
return { cards, chance, amount, expected: chance };
const baseAmount = shredderBonusIncome(cards);
const amount = Math.ceil(baseAmount * shredderPayoutMultiplier(game));
return { cards, chance: amount > 0 ? 1 : 0, amount, expected: amount, baseAmount };
}
export function applyShredderBonus(game) {
@ -102,28 +107,23 @@ export function applyShredderBonus(game) {
return result;
}
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 - 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, exemption, maxRateCash, taxable, rate, ratePercent: Math.round(rate * 100), tax, step, legalWork };
}
export function fairiesTributeInfo(game, turn = game.turn) {
export function fairiesTributeInfo(game, turn = game.turn, basisProfit = null) {
const day = Math.max(1, Math.floor(Number(turn) || 1));
const base = Math.ceil(day * ECONOMY.fairiesTribute.perDay);
const rules = ECONOMY.fairiesTribute || { perDay: 10, profitExemption: 300, legalWorkExemptionBonus: 250, profitRate: 0.12 };
const base = Math.ceil(day * (rules.perDay || 10));
const profit = Math.max(0, Math.floor(Number(basisProfit ?? game?.lastResult?.profit ?? game?.stats?.profit ?? 0) || 0));
const legalWork = Math.max(0, game?.cardEffects?.legalWork || 0);
const profitExemption = Math.max(0, (rules.profitExemption || 0) + legalWork * (rules.legalWorkExemptionBonus || 0));
const taxableProfit = Math.max(0, profit - profitExemption);
const profitTribute = Math.ceil(taxableProfit * Math.max(0, Number(rules.profitRate) || 0));
const gross = base + profitTribute;
const flatteryCount = Math.max(0, game?.cardEffects?.fairiesFlatteryNext || game?.cardEffects?.flattery || 0);
const multiplier = flatteryCount > 0 ? Math.pow(0.5, flatteryCount) : 1;
const amount = Math.max(0, Math.ceil(base * multiplier));
const reduction = Math.max(0, base - amount);
return { day, base, flatteryCount, perCardReduction: Math.ceil(base * 0.5), reduction, amount };
const flatteryMultiplier = flatteryCount > 0 ? Math.pow(0.5, flatteryCount) : 1;
const eventMultiplier = Math.max(0.01, Number(game?.pendingFairiesTributeMultiplier) || 1);
const multiplier = flatteryMultiplier * eventMultiplier;
const amount = Math.max(0, Math.ceil(gross * multiplier));
const reduction = Math.max(0, gross - amount);
return { day, base, profit, legalWork, profitExemption, taxableProfit, profitTribute, gross, flatteryCount, eventMultiplier, perCardReduction: Math.ceil(gross * 0.5), reduction, amount };
}
export function resaleValueFor(hit, game) {
@ -146,7 +146,6 @@ function positivePayout(_game, amount) {
}
export function applyCashDelta(game, delta) {
const before = game.cash;
game.cash += delta;
game.stats.profit += delta;
game.totals.profit += delta;
@ -173,7 +172,6 @@ export function spendCash(game, amount) {
export function refundCash(game, amount) {
if (amount <= 0) return;
const before = game.cash;
game.cash += amount;
game.stats.profit += amount;
game.totals.profit += amount;
@ -222,7 +220,7 @@ export function applyExplosionDamage(game, price) {
export function settleTruckRevenue(game) {
const target = truckTarget(game);
const targetCount = targetTruckCount(game.stats, target);
const unitPrice = upgradedTruckPrice(game);
const unitPrice = target === 'poop' ? Math.ceil(upgradedTruckPrice(game) * poopValueMultiplier(game)) : upgradedTruckPrice(game);
const base = targetCount * unitPrice;
const adjusted = positivePayout(game, base);
game.stats.pendingTruckRevenue = adjusted;
@ -233,36 +231,33 @@ export function settleTruckRevenue(game) {
return { base, adjusted, target, targetCount, unitPrice };
}
export function collectZundaTax(game, basisProfit = 0) {
const info = zundaTaxInfo(basisProfit, game);
if (info.tax <= 0) return info;
// ZUNDA TAX is settled between days. It reduces cash and lifetime totals,
// but it is intentionally excluded from the next day's NET display.
game.totals.zundaTax = (game.totals.zundaTax || 0) + info.tax;
game.totals.penalty = (game.totals.penalty || 0) + info.tax;
game.totals.profit = (game.totals.profit || 0) - info.tax;
game.cash -= info.tax;
return info;
}
export function collectFairiesTribute(game, turn = game.turn) {
const info = fairiesTributeInfo(game, turn);
export function collectFairiesTribute(game, turn = game.turn, basisProfit = null, options = {}) {
const info = fairiesTributeInfo(game, turn, basisProfit);
if (game.cardEffects) {
game.cardEffects.fairiesFlatteryNext = 0;
if (game.cardEffects.flattery != null) delete game.cardEffects.flattery;
}
if (game.pendingFairiesTributeMultiplier != null) game.pendingFairiesTributeMultiplier = 1;
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);
if (options.recordStats) {
game.stats.fairiesTribute = (game.stats.fairiesTribute || 0) + info.amount;
game.totals.fairiesTribute = (game.totals.fairiesTribute || 0) + info.amount;
applyPenalty(game, info.amount);
} else {
game.totals.fairiesTribute = (game.totals.fairiesTribute || 0) + info.amount;
game.totals.penalty = (game.totals.penalty || 0) + info.amount;
game.totals.profit = (game.totals.profit || 0) - info.amount;
game.cash -= info.amount;
}
return info;
}
export function collectChemicalWeaponSubsidy(game) {
const cards = effectCount(game, 'chemicalWeaponSubsidy');
const poopTruck = Math.max(0, game?.stats?.poopTruck || 0);
const blocks = Math.floor(poopTruck / 100);
const amount = cards > 0 && blocks > 0 ? blocks * 2000 * cards : 0;
const rules = ECONOMY.chemicalWeaponSubsidy || { poopPerBlock: 70, yenPerBlock: 1200 };
const blocks = Math.floor(poopTruck / Math.max(1, rules.poopPerBlock || 70));
const amount = cards > 0 && blocks > 0 ? blocks * Math.max(0, rules.yenPerBlock || 1200) * cards : 0;
if (amount > 0) {
game.stats.chemicalWeaponSubsidy = (game.stats.chemicalWeaponSubsidy || 0) + amount;
game.totals.chemicalWeaponSubsidy = (game.totals.chemicalWeaponSubsidy || 0) + amount;
@ -271,33 +266,10 @@ export function collectChemicalWeaponSubsidy(game) {
return { cards, poopTruck, blocks, amount };
}
export function collectLoanRepayments(game) {
const effects = game?.cardEffects;
if (!effects || !Array.isArray(effects.loans) || !effects.loans.length) return { amount: 0, paidLoans: 0, remainingLoans: 0 };
let amount = 0;
let paidLoans = 0;
const nextLoans = [];
for (const loan of effects.loans) {
if ((loan.daysRemaining || 0) <= 0) continue;
const payment = Math.max(0, Math.ceil(loan.dailyPayment || 400));
amount += payment;
paidLoans += 1;
const daysRemaining = Math.max(0, (loan.daysRemaining || 0) - 1);
if (daysRemaining > 0) nextLoans.push({ ...loan, daysRemaining });
}
effects.loans = nextLoans;
if (amount > 0) {
game.stats.loanRepayment = (game.stats.loanRepayment || 0) + amount;
game.totals.loanRepayment = (game.totals.loanRepayment || 0) + amount;
applyPenalty(game, amount);
}
return { amount, paidLoans, remainingLoans: nextLoans.length };
}
export function finalScore(game) {
const value = factoryValue(game);
const days = Math.max(1, game.totals.turnsCompleted || game.turn || 1);
const base = Math.max(0, game.cash + game.totals.revenue + value * 0.5 + game.totals.contractBonus * 0.5);
const base = Math.max(0, game.cash + game.totals.revenue + value * 0.5 + game.totals.eventBonus * 0.5);
const dailyEarned = Math.ceil(base / days);
const dayPenalty = Math.floor(days * 35 + Math.max(0, days - 5) * 15);
const correctBonus = game.totals.correct * 5;

318
src/systems/events.js Normal file
View file

@ -0,0 +1,318 @@
import { TURN_SECONDS, POOP_RATE, POOP_RATE_CARD_BONUS, DAILY_EVENT_CHANCE, DAILY_EVENT_FIRST_TURN } from '../core/config.js';
import { getSpawnRange } from '../core/state.js';
import { applyRevenue } from './economy.js';
const EVENT_TARGETS = [
{
key: 'premiumFemaleShipment',
target: 'female',
truckTarget: 'female',
title: 'Premium Female Shipment',
route: 'Female -> Truck',
note: 'Today only: ship female chicks to Truck. Meet the export target for a bonus.',
objective: 'truckTarget'
},
{
key: 'contaminantSampleExport',
target: 'poop',
truckTarget: 'poop',
title: 'Contaminant Sample Export',
route: 'Poop value x2',
note: 'Today only: poop is valid Truck cargo and all poop income is doubled.',
objective: 'truckTarget',
poopValueMultiplier: 2
},
{
key: 'healthInspection',
title: 'Health Inspection',
route: 'Poop -> Shredder',
note: 'Today only: poop mistakes are fined double. Shred enough poop for an inspection bonus.',
objective: 'poopTrash',
poopFineMultiplier: 2
},
{
key: 'conveyorJamWarning',
title: 'Conveyor Jam Warning',
route: 'No explosions',
note: 'Today only: jam explosions trigger at 85% congestion. Finish with zero explosions for a bonus.',
objective: 'noExplosion',
congestionExplosionThreshold: 0.85,
congestionWarningThreshold: 0.68
},
{
key: 'powerSavingOrder',
title: 'Power Saving Order',
route: 'Slow factory subsidy',
note: 'Today only: conveyors -20% speed and Auto Scanners +20% cooldown. A subsidy is paid at settlement.',
objective: 'flatBonus',
conveyorSpeedMultiplier: 0.8,
autoScannerCooldownMultiplier: 1.2
},
{
key: 'overtimeDay',
title: 'Overtime Day',
route: '80 second day',
note: 'Today only: the work day is longer. No extra reward or penalty.',
objective: 'none',
daySeconds: 80
},
{
key: 'shortShift',
title: 'Short Shift',
route: '40 second day',
note: 'Today only: the work day is shorter. The next Fairies tribute is reduced by 10%.',
objective: 'none',
daySeconds: 40,
fairiesTributeMultiplier: 0.9
},
{
key: 'chickBoom',
title: 'Chick Boom',
route: 'EGG interval x0.65',
note: 'Today only: eggs hatch faster and poop rate rises by 5 percentage points.',
objective: 'none',
productionMultiplier: 1 / 0.65,
poopRateAdd: 0.05
},
{
key: 'rottenFeed',
title: 'Rotten Feed',
route: 'Poop surge',
note: 'Today only: poop rate rises by 20 percentage points and Shredder poop income is multiplied by 1.5.',
objective: 'none',
poopRateAdd: 0.20,
shredderIncomeMultiplier: 1.5
},
{
key: 'maintenanceAudit',
title: 'Maintenance Audit',
route: 'Durability check',
note: 'Today only: machines below 50% remaining durability fail the audit. Passing with none below 50% pays a bonus.',
objective: 'maintenanceAudit'
}
];
function fixed(n, fallback = 0) {
const v = Number(n);
return Number.isFinite(v) ? v : fallback;
}
function rewardFor(game, estimate = 0, scale = 1) {
const day = Math.max(1, Number(game?.turn || 1) + 1);
const valuePart = Math.floor(factoryValue(game) * (0.08 + Math.random() * 0.08));
const cashPart = Math.floor(Math.max(0, game.cash || 0) * 0.25);
const outputPart = Math.floor(Math.max(0, estimate) * Math.max(1, day) * 0.75);
return Math.max(120, Math.ceil(Math.max(valuePart, cashPart, outputPart) * scale));
}
function flatEventBonus(game) {
return Math.max(0, Math.ceil(500 + Math.max(1, game?.turn || 1) * 25));
}
export function factoryValue(game) {
return [
...game.eggFarms.map(f => f.price || 0),
...game.scanners.map(s => s.price || 0),
...Object.values(game.facilities).map(f => f.price || 0),
...[...game.conveyorMeta.values()].map(m => m.price || 0)
].reduce((a, b) => a + b, 0);
}
export function estimatedChicksPerTurn(game, productionMultiplier = 1, daySeconds = TURN_SECONDS) {
return game.eggFarms.reduce((sum, farm) => {
const [min, max] = getSpawnRange(farm);
const avg = Math.max(0.1, (min + max) / 2);
return sum + (Math.max(1, daySeconds) / avg) * Math.max(0.01, productionMultiplier || 1);
}, 0);
}
function estimateForTemplate(game, template) {
const production = fixed(template.productionMultiplier, 1);
const seconds = fixed(template.daySeconds, TURN_SECONDS);
const estimate = estimatedChicksPerTurn(game, production, seconds);
const feedCards = Math.max(0, Number(game?.cardEffects?.hatchingFeed) || 0);
const poopRateCards = Math.max(0, Number(game?.cardEffects?.poopRateBooster) || 0);
const feedMultiplier = Math.pow(1.03, feedCards);
const baseRate = POOP_RATE + POOP_RATE_CARD_BONUS * poopRateCards;
const poopRate = Math.min(0.95, Math.max(0, (baseRate + fixed(template.poopRateAdd, 0)) * fixed(template.poopMultiplier, 1) * feedMultiplier));
return { estimate, poopRate };
}
export function rollEventOffer(game) {
const nextTurn = game.turn + 1;
if (nextTurn < DAILY_EVENT_FIRST_TURN) return null;
if (Math.random() > DAILY_EVENT_CHANCE) return null;
const template = EVENT_TARGETS[Math.floor(Math.random() * EVENT_TARGETS.length)];
const { estimate, poopRate } = estimateForTemplate(game, template);
let targetAmount = 0;
if (template.objective === 'truckTarget') targetAmount = Math.max(1, Math.ceil(estimate * 0.3));
else if (template.objective === 'poopTrash') targetAmount = Math.max(1, Math.ceil(estimate * poopRate * 0.8));
let reward = 0;
if (['truckTarget', 'poopTrash', 'noExplosion'].includes(template.objective)) reward = rewardFor(game, estimate);
if (template.objective === 'flatBonus') reward = flatEventBonus(game);
if (template.objective === 'maintenanceAudit') reward = flatEventBonus(game);
return {
id: `event-${game.turn}-${Date.now()}-${Math.floor(Math.random() * 9999)}`,
...template,
target: template.target || null,
truckTarget: template.truckTarget || null,
forced: true,
productionMultiplier: fixed(template.productionMultiplier, 1),
poopMultiplier: fixed(template.poopMultiplier, 1),
poopRateAdd: fixed(template.poopRateAdd, 0),
daySeconds: fixed(template.daySeconds, TURN_SECONDS),
targetAmount,
estimatedOutput: Math.round(estimate),
reward,
createdTurn: game.turn,
oneTurnOnly: true
};
}
export function activateEventOffer(game) {
game.eventActive = game.eventOffer ? { ...game.eventOffer } : null;
game.eventOffer = null;
}
export function clearActiveEvent(game) {
game.eventActive = null;
}
export function eventDaySeconds(game) {
return Math.max(1, fixed(game.eventActive?.daySeconds, TURN_SECONDS));
}
export function productionMultiplier(game) {
return fixed(game.eventActive?.productionMultiplier, 1);
}
export function currentPoopRate(game) {
const feedCards = Math.max(0, Number(game?.cardEffects?.hatchingFeed) || 0);
const poopRateCards = Math.max(0, Number(game?.cardEffects?.poopRateBooster) || 0);
const feedMultiplier = Math.pow(1.03, feedCards);
const baseRate = POOP_RATE + POOP_RATE_CARD_BONUS * poopRateCards;
const add = fixed(game.eventActive?.poopRateAdd, 0);
return Math.min(0.95, Math.max(0, (baseRate + add) * fixed(game.eventActive?.poopMultiplier, 1) * feedMultiplier));
}
export function truckTarget(game) {
return game.eventActive?.truckTarget || game.eventActive?.target || 'female';
}
export function targetTruckCount(stats, target) {
if (target === 'male') return stats.truckMale || 0;
if (target === 'poop') return stats.poopTruck || 0;
return stats.truckFemale || 0;
}
export function isTargetTruckCargo(game, sex) {
return truckTarget(game) === sex;
}
export function shouldFineMaleTruck(game) {
return truckTarget(game) !== 'male';
}
export function poopValueMultiplier(game) {
return Math.max(0.01, fixed(game.eventActive?.poopValueMultiplier, 1));
}
export function shredderPayoutMultiplier(game) {
return Math.max(0.01, poopValueMultiplier(game) * fixed(game.eventActive?.shredderIncomeMultiplier, 1));
}
export function poopFineMultiplier(game) {
return Math.max(0.01, fixed(game.eventActive?.poopFineMultiplier, 1));
}
export function congestionExplosionThreshold(game) {
return Math.max(0.1, Math.min(1, fixed(game.eventActive?.congestionExplosionThreshold, 1)));
}
export function congestionWarningThreshold(game) {
return Math.max(0.1, Math.min(1, fixed(game.eventActive?.congestionWarningThreshold, 0.8)));
}
export function fairiesTributeMultiplierFromEvent(event) {
return Math.max(0.01, fixed(event?.fairiesTributeMultiplier, 1));
}
function countLowDurabilityMachines(game) {
let count = 0;
const add = (uses, durability) => {
const d = Math.max(1, fixed(durability, 1));
const u = Math.max(0, fixed(uses, 0));
if ((1 - Math.min(1, u / d)) < 0.5) count += 1;
};
for (const meta of game.conveyorMeta?.values?.() || []) add(meta.uses, meta.durability);
for (const farm of game.eggFarms || []) add(farm.maintenance?.uses, farm.maintenance?.durability);
for (const scanner of game.scanners || []) if (scanner.kind === 'auto') add(scanner.maintenance?.uses, scanner.maintenance?.durability);
for (const f of Object.values(game.facilities || {})) {
const kind = f?.baseId || f?.id;
if (kind === 'mixer' || kind === 'trash') add(f.maintenance?.uses, f.maintenance?.durability);
}
return count;
}
export function resolveEvent(game) {
const event = game.eventActive;
if (!event) return null;
let count = 0;
let success = true;
let bonus = 0;
let detail = '';
if (event.objective === 'truckTarget') {
count = targetTruckCount(game.stats, event.target);
success = count >= event.targetAmount;
bonus = success ? event.reward : 0;
} else if (event.objective === 'poopTrash') {
count = game.stats.poopTrash || 0;
success = count >= event.targetAmount;
bonus = success ? event.reward : 0;
} else if (event.objective === 'noExplosion') {
count = game.stats.explosions || 0;
success = count === 0;
bonus = success ? event.reward : 0;
detail = `${count} explosion(s)`;
} else if (event.objective === 'flatBonus') {
success = true;
bonus = event.reward;
} else if (event.objective === 'maintenanceAudit') {
count = countLowDurabilityMachines(game);
success = count === 0;
if (success) bonus = event.reward;
detail = `${count} machine(s) below 50%`;
} else {
success = true;
}
if (bonus > 0) {
applyRevenue(game, bonus);
game.stats.eventBonus = (game.stats.eventBonus || 0) + bonus;
game.totals.eventBonus += bonus;
game.totals.eventSuccess += 1;
} else if (event.objective && event.objective !== 'none') {
game.stats.eventBonus = game.stats.eventBonus || 0;
game.totals.eventFailed += success ? 0 : 1;
if (success) game.totals.eventSuccess += 1;
}
const result = {
title: event.title,
key: event.key,
target: event.target,
route: event.route,
objective: event.objective,
targetAmount: event.targetAmount || 0,
count,
success,
bonus,
reward: event.reward || 0,
productionMultiplier: event.productionMultiplier,
poopMultiplier: event.poopMultiplier,
daySeconds: event.daySeconds,
detail
};
game.stats.eventResult = result;
return result;
}

View file

@ -59,8 +59,9 @@ export function ensureMaintenanceState(game) {
if (scanner.kind === 'auto') attachMaintenance(scanner, 'autoScanner', game);
}
for (const f of Object.values(game.facilities || {})) {
if (f.id === 'mixer') attachMaintenance(f, 'mixer', game);
if (f.id === 'trash') attachMaintenance(f, 'trash', game);
const kind = facilityKind(f);
if (kind === 'mixer') attachMaintenance(f, 'mixer', game);
if (kind === 'trash') attachMaintenance(f, 'trash', game);
}
}
@ -213,9 +214,10 @@ export function equipmentMaintenanceTargets(game) {
for (const [k, meta] of game.conveyorMeta || []) targets.push({ type: 'conveyor', key: k, meta, label: `${meta.kind === 'boostConveyor' ? 'Boost Belt' : 'Belt'} ${k}`, center: cellCenter(parseKey(k).col, parseKey(k).row) });
for (const farm of game.eggFarms || []) targets.push({ type: 'eggFarm', ref: farm, label: `EGG #${farm.id}`, center: cellCenter(farm.col, farm.row) });
for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) });
for (const id of ['mixer', 'trash']) {
const f = game.facilities?.[id];
if (f) targets.push({ type: id, ref: f, label: f.shortName || f.name || id, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
for (const f of Object.values(game.facilities || {})) {
const kind = facilityKind(f);
if (!['mixer', 'trash'].includes(kind)) continue;
targets.push({ type: kind, ref: f, label: `${f.shortName || f.name || kind} #${f.id}`, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
}
return targets;
}

View file

@ -1,9 +1,9 @@
import { BUILD_TOOL_IDS, 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, DAILY_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, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, zundaTaxInfo } from './economy.js';
import { truckTarget } from './contracts.js';
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js';
import { truckTarget } from './events.js';
import { conveyorSpeedForGame } from './cards.js';
import { maintenanceSummary, repairmanDailyCost } from './maintenance.js';
@ -27,7 +27,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function updatePanels() {
updateToolButtons();
updateBuildStatus();
updateContractPanel();
updateEventPanel();
updateTurnSummary();
updateHistoryButtons();
}
@ -89,52 +89,46 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function updateTurnSummary() {
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length;
const issues = facilityConnectionIssues(game);
const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const tax = zundaTaxInfo(zundaBasisProfit, game);
const tributeDay = game.phase === 'running' ? Math.max(1, (game.totals.turnsCompleted || 0) + 1) : Math.max(1, Number(game.turn) || 1);
const tribute = fairiesTributeInfo(game, tributeDay);
const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null;
const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0;
const tributeBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const tributeDay = Math.max(1, Number(game.turn) || 1);
const tribute = fairiesTributeInfo(game, tributeDay, tributeBasisProfit);
ui.turnSummary.innerHTML = [
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Grid box: <b>${game.gridRows || 10}x${game.gridCols || 20}</b> | Owned: <b>${game.gridOwnedCells || game.ownedCells?.size || ((game.gridRows || 10) * (game.gridCols || 20))}</b> cells | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`,
`Truck target: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`,
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`,
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | basis net ${yen(zundaBasisProfit)} | taxable ${yen(tax.taxable)} x${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
lastTribute != null
? `Fairies tribute paid before Build: <b>-${yen(lastTribute)}</b>${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}`
: `Fairies tribute before Build: <b>-${yen(tribute.amount)}</b>${tribute.reduction ? ` | Flattery -${yen(tribute.reduction)}` : ''}`,
`Fairies tribute on Next Day: <b>-${yen(tribute.amount)}</b> | base ${yen(tribute.base)} + profit share ${yen(tribute.profitTribute)} | basis net ${yen(tributeBasisProfit)} | exemption ${yen(tribute.profitExemption)}${tribute.reduction ? ` | reduction -${yen(tribute.reduction)}` : ''}`,
`Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Repairman: <b>${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}</b>`,
`Manual combo: <b>${game.manualCombo?.count || 0}</b> | Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Belt: <b>${displaySpeed()}px/s</b> | Farms with scanner route: <b>${connected}/${game.eggFarms.length}</b>`,
game.cardEffects?.usedMachineActive ? '<span class="cash-negative">Procurement: USED MACHINE MODE / no refunds / 60% durability</span>' : '',
issues.length ? `<span class="cash-negative">Blocked: ${issues[0]}</span>` : `<span class="cash-positive">${TEXT.status.allPortsConnected}</span>`
].join('<br>');
}
function updateContractPanel() {
if (!ui.contractPanel) return;
const offer = game.contractOffer;
function updateEventPanel() {
if (!ui.eventPanel) return;
const offer = game.eventOffer;
if (game.phase !== 'build') {
ui.contractPanel.innerHTML = TEXT.status.eventPanelRunning;
ui.eventPanel.innerHTML = TEXT.status.eventPanelRunning;
return;
}
if (!offer) {
ui.contractPanel.className = 'contract-card empty';
ui.eventPanel.className = 'event-card empty';
const nextTurn = game.turn + 1;
if (nextTurn < CONTRACT_EVENT_FIRST_TURN) {
ui.contractPanel.innerHTML = `<strong>Event lock active.</strong><span>No irregular events during Days 1-7. First possible forced event: Day ${CONTRACT_EVENT_FIRST_TURN}.</span>`;
if (nextTurn < DAILY_EVENT_FIRST_TURN) {
ui.eventPanel.innerHTML = `<strong>Event lock active.</strong><span>No irregular events during Days 1-7. First possible forced event: Day ${DAILY_EVENT_FIRST_TURN}.</span>`;
} else {
ui.contractPanel.innerHTML = '<strong>No event.</strong><span>Random forced events can appear later.</span>';
ui.eventPanel.innerHTML = '<strong>No event.</strong><span>Random forced events can appear later.</span>';
}
return;
}
ui.contractPanel.className = 'contract-card accepted';
ui.contractPanel.innerHTML = `
<div class="contract-status">FORCED NEXT DAY</div>
ui.eventPanel.className = 'event-card accepted';
ui.eventPanel.innerHTML = `
<div class="event-status">FORCED NEXT DAY</div>
<h3>${offer.title}</h3>
<div class="contract-metrics">
<span>Route: ${offer.route}</span>
<span>Target: ${offer.targetAmount} truck exports</span>
<span>Reward: ${yen(offer.reward)}</span>
<span>Output x${offer.productionMultiplier} / Poop x${offer.poopMultiplier}</span>
<div class="event-metrics">
<span>Effect: ${offer.route}</span>
<span>${offer.targetAmount ? `Target: ${offer.targetAmount}` : 'Target: none'}</span>
<span>${offer.reward ? `Reward: ${yen(offer.reward)}` : 'Reward: none'}</span>
<span>Time ${offer.daySeconds || 60}s / Output x${Number(offer.productionMultiplier || 1).toFixed(2)} / Poop +${Math.round((offer.poopRateAdd || 0) * 100)}pt</span>
</div>`;
}
function updateUI() {
@ -176,8 +170,9 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.buttons.s2Left.textContent = `S2 ${s2.keys?.left?.label || 'Left'} -> Waste`;
ui.buttons.s2Right.textContent = `S2 ${s2.keys?.right?.label || 'Right'} -> Truck`;
}
const nextTax = zundaTaxInfo(Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)), game).tax;
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA ${yen(nextTax)}` : TEXT.actions.nextDay;
const nextTributeBasis = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const nextTribute = fairiesTributeInfo(game, Math.max(1, Number(game.turn) || 1), nextTributeBasis).amount;
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTribute > 0 ? `Next Day - Fairies ${yen(nextTribute)}` : TEXT.actions.nextDay;
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
}
@ -187,16 +182,16 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.targetBrief.textContent = `TRUCK TARGET: ${target}`;
ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s | COMBO: ${game.manualCombo?.count || 0}`;
ui.speedBrief.title = `Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${CONVEYOR_SPEED_MAX}px/s.`;
const active = game.contractActive;
const offer = game.contractOffer;
const active = game.eventActive;
const offer = game.eventOffer;
if (active) {
ui.eventBrief.textContent = `ACTIVE EVENT: ${active.route} | Output x${active.productionMultiplier} | Poop x${active.poopMultiplier}`;
ui.eventBrief.textContent = `ACTIVE EVENT: ${active.title} | ${active.route}`;
ui.eventBrief.className = 'event-brief active';
} else if (game.phase === 'build' && offer) {
ui.eventBrief.textContent = `NEXT DAY EVENT: ${offer.route} | Target ${offer.targetAmount} | Reward ${yen(offer.reward)}`;
ui.eventBrief.textContent = `NEXT DAY EVENT: ${offer.title} | ${offer.route}${offer.targetAmount ? ` | Target ${offer.targetAmount}` : ''}${offer.reward ? ` | Reward ${yen(offer.reward)}` : ''}`;
ui.eventBrief.className = 'event-brief next';
} else if (game.phase === 'build' && game.turn + 1 < CONTRACT_EVENT_FIRST_TURN) {
ui.eventBrief.textContent = `NO EVENTS UNTIL DAY ${CONTRACT_EVENT_FIRST_TURN}`;
} else if (game.phase === 'build' && game.turn + 1 < DAILY_EVENT_FIRST_TURN) {
ui.eventBrief.textContent = `NO EVENTS UNTIL DAY ${DAILY_EVENT_FIRST_TURN}`;
ui.eventBrief.className = 'event-brief locked';
} else {
ui.eventBrief.textContent = 'NO ACTIVE EVENT';
@ -237,11 +232,10 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
<div><strong>DUD refund income</strong>${positive(r.dudRefundIncome || 0)}</div>
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
<div><strong>Chemical subsidy</strong>${positive(r.chemicalWeaponSubsidy || 0)}</div>
<div><strong>Contract bonus</strong>${positive(r.contractBonus || 0)}</div>
<div><strong>Event bonus</strong>${positive(r.eventBonus || 0)}</div>
<div><strong>Penalties</strong>${negative(penalties)}</div>
<div><strong>Fairies fee</strong>${negative(r.fairiesTribute?.amount || r.fairiesTribute || 0)}</div>
<div><strong>Repair worker wages</strong>${negative(r.repairWorkerWages || 0)}</div>
<div><strong>Loan repayments</strong>${negative(r.loanRepayment || 0)}</div>
</div>
<p class="formula-box compact">Income ${yen(r.revenue)} - losses ${yen(r.penalty)} = <b class="${netClass}">${yen(r.profit)}</b></p>`;
ui.modalActions.innerHTML = '';
@ -277,8 +271,8 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.modalBody.innerHTML = `<p class="gameover-reason">Your cash went negative.</p>
<div class="result-grid gameover-summary"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div></div>
<details class="gameover-details"><summary>Score breakdown</summary>
<div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value 繝サ繝サ繝サ0.5 + Contract bonus 繝サ繝サ繝サ0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base 繝サ繝サ・ス・キ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct繝サ繝サ繝サ - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid gameover-breakdown"><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>
<div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value ×0.5 + Event bonus ×0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base ÷ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct bonus - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid gameover-breakdown"><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Event Bonus</strong><span>${yen(game.totals.eventBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Events</strong><span>${game.totals.eventSuccess} success / ${game.totals.eventFailed} failed</span></div></div>
</details>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button(TEXT.actions.restart || 'Restart', startGame, 'primary-button restart-button'));

View file

@ -66,31 +66,32 @@ h1, h2, p { margin: 0; }
.facility-action.danger { background: #ffdada; }
.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); }
.sort-button { min-height: 46px; font-size: 12px; }
/* Debug UI is intentionally kept for active balancing builds. Do not remove during cleanup/audit passes unless explicitly requested. */
.debug-panel {
position: absolute;
z-index: 9;
left: 10px;
bottom: 10px;
width: min(292px, calc(100% - 20px));
width: min(410px, calc(100% - 20px));
border: 3px solid var(--line);
background: rgba(255,255,255,.94);
background: rgba(255,255,255,.96);
box-shadow: 4px 4px 0 rgba(16,32,21,.16);
font-weight: 900;
}
.debug-panel summary {
cursor: pointer;
padding: 4px 8px;
padding: 5px 8px;
color: var(--danger);
font-size: 14px;
line-height: 1.1;
}
.debug-body {
display: grid;
grid-template-columns: 1fr 74px;
gap: 5px;
padding: 6px;
grid-template-columns: 1fr 92px;
gap: 6px;
padding: 7px;
border-top: 3px solid var(--line);
font-size: 13px;
font-size: 12px;
line-height: 1.15;
}
.debug-body label,
@ -101,12 +102,20 @@ h1, h2, p { margin: 0; }
font: inherit;
}
.debug-check,
.debug-card {
.debug-card,
.debug-target,
.debug-readout {
grid-column: 1 / -1;
}
.debug-row {
display: grid;
grid-template-columns: 1fr 92px;
gap: 6px;
grid-column: 1 / -1;
}
.debug-day {
display: grid;
grid-template-columns: 34px 1fr;
grid-template-columns: 38px 1fr;
align-items: center;
gap: 4px;
}
@ -115,7 +124,7 @@ h1, h2, p { margin: 0; }
background: var(--white);
cursor: pointer;
font-weight: 900;
padding: 4px 5px;
padding: 5px 6px;
line-height: 1.05;
}
.debug-body input[type="number"],
@ -123,18 +132,20 @@ h1, h2, p { margin: 0; }
width: 100%;
border: 2px solid var(--line);
background: var(--white);
padding: 3px 4px;
min-height: 28px;
padding: 4px 5px;
min-height: 29px;
}
.debug-target select:disabled {
opacity: .55;
}
.debug-readout {
grid-column: 1 / -1;
border: 2px solid var(--line);
background: #f7fff5;
color: var(--muted);
padding: 5px;
line-height: 1.2;
max-height: 48px;
overflow: hidden;
line-height: 1.25;
max-height: 64px;
overflow: auto;
}
.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); }
.modal.visible { display: flex; }
@ -168,17 +179,17 @@ h1, h2, p { margin: 0; }
.route-cards, .result-grid { grid-template-columns: 1fr; }
}
.contract-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
.contract-card.empty { color: var(--muted); background: rgba(255,255,255,.72); }
.contract-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; }
.contract-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; }
.contract-card p { margin: 5px 0; }
.contract-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
.contract-card.declined { background: #f1f1f1; color: var(--muted); }
.contract-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; }
.contract-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; }
.contract-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); }
.contract-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.event-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
.event-card.empty { color: var(--muted); background: rgba(255,255,255,.72); }
.event-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; }
.event-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; }
.event-card p { margin: 5px 0; }
.event-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
.event-card.declined { background: #f1f1f1; color: var(--muted); }
.event-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; }
.event-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; }
.event-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); }
.event-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.result-section-title { margin: 16px 0 8px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .12em; color: var(--green); }
.result-section-title:first-child { margin-top: 0; }
.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
@ -293,7 +304,7 @@ body { font-size: 16px; }
.build-panel h2 { font-size: 13px; }
.tool-button strong { font-size: 14px; }
.tool-button span { font-size: 11px; }
.mini-box, .contract-card { font-size: 12px; }
.mini-box, .event-card { font-size: 12px; }
.sort-button { font-size: 14px; }
.modal-card h2 { font-size: 29px; }
.card-choice strong { font-size: 16px; }
@ -462,7 +473,7 @@ body { font-size: 18px; }
.priority-strip > div { font-size: 15px; }
.hud-card span { font-size: 12px; }
.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); }
.mini-box, .contract-card { font-size: 14px; line-height: 1.45; }
.mini-box, .event-card { font-size: 14px; line-height: 1.45; }
.compact-rules .mini-box { font-size: 13px; }
.tool-button {
position: relative;
@ -559,7 +570,7 @@ body { font-size: 20px; }
.priority-strip > div { font-size: 16px; }
.hud-card span { font-size: 13px; }
.hud-card strong { font-size: clamp(22px, 1.8vw, 30px); }
.mini-box, .contract-card { font-size: 15px; line-height: 1.5; }
.mini-box, .event-card { font-size: 15px; line-height: 1.5; }
.compact-rules .mini-box { font-size: 14px; }
.tool-button strong { font-size: 16px; line-height: 1.15; }
.tool-button span { font-size: 13px; line-height: 1.2; }