Compare commits

..

3 commits

Author SHA1 Message Date
33cad83ccb hmm 2026-06-08 18:01:20 +09:00
2c7e525c53 k 2026-06-08 13:18:33 +09:00
ca40a0cb73 cards and lot of 2026-06-07 22:04:17 +09:00
27 changed files with 3506 additions and 825 deletions

View file

@ -1,25 +1,28 @@
# Chick Sorter v14.0 Zunda Tax / Resale
# Chick Sorter v27.0 Cleanup Build
## Changes in this build
- Changed Egg Farm build price to `¥250`.
- Replaced the previous daily Fairies fee with `ZUNDA TAX`, paid when `Next Day` is pressed.
- `ZUNDA TAX` taxes only cash above `¥500`: `ceil((cash - 500) × rate)`, with 5% added per `¥500` band and a 95% cap.
- Removing equipment from a previous Build phase now sells it for 50% of its build price; same-Build removals still refund 100%.
- Removed visible `STOPPED` text/highlight. Blocked chicks simply stay still.
- Removed route-overlay arrows/colored flow arrows. Conveyor movement is now shown by small direction triangles inside belt tiles.
- Moved the starter S1 position lower so the Egg Farm and S1 have more distance.
- Moved the Truck to the bottom side of the grid and connected the starter S2-to-Truck belt to the new receiver.
- Made scanner and facility ports strict: a belt must occupy the exact port cell to count as connected.
- Expanded `Next Day` validation to scanner inputs/outputs and planned output routes, not just machine receiver cells.
- Build buttons for unaffordable equipment, or already-built unique machines, are disabled and visually faded.
- Removed Close buttons from clicked-equipment/upgrade popovers. Click outside the popover to close it.
- Kept `src/index.html` and `src/styles.css` removed; the project uses the root `index.html` and `styles.css` only.
- Continued centralized price/income/fine/facility definitions in `src/core/config.js`, shared text in `src/core/text.js`, and economy logic in `src/systems/economy.js`.
Open `index.html` in a browser.
## Structure ideas for the next cleanup pass
- Move HTML button content to a data-rendered build panel instead of hard-coded markup.
- Give every equipment object a `defId`, so runtime state never duplicates names, prices, sizes, or upgrade rules.
- Store routes as cell arrays and convert to pixel paths only at movement/draw time.
- Cache route plans per build session, then invalidate once on build/erase/drag instead of recomputing every draw.
- Split route validation into errors and warnings, so optional/unused equipment can be supported later without blocking the day.
- Remove legacy fallback paths after save compatibility is no longer needed.
## Current baseline
- Build, move, box-select, sell, and repair factory equipment during Build phase.
- Start the next day only when at least one EGG route reaches a scanner and then any valid exit.
- Fairies tribute is paid after day-end settlement and before the next Build phase.
- Bankruptcy is checked only at that day-end transition. Temporary negative cash during production does not immediately end the game.
- Cards use English names and descriptions with numeric effect summaries.
- `poop.png` is the only bundled image asset currently used by default; other art falls back to canvas drawing.
## Controls
- S1 manual scanner: `A` = left / `D` = right.
- S2 manual scanner: `←` = left / `→` = right.
- Build phase: left-drag empty map for box selection; drag selected equipment to move it.
- Right-drag: pan.
- `Ctrl+Z` / `Ctrl+Y`: undo / redo.
- `Esc`: cancel upgrade-card target selection.
## 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.
- 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.

View file

@ -1,16 +1,3 @@
# Image asset folder
# Image assets
The game currently uses simple canvas placeholders. You can override them by adding images with these filenames:
- `chick_male.png`
- `chick_female.png`
- `tarinai.png`
- `conveyor.png`
- `scanner_manual.png`
- `scanner_auto.png`
- `egg_farm.png`
- `mixer.png`
- `shredder.png`
- `truck.png`
The render code checks whether each image loads and falls back to simple shapes when it does not.
`poop.png` is bundled and loaded by default. Other equipment/chick art is optional and currently falls back to canvas drawing unless external art loading is enabled in `src/render/draw.js`.

BIN
assets/images/poop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v14.0 Zunda Tax / Resale</title>
<title>Chick Sorter v27.0</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
@ -35,8 +35,8 @@
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v14.0</span></h1>
<p>Build, drag, connect all receiver ports, then start the next day.</p>
<h1>CHICK SORTER <span>v27.0</span></h1>
<p>Build, drag, 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>
</div>
@ -47,12 +47,13 @@
<div class="large-tools">
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 4.5s cooldown</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 2.25s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Erase</strong><span>Remove any equipment</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>
<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>
@ -62,13 +63,7 @@
<h2>Irregular One-Day Event</h2>
<div id="contractPanel" class="contract-card empty">Irregular forced events appear during Build phase.</div>
</section>
<section class="facility-section">
<h2>Selected Facility</h2>
<div id="facilityPanel" class="facility-panel-empty">Click equipment in Build phase.</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>
@ -81,6 +76,7 @@
<button id="scanner2MixerButton" class="sort-button" type="button">S2 -> Waste</button>
<button id="scanner2TruckButton" class="sort-button" type="button">S2 -> Truck</button>
</div>
<div id="hoverTooltip" class="hover-tooltip" aria-hidden="true"></div>
</section>
</main>
@ -92,6 +88,6 @@
</div>
</div>
<script type="module" src="./src/game.js?v=14.0-zunda-tax-resale"></script>
<script type="module" src="./src/game.js?v=27.0-cleanup"></script>
</body>
</html>

131
src/core/balance.js Normal file
View file

@ -0,0 +1,131 @@
// 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.0 cleanup build',
time: {
daySeconds: 60,
farmShutdownGraceSeconds: 10
},
cash: {
starting: 250
},
grid: {
x: 190,
y: 142,
cols: 22,
rows: 13,
cell: 46
},
map: {
blockedRatio: 0.075
},
economy: {
income: {
mixer: 5,
truck: 10
},
poopFine: 10,
zundaTax: {
exemption: 500,
legalWorkExemptionBonus: 250,
maxRateCash: 10000,
stepAmount: 500,
stepRate: 0.05,
maxRate: 0.95
},
fairiesTribute: {
perDay: 10
},
incomeUpgradeRate: 1.05,
explosionDamageDivisor: 30,
maleTruckFinePerHalfDay: 30,
shredderBonus: {
maxCards: 30,
probabilityPercentDivisor: 2
}
},
production: {
poopRate: 0.08,
autoScannerCooldown: 2.25,
autoScannerUpgradeRate: 0.95,
autoScannerMinCooldown: 0.5,
eggSpawnRanges: [
[1.95, 4.35],
[1.55, 3.45],
[1.25, 2.80],
[1.05, 2.35]
]
},
conveyor: {
baseSpeed: 52,
maxSpeed: 300,
bearingSpeedMultiplier: 1.075
},
cards: {
commonWeight: 8,
rareWeight: 2,
ultraRareWeight: 0.45,
baseDraftSize: 3,
maxDudsPerDraft: 2,
dudChancePerCard: 0.30,
extraCardsAddedPicks: 2,
rerollCost: {
dayDivisor: 2,
multiplier: 100
}
},
contracts: {
eventChance: 0.70,
firstTurn: 8
},
maintenance: {
fullPerformanceUntilWear: 0.50,
minimumPerformance: 0.50,
durability: {
conveyor: 1300,
eggFarm: 475,
autoScanner: 850,
mixer: 775,
trash: 950
},
processingDelayMaxSeconds: {
mixer: 1.20,
trash: 1.00
},
repairman: {
dailyCost: 100,
secondsPerOnePercent: 1 / 3,
walkSpeed: 140
}
},
facilities: {
conveyor: {
id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30,
buildable: true, upgradeable: false
},
eggFarm: {
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]
},
manualScanner: {
id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260,
buildable: true, upgradeable: false
},
autoScanner: {
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360,
buildable: true, upgradeable: false
},
mixer: {
id: 'mixer', type: 'facility', name: 'Mixer', shortName: 'MIXER', price: 450,
buildable: true, upgradeable: true, incomeKey: 'mixer', body: { w: 164, h: 126 }
},
trash: {
id: 'trash', type: 'facility', name: 'Waste Shredder', shortName: 'SHREDDER', price: 240,
buildable: true, upgradeable: true, body: { w: 180, h: 100 }
},
truck: {
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 }
}
}
};

View file

@ -1,65 +1,26 @@
export const VERSION = 'v14.0 Zunda Tax / Resale';
import { BALANCE } from './balance.js';
export const TURN_SECONDS = 60;
export const STARTING_CASH = 250;
export const VERSION = BALANCE.version;
export const ECONOMY = {
income: {
mixer: 5,
truck: 10
},
poopFine: 10,
zundaTax: {
exemption: 500,
stepAmount: 500,
stepRate: 0.05,
maxRate: 0.95
},
incomeUpgradeRate: 1.05,
explosionDamageDivisor: 30,
maleTruckFinePerHalfDay: 30
};
export const TURN_SECONDS = BALANCE.time.daySeconds;
export const STARTING_CASH = BALANCE.cash.starting;
export const ECONOMY = BALANCE.economy;
export const FARM_SHUTDOWN_GRACE_SECONDS = 10;
export const AUTO_SCANNER_COOLDOWN = 4.5;
export const CONTRACT_EVENT_CHANCE = 0.70;
export const CONTRACT_EVENT_FIRST_TURN = 8;
export const POOP_RATE = 0.08;
export const CONVEYOR_SPEED = 52;
export const CONVEYOR_SPEED_MAX = 300;
export const FARM_SHUTDOWN_GRACE_SECONDS = BALANCE.time.farmShutdownGraceSeconds;
export const AUTO_SCANNER_COOLDOWN = BALANCE.production.autoScannerCooldown;
export const AUTO_SCANNER_UPGRADE_RATE = BALANCE.production.autoScannerUpgradeRate;
export const AUTO_SCANNER_MIN_COOLDOWN = BALANCE.production.autoScannerMinCooldown;
export const CONTRACT_EVENT_CHANCE = BALANCE.contracts.eventChance;
export const CONTRACT_EVENT_FIRST_TURN = BALANCE.contracts.firstTurn;
export const POOP_RATE = BALANCE.production.poopRate;
export const CONVEYOR_SPEED = BALANCE.conveyor.baseSpeed;
export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed;
export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier;
export const GRID = { x: 190, y: 142, cols: 22, rows: 13, cell: 46 };
export const FACILITY_DEFS = {
conveyor: {
id: 'conveyor', type: 'conveyor', name: 'Conveyor', shortName: 'Belt', price: 30,
buildable: true, upgradeable: false
},
eggFarm: {
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]
},
manualScanner: {
id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260,
buildable: true, upgradeable: false
},
autoScanner: {
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360,
buildable: true, upgradeable: false
},
mixer: {
id: 'mixer', type: 'facility', name: 'Mixer', shortName: 'MIXER', price: 450,
buildable: true, upgradeable: true, incomeKey: 'mixer', body: { w: 164, h: 126 }
},
trash: {
id: 'trash', type: 'facility', name: 'Waste Shredder', shortName: 'SHREDDER', price: 240,
buildable: true, upgradeable: false, body: { w: 180, h: 100 }
},
truck: {
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 }
}
};
export const CARD_BALANCE = BALANCE.cards;
export const EGG_SPAWN_RANGES = BALANCE.production.eggSpawnRanges;
export const GRID = BALANCE.grid;
export const FACILITY_DEFS = BALANCE.facilities;
export const BUILD_TOOL_IDS = ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck'];
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];

View file

@ -74,8 +74,12 @@ export function createFacility(game, id, p, price) {
return layoutFacilityOnEdge(f, entry, side);
}
export function rollChickSex(game) {
return Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female');
}
export function createChick(game, routeData) {
const sex = Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female');
const sex = rollChickSex(game);
const start = routeData.route[0];
return {
id: game.nextId++, sex,

43
src/core/mapGen.js Normal file
View file

@ -0,0 +1,43 @@
import { GRID } from './config.js';
import { key } from './utils.js';
function hashSeed(seedText) {
let h = 2166136261;
for (let i = 0; i < String(seedText).length; i += 1) {
h ^= String(seedText).charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function mulberry32(seed) {
let t = seed >>> 0;
return () => {
t += 0x6D2B79F5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
export function createRunSeed() {
return `RUN-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`.toUpperCase();
}
export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] } = {}) {
const rng = mulberry32(hashSeed(seed || createRunSeed()));
const reservedSet = new Set(reserved.map(p => typeof p === 'string' ? p : key(p.col, p.row)));
const candidates = [];
for (let row = 0; row < GRID.rows; row += 1) {
for (let col = 0; col < GRID.cols; col += 1) {
const k = key(col, row);
if (!reservedSet.has(k)) candidates.push({ col, row, k });
}
}
for (let i = candidates.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
const target = Math.round(GRID.cols * GRID.rows * targetRatio);
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
}

View file

@ -1,5 +1,7 @@
import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
import { randomBetween } from './utils.js';
import { STARTING_CASH, TURN_SECONDS, FARM_SHUTDOWN_GRACE_SECONDS, BUILD_COSTS, FACILITY_DEFS, GRID, EGG_SPAWN_RANGES } from './config.js';
import { BALANCE } from './balance.js';
import { createRunSeed, generateBlockedCells } from './mapGen.js';
import { randomBetween, key, inGrid } from './utils.js';
export function newTurnStats() {
return {
@ -19,14 +21,22 @@ export function newTurnStats() {
mixerRevenue: 0,
truckRevenue: 0,
zundaTax: 0,
fairiesTribute: 0,
loanRepayment: 0,
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
cardRerollCost: 0,
mixerPoopFine: 0,
truckPoopFine: 0,
maleTruckFine: 0,
explosionDamage: 0,
shredderBonus: 0,
correct: 0,
mistakes: 0,
contractBonus: 0,
contractResult: null
contractResult: null,
conveyorPasses: 0,
eggProduced: 0
};
}
@ -45,13 +55,21 @@ export function newTotalStats() {
poopTrash: 0,
poopMixer: 0,
explosionDamage: 0,
shredderBonus: 0,
contractBonus: 0,
zundaTax: 0,
fairiesTribute: 0,
loanRepayment: 0,
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
cardRerollCost: 0,
mixerPoopFine: 0,
truckPoopFine: 0,
maleTruckFine: 0,
contractSuccess: 0,
contractFailed: 0
contractFailed: 0,
conveyorPasses: 0,
eggProduced: 0
};
}
@ -63,8 +81,12 @@ export function createGame() {
timeLeft: TURN_SECONDS,
shutdownTimeLeft: 0,
shutdownGraceSeconds: FARM_SHUTDOWN_GRACE_SECONDS,
cleanupTimer: null,
cleanupBlown: false,
lastTimestamp: 0,
nextId: 10,
runSeed: createRunSeed(),
blockedCells: new Set(),
buildTool: null,
buildSession: 0,
undoStack: [],
@ -75,7 +97,7 @@ export function createGame() {
groupDrag: null,
pan: null,
view: { x: 0, y: 0 },
debug: false,
hover: null,
chicks: [],
effects: [],
floatingTexts: [],
@ -87,11 +109,19 @@ export function createGame() {
scanners: [],
facilities: {},
branchCounters: new Map(),
routingVersion: 0,
factoryGraph: null,
factoryGraphCommittedVersion: -1,
factoryGraphSnapshot: null,
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, durabilityCoating: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null,
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + GRID.rows * GRID.cell + 84, target: null, repairedToday: 0 },
stats: newTurnStats(),
lastResult: null,
totals: newTotalStats()
@ -99,8 +129,7 @@ export function createGame() {
}
export function getSpawnRange(farm) {
const ranges = [[1.95, 4.35], [1.55, 3.45], [1.25, 2.80], [1.05, 2.35]];
return ranges[(farm?.level || 1) - 1] || ranges[0];
return EGG_SPAWN_RANGES[(farm?.level || 1) - 1] || EGG_SPAWN_RANGES[EGG_SPAWN_RANGES.length - 1] || [2, 4];
}
export function nextSpawnDelay(farm) {
@ -131,34 +160,107 @@ export function defaultFacilities() {
};
}
const INITIAL_CONVEYOR_BACKBONE = Object.freeze([
// Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it.
[4, 1], [4, 2], [4, 3], [4, 4],
// S1 left output -> Mixer receiver on the left grid edge.
[3, 5], [2, 5], [1, 5], [0, 5],
// S1 right output -> S2 top input.
[5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5],
[11, 6], [11, 7],
// S2 left output -> Waste Shredder receiver on bottom grid edge.
[10, 8], [10, 9], [10, 10], [10, 11], [10, 12],
// S2 right output -> Truck receiver on the bottom grid edge.
[12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12]
]);
const INITIAL_SCANNERS = Object.freeze([
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 5, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' },
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, row: 8, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }
]);
const INITIAL_EGG_FARMS = Object.freeze([
{ type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
]);
function addConveyorTile(game, col, row, options = {}) {
if (!inGrid(col, row)) return;
const k = key(col, row);
game.conveyorTiles.add(k);
if (!game.conveyorMeta.has(k) || options.forceMeta) {
game.conveyorMeta.set(k, {
price: BUILD_COSTS.conveyor,
builtSession: null,
uses: 0,
durability: BALANCE.maintenance.durability.conveyor,
maintenanceType: 'conveyor'
});
}
}
function scannerPortCells(scanner) {
return [
{ col: scanner.col, row: scanner.row - 1 },
{ col: scanner.col - 1, row: scanner.row },
{ col: scanner.col + 1, row: scanner.row }
].filter(p => inGrid(p.col, p.row));
}
function farmOutputCells(farm) {
return [
{ col: farm.col, row: farm.row + 1 },
{ col: farm.col - 1, row: farm.row },
{ col: farm.col + 1, row: farm.row },
{ col: farm.col, row: farm.row - 1 }
].filter(p => inGrid(p.col, p.row));
}
function protectedInitialCells(game) {
const reserved = new Set([...game.conveyorTiles]);
const add = p => { if (p && inGrid(p.col, p.row)) reserved.add(key(p.col, p.row)); };
for (const scanner of game.scanners) {
add(scanner);
for (const p of scannerPortCells(scanner)) add(p);
}
for (const farm of game.eggFarms) {
add(farm);
for (const p of farmOutputCells(farm)) add(p);
}
for (const f of Object.values(game.facilities)) add(f.entry);
return reserved;
}
function installInitialConveyorBackbone(game) {
for (const [col, row] of INITIAL_CONVEYOR_BACKBONE) addConveyorTile(game, col, row);
}
function scrubBlockedCellsFromInitialBackbone(game) {
const protectedCells = protectedInitialCells(game);
for (const k of protectedCells) game.blockedCells.delete(k);
}
export function resetLayout(game) {
game.conveyorTiles.clear();
game.conveyorMeta.clear();
const initial = [
// Egg Farm -> S1 top input. S1 starts lower so the farm is not pressed against it.
[4, 1], [4, 2], [4, 3], [4, 4],
// S1 left output -> Mixer receiver on the left grid edge.
[3, 5], [2, 5], [1, 5], [0, 5],
// S1 right output -> S2 top input.
[5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5],
[11, 6], [11, 7],
// S2 left output -> Waste Shredder receiver on bottom grid edge.
[10, 8], [10, 9], [10, 10], [10, 11], [10, 12],
// S2 right output -> Truck receiver on the bottom grid edge.
[12, 8], [13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [17, 9], [17, 10], [17, 11], [17, 12]
];
for (const [col, row] of initial) {
const k = `${col},${row}`;
game.conveyorTiles.add(k);
game.conveyorMeta.set(k, { price: BUILD_COSTS.conveyor, builtSession: null });
}
game.blockedCells = new Set();
installInitialConveyorBackbone(game);
game.facilities = defaultFacilities();
game.scanners = [
{ type: 'scanner', id: 1, kind: 'manual', slot: 0, role: 0, col: 4, row: 5, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' },
{ type: 'scanner', id: 2, kind: 'manual', slot: 1, role: 1, col: 11, row: 8, level: 1, queue: [], cooldown: 0, price: BUILD_COSTS.manualScanner, builtSession: null, autoMode: 'standard' }
];
const farm = { type: 'eggFarm', id: 1, col: 4, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null };
farm.nextSpawn = nextSpawnDelay(farm);
farm.lastInterval = farm.nextSpawn;
game.eggFarms = [farm];
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, queue: [] }));
game.eggFarms = INITIAL_EGG_FARMS.map(farm => {
const nextSpawn = nextSpawnDelay(farm);
return { ...farm, nextSpawn, lastInterval: nextSpawn };
});
// Generate no-build cells only after the complete initial factory footprint is
// installed. The protected footprint includes every default conveyor segment,
// scanner body/ports, farm output, and machine receiver. This guarantees that
// the starter line is always continuous from Egg Farm -> S1 -> S2 -> exits even
// when roughly 20% of the map is blocked.
const reserved = protectedInitialCells(game);
game.blockedCells = generateBlockedCells({ seed: game.runSeed, targetRatio: BALANCE.map.blockedRatio, reserved: [...reserved] });
scrubBlockedCellsFromInitialBackbone(game);
installInitialConveyorBackbone(game);
game.routingVersion = (game.routingVersion || 0) + 1;
game.factoryGraph = null;
game.factoryGraphCommittedVersion = -1;
}

View file

@ -23,10 +23,10 @@ export const TEXT = {
autoMenu: 'Auto Menu'
},
status: {
build: tool => `BUILD: ${(tool || 'SELECT').toUpperCase()} | Right-drag pan | Click equipment for menu`,
build: tool => `BUILD: ${(tool === 'erase' ? 'SELL' : (tool || 'SELECT')).toUpperCase()} | Right-drag pan | Hover equipment for info`,
sorting: 'Sorting: use scanner keys. Build after clearing the day.',
allPortsConnected: 'All required ports connected.',
equipmentPanelEmpty: 'Click equipment in Build phase.',
allPortsConnected: 'Start condition met: one EGG route reaches an exit.',
equipmentPanelEmpty: 'Hover equipment in Build phase.',
eventPanelRunning: 'Forced events appear in Build phase.'
},
routeLabels: {
@ -43,7 +43,7 @@ export const TEXT = {
notEnoughCash: 'Not enough cash',
facilityExists: 'Facility already exists',
facilityOverlap: 'Facility overlap',
nothingToErase: 'Nothing to erase',
nothingToErase: 'Nothing to sell',
noUpgrade: 'No upgrade available'
}
};
@ -65,8 +65,22 @@ export function buildToolPriceText(id) {
return yen(def.price);
}
export function buildToolFlavorText(id) {
const flavors = {
conveyor: 'A narrow green belt. Routes decide whether chicks live, ship, or become invoices.',
eggFarm: 'A tiny gatehouse producing questionable eggs on schedule.',
autoScanner: 'An automated judge. Faster than hands, still very sure of itself.',
manualScanner: 'A manual checkpoint. The operator is the algorithm.',
mixer: 'Male chicks become revenue here. Do not feed it poop.',
trash: 'A polite shredder for poop and other regrets.',
truck: 'Ships the target cargo. Wrong cargo still leaves a paper trail.'
};
return flavors[id] || '';
}
export function buildToolButtonHtml(id) {
return `<strong>${equipmentName(id)}</strong><span>${buildToolPriceText(id)}</span>`;
const flavor = buildToolFlavorText(id);
return `<strong>${equipmentName(id)}</strong><span>${buildToolPriceText(id)}</span>${flavor ? `<small class="tool-flavor">${flavor}</small>` : ''}`;
}
export function buildToolButtonIds() {

View file

@ -1,16 +1,18 @@
import { TURN_SECONDS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, THEME } from './core/config.js';
import { TURN_SECONDS, THEME } from './core/config.js';
import { buildToolButtonHtml } from './core/text.js';
import { createGame, resetLayout, newTurnStats } from './core/state.js';
import { clamp, pointToCell, cellCenter, yen } from './core/utils.js';
import { facilityConnectionIssues } from './systems/routing.js';
import { collectZundaTax, settleTruckRevenue } from './systems/economy.js';
import { commitFactoryGraphForDay, facilityConnectionIssues } from './systems/routing.js';
import { applyRescueLoanIfNeeded, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
import { undo, redo } from './systems/history.js';
import { drawAll } from './render/draw.js';
import { createBuildSystem } from './systems/buildSystem.js';
import { createUISystem } from './systems/uiSystem.js';
import { createChickSystem } from './systems/chickSystem.js';
import { conveyorSpeedForGame, createCardSystem } from './systems/cards.js';
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js';
import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
@ -20,8 +22,10 @@ const ui = {
money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'),
eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'),
turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'),
buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'), facilityPanel: document.getElementById('facilityPanel'),
buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'),
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
hoverTooltip: document.getElementById('hoverTooltip'),
hireRepairmanButton: document.getElementById('hireRepairmanButton'),
buttons: {
s1Left: document.getElementById('scanner1MixerButton'), s1Right: document.getElementById('scanner1TruckButton'), s2Left: document.getElementById('scanner2MixerButton'), s2Right: document.getElementById('scanner2TruckButton'),
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
@ -48,14 +52,19 @@ const game = createGame();
let build;
let uiSystem;
let chicks;
let cardSystem;
function currentConveyorSpeed() { return Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED); }
function currentConveyorSpeed(conveyorKey = null) {
const base = conveyorSpeedForGame(game, conveyorKey);
return conveyorKey ? base * conveyorSpeedFactorForKey(game, conveyorKey) : base;
}
function startGame() {
Object.assign(game, createGame());
game.phase = 'running';
game.view = { x: 0, y: 0 };
resetLayout(game);
ensureMaintenanceState(game);
uiSystem.hideModal();
chicks.updateCongestion();
uiSystem.updatePanels();
@ -64,18 +73,34 @@ function startGame() {
function startNextTurn() {
if (game.phase !== 'build') return;
if (game.cardDraft?.pending || game.cardTargetPick?.pending) {
build.fail(game.cardTargetPick?.pending ? 'Choose upgrade target first.' : 'Choose an upgrade card first.');
uiSystem.updatePanels();
return;
}
const issues = facilityConnectionIssues(game);
if (issues.length) {
build.fail(`Cannot start: ${issues[0]}`);
uiSystem.updatePanels();
return;
}
ensureMaintenanceState(game);
commitFactoryGraphForDay(game);
activateRepairmanForDay(game);
const zundaBasisCash = game.cash;
const zunda = collectZundaTax(game, zundaBasisCash);
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);
// Negative cash during the next production day is allowed; bankruptcy is checked only at day-end settlement before Build phase.
activateAcceptedContract(game);
game.phase = 'running';
game.turn += 1;
game.timeLeft = TURN_SECONDS;
game.shutdownTimeLeft = 0;
game.cleanupTimer = null;
game.cleanupBlown = false;
game.chicks = [];
game.effects = [];
game.floatingTexts = [];
@ -83,8 +108,6 @@ function startNextTurn() {
game.truckCargo = [];
game.view = { x: 0, y: 0 };
game.stats = newTurnStats();
const zunda = collectZundaTax(game, zundaBasisCash);
if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger);
for (const scanner of game.scanners) { scanner.queue = []; scanner.cooldown = 0; }
for (const farm of game.eggFarms) { farm.nextSpawn = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; }
game.buildTool = null;
@ -97,25 +120,52 @@ function startNextTurn() {
uiSystem.updateUI();
}
function hireRepairman() {
ensureMaintenanceState(game);
const result = hireRepairmanForNextDay(game);
if (!result.ok) {
floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, result.reason || 'Cannot hire repairman.', THEME.danger);
build?.fail?.(result.reason || 'Cannot hire repairman.');
} else {
floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `Repairman hired -${yen(result.cost)}`, THEME.green);
}
uiSystem.updatePanels();
uiSystem.updateUI();
}
function completeTurn() {
if (game.phase !== 'running') return;
const ship = settleTruckRevenue(game);
const contract = resolveContract(game);
const chemical = collectChemicalWeaponSubsidy(game);
if (chemical.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `CHEM SUBSIDY +${yen(chemical.amount)}`, THEME.green);
clearActiveContract(game);
const settlementDay = Math.max(1, (game.totals.turnsCompleted || 0) + 1);
const tribute = collectFairiesTribute(game, settlementDay);
game.lastBuildFees = { fairiesTribute: tribute.amount || 0, reduction: tribute.reduction || 0, turn: settlementDay };
if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger);
const rescue = applyRescueLoanIfNeeded(game);
if (rescue.amount > 0) floating(game, canvas.width / 2 - game.view.x, 176 - game.view.y, `RESCUE +${yen(rescue.amount)}`, THEME.green);
game.totals.turnsCompleted += 1;
game.lastResult = { ...game.stats, ship, contract, turn: game.turn, cash: game.cash };
game.lastResult = { ...game.stats, ship, contract, chemical, rescue, fairiesTribute: tribute, turn: game.turn, cash: game.cash };
if (game.cash < 0) { game.phase = 'gameover'; uiSystem.showGameOver(); return; }
game.phase = 'build';
game.truckCargo = [];
game.chicks = [];
game.shutdownTimeLeft = 0;
game.cleanupTimer = null;
game.cleanupBlown = false;
game.buildSession += 1;
game.undoStack = [];
game.redoStack = [];
game.timeLeft = 0;
for (const scanner of game.scanners) scanner.queue = [];
for (const farm of game.eggFarms) { farm.shutterProgress = 0; farm.shutterSparked = false; }
deactivateRepairman(game);
game.contractOffer = rollContractOffer(game);
if (cardSystem) cardSystem.prepareDraft();
game.cardTargetPick = null;
game.buildTool = null;
game.groupDrag = null;
game.selectionBox = null;
@ -157,14 +207,49 @@ function canvasPoint(event) { const p = rawCanvasPoint(event); return { x: p.x -
function startPan(event) { const p = rawCanvasPoint(event); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y }; }
function updatePan(event) { if (!game.pan) return; const p = rawCanvasPoint(event); game.view.x = clamp(game.pan.viewX + p.x - game.pan.start.x, -620, 420); game.view.y = clamp(game.pan.viewY + p.y - game.pan.start.y, -340, 240); }
function selectionForHit(hit) { if (!hit) return null; if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey }; return { type: hit.type, id: hit.ref.id }; }
function objectFromHit(hit) {
if (!hit) return null;
if (hit.type === 'conveyor') return { type: 'conveyor', id: hit.oldKey };
return hit.ref || null;
}
function hideHoverTooltip() {
if (!ui.hoverTooltip) return;
ui.hoverTooltip.classList.remove('visible');
ui.hoverTooltip.setAttribute('aria-hidden', 'true');
game.hover = null;
}
function showHoverTooltip(event, hit) {
if (!ui.hoverTooltip || game.phase !== 'build' || game.cardTargetPick?.pending || game.groupDrag || game.selectionBox || game.pan) return hideHoverTooltip();
const obj = objectFromHit(hit);
if (!obj) return hideHoverTooltip();
const title = build.selectedTitle(obj);
const flavor = build.flavorText ? build.flavorText(obj) : '';
const lines = build.selectedInfoLines(obj).slice(0, 7);
ui.hoverTooltip.innerHTML = `<strong>${title}</strong>${flavor ? `<em>${flavor}</em>` : ''}${lines.map(line => `<span>${line}</span>`).join('')}`;
const x = Math.min(window.innerWidth - 330, event.clientX + 18);
const y = Math.min(window.innerHeight - 220, event.clientY + 18);
ui.hoverTooltip.style.left = `${Math.max(8, x)}px`;
ui.hoverTooltip.style.top = `${Math.max(8, y)}px`;
ui.hoverTooltip.classList.add('visible');
ui.hoverTooltip.setAttribute('aria-hidden', 'false');
game.hover = { type: hit.type, id: hit.oldKey || hit.ref?.id || null };
}
function updateHover(event) {
if (game.phase !== 'build') return hideHoverTooltip();
showHoverTooltip(event, build.equipmentAtPoint(canvasPoint(event)));
}
function clickSelect(event) {
hideHoverTooltip();
const p = canvasPoint(event), hit = build.equipmentAtPoint(p);
const sel = selectionForHit(hit);
if (!sel) { game.selected = null; game.multiSelected = []; uiSystem.updatePanels(); return; }
game.selected = sel;
game.multiSelected = [sel];
uiSystem.updatePanels();
build.showSelectedMenu();
}
canvas.addEventListener('contextmenu', event => event.preventDefault());
@ -174,6 +259,7 @@ canvas.addEventListener('pointerdown', event => {
const world = canvasPoint(event);
if (event.button === 2) { startPan(event); return; }
if (event.button !== 0) return;
if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; }
if (game.buildTool === 'erase') { build.eraseAtPoint(world); return; }
if (game.buildTool) {
const hit = build.equipmentAtPoint(world);
@ -185,7 +271,8 @@ canvas.addEventListener('pointerdown', event => {
build.startSelectionBox(event);
});
canvas.addEventListener('pointermove', event => {
if (game.phase !== 'build') return;
if (game.phase !== 'build') { hideHoverTooltip(); return; }
updateHover(event);
if (game.pan && (event.buttons & 2)) updatePan(event);
if (game.groupDrag && (event.buttons & 1)) build.updateGroupDrag(event);
if (game.selectionBox && (event.buttons & 1)) build.updateSelectionBox(event);
@ -201,7 +288,8 @@ canvas.addEventListener('pointerup', event => {
game.groupDrag = null; game.pan = null;
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
});
canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; });
canvas.addEventListener('pointerleave', hideHoverTooltip);
canvas.addEventListener('pointercancel', () => { game.groupDrag = null; game.selectionBox = null; game.pan = null; hideHoverTooltip(); });
document.addEventListener('pointerdown', event => {
if (!ui.modal.classList.contains('visible') || !ui.modal.classList.contains('equipment-popover')) return;
if (event.target.closest('#modal .modal-card')) return;
@ -210,10 +298,14 @@ document.addEventListener('pointerdown', event => {
ui.buttons.s1Left.addEventListener('click', () => chicks.sortSlot(0, 'left')); ui.buttons.s1Right.addEventListener('click', () => chicks.sortSlot(0, 'right')); ui.buttons.s2Left.addEventListener('click', () => chicks.sortSlot(1, 'left')); ui.buttons.s2Right.addEventListener('click', () => chicks.sortSlot(1, 'right'));
ui.buttons.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.eggFarm.addEventListener('click', () => build.setBuildTool('eggFarm')); ui.buttons.autoScanner.addEventListener('click', () => build.setBuildTool('autoScanner')); ui.buttons.manualScanner.addEventListener('click', () => build.setBuildTool('manualScanner')); ui.buttons.mixer.addEventListener('click', () => build.setBuildTool('mixer')); ui.buttons.trash.addEventListener('click', () => build.setBuildTool('trash')); ui.buttons.truck.addEventListener('click', () => build.setBuildTool('truck')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
ui.hireRepairmanButton?.addEventListener('click', hireRepairman);
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) uiSystem.updatePanels(); }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) uiSystem.updatePanels(); });
window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (name === 'f1') { event.preventDefault(); game.debug = !game.debug; } });
window.addEventListener('keydown', event => { if (event.repeat) return; const name = event.key.toLowerCase(); if (name === 'a') { event.preventDefault(); chicks.sortSlot(0, 'left'); } if (name === 'd') { event.preventDefault(); chicks.sortSlot(0, 'right'); } if (event.key === 'ArrowLeft') { event.preventDefault(); chicks.sortSlot(1, 'left'); } if (event.key === 'ArrowRight') { event.preventDefault(); chicks.sortSlot(1, 'right'); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) uiSystem.updatePanels(); } if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) uiSystem.updatePanels(); } if (event.key === 'Escape' && game.cardTargetPick?.pending) { event.preventDefault(); cardSystem.cancelTargetPick(); } });
build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () => uiSystem?.updatePanels() });
chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() });
uiSystem = createUISystem({ game, ui, build, startGame, activeQueuedChick: chicks.activeQueuedChick });
initializeStaticText(); resetLayout(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);
cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() });
uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick });
initializeStaticText(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); requestAnimationFrame(update);

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v11.0 Routing / Compact Upgrades</title>
<title>Chick Sorter v27.0</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">JPY 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">JPY 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>
@ -35,8 +35,8 @@
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v11.0</span></h1>
<p>Build, drag, connect all receiver ports, then start the next day.</p>
<h1>CHICK SORTER <span>v27.0</span></h1>
<p>Build, drag, 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>
</div>
@ -45,14 +45,15 @@
<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>JPY 30 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>JPY 300</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>JPY 360 / 4.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>JPY 260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>JPY 450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>JPY 240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>JPY 450</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Erase</strong><span>Remove any equipment</span></button>
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 2.25s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>¥450</span></button>
<button id="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>
<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>
@ -62,13 +63,7 @@
<h2>Irregular One-Day Event</h2>
<div id="contractPanel" class="contract-card empty">Irregular forced events appear during Build phase.</div>
</section>
<section class="facility-section">
<h2>Selected Facility</h2>
<div id="facilityPanel" class="facility-panel-empty">Click equipment in Build phase.</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>
@ -81,6 +76,20 @@
<button id="scanner2MixerButton" class="sort-button" type="button">S2 -> Waste</button>
<button id="scanner2TruckButton" class="sort-button" type="button">S2 -> Truck</button>
</div>
<div id="hoverTooltip" class="hover-tooltip" aria-hidden="true"></div>
<details id="debugPanel" class="debug-panel">
<summary>DBG</summary>
<div class="debug-body">
<label><input id="debugInfiniteCash" type="checkbox" /> Infinite cash</label>
<label>Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
<button id="debugSetDayButton" type="button">Set Day</button>
<label>Upgrade <select id="debugUpgradeSelect"></select></label>
<button id="debugApplyUpgradeButton" type="button">Free Buy</button>
<div id="debugObservations" class="debug-readout"></div>
<div id="debugEventLog" class="debug-readout event-log"></div>
</div>
</details>
</section>
</main>
@ -92,6 +101,6 @@
</div>
</div>
<script type="module" src="./game.js?v=11.0-routing-compact-upgrades"></script>
<script type="module" src="./game.js?v=27.0-combo-settlement"></script>
</body>
</html>

View file

@ -2,11 +2,17 @@ import { GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
import { key, parseKey, pointToCell, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
import { getConveyorNeighbors, routeFromFarmToScanner, outputRoute, scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor } from '../systems/routing.js';
import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
import { cardTargetBounds } from '../systems/cards.js';
import { wearRatio } from '../systems/maintenance.js';
// Optional art overlay hook.
// Keep external art probing disabled by default so a clean checkout does not emit 404s for missing PNGs.
// To use replacement art, set ENABLE_EXTERNAL_ART_ASSETS to true and place files under assets/images/.
const ENABLE_EXTERNAL_ART_ASSETS = false;
const ASSET_PATHS = {
chickMale: './assets/images/chick_male.png',
chickFemale: './assets/images/chick_female.png',
poop: './assets/images/tarinai.png',
poop: new URL('../../assets/images/poop.png', import.meta.url).href,
conveyor: './assets/images/conveyor.png',
scannerManual: './assets/images/scanner_manual.png',
scannerAuto: './assets/images/scanner_auto.png',
@ -19,16 +25,16 @@ const ASSET_PATHS = {
export const assets = {};
for (const [name, src] of Object.entries(ASSET_PATHS)) {
const img = new Image();
img.src = src;
img.loaded = false;
img.onload = () => { img.loaded = true; };
img.onerror = () => { img.loaded = false; };
if ((ENABLE_EXTERNAL_ART_ASSETS || name === 'poop') && src) {
img.onload = () => { img.loaded = true; };
img.src = src;
}
assets[name] = img;
}
// ART OVERLAY HOOK:
// Drop replacement PNG/WebP files into assets/images/ using the names in ASSET_PATHS.
// Each draw* function first checks whether an image loaded; if not, it falls back to simple flat shapes.
// Keep these placeholder shapes simple so your later artwork can be layered over them without fighting the UI.
// Each draw* function first checks whether an image loaded; if not, it falls back to simple canvas shapes.
export function drawAll(ctx, canvas, game, helpers) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
@ -37,18 +43,19 @@ export function drawAll(ctx, canvas, game, helpers) {
const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
const sy = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
ctx.translate(game.view.x + sx, game.view.y + sy);
drawGrid(ctx);
drawGrid(ctx, game);
drawConveyors(ctx, game);
drawScannerConnectors(ctx, game);
drawFacilities(ctx, game);
drawEggFarms(ctx, game);
drawScanners(ctx, game);
drawChicks(ctx, game, helpers.activeQueuedChick);
drawRepairman(ctx, game);
drawCardTargetOverlay(ctx, canvas, game);
drawSelectedTooltip(ctx, game, helpers.selectedObject, helpers.selectedTitle);
drawSelectionBox(ctx, game);
drawEffects(ctx, game);
drawFloatingTexts(ctx, game);
if (game.debug) drawDebug(ctx, game);
ctx.restore();
drawCanvasHints(ctx, game, canvas);
}
@ -74,7 +81,7 @@ function drawBackground(ctx, canvas) {
for (let y = 0; y < canvas.height; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
ctx.restore();
}
function drawGrid(ctx) {
function drawGrid(ctx, game) {
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,.46)';
ctx.strokeStyle = 'rgba(16,32,21,.055)';
@ -88,8 +95,36 @@ function drawGrid(ctx) {
const y = GRID.y + r * GRID.cell;
ctx.beginPath(); ctx.moveTo(GRID.x, y); ctx.lineTo(GRID.x + GRID.cols * GRID.cell, y); ctx.stroke();
}
for (const k of game.blockedCells || []) {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
ctx.fillStyle = 'rgba(16,32,21,.30)';
ctx.strokeStyle = 'rgba(16,32,21,.60)';
ctx.lineWidth = 2;
rect(ctx, c.x - GRID.cell / 2 + 4, c.y - GRID.cell / 2 + 4, GRID.cell - 8, GRID.cell - 8, true, true);
}
ctx.restore();
}
function drawDirtOverlay(ctx, x, y, w, h, target, radius = 0) {
const wear = wearRatio(target);
if (wear < 0.50) return;
ctx.save();
ctx.globalAlpha = Math.min(0.62, 0.12 + wear * 0.54);
ctx.fillStyle = '#6b4a23';
if (radius > 0) {
ctx.beginPath(); ctx.roundRect?.(x, y, w, h, radius);
if (ctx.roundRect) ctx.fill();
else rect(ctx, x, y, w, h, true, false);
} else rect(ctx, x, y, w, h, true, false);
ctx.globalAlpha = Math.min(0.9, 0.25 + wear * 0.6);
ctx.strokeStyle = '#3a2a16';
ctx.lineWidth = Math.max(2, Math.floor(2 + wear * 5));
ctx.setLineDash([5, 6]);
rect(ctx, x + 3, y + 3, w - 6, h - 6, false, true);
ctx.restore();
}
function componentRatio(game, k) {
const id = game.componentLookup?.get(k);
const data = id ? game.congestion.get(id) : null;
@ -133,6 +168,8 @@ function drawConveyors(ctx, game) {
ctx.fillStyle = ratio > 0.5 ? mixHex(THEME.white, '#ffd6d6', Math.max(0, (ratio - .5) / .5)) : THEME.white;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x - 8, c.y - 8, 16, 16, true, true);
drawDirtOverlay(ctx, c.x - 14, c.y - 14, 28, 28, { meta: game.conveyorMeta.get(k) });
drawSelection(ctx, game, 'conveyor', k, c.x, c.y, 38, 38);
drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []);
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
}
@ -219,7 +256,11 @@ function drawEggFarms(ctx, game) { for (const farm of game.eggFarms) drawEggFarm
function drawEggFarm(ctx, farm, game) {
const c = cellCenter(farm.col, farm.row);
ctx.save();
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) { ctx.restore(); return; }
if (drawImageIfLoaded(ctx, assets.eggFarm, c.x - 29, c.y - 29, 58, 58)) {
drawDirtOverlay(ctx, c.x - 29, c.y - 29, 58, 58, farm);
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
ctx.restore(); return;
}
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 28, c.y - 28, 56, 56, true, true);
ctx.fillStyle = THEME.green; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4);
@ -233,6 +274,7 @@ function drawEggFarm(ctx, farm, game) {
for (let yy = c.y - 24; yy < c.y - 28 + h; yy += 8) { ctx.beginPath(); ctx.moveTo(c.x - 24, yy); ctx.lineTo(c.x + 24, yy); ctx.stroke(); }
if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); }
}
drawDirtOverlay(ctx, c.x - 28, c.y - 28, 56, 56, farm);
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
ctx.restore();
}
@ -241,22 +283,74 @@ function drawScanner(ctx, scanner, game) {
const c = scannerCenter(scanner);
ctx.save();
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) { ctx.restore(); return; }
if (drawImageIfLoaded(ctx, img, c.x - 54, c.y - 38, 108, 76)) {
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 54, c.y - 38, 108, 76, scanner);
else drawManualKeyboardIcon(ctx, scanner, c);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
ctx.restore(); return;
}
ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 52, c.y - 36, 104, 72, true, true);
ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink;
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'center';
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 12);
ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 7);
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 14);
ctx.font = '900 9px ui-monospace, monospace';
ctx.fillText(scanner.role === 0 ? 'A/L:M D/R:NEXT' : 'L:WASTE R:TRUCK', c.x, c.y + 3);
const q = scanner.queue.length;
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.fillText(`Q:${q}${scanner.kind === 'auto' ? ` CD:${scanner.cooldown.toFixed(1)}` : ''}`, c.x, c.y + 25);
if (scanner.kind === 'manual') {
drawManualKeyboardIcon(ctx, scanner, c);
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 8px ui-monospace, monospace';
ctx.fillText(`Q:${q}`, c.x, c.y + 32);
} else {
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 25);
drawDirtOverlay(ctx, c.x - 52, c.y - 36, 104, 72, scanner);
}
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 116, 84);
ctx.restore();
}
function drawManualKeyboardIcon(ctx, scanner, c) {
if (scanner.kind !== 'manual') return;
const slot = scanner.slot ?? 0;
const labels = slot === 0
? [{ side: 'left', text: 'A' }, { side: 'right', text: 'D' }]
: [{ side: 'left', text: '←' }, { side: 'right', text: '→' }];
const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null;
const baseX = c.x - 30;
const baseY = c.y + 8;
ctx.save();
ctx.lineWidth = 3;
ctx.fillStyle = '#f7fff5';
ctx.strokeStyle = THEME.ink;
rect(ctx, baseX - 4, baseY - 4, 68, 25, true, true);
ctx.font = '900 13px ui-monospace, monospace';
ctx.textAlign = 'center';
for (let i = 0; i < labels.length; i += 1) {
const keyDef = labels[i];
const pressed = pressedSide === keyDef.side;
const x = baseX + i * 32;
const y = baseY + (pressed ? 3 : 0);
ctx.fillStyle = pressed ? THEME.greenSoft : THEME.white;
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = pressed ? 4 : 3;
rect(ctx, x, y, 28, 17, true, true);
if (!pressed) {
ctx.fillStyle = 'rgba(16,32,21,.18)';
rect(ctx, x + 3, y + 17, 28, 3, true, false);
}
ctx.fillStyle = THEME.ink;
ctx.fillText(keyDef.text, x + 14, y + 13);
}
ctx.fillStyle = THEME.muted;
ctx.font = '900 6px ui-monospace, monospace';
ctx.fillText('KEYBOARD', c.x, baseY - 7);
ctx.restore();
}
function targetForTruck(game) {
return game.contractActive?.target || game.contractOffer?.target || 'female';
}
@ -337,13 +431,14 @@ function drawMixer(ctx, game) {
const m = game.facilities.mixer;
ctx.save();
drawExternalDuct(ctx, m);
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { ctx.restore(); return; }
if (drawImageIfLoaded(ctx, assets.mixer, m.x, m.y, m.w, m.h)) { drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m); drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, m.x, m.y, m.w, m.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 17px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`MIXER L${m.level}`, m.x + m.w / 2, m.y + 28);
ctx.font = '900 10px ui-monospace, monospace'; ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + m.w / 2, m.y + 48);
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
ctx.strokeStyle = THEME.green; ctx.lineWidth = 5;
for (let i = 0; i < 3; i += 1) { ctx.beginPath(); ctx.arc(m.x + m.w / 2, m.y + 78, 16 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); }
drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m);
drawSelection(ctx, game, 'facility', 'mixer', m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
ctx.restore();
}
@ -351,11 +446,12 @@ function drawTrash(ctx, game) {
const t = game.facilities.trash;
ctx.save();
drawExternalDuct(ctx, t);
if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
if (drawImageIfLoaded(ctx, assets.shredder, t.x, t.y, t.w, t.h)) { drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t); drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`SHREDDER L${t.level}`, t.x + t.w / 2, t.y + 25);
for (let i = 0; i < 7; i += 1) { ctx.fillRect(t.x + 34 + i * 16, t.y + 48, 7, t.h - 66); }
drawTargetBadge(ctx, t.x + 14, t.y + t.h - 42, t.w - 28, 'SEND POOP', 'poop', 'shredder');
drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t);
drawSelection(ctx, game, 'facility', 'trash', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore();
}
@ -363,7 +459,7 @@ function drawTruck(ctx, game) {
const t = game.facilities.truck;
ctx.save();
drawExternalDuct(ctx, t);
if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { ctx.restore(); return; }
if (drawImageIfLoaded(ctx, assets.truck, t.x, t.y, t.w, t.h)) { drawSelection(ctx, game, 'facility', 'truck', t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12); ctx.restore(); return; }
ctx.fillStyle = THEME.white; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4; rect(ctx, t.x, t.y, t.w, t.h, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 15px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`TRUCK L${t.level}`, t.x + t.w / 2, t.y + 24);
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + t.w / 2, t.y + 42);
@ -429,7 +525,13 @@ function isSelected(game, type, id) {
}
function drawSelection(ctx, game, type, id, x, y, w, h) {
if (!isSelected(game, type, id)) return;
ctx.save(); ctx.strokeStyle = THEME.green; ctx.lineWidth = 4; ctx.setLineDash([8, 5]); ctx.strokeRect(x - w / 2, y - h / 2, w, h); ctx.restore();
ctx.save();
ctx.fillStyle = 'rgba(34,185,79,.18)';
ctx.strokeStyle = THEME.green;
ctx.lineWidth = (game.multiSelected || []).length > 1 ? 6 : 4;
ctx.setLineDash((game.multiSelected || []).length > 1 ? [] : [8, 5]);
rect(ctx, x - w / 2, y - h / 2, w, h, true, true);
ctx.restore();
}
function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) {
if (game.phase !== 'build') return;
@ -437,14 +539,51 @@ function drawSelectedTooltip(ctx, game, selectedObject, selectedTitle) {
ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.96)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, 54 - game.view.x, 66 - game.view.y, 214, 42, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(`${game.multiSelected.length} ITEMS SELECTED`, 65 - game.view.x, 83 - game.view.y); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag any selected item to move group', 65 - game.view.x, 99 - game.view.y); ctx.restore();
return;
}
const obj = selectedObject();
if (!obj) return;
let x = 80, y = 80;
if (obj.type === 'conveyor') { const p = parseKey(obj.id); const c = cellCenter(p.col, p.row); x = c.x + 22; y = c.y - 56; }
else if (obj.type === 'eggFarm' || obj.type === 'scanner') { const c = obj.type === 'eggFarm' ? cellCenter(obj.col, obj.row) : scannerCenter(obj); x = c.x + 40; y = c.y - 56; }
else if (obj.type === 'facility') { x = obj.x + obj.w / 2 - 76; y = obj.y - 48; }
ctx.save(); ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 3; rect(ctx, x, y, 176, 40, true, true); ctx.fillStyle = THEME.ink; ctx.font = '900 11px ui-monospace, monospace'; ctx.fillText(selectedTitle(obj), x + 9, y + 16); ctx.fillStyle = THEME.muted; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText('drag to move / panel to edit', x + 9, y + 31); ctx.restore();
// Single equipment inspection is handled by the DOM hover tooltip.
// Click selection remains only for dragging/multi-select; no clicked equipment popover is drawn.
}
function drawCardTargetOverlay(ctx, canvas, game) {
if (!game.cardTargetPick?.pending) return;
const items = cardTargetBounds(game);
ctx.save();
// The canvas is already translated by the camera. Offset the dark layer back to screen space
// so every non-candidate object is reliably dimmed even after panning.
ctx.fillStyle = 'rgba(0,0,0,.66)';
ctx.beginPath();
ctx.rect(-game.view.x, -game.view.y, canvas.width, canvas.height);
for (const item of items) {
const b = item.bounds;
const pad = 8;
ctx.rect(b.x - pad, b.y - pad, b.w + pad * 2, b.h + pad * 2);
}
ctx.fill('evenodd');
const isBlockedCellMode = game.cardTargetPick?.mode === 'blockedCell';
if (!items.length) {
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3;
rect(ctx, 260 - game.view.x, 96 - game.view.y, 420, 42, true, true);
ctx.fillStyle = THEME.danger;
ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(isBlockedCellMode ? 'NO BLOCKED CELL. PRESS ESC.' : 'NO VALID UPGRADE TARGET. PRESS ESC.', 470 - game.view.x, 122 - game.view.y);
ctx.restore();
return;
}
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3;
const remaining = Math.max(1, game.cardTargetPick?.remaining || 1);
const msg = isBlockedCellMode ? `CLICK BLOCKED CELL: ${remaining} LEFT. PRESS ESC TO CANCEL.` : 'CLICK AN UPGRADEABLE MACHINE. PRESS ESC TO CANCEL.';
rect(ctx, 216 - game.view.x, 96 - game.view.y, 560, 42, true, true);
ctx.fillStyle = THEME.ink;
ctx.font = '900 12px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(msg, 496 - game.view.x, 122 - game.view.y);
ctx.restore();
}
function drawSelectionBox(ctx, game) {
if (game.phase !== 'build' || !game.selectionBox) return;
const b = game.selectionBox;
@ -483,6 +622,41 @@ function drawFloatingTexts(ctx, game) {
for (const t of game.floatingTexts) { const alpha = Math.max(0, t.life / t.maxLife); ctx.globalAlpha = alpha; ctx.lineWidth = 5; ctx.strokeStyle = THEME.white; ctx.fillStyle = t.color; ctx.strokeText(t.text, t.x, t.y); ctx.fillText(t.text, t.x, t.y); }
ctx.restore();
}
function drawRepairman(ctx, game) {
const r = game.repairman;
const visible = !!r?.active || (game.phase === 'build' && !!r?.hiredForNextDay);
if (!visible) return;
ctx.save();
ctx.lineWidth = 3;
ctx.fillStyle = '#fff0d1';
ctx.strokeStyle = THEME.ink;
ctx.beginPath(); ctx.arc(r.x, r.y, 17, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#ffd6e7';
ctx.beginPath(); ctx.arc(r.x - 6, r.y - 2, 3.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(r.x + 6, r.y - 2, 3.2, 0, Math.PI * 2); ctx.fill();
const exploited = !!game.cardEffects?.laborExploitation;
ctx.fillStyle = exploited ? THEME.danger : THEME.ink;
ctx.beginPath(); ctx.arc(r.x - 5, r.y - 5, exploited ? 2.8 : 2.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(r.x + 5, r.y - 5, exploited ? 2.8 : 2.2, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = exploited ? 3 : 2;
ctx.beginPath(); ctx.arc(r.x, r.y + (exploited ? 0 : 1), exploited ? 8 : 7, 0.10 * Math.PI, 0.90 * Math.PI); ctx.stroke();
ctx.fillStyle = THEME.green;
rect(ctx, r.x - 9, r.y + 14, 18, 8, true, true);
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(r.x - 11, r.y + 18); ctx.lineTo(r.x - 20, r.y + 24); ctx.stroke();
ctx.beginPath(); ctx.moveTo(r.x + 11, r.y + 18); ctx.lineTo(r.x + 20, r.y + 24); ctx.stroke();
ctx.fillStyle = THEME.ink;
ctx.font = '900 9px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(r.active ? 'FIX' : 'HIRED', r.x, r.y + 33);
if (r.target?.label) label(ctx, r.x, r.y - 24, r.target.label, THEME.green);
else if (!r.active && r.hiredForNextDay) label(ctx, r.x, r.y - 24, 'NEXT DAY', THEME.green);
ctx.restore();
}
function drawCanvasHints(ctx, game, canvas) {
ctx.save();
ctx.font = '900 13px ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillStyle = THEME.ink;
@ -493,13 +667,8 @@ function drawCanvasHints(ctx, game, canvas) {
ctx.font = '900 13px ui-monospace, monospace'; ctx.fillStyle = THEME.ink;
const lines = [];
if (game.phase === 'running') lines.push(game.timeLeft <= 0 ? 'TIME UP: FARMS SHUT. CLEAR LINE.' : 'A/D: S1 ←/→: S2');
if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect all receiver ports`);
if (game.phase === 'build') lines.push(`BUILD: ${game.buildTool || 'SELECT'} | connect 1 EGG route to any exit`);
let y = 76;
for (const line of lines) { ctx.strokeStyle = THEME.white; ctx.lineWidth = 4; ctx.strokeText(line, 24, y); ctx.fillText(line, 24, y); y += 20; }
ctx.restore();
}
function drawDebug(ctx, game) {
ctx.save(); ctx.font = '900 9px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillStyle = THEME.danger;
for (const k of game.conveyorTiles) { const { col, row } = parseKey(k); const c = cellCenter(col, row); const comp = game.componentLookup?.get(k) || '?'; ctx.fillText(`${col},${row}/C${comp}`, c.x, c.y + 24); }
ctx.restore();
}

View file

@ -1,367 +1,135 @@
:root {
--bg: #020303;
--screen: #050607;
--panel: rgba(10, 12, 14, 0.94);
--panel-solid: #0b0d0f;
--panel-2: #111417;
--line: rgba(132, 140, 148, 0.18);
--line-strong: rgba(177, 185, 193, 0.62);
--text: #d7dadd;
--muted: #8d949b;
--accent: #b4bac0;
--accent-2: #b99561;
--danger: #d66d6d;
--good: #9fd27d;
--shadow: 0 22px 70px rgba(0, 0, 0, 0.70);
--bg: #f2fff0;
--screen: #eefbea;
--panel: rgba(245, 255, 242, 0.96);
--ink: #102015;
--muted: #526456;
--line: #102015;
--green: #22b94f;
--green-soft: #b7f5c6;
--white: #ffffff;
--danger: #c42424;
--shadow: 8px 8px 0 rgba(16, 32, 21, 0.22);
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
background:
linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px),
linear-gradient(180deg, rgba(255,255,255,0.010) 1px, transparent 1px),
#020303;
background-size: 24px 24px;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
overflow-x: hidden;
}
body { margin: 0; background: var(--bg); color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; overflow-x: hidden; }
button { font: inherit; }
h1, h2, p { margin: 0; }
#app {
width: min(100vw, 1680px);
margin: 0 auto;
padding: 0 8px 12px;
}
.game-shell {
position: relative;
width: 100%;
margin-top: 0;
min-height: min(100vh, 920px);
border: 1px solid #30363d;
border-top: 0;
border-radius: 0;
background: var(--screen);
box-shadow: var(--shadow);
overflow: hidden;
}
.game-shell::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(255,255,255,0.035), transparent 90px);
z-index: 1;
}
#gameCanvas {
position: relative;
z-index: 0;
display: block;
width: 100%;
height: min(calc(100vh - 10px), 900px);
min-height: 660px;
background: #050607;
touch-action: none;
}
.hud {
position: absolute;
z-index: 4;
display: grid;
gap: 6px;
pointer-events: none;
}
.hud-top-left {
top: 10px;
left: 10px;
grid-template-columns: repeat(5, minmax(78px, auto));
}
.hud-top-right {
top: 10px;
right: 10px;
grid-template-columns: repeat(4, minmax(78px, auto));
}
.hud-card {
min-width: 78px;
padding: 6px 8px;
border: 1px solid rgba(82, 91, 100, 0.76);
border-radius: 0;
background: rgba(9, 11, 13, 0.92);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.018), 0 8px 20px rgba(0, 0, 0, 0.26);
backdrop-filter: blur(3px);
}
.hud-card span {
display: block;
font-size: 9px;
line-height: 1;
letter-spacing: 0.09em;
color: #7c858e;
margin-bottom: 4px;
}
.hud-card strong {
font-size: clamp(15px, 1.35vw, 21px);
line-height: 1;
}
.hud-card.cash strong { color: #d7b27d; }
.hud-card.compact strong { font-size: clamp(13px, 1.1vw, 18px); }
.hud-card.profit strong { color: var(--good); }
#app { width: min(100vw, 1680px); margin: 0 auto; padding: 0 8px 12px; }
.game-shell { position: relative; width: 100%; min-height: min(100vh, 920px); border: 4px solid var(--line); border-top: 0; background: var(--screen); overflow: hidden; box-shadow: var(--shadow); }
#gameCanvas { display: block; width: 100%; height: min(calc(100vh - 10px), 900px); min-height: 720px; background: var(--screen); touch-action: none; }
.hud { position: absolute; z-index: 4; display: grid; gap: 6px; pointer-events: none; }
.hud-top-left { top: 10px; left: 10px; grid-template-columns: repeat(5, minmax(86px, auto)); }
.hud-top-right { top: 10px; right: 10px; grid-template-columns: repeat(4, minmax(78px, auto)); }
.hud-card { min-width: 82px; padding: 7px 9px; border: 3px solid var(--line); background: rgba(255,255,255,.88); box-shadow: 4px 4px 0 rgba(16,32,21,.16); }
.hud-card.primary { min-width: 116px; padding: 10px 12px; background: rgba(255,255,255,.96); }
.hud-card.primary span { font-size: 9px; }
.hud-card.primary strong { font-size: clamp(22px, 2vw, 32px); }
.hud-card.time-critical { background: #fff0d1; box-shadow: inset 0 0 0 4px var(--danger), 4px 4px 0 rgba(16,32,21,.16); }
.hud-card span { display: block; font-size: 9px; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; }
.hud-card strong { font-size: clamp(15px, 1.3vw, 22px); line-height: 1; color: var(--ink); }
.hud-card.cash strong, .hud-card.profit strong, .cash-positive { color: var(--green) !important; }
.cash-negative { color: var(--danger) !important; }
.cash-positive { color: var(--good) !important; }
.upgrade-panel {
position: absolute;
z-index: 5;
left: 10px;
right: 10px;
bottom: 64px;
display: grid;
gap: 9px;
padding: 10px;
border: 1px solid #343a40;
border-radius: 0;
background: rgba(8, 10, 12, 0.94);
box-shadow: 0 20px 55px rgba(0, 0, 0, 0.56);
backdrop-filter: blur(4px);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
}
.panel-head h1 {
font-size: 18px;
letter-spacing: 0.08em;
}
.panel-head h1 span {
color: #050607;
font-size: 11px;
background: #b4bac0;
border-radius: 0;
padding: 2px 7px;
vertical-align: middle;
}
.panel-head p,
.mini-box,
.upgrade-desc { color: var(--muted); }
.panel-grid {
display: grid;
grid-template-columns: 260px minmax(420px, 1fr) 340px;
gap: 10px;
}
.upgrade-panel h2 {
color: var(--accent);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.10em;
margin-bottom: 7px;
}
.tool-list,
.upgrade-list {
display: grid;
gap: 7px;
}
.tool-list { grid-template-columns: 1fr 1fr; }
.tool-button,
.primary-button,
.sort-button,
.buy-button {
border-radius: 0;
cursor: pointer;
font-weight: 850;
text-transform: uppercase;
letter-spacing: 0.035em;
transition: transform 0.06s ease, filter 0.12s ease, opacity 0.12s ease, border-color 0.12s ease;
}
.tool-button:active,
.primary-button:active,
.sort-button:active,
.buy-button:active { transform: translateY(1px); }
.tool-button:disabled,
.primary-button:disabled,
.sort-button:disabled,
.buy-button:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.tool-button {
color: var(--text);
background: #111417;
border: 1px solid rgba(82, 91, 100, 0.76);
padding: 8px 9px;
text-align: left;
font-size: 11px;
}
.tool-button.active {
border-color: #b4bac0;
box-shadow: inset 0 0 0 1px rgba(180, 186, 192, 0.35);
}
.tool-button.danger.active {
border-color: var(--danger);
box-shadow: inset 0 0 0 1px rgba(214, 109, 109, 0.30);
}
.primary-button {
border: 1px solid #b4bac0;
padding: 9px 16px;
background: #b4bac0;
color: #050607;
white-space: nowrap;
}
.upgrade-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.upgrade-item {
border: 1px solid #30363d;
border-radius: 0;
padding: 8px;
background: #0f1215;
}
.upgrade-head {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.upgrade-head strong { font-size: 12px; }
.upgrade-level { color: var(--muted); font-size: 11px; white-space: nowrap; }
.upgrade-desc { font-size: 11px; line-height: 1.28; margin-bottom: 7px; }
.buy-button {
color: var(--text);
background: #1a1f24;
border: 1px solid #4b535b;
padding: 6px 8px;
font-size: 11px;
}
.buy-button:not(:disabled):hover,
.tool-button:not(:disabled):hover,
.sort-button:not(:disabled):hover { filter: brightness(1.14); }
.mini-box {
padding: 8px;
border: 1px solid #30363d;
border-radius: 0;
background: #080a0c;
font-size: 11px;
line-height: 1.45;
}
.mini-box strong { color: var(--text); }
.priority-strip { position: absolute; z-index: 5; top: 76px; left: 10px; display: grid; grid-template-columns: minmax(280px, 1.2fr) minmax(170px, .75fr) minmax(130px, .55fr); gap: 8px; max-width: calc(100% - 420px); pointer-events: none; }
.priority-strip > div { border: 3px solid var(--line); background: rgba(255,255,255,.94); padding: 8px 10px; font-weight: 900; text-transform: uppercase; box-shadow: 4px 4px 0 rgba(16,32,21,.14); font-size: 12px; letter-spacing: .03em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.event-brief.active { background: #fff0d1; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.14); }
.event-brief.next { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
.event-brief.locked { background: #edf3ec; color: var(--muted); }
.target-brief { color: var(--green); }
.speed-brief { color: var(--ink); }
.control-dock {
position: absolute;
z-index: 7;
left: 50%;
bottom: 10px;
transform: translateX(-50%);
display: grid;
grid-template-columns: repeat(4, 150px);
gap: 8px;
padding: 6px;
border: 1px solid #343a40;
background: rgba(8, 10, 12, 0.92);
}
.sort-button {
border: 1px solid #4b535b;
min-height: 46px;
font-size: 12px;
color: #f0f1f2;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.025);
}
.sort-button.mixer {
background: #1b2025;
}
.sort-button.truck {
background: #2a2118;
border-color: #705a3c;
}
.modal {
position: fixed;
inset: 0;
z-index: 20;
display: none;
align-items: center;
justify-content: center;
padding: 22px;
background: rgba(0, 0, 0, 0.72);
}
.build-panel { position: absolute; z-index: 6; top: 118px; right: 12px; bottom: 12px; width: clamp(278px, 23vw, 360px); min-height: 0; padding: 10px; border: 4px solid var(--line); background: var(--panel); box-shadow: var(--shadow); overflow: auto; }
.panel-head { display: grid; gap: 8px; margin-bottom: 10px; }
.panel-head h1 { font-size: 15px; letter-spacing: .06em; }
.panel-head h1 span { font-size: 9px; color: var(--white); background: var(--green); border: 2px solid var(--line); padding: 1px 6px; }
.panel-head p { color: var(--muted); margin-top: 4px; font-size: 9px; line-height: 1.25; }
.panel-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
.build-panel h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }
.large-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.history-tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; grid-column: 1 / -1; }
.tool-button.small { min-height: 40px; padding: 6px; }
.tool-button, .primary-button, .sort-button, .facility-action { border: 3px solid var(--line); background: var(--white); color: var(--ink); font-weight: 900; text-transform: uppercase; cursor: pointer; box-shadow: 4px 4px 0 rgba(16,32,21,.18); }
.tool-button { min-height: 50px; text-align: left; padding: 8px; }
.tool-button strong { display: block; font-size: 12px; }
.tool-button span { display: block; margin-top: 4px; font-size: 9px; color: var(--muted); }
.tool-button.active { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.18); }
.tool-button.danger.active { background: #ffdada; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); }
.primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; }
.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; }
.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; }
.facility-card h3 { color: var(--ink); font-size: 12px; margin: 0 0 6px; }
.facility-card p { margin: 4px 0; }
.facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.facility-action { padding: 7px 9px; font-size: 9px; }
.facility-action.good { background: var(--green-soft); }
.facility-action.warn { background: #fff0d1; }
.facility-action.danger { background: #ffdada; }
.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); }
.sort-button { min-height: 46px; font-size: 12px; }
.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); }
.modal.visible { display: flex; }
.modal-card {
width: min(720px, 96vw);
border: 1px solid #4b535b;
border-radius: 0;
padding: 22px;
background: #090b0d;
box-shadow: 0 34px 110px rgba(0, 0, 0, 0.78);
}
.modal-card h2 {
font-size: 24px;
margin-bottom: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.modal-card p { color: var(--text); line-height: 1.55; }
.modal-card ul {
margin: 12px 0 0;
padding-left: 22px;
color: var(--muted);
line-height: 1.55;
}
.modal-card strong { color: var(--text); }
.modal-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 1100px) {
.modal-card { width: min(860px, 96vw); border: 4px solid var(--line); padding: 24px; background: var(--white); box-shadow: var(--shadow); }
.modal-card h2 { font-size: 26px; text-transform: uppercase; margin-bottom: 12px; }
.modal-card p { line-height: 1.55; }
.modal-card ul { margin: 12px 0 0; padding-left: 22px; line-height: 1.55; color: var(--muted); }
.modal-actions { margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
.route-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.route-card { border: 3px solid var(--line); background: #f7fff5; padding: 14px; display: grid; grid-template-columns: 44px 1fr 28px 1fr; align-items: center; gap: 4px 8px; }
.route-card .route-icon { grid-row: 1 / span 2; font-size: 30px; text-align: center; }
.route-card strong, .route-card em { font-style: normal; font-weight: 900; text-transform: uppercase; }
.route-card b { font-size: 24px; color: var(--green); text-align: center; }
.route-card small { grid-column: 2 / span 3; color: var(--muted); font-weight: 900; }
.result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.result-grid div { border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
.result-grid strong { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; margin-bottom: 6px; }
.result-grid span { font-size: 16px; font-weight: 900; color: var(--ink); }
.game-shell:not(.phase-build) .build-panel { display: none; }
.game-shell.phase-running .hud-top-right { display: none; }
.game-shell:not(.phase-running) .control-dock { display: none; }
@media (max-width: 1180px) {
#app { padding: 0; }
.game-shell { min-height: 100vh; }
#gameCanvas { height: 100vh; min-height: 680px; }
.hud-top-left,
.hud-top-right { grid-template-columns: repeat(2, minmax(74px, auto)); }
.panel-grid { grid-template-columns: 1fr; }
.upgrade-list { grid-template-columns: 1fr; }
.upgrade-panel { max-height: 44vh; overflow: auto; }
#gameCanvas { height: 100vh; }
.hud-top-left, .hud-top-right { grid-template-columns: repeat(2, minmax(76px, auto)); }
.priority-strip { top: 150px; left: 8px; right: 8px; max-width: none; grid-template-columns: 1fr; }
.build-panel { top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 42vh; }
.large-tools { grid-template-columns: 1fr 1fr; }
.control-dock { grid-template-columns: repeat(2, 150px); }
.route-cards, .result-grid { grid-template-columns: 1fr; }
}
/* v6 visibility pass: play-first layering */
.game-shell:not(.phase-build) .upgrade-panel {
display: none;
}
.game-shell.phase-running .hud-top-right {
display: none;
}
.game-shell:not(.phase-running) .control-dock {
display: none;
}
.game-shell.phase-build .upgrade-panel {
bottom: 10px;
}
.game-shell.phase-running .hud-card {
background: rgba(5, 7, 8, 0.84);
border-color: rgba(124, 133, 142, 0.42);
}
.contract-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
.contract-card.empty { color: var(--muted); background: rgba(255,255,255,.72); }
.contract-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; }
.contract-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; }
.contract-card p { margin: 5px 0; }
.contract-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
.contract-card.declined { background: #f1f1f1; color: var(--muted); }
.contract-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; }
.contract-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; }
.contract-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); }
.contract-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.result-section-title { margin: 16px 0 8px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .12em; color: var(--green); }
.result-section-title:first-child { margin-top: 0; }
.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
.compact-rules .mini-box { padding: 8px; font-size: 9px; }
.primary-button:disabled { filter: grayscale(1); background: #c9d2ca; color: #526456; }
.formula-box {
border: 1px solid #30363d;
background: #101418;
border: 3px solid var(--line);
background: #f7fff5;
padding: 10px;
margin: 10px 0;
line-height: 1.5;
color: var(--text);
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.equipment-menu-lines {
border: 1px solid #30363d;
background: #080a0c;
border: 3px solid var(--line);
background: #ffffff;
padding: 10px;
margin: 8px 0;
color: var(--muted);
@ -381,12 +149,13 @@ h1, h2, p { margin: 0; }
position: absolute;
left: var(--popover-x, 16px);
top: var(--popover-y, 16px);
width: min(320px, calc(100vw - 24px));
max-height: min(360px, calc(100vh - 24px));
width: min(250px, calc(100vw - 24px));
max-height: min(280px, calc(100vh - 24px));
overflow: auto;
padding: 10px;
padding: 8px;
border-width: 3px;
pointer-events: auto;
box-shadow: 6px 6px 0 rgba(0,0,0,.28);
box-shadow: 6px 6px 0 rgba(16,32,21,.18);
}
.modal.equipment-popover .modal-card h2 {
font-size: 14px;
@ -398,16 +167,457 @@ h1, h2, p { margin: 0; }
justify-content: flex-start;
}
.modal.equipment-popover .modal-actions button {
padding: 7px 9px;
font-size: 10px;
padding: 5px 7px;
font-size: 9px;
}
.equipment-menu-lines.compact {
padding: 7px;
margin: 6px 0;
font-size: 10px;
font-size: 9px;
}
.formula-box.compact {
padding: 7px;
margin: 6px 0;
font-size: 10px;
font-size: 9px;
}
/* v15 pre-build card draft */
.card-choices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 220px));
justify-content: center;
align-items: stretch;
gap: 10px;
margin-top: 14px;
}
.card-choice {
min-height: 132px;
border: 3px solid var(--line);
background: #f7fff5;
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.16);
padding: 12px;
text-align: left;
cursor: pointer;
font-weight: 900;
}
.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); }
.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; }
.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; }
.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; }
.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); }
.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); }
.card-choice.ultra-rare span { background: #c42424; color: #fff; }
.muted-card-note { color: var(--muted); font-weight: 700; }
.card-targets { display: grid; gap: 8px; margin-top: 14px; }
.card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
.card-target-button:hover { background: var(--green-soft); }
/* v19 readability pass */
body { font-size: 16px; }
.hud-card span { font-size: 11px; }
.hud-card strong { font-size: clamp(18px, 1.5vw, 25px); }
.priority-strip > div { font-size: 14px; }
.panel-head h1 { font-size: 18px; }
.panel-head p { font-size: 11px; }
.build-panel h2 { font-size: 13px; }
.tool-button strong { font-size: 14px; }
.tool-button span { font-size: 11px; }
.mini-box, .facility-panel-empty, .facility-card, .contract-card { font-size: 12px; }
.facility-card h3 { font-size: 14px; }
.sort-button { font-size: 14px; }
.modal-card h2 { font-size: 29px; }
.card-choice strong { font-size: 16px; }
.card-choice small { font-size: 13px; }
.tool-button.unaffordable, .tool-button.already-built { opacity: .42; filter: grayscale(.7); }
.hover-tooltip {
position: fixed;
z-index: 30;
width: min(320px, calc(100vw - 20px));
display: none;
border: 3px solid var(--line);
background: rgba(255,255,255,.96);
color: var(--ink);
box-shadow: 6px 6px 0 rgba(16,32,21,.18);
padding: 10px;
pointer-events: none;
line-height: 1.35;
}
.hover-tooltip.visible { display: block; }
.hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; }
.hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; }
.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; }
.debug-panel {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 12;
border: 3px solid var(--line);
background: rgba(255,255,255,.94);
box-shadow: 4px 4px 0 rgba(16,32,21,.16);
max-width: 330px;
}
.debug-panel summary {
cursor: pointer;
padding: 5px 8px;
font-weight: 900;
font-size: 12px;
user-select: none;
}
.debug-body {
display: grid;
gap: 7px;
padding: 8px;
border-top: 3px solid var(--line);
font-size: 12px;
}
.debug-body label { display: grid; gap: 3px; font-weight: 900; color: var(--muted); }
.debug-body input, .debug-body select, .debug-body button {
border: 2px solid var(--line);
background: var(--white);
color: var(--ink);
font: inherit;
font-weight: 900;
padding: 5px 6px;
}
.debug-body button { cursor: pointer; background: var(--green-soft); }
.card-choice.dud {
background: #eeeeee;
color: #777;
filter: grayscale(1);
border-style: dashed;
box-shadow: 5px 5px 0 rgba(16,32,21,.10);
}
.card-choice.dud span { background: #f8f8f8; }
.card-choice.flung {
pointer-events: none;
transform: translate(var(--fling-x, 480px), var(--fling-y, -320px)) rotate(var(--fling-r, 68deg)) scale(.68);
opacity: 0;
transition: transform .34s cubic-bezier(.18,.84,.26,.99), opacity .28s ease-in;
}
/* v20 Phase 1 observability */
.debug-readout {
border: 2px solid var(--line);
background: rgba(247,255,245,.92);
padding: 6px;
display: grid;
gap: 3px;
line-height: 1.25;
}
.debug-readout strong {
display: block;
color: var(--ink);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
}
.debug-readout span {
display: block;
color: var(--muted);
font-size: 11px;
overflow-wrap: anywhere;
}
.debug-readout.event-log {
max-height: 160px;
overflow: auto;
}
.route-card .route-icon.male,
.route-card .route-icon.female,
.route-card .route-icon.poop {
display: inline-grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 999px;
border: 3px solid var(--line, #102015);
background: var(--white, #fff);
line-height: 1;
}
.route-card .route-icon.male { color: #2477ff; background: #d9ecff; }
.route-card .route-icon.female { color: #d62a85; background: #ffe0f0; }
.route-card .route-icon.poop { color: #7b4a23; background: #fff0d1; font-size: 24px; }
.restart-button { min-width: 160px; }
.restart-fallback-note { margin-top: 12px; color: var(--muted, #526456); font-weight: 700; }
/* v24.4 hover-only build-button flavor / cashout guidance / compact game-over */
.tool-button .tool-flavor {
display: block;
max-height: 0;
opacity: 0;
overflow: hidden;
margin-top: 0;
color: var(--muted);
font-size: 8px;
line-height: 1.22;
text-transform: none;
letter-spacing: 0;
transition: max-height .12s ease, opacity .12s ease, margin-top .12s ease;
}
.tool-button:hover .tool-flavor {
max-height: 42px;
opacity: 1;
margin-top: 5px;
}
.large-tools .tool-button { min-height: 58px; }
.large-tools .tool-button:hover { min-height: 78px; }
.history-tools .tool-button.small { min-height: 40px; }
.primary-button.cashout-emphasis,
.tool-button.cashout-emphasis {
opacity: 1 !important;
filter: none !important;
animation: cashoutPulse 0.9s ease-in-out infinite alternate;
}
.primary-button.cashout-emphasis {
box-shadow: inset 0 0 0 4px #fff0d1, 6px 6px 0 rgba(16,32,21,.22);
}
.tool-button.cashout-emphasis {
background: #fff0d1;
box-shadow: inset 0 0 0 3px var(--danger), 6px 6px 0 rgba(16,32,21,.20);
}
@keyframes cashoutPulse {
from { transform: translate(0, 0); }
to { transform: translate(-1px, -1px); }
}
.modal.gameover-modal { padding: 12px; }
.modal.gameover-modal .modal-card {
width: min(640px, 92vw);
max-height: min(82vh, 720px);
overflow: auto;
padding: 16px;
}
.modal.gameover-modal .modal-card h2 {
font-size: 22px;
margin-bottom: 8px;
}
.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; }
.gameover-summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
.gameover-breakdown {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
.modal.gameover-modal .result-grid div { padding: 7px; }
.modal.gameover-modal .result-grid strong { font-size: 9px; margin-bottom: 3px; }
.modal.gameover-modal .result-grid span { font-size: 12px; }
.gameover-details {
margin-top: 10px;
border: 3px solid var(--line);
background: #f7fff5;
padding: 8px;
}
.gameover-details summary {
cursor: pointer;
font-weight: 900;
text-transform: uppercase;
}
.modal.gameover-modal .formula-box.compact { font-size: 9px; line-height: 1.35; }
@media (max-width: 720px) {
.gameover-summary, .gameover-breakdown { grid-template-columns: 1fr 1fr; }
}
/* v26.0 readability and stable build-menu tooltip pass */
body { font-size: 18px; }
.build-panel { width: clamp(330px, 26vw, 420px); }
.panel-head h1 { font-size: 20px; }
.panel-head h1 span { font-size: 12px; }
.panel-head p { font-size: 13px; line-height: 1.35; }
.build-panel h2 { font-size: 14px; }
.priority-strip > div { font-size: 15px; }
.hud-card span { font-size: 12px; }
.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); }
.mini-box, .contract-card, .facility-panel-empty, .facility-card { font-size: 14px; line-height: 1.45; }
.compact-rules .mini-box { font-size: 13px; }
.tool-button {
position: relative;
overflow: visible;
min-height: 62px;
padding: 10px;
}
.large-tools .tool-button { min-height: 62px; }
.large-tools .tool-button:hover { min-height: 62px; }
.tool-button strong { font-size: 15px; line-height: 1.15; }
.tool-button span { font-size: 12px; }
.tool-button .tool-flavor {
display: none;
position: absolute;
z-index: 60;
left: 0;
top: calc(100% + 7px);
width: min(300px, calc(100vw - 40px));
max-height: none;
opacity: 1;
overflow: visible;
margin: 0;
padding: 10px 12px;
border: 3px solid var(--line);
background: rgba(255,255,255,.98);
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
font-size: 13px;
line-height: 1.35;
text-transform: none;
letter-spacing: 0;
pointer-events: none;
transition: none;
}
.tool-button .tool-flavor::before {
content: '';
position: absolute;
left: 16px;
top: -10px;
width: 16px;
height: 16px;
border-left: 3px solid var(--line);
border-top: 3px solid var(--line);
background: rgba(255,255,255,.98);
transform: rotate(45deg);
}
.tool-button:hover .tool-flavor {
display: block;
max-height: none;
opacity: 1;
margin: 0;
}
.primary-button { font-size: 16px; padding: 13px 16px; }
#nextTurnButton { min-height: 56px; font-size: 18px; }
@keyframes cashoutPulse {
from { box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); filter: brightness(1); }
to { box-shadow: inset 0 0 0 5px #fff0d1, 4px 4px 0 rgba(16,32,21,.20); filter: brightness(1.08); }
}
.card-choices { grid-template-columns: repeat(auto-fill, minmax(230px, 270px)); gap: 14px; }
.card-choice { min-height: 164px; padding: 15px; }
.card-choice strong { font-size: 18px; }
.card-choice span { font-size: 11px; }
.card-choice small { font-size: 15px; line-height: 1.4; }
.muted-card-note { font-size: 15px; line-height: 1.45; }
.modal-card { width: min(980px, 96vw); }
.modal-card p, .modal-card li { font-size: 16px; }
.modal-actions .reroll-button {
order: 99;
flex: 1 1 100%;
min-height: 64px;
margin-top: 8px;
font-size: 20px;
text-align: center;
justify-content: center;
background: #fff0d1;
box-shadow: inset 0 0 0 4px var(--green), 6px 6px 0 rgba(16,32,21,.20);
}
.modal-actions .reroll-button:disabled { box-shadow: 4px 4px 0 rgba(16,32,21,.12); }
.hover-tooltip strong { font-size: 17px; }
.hover-tooltip span { font-size: 14px; }
.sort-button { font-size: 16px; min-height: 56px; }
.control-dock { grid-template-columns: repeat(4, 172px); }
@media (max-width: 1180px) {
.build-panel { width: auto; }
.control-dock { grid-template-columns: repeat(2, 172px); }
}
/* v27 combo / tags / settlement clarity */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0 0 8px;
}
.card-tags i {
display: inline-block;
border: 2px solid var(--line);
background: rgba(255,255,255,.86);
color: var(--ink);
font-style: normal;
font-size: 10px;
font-weight: 900;
padding: 1px 6px;
letter-spacing: .05em;
}
.settlement-hero {
border: 4px solid var(--line);
background: #f7fff5;
padding: 14px;
margin-bottom: 12px;
box-shadow: 5px 5px 0 rgba(16,32,21,.14);
}
.settlement-hero.positive { box-shadow: inset 0 0 0 4px var(--green), 5px 5px 0 rgba(16,32,21,.14); }
.settlement-hero.negative { box-shadow: inset 0 0 0 4px var(--danger), 5px 5px 0 rgba(16,32,21,.14); }
.settlement-hero strong { display: block; font-size: 34px; line-height: 1; color: var(--green); }
.settlement-hero.negative strong { color: var(--danger); }
.settlement-hero span { display: block; margin-top: 8px; font-weight: 900; color: var(--muted); }
.settlement-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.settlement-grid div {
border: 3px solid var(--line);
background: rgba(255,255,255,.9);
padding: 9px;
}
.settlement-grid strong { display: block; font-size: 11px; text-transform: uppercase; color: var(--muted); }
.settlement-grid span { display: block; margin-top: 5px; font-size: 20px; font-weight: 900; color: var(--ink); }
.settlement-table {
width: 100%;
border-collapse: collapse;
border: 3px solid var(--line);
background: #fff;
margin: 10px 0;
}
.settlement-table td {
border-bottom: 2px solid rgba(16,32,21,.18);
padding: 7px 9px;
font-size: 15px;
font-weight: 900;
}
.settlement-table td:last-child { text-align: right; }
.settlement-table tr.income td:last-child { color: var(--green); }
.settlement-table tr.cost td:last-child { color: var(--danger); }
.settlement-notes {
display: grid;
gap: 5px;
border: 3px solid var(--line);
background: #f7fff5;
padding: 10px;
color: var(--muted);
font-weight: 800;
}
.key-config-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin: 10px 0;
}
.key-config-grid label {
display: grid;
gap: 4px;
font-weight: 900;
color: var(--muted);
}
.key-config-grid input,
.key-config-grid select {
border: 3px solid var(--line);
background: var(--white);
color: var(--ink);
padding: 7px 8px;
font: inherit;
font-weight: 900;
}
.modal.rescue-modal .modal-card {
width: min(560px, 92vw);
}
.hover-tooltip .cash-negative { color: var(--danger) !important; font-weight: 900; }
@media (max-width: 720px) {
.settlement-grid { grid-template-columns: 1fr 1fr; }
.settlement-hero strong { font-size: 26px; }
}

View file

@ -1,18 +1,21 @@
import { AUTO_SCANNER_COOLDOWN, MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
import { nextSpawnDelay, getSpawnRange } from '../core/state.js';
import { MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
import { getSpawnRange } from '../core/state.js';
import { createEggFarm, createScanner, createFacility } from '../core/entities.js';
import { key, parseKey, pointToCell, cellCenter, yen } from '../core/utils.js';
import { TEXT, equipmentName } from '../core/text.js';
import { farmAt, scannerAt, scannerCenter, routeFromFarmToScanner, refreshRoutingAfterEdit } from './routing.js';
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, upgradeCostFor, resaleValueFor } from './economy.js';
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { autoScannerCooldownSeconds } from './cards.js';
import { record } from './history.js';
import { floating, eraseEffect } from './effects.js';
import { createSelectionSystem } from './selectionSystem.js';
import { ensureMaintenanceState, remainingPercent, performanceFactor, autoScannerDelayMultiplier, eggSpawnDelayMultiplier, facilityProcessingDelay, durabilityCapFor } from './maintenance.js';
export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) {
function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows; }
function isBlockedCell(col, row) { return game.blockedCells?.has?.(key(col, row)); }
function rectOfFacility(f) { return { x: f.x, y: f.y, w: f.w, h: f.h }; }
function rectsOverlap(a, b, margin = 10) {
@ -25,6 +28,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function isEquipmentCell(col, row, moving = null) {
if (!pointInGrid(col, row)) return false;
if (isBlockedCell(col, row)) return true;
const k = key(col, row);
const existingFarm = farmAt(game, col, row);
if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true;
@ -56,6 +60,38 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return equipmentBasePrice(hit);
}
function usedMachineMode() {
return !!game.cardEffects?.usedMachineActive;
}
function buildQualityPatch() {
return usedMachineMode()
? { usedMachine: true, noRefund: true, durabilityBaseMultiplier: 0.6 }
: { usedMachine: false, noRefund: false, durabilityBaseMultiplier: 1 };
}
function applyBuildQuality(obj) {
const patch = buildQualityPatch();
Object.assign(obj, patch);
return obj;
}
function lastProtectedSaleReason(hit) {
const obj = hit?.ref || hit || {};
if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') {
return game.eggFarms.length <= 1 ? 'Cannot sell the last EGG FARM.' : '';
}
if ((hit?.type === 'facility' || obj.type === 'facility') && ['mixer', 'trash', 'truck'].includes(obj.id)) {
const count = Object.values(game.facilities).filter(f => f.id === obj.id).length;
if (count <= 1) return `Cannot sell the last ${obj.shortName || obj.name || obj.id}.`;
}
return '';
}
function canSellHit(hit) {
return !lastProtectedSaleReason(hit);
}
function fail(reason) {
ui.buildStatus.textContent = reason;
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, reason.toUpperCase(), THEME.danger);
@ -66,19 +102,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const { col, row } = cell;
if (game.buildTool === 'conveyor') {
if (game.conveyorTiles.has(key(col, row))) return fail(TEXT.fail.cellOccupied);
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
if (farmAt(game, col, row) || scannerAt(game, col, row)) return fail(TEXT.fail.cellOccupied);
const cost = buildPrice('conveyor');
const cost = buildPrice('conveyor', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
record(game);
spendCash(game, cost);
const k = key(col, row);
game.conveyorTiles.add(k);
game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession });
const quality = buildQualityPatch();
game.conveyorMeta.set(k, { price: cost, builtSession: game.buildSession, uses: 0, durability: durabilityCapFor(game, 'conveyor', quality.durabilityBaseMultiplier), maintenanceType: 'conveyor', ...quality });
game.selected = { type: 'conveyor', id: k };
refreshRoutingAfterEdit(game);
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
return;
}
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
if (isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied);
if (game.buildTool === 'eggFarm') buildFarm(col, row);
else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
@ -92,11 +131,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
function buildFarm(col, row) {
const cost = buildPrice('eggFarm');
const cost = buildPrice('eggFarm', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
record(game);
spendCash(game, cost);
const farm = createEggFarm(game, col, row);
const farm = applyBuildQuality(createEggFarm(game, col, row));
farm.price = cost;
game.eggFarms.push(farm);
refreshRoutingAfterEdit(game);
game.selected = { type: 'eggFarm', id: farm.id };
@ -104,11 +144,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
function buildScanner(col, row, kind) {
const cost = kind === 'auto' ? buildPrice('autoScanner') : buildPrice('manualScanner');
const cost = kind === 'auto' ? buildPrice('autoScanner', game) : buildPrice('manualScanner', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
record(game);
spendCash(game, cost);
const scanner = createScanner(game, col, row, kind);
const scanner = applyBuildQuality(createScanner(game, col, row, kind));
scanner.price = cost;
game.scanners.push(scanner);
refreshRoutingAfterEdit(game);
game.selected = { type: 'scanner', id: scanner.id };
@ -116,10 +157,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
function buildFacility(p, id) {
const cost = buildPrice(id);
const cost = buildPrice(id, game);
if (game.facilities[id]) return fail(TEXT.fail.facilityExists);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
const f = createFacility(game, id, p, cost);
const f = applyBuildQuality(createFacility(game, id, p, cost));
if (f.entry && isBlockedCell(f.entry.col, f.entry.row)) return fail('Receiver cell is blocked.');
if (facilityOverlaps(f)) return fail(TEXT.fail.facilityOverlap);
record(game);
spendCash(game, cost);
@ -140,6 +182,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function eraseAtPoint(p) {
const hit = equipmentAtPoint(p);
if (!hit) return fail(TEXT.fail.nothingToErase);
const saleReason = lastProtectedSaleReason(hit);
if (saleReason) return fail(saleReason);
record(game);
if (hit.type === 'conveyor') {
const c = parseKey(hit.oldKey);
@ -198,23 +242,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const selection = createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap });
function selectedUpgradeCost(obj) {
return upgradeCostFor(obj);
}
function upgradeSelected() {
const obj = selectedObject();
const cost = selectedUpgradeCost(obj);
if (!cost) return fail(TEXT.fail.noUpgrade);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
record(game);
spendCash(game, cost);
obj.level += 1;
if (obj.type === 'eggFarm') {
obj.nextSpawn = nextSpawnDelay(obj);
obj.lastInterval = obj.nextSpawn;
}
updatePanels();
function selectedUpgradeCost(_obj) {
return null;
}
function removeSelected() {
@ -243,7 +272,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function showAutoScannerMenu(scanner) {
ui.modalTitle.textContent = 'Auto Scanner';
ui.modalBody.innerHTML = `<p>Standard auto scanner.</p><ul><li>Role 0: male left, others right.</li><li>Role 1: poop left, others right.</li><li>Cooldown: ${AUTO_SCANNER_COOLDOWN}s.</li></ul>`;
ui.modalBody.innerHTML = `<p>Standard auto scanner.</p><ul><li>Role 0: male left, others right.</li><li>Role 1: poop left, others right.</li><li>Cooldown: ${autoScannerCooldownSeconds(scanner).toFixed(1)}s.</li></ul>`;
ui.modalActions.innerHTML = '';
const r0 = modalButton('Set Role 0', () => { record(game); scanner.role = 0; hideModal(); updatePanels(); });
const r1 = modalButton('Set Role 1', () => { record(game); scanner.role = 1; hideModal(); updatePanels(); });
@ -288,18 +317,18 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
<div class="equipment-menu-lines compact">${lines.map(x => `<p>${x}</p>`).join('')}</div>
${['mixer', 'truck'].includes(obj.id) ? '<p class="formula-box compact">Income/Fine = ceil(base × 1.05^upgrades)</p>' : ''}`;
ui.modalActions.innerHTML = '';
const cost = selectedUpgradeCost(obj);
if (cost) {
const upgrade = modalButton(`Upgrade ${yen(cost)}`, () => { upgradeSelected(); hideModal(); }, 'primary-button');
upgrade.disabled = game.cash < cost;
if (upgrade.disabled) upgrade.title = TEXT.fail.notEnoughCash;
ui.modalActions.appendChild(upgrade);
}
if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn'));
const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj };
const resale = resaleValueFor(hit, game);
const removeLabel = resale.sameBuild ? `Remove / Refund ${yen(resale.amount)}` : `Sell ${yen(resale.amount)}`;
ui.modalActions.appendChild(modalButton(removeLabel, () => { removeSelected(); hideModal(); }, 'facility-action danger'));
const saleReason = lastProtectedSaleReason(hit);
if (saleReason) {
const disabled = modalButton(saleReason, () => {}, 'facility-action danger disabled');
disabled.disabled = true;
ui.modalActions.appendChild(disabled);
} else {
const resale = resaleValueFor(hit, game);
const removeLabel = resale.sameBuild ? `Remove / Refund ${yen(resale.amount)}` : `Sell ${yen(resale.amount)}`;
ui.modalActions.appendChild(modalButton(removeLabel, () => { removeSelected(); hideModal(); }, 'facility-action danger'));
}
positionEquipmentPopover(obj);
ui.modal.classList.add('visible', 'equipment-popover');
}
@ -321,39 +350,46 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
for (const farm of game.eggFarms) { const c = cellCenter(farm.col, farm.row); boxes.push({ id: `eggFarm:${farm.id}`, price: equipmentPrice({ type: 'eggFarm', ref: farm }), x: c.x - 24, y: c.y - 24, w: 48, h: 48 }); }
for (const scanner of game.scanners) { const c = scannerCenter(scanner); boxes.push({ id: `scanner:${scanner.id}`, price: equipmentPrice({ type: 'scanner', ref: scanner }), x: c.x - 52, y: c.y - 36, w: 104, h: 72 }); }
for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: buildPrice('conveyor'), x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); }
for (const k of game.conveyorTiles) { const p = parseKey(k); const c = cellCenter(p.col, p.row); boxes.push({ id: `conveyor:${k}`, price: buildPrice('conveyor', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 }); }
return boxes;
}
function selectedInfoLines(obj) {
ensureMaintenanceState(game);
const lines = [];
if (obj.type === 'eggFarm') {
const [min, max] = getSpawnRange(obj);
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
{ const reason = lastProtectedSaleReason({ type: obj.type, ref: obj }); lines.push(reason ? `Sale: blocked (${reason})` : `Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); }
lines.push(`Level: ${obj.level}/4`);
lines.push(`Random interval: ${min.toFixed(2)}-${max.toFixed(2)}s`);
lines.push(`Durability: ${remainingPercent(obj)}% | Spawn delay x${eggSpawnDelayMultiplier(obj).toFixed(2)}`);
} else if (obj.type === 'scanner') {
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
lines.push(`Type: ${obj.kind.toUpperCase()} / Standard`);
lines.push(`Role: ${obj.role === 0 ? 'Male left / Others right' : 'Poop left / Others right'}`);
lines.push(`Queue: ${obj.queue.length}`);
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${AUTO_SCANNER_COOLDOWN.toFixed(1)}s`);
if (obj.kind === 'auto') lines.push(`Cooldown: ${obj.cooldown.toFixed(1)}s / ${autoScannerCooldownSeconds(obj, game).toFixed(1)}s`);
if (obj.kind === 'auto') lines.push(`Durability: ${remainingPercent(obj)}% | Delay x${autoScannerDelayMultiplier(obj).toFixed(2)}`);
if (obj.kind === 'manual') lines.push('Durability: none | Always normal');
} else if (obj.type === 'conveyor') {
const meta = game.conveyorMeta.get(obj.id);
const comp = game.componentLookup?.get(obj.id);
const c = comp ? game.congestion.get(comp) : null;
lines.push(`Price: ${yen(buildPrice('conveyor'))}`);
lines.push(`Price: ${yen(equipmentPrice({ type: 'conveyor', oldKey: obj.id, ref: obj }))}`);
lines.push(`Cell: ${obj.id}`);
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`);
lines.push('Cross conveyors force straight travel. Branches use round-robin. Merges merge normally.');
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`);
} else if (obj.type === 'facility') {
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
lines.push(['mixer', 'truck'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`);
{ const reason = lastProtectedSaleReason({ type: obj.type, ref: obj }); lines.push(reason ? `Sale: blocked (${reason})` : `Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`); }
lines.push(['mixer', 'truck', 'trash'].includes(obj.id) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`);
if (obj.id === 'truck') lines.push('Durability: none | Always normal');
if (['mixer', 'trash'].includes(obj.id)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj.id).toFixed(2)}s`);
if (obj.id === 'mixer') {
lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`);
lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`);
@ -362,11 +398,31 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
lines.push(`Income: ${yen(upgradedTruckPrice(game))} per target cargo | upgrade x${incomeMultiplier(game, 'truck').toFixed(3)}`);
lines.push(`Poop shipment fine: -${yen(truckPoopPenalty(game))}`);
}
if (obj.id === 'trash') {
const cards = shredderUpgradeCount(game);
const chance = shredderBonusChance(cards);
lines.push(`Bonus cards: ${cards}/${shredderBonusMaxCards()}`);
lines.push(`Bonus chance: ${(chance * 100).toFixed(2)}% for ${yen(1)} per processed item.`);
lines.push(`Expected value: ${chance.toFixed(3)} JPY per item.`);
}
if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
}
return lines;
}
function flavorText(obj) {
if (!obj) return '';
if (obj.type === 'conveyor') return 'A stubborn belt tile. It only cares about the next cell.';
if (obj.type === 'eggFarm') return 'A tiny gatehouse producing questionable eggs on schedule.';
if (obj.type === 'scanner' && obj.kind === 'auto') return 'An automated judge. Faster than hands, still very sure of itself.';
if (obj.type === 'scanner') return 'A manual checkpoint. The operator is the algorithm.';
if (obj.type === 'facility' && obj.id === 'mixer') return 'Male chicks become revenue here. Do not feed it poop.';
if (obj.type === 'facility' && obj.id === 'trash') return 'Poop goes in. Sometimes coins come out, for reasons best left unaudited.';
if (obj.type === 'facility' && obj.id === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.';
return 'Factory equipment.';
}
return {
buildAtCell, buildAtPoint, eraseAtPoint, equipmentAtPoint,
startSelectionBox: selection.startSelectionBox,
@ -376,8 +432,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
updateGroupDrag: selection.updateGroupDrag,
finishGroupDrag: selection.finishGroupDrag,
selectedObject, selectedTitle, equipmentPrice, selectedUpgradeCost,
selectedInfoLines,
upgradeSelected, removeSelected, switchScannerRole,
selectedInfoLines, flavorText,
removeSelected, switchScannerRole,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes,
routeFromFarmToScanner

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

@ -0,0 +1,767 @@
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
import { nextSpawnDelay } from '../core/state.js';
import { cellCenter, key, yen } from '../core/utils.js';
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { floating, shake, eraseEffect, shockwave, sparkBurst, smokeBurst } from './effects.js';
import { averageConveyorPerformance, autoScannerDelayMultiplier, ensureMaintenanceState } from './maintenance.js';
import { scannerCenter, refreshRoutingAfterEdit } from './routing.js';
const COMMON_WEIGHT = CARD_BALANCE.commonWeight;
const RARE_WEIGHT = CARD_BALANCE.rareWeight;
const ULTRA_RARE_WEIGHT = CARD_BALANCE.ultraRareWeight || 0.45;
const BASE_DRAFT_SIZE = CARD_BALANCE.baseDraftSize;
const DEFAULT_EFFECTS = Object.freeze({
bearing: 0,
legalWork: 0,
fairiesFlatteryNext: 0,
crowdedFarming: 0,
extraEggOutlet: 0,
hatchingFeed: 0,
safetyCover: 0,
recyclingSubsidy: 0,
chemicalWeaponSubsidy: 0,
preventiveMaintenance: 0,
durabilityCoating: 0,
laborExploitation: 0,
usedMachineActive: false,
rescueLoanCharges: 0,
loans: []
});
export const CARD_DEFS = [
{
id: 'upgradeEgg',
title: 'EGG Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'eggFarm',
description: 'Choose one EGG FARM on the map and raise it by 1 level.'
},
{
id: 'upgradeAutoScanner',
title: 'AUTO SCANNER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'autoScanner',
description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.'
},
{
id: 'upgradeMixer',
title: 'MIXER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'mixer',
description: 'Choose MIXER on the map and raise it by 1 level.'
},
{
id: 'upgradeTruck',
title: 'TRUCK Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'truck',
description: 'Choose TRUCK on the map and raise it by 1 level.'
},
{
id: 'upgradeTrash',
title: 'SHREDDER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'trash',
description: 'Choose SHREDDER and raise it by 1 level. Bonus chance increases by the current upgrade count. Max 30 upgrades.'
},
{
id: 'crowdedFarming',
title: 'Crowded Farming Contract',
rarity: 'common',
type: 'instant',
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.'
},
{
id: 'hatchingFeed',
title: 'Hatching Feed',
rarity: 'common',
type: 'instant',
description: 'EGG production interval -4%. Poop rate +3%. No card limit.'
},
{
id: 'safetyCover',
title: 'Safety Cover',
rarity: 'common',
type: 'instant',
description: 'Explosion damage -5%. No card limit.'
},
{
id: 'preventiveMaintenance',
title: 'Preventive Maintenance Manual',
rarity: 'common',
type: 'instant',
description: 'All equipment deterioration speed -5%. No card limit.'
},
{
id: 'durabilityCoating',
title: 'Durability Coating',
rarity: 'common',
type: 'instant',
description: 'All equipment maximum durability +3%. No card limit.'
},
{
id: 'bearing',
title: 'High-Quality Bearing',
rarity: 'common',
type: 'instant',
description: 'Conveyor speed +7.5%.'
},
{
id: 'extraEggOutlet',
title: 'Extra Egg Outlet',
rarity: 'rare',
type: 'instant',
description: 'EGG output ports +1. Each connected belt can receive eggs. Max 3 cards.'
},
{
id: 'recyclingSubsidy',
title: 'Recycling Subsidy',
rarity: 'rare',
type: 'instant',
description: 'SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.'
},
{
id: 'dynamite',
title: 'Dynamite',
rarity: 'rare',
type: 'cellAction',
target: 'blockedCell',
description: 'Remove any 3 blocked cells with a burst effect.'
},
{
id: 'usedMachine',
title: 'Used Machines',
rarity: 'rare',
type: 'instant',
description: 'Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.'
},
{
id: 'newMachine',
title: 'New Machines',
rarity: 'common',
type: 'instant',
description: 'Cancel Used Machines and restore normal prices, refunds, and durability.'
},
{
id: 'flattery',
title: 'Flattery',
rarity: 'rare',
type: 'instant',
description: 'Next Fairies tribute -50%.'
},
{
id: 'loan',
title: 'Loan',
rarity: 'rare',
type: 'instant',
description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.'
},
{
id: 'extraCards',
title: 'Extra Cards',
rarity: 'rare',
type: 'instant',
description: 'Draw 2 cards now and choose both.'
},
{
id: 'legalWork',
title: 'Legal Work',
rarity: 'rare',
type: 'instant',
description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.'
},
{
id: 'chemicalWeaponSubsidy',
title: 'Chemical Weapons Subsidy',
rarity: 'ultraRare',
type: 'instant',
description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.'
},
{
id: 'laborExploitation',
title: 'Motivational Exploitation',
rarity: 'ultraRare',
type: 'instant',
description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.'
},
{
id: 'rescueLoan',
title: 'Emergency Loan',
rarity: 'ultraRare',
type: 'instant',
description: 'If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.'
}
];
function effectCount(game, id) {
const effects = ensureCardState(game);
return Math.max(0, Number(effects[id]) || 0);
}
export function ensureCardState(game) {
if (!game.cardEffects) game.cardEffects = { ...DEFAULT_EFFECTS, loans: [] };
for (const [k, v] of Object.entries(DEFAULT_EFFECTS)) {
if (game.cardEffects[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v;
}
if (game.cardEffects.flattery != null) {
game.cardEffects.fairiesFlatteryNext = Math.max(0, game.cardEffects.fairiesFlatteryNext || 0) + Math.max(0, game.cardEffects.flattery || 0);
delete game.cardEffects.flattery;
}
if (!Array.isArray(game.cardEffects.loans)) game.cardEffects.loans = [];
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
if (!game.cardDraft) game.cardDraft = { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;
return game.cardEffects;
}
export function eggProductionDelayMultiplier(game) {
const crowded = Math.max(0.5, 1 - 0.05 * Math.min(10, effectCount(game, 'crowdedFarming')));
const feed = Math.pow(0.96, effectCount(game, 'hatchingFeed'));
return crowded * feed;
}
export function poopRateCardMultiplier(game) {
return Math.pow(1.03, effectCount(game, 'hatchingFeed'));
}
export function extraEggOutletCount(game) {
return Math.min(3, effectCount(game, 'extraEggOutlet'));
}
export function conveyorSpeedForGame(game, conveyorKey = null) {
const effects = ensureCardState(game);
const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0));
const perf = conveyorKey ? 1 : averageConveyorPerformance(game);
return Math.min(CONVEYOR_SPEED_MAX, speed * perf);
}
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;
}
export function flatteryReductionForTurn(game, turn = game.turn) {
const day = Math.max(1, Math.floor(Number(turn) || 1));
const base = Math.ceil(day * 10);
const charges = Math.max(0, ensureCardState(game).fairiesFlatteryNext || 0);
return charges > 0 ? Math.ceil(base * (1 - Math.pow(0.5, charges))) : 0;
}
export function rerollCost(game) {
const draft = game.cardDraft || { rerolls: 0 };
const nextRerollCount = Math.max(1, (draft.rerolls || 0) + 1);
return Math.ceil((Math.max(1, game.turn || 1) / CARD_BALANCE.rerollCost.dayDivisor) * nextRerollCount * CARD_BALANCE.rerollCost.multiplier);
}
export function cardById(id) {
return CARD_DEFS.find(card => card.id === id) || null;
}
function targetLabel(game, target) {
if (target.type === 'blockedCell') return `Blocked cell ${target.col},${target.row}`;
if (target.type === 'eggFarm') {
return `EGG #${target.id} L${target.level} -> L${target.level + 1}`;
}
if (target.type === 'scanner') {
return `AUTO #${target.id} L${target.level} -> L${target.level + 1} | CD ${autoScannerCooldownSeconds(target, game).toFixed(1)}s -> ${autoScannerCooldownSeconds({ ...target, level: target.level + 1 }, game).toFixed(1)}s`;
}
if (target.type === 'facility' && target.id === 'mixer') {
const beforeLevel = target.level;
const before = upgradedMixerPrice(game);
target.level = beforeLevel + 1;
const after = upgradedMixerPrice(game);
target.level = beforeLevel;
return `MIXER L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`;
}
if (target.type === 'facility' && target.id === 'truck') {
const beforeLevel = target.level;
const before = upgradedTruckPrice(game);
target.level = beforeLevel + 1;
const after = upgradedTruckPrice(game);
target.level = beforeLevel;
return `TRUCK L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`;
}
if (target.type === 'facility' && target.id === 'trash') {
const before = shredderUpgradeCount(game);
const after = Math.min(shredderBonusMaxCards(), before + 1);
const beforePercent = (shredderBonusChance(before) * 100).toFixed(2);
const afterPercent = (shredderBonusChance(after) * 100).toFixed(2);
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | ${beforePercent}% -> ${afterPercent}% per item`;
}
return `${target.name || target.id} L${target.level || 1} -> L${(target.level || 1) + 1}`;
}
function blockedCellTargets(game) {
return [...(game.blockedCells || [])].map(k => {
const [col, row] = String(k).split(',').map(Number);
return { type: 'blockedCell', id: k, col, row };
}).filter(t => Number.isFinite(t.col) && Number.isFinite(t.row));
}
export function targetsForCard(game, cardOrId) {
const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId;
ensureCardState(game);
if (!card) return [];
if (card.target === 'blockedCell') return blockedCellTargets(game);
if (card.target === 'eggFarm') return game.eggFarms.filter(f => (f.level || 1) < 4);
if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto');
if (card.target === 'mixer') return game.facilities.mixer ? [game.facilities.mixer] : [];
if (card.target === 'truck') return game.facilities.truck ? [game.facilities.truck] : [];
if (card.target === 'trash') return game.facilities.trash && rawShredderUpgradeCount(game) < shredderBonusMaxCards() ? [game.facilities.trash] : [];
return [];
}
function cardAtCap(game, card) {
const effects = ensureCardState(game);
if (card.id === 'crowdedFarming') return (effects.crowdedFarming || 0) >= 10;
if (card.id === 'extraEggOutlet') return (effects.extraEggOutlet || 0) >= 3;
if (card.id === 'recyclingSubsidy') return (effects.recyclingSubsidy || 0) >= 3;
if (card.id === 'laborExploitation') return !!effects.laborExploitation;
if (card.id === 'usedMachine') return !!effects.usedMachineActive;
if (card.id === 'newMachine') return !effects.usedMachineActive;
return false;
}
function availableCards(game) {
return CARD_DEFS.filter(card => {
if (cardAtCap(game, card)) return false;
if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false;
return true;
});
}
function rarityWeight(card) {
if (card.rarity === 'ultraRare') return ULTRA_RARE_WEIGHT;
if (card.rarity === 'rare') return RARE_WEIGHT;
return COMMON_WEIGHT;
}
function weightedPick(pool) {
const total = pool.reduce((sum, card) => sum + rarityWeight(card), 0);
let roll = Math.random() * total;
for (const card of pool) {
roll -= rarityWeight(card);
if (roll <= 0) return card;
}
return pool[pool.length - 1];
}
let dudSerial = 0;
function dudCard() {
dudSerial += 1;
return {
id: `dud:${Date.now()}:${dudSerial}`,
title: 'Dud',
rarity: 'dud',
type: 'dud',
description: 'Click to flick this dud away. It does not consume a pick.'
};
}
function applyDudsToChoices(choices) {
const result = [...choices];
let duds = 0;
const maxDuds = Math.max(0, CARD_BALANCE.maxDudsPerDraft || 0);
const chance = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
for (let i = 0; i < result.length && duds < maxDuds; i += 1) {
if (Math.random() < chance) {
result[i] = dudCard();
duds += 1;
}
}
return result;
}
export function dealCards(game, count = BASE_DRAFT_SIZE) {
ensureCardState(game);
const source = availableCards(game);
if (!source.length) return [];
const choices = [];
let pool = [...source];
while (choices.length < count && source.length) {
if (!pool.length) pool = [...source];
const picked = weightedPick(pool);
choices.push(picked);
const i = pool.findIndex(card => card.id === picked.id);
if (i >= 0) pool.splice(i, 1);
}
return applyDudsToChoices(choices);
}
function targetKey(target) {
if (!target) return '';
if (target.type === 'blockedCell') return `blockedCell:${target.id}`;
if (target.type === 'eggFarm') return `eggFarm:${target.id}`;
if (target.type === 'scanner') return `scanner:${target.id}`;
if (target.type === 'facility') return `facility:${target.id}`;
return `${target.type}:${target.id}`;
}
function boundsForTarget(target) {
if (!target) return null;
if (target.type === 'blockedCell') {
const c = cellCenter(target.col, target.row);
return { x: c.x - GRID.cell * 0.48, y: c.y - GRID.cell * 0.48, w: GRID.cell * 0.96, h: GRID.cell * 0.96, cx: c.x, cy: c.y };
}
if (target.type === 'eggFarm') {
const c = cellCenter(target.col, target.row);
return { x: c.x - 34, y: c.y - 34, w: 68, h: 68, cx: c.x, cy: c.y };
}
if (target.type === 'scanner') {
const c = scannerCenter(target);
return { x: c.x - 58, y: c.y - 42, w: 116, h: 84, cx: c.x, cy: c.y };
}
if (target.type === 'facility') {
return { x: target.x, y: target.y, w: target.w, h: target.h, cx: target.x + target.w / 2, cy: target.y + target.h / 2 };
}
return null;
}
export function cardTargetBounds(game) {
if (!game.cardTargetPick?.pending) return [];
const allowed = new Set(game.cardTargetPick.targetKeys || []);
return targetsForCard(game, game.cardTargetPick.cardId)
.filter(target => allowed.has(targetKey(target)))
.map(target => ({ key: targetKey(target), label: targetLabel(game, target), target, bounds: boundsForTarget(target) }))
.filter(item => item.bounds);
}
function targetAtPoint(game, p) {
const items = cardTargetBounds(game);
for (const item of items) {
const b = item.bounds;
if (p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h) return item.target;
}
return null;
}
function applyEquipmentUpgrade(game, target) {
target.level = (target.level || 1) + 1;
if (target.type === 'eggFarm') {
target.nextSpawn = nextSpawnDelay(target);
target.lastInterval = target.nextSpawn;
const c = cellCenter(target.col, target.row);
floating(game, c.x, c.y - 30, `LV ${target.level}`, THEME.green);
return;
}
if (target.type === 'scanner') {
const c = scannerCenter(target);
floating(game, c.x, c.y - 34, `LV ${target.level}`, THEME.green);
return;
}
if (target.type === 'facility') {
floating(game, target.x + target.w / 2, target.y + 18, `LV ${target.level}`, THEME.green);
}
}
function applyInstantCard(game, card) {
const effects = ensureCardState(game);
const inc = id => { effects[id] = Math.max(0, effects[id] || 0) + 1; };
if (card.id === 'bearing') inc('bearing');
if (card.id === 'legalWork') inc('legalWork');
if (card.id === 'crowdedFarming') effects.crowdedFarming = Math.min(10, (effects.crowdedFarming || 0) + 1);
if (card.id === 'extraEggOutlet') effects.extraEggOutlet = Math.min(3, (effects.extraEggOutlet || 0) + 1);
if (card.id === 'hatchingFeed') inc('hatchingFeed');
if (card.id === 'safetyCover') inc('safetyCover');
if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1);
if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy');
if (card.id === 'preventiveMaintenance') inc('preventiveMaintenance');
if (card.id === 'durabilityCoating') { inc('durabilityCoating'); ensureMaintenanceState(game); }
if (card.id === 'flattery') inc('fairiesFlatteryNext');
if (card.id === 'usedMachine') effects.usedMachineActive = true;
if (card.id === 'newMachine') effects.usedMachineActive = false;
if (card.id === 'laborExploitation') effects.laborExploitation = 1;
if (card.id === 'rescueLoan') {
inc('rescueLoanCharges');
if (game.cash < 0) {
effects.rescueLoanCharges = Math.max(0, (effects.rescueLoanCharges || 0) - 1);
applyRevenue(game, 500);
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, '+¥500 RESCUE', THEME.green);
} else {
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green);
}
}
if (card.id === 'loan') {
const day = Math.max(1, Math.floor(Number(game.turn) || 1));
const amount = 300 * day;
applyRevenue(game, amount);
effects.loans.push({ daysRemaining: day, dailyPayment: 400, borrowedOnTurn: game.turn || 1 });
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, `LOAN +${yen(amount)}`, THEME.green);
}
}
function formatMultiplier(value) {
const rounded = Math.round(Number(value || 0) * 100) / 100;
return `x${rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')}`;
}
function formatPercent(value) {
return `${Math.round(Number(value || 0) * 100)}%`;
}
function eggRangeLabel(level) {
const range = EGG_SPAWN_RANGES[Math.max(0, Math.min(EGG_SPAWN_RANGES.length - 1, level - 1))] || EGG_SPAWN_RANGES[0] || [0, 0];
return `${range[0].toFixed(2)}-${range[1].toFixed(2)}s`;
}
function incomeAtLevel(base, level) {
return Math.ceil(base * Math.pow(ECONOMY.incomeUpgradeRate, Math.max(0, level - 1)));
}
function cardDescription(game, card) {
const e = ensureCardState(game);
if (card.id === 'upgradeEgg') {
const targets = targetsForCard(game, card);
const level = Math.min(3, Math.max(1, targets[0]?.level || 1));
return `Choose one EGG. Level +1. Production interval ${eggRangeLabel(level)} -> ${eggRangeLabel(level + 1)}.`;
}
if (card.id === 'upgradeAutoScanner') {
const scanner = targetsForCard(game, card)[0] || { level: 1 };
const before = autoScannerCooldownSeconds(scanner, game);
const after = autoScannerCooldownSeconds({ ...scanner, level: (scanner.level || 1) + 1 }, game);
return `Choose one AUTO SCANNER. Level +1. Cooldown ${before.toFixed(2)}s -> ${after.toFixed(2)}s.`;
}
if (card.id === 'upgradeMixer') {
const level = game.facilities?.mixer?.level || 1;
return `MIXER level +1. Male-chick income ${yen(incomeAtLevel(ECONOMY.income.mixer, level))} -> ${yen(incomeAtLevel(ECONOMY.income.mixer, level + 1))}.`;
}
if (card.id === 'upgradeTruck') {
const level = game.facilities?.truck?.level || 1;
return `TRUCK level +1. Correct shipment income ${yen(incomeAtLevel(ECONOMY.income.truck, level))} -> ${yen(incomeAtLevel(ECONOMY.income.truck, level + 1))}.`;
}
if (card.id === 'upgradeTrash') {
const before = shredderUpgradeCount(game);
const after = Math.min(shredderBonusMaxCards(), before + 1);
return `SHREDDER level +1. Bonus chance per item ${formatPercent(shredderBonusChance(before))} -> ${formatPercent(shredderBonusChance(after))}.`;
}
if (card.id === 'bearing') {
const before = Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, e.bearing || 0)));
const after = Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, (e.bearing || 0) + 1)));
return `Conveyor speed +7.5%. After pick: ${before.toFixed(1)} -> ${after.toFixed(1)} px/s.`;
}
if (card.id === 'legalWork') {
const before = Math.max(0, e.legalWork || 0);
return `ZUNDA TAX exemption +${yen(250)}. After pick: exemption bonus ${yen(250 * (before + 1))}.`;
}
if (card.id === 'crowdedFarming') {
const next = Math.min(10, (e.crowdedFarming || 0) + 1);
return `Held ${e.crowdedFarming || 0}/10. After pick: EGG interval ${formatPercent(Math.max(0.5, 1 - 0.05 * next))}, deterioration ${formatPercent(Math.min(1.4, 1 + 0.04 * next))}.`;
}
if (card.id === 'extraEggOutlet') return `Held ${e.extraEggOutlet || 0}/3. After pick: up to ${1 + Math.min(3, (e.extraEggOutlet || 0) + 1)} EGG output ports.`;
if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`;
if (card.id === 'safetyCover') return `Held ${e.safetyCover || 0}. After pick: explosion damage ${formatPercent(Math.pow(0.95, (e.safetyCover || 0) + 1))}.`;
if (card.id === 'recyclingSubsidy') return `Held ${e.recyclingSubsidy || 0}/3. After pick: normal SELL refund ${Math.round(Math.min(0.8, 0.5 + 0.1 * ((e.recyclingSubsidy || 0) + 1)) * 100)}%.`;
if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(2000 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 100 truck-shipped poop each day.`;
if (card.id === 'preventiveMaintenance') return `Held ${e.preventiveMaintenance || 0}. After pick: deterioration speed ${formatPercent(Math.pow(0.95, (e.preventiveMaintenance || 0) + 1))}.`;
if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`;
if (card.id === 'flattery') return `Next Fairies tribute -50%. Held for next tribute: ${e.fairiesFlatteryNext || 0}.`;
if (card.id === 'usedMachine') return 'Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.';
if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.';
if (card.id === 'laborExploitation') return 'Repairman labor cost -30% permanently. One time only.';
if (card.id === 'rescueLoan') return `At day-end bankruptcy check, gain ${yen(500)} if cash is below ${yen(0)}. Held: ${e.rescueLoanCharges || 0}.`;
if (card.id === 'loan') {
const day = Math.max(1, Math.floor(Number(game.turn) || 1));
return `Gain ${yen(300 * day)} now. Starting tomorrow, pay ${yen(400)} per day for ${day} days.`;
}
return card.description;
}
function removeOneChoice(game, card) {
const choices = game.cardDraft?.choices || [];
const index = choices.findIndex(choice => choice.id === card.id);
if (index >= 0) choices.splice(index, 1);
}
export function createCardSystem({ game, ui, onUpdatePanels }) {
function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
function prepareDraft() {
ensureCardState(game);
game.cardTargetPick = null;
game.cardDraft = { pending: true, choices: dealCards(game, BASE_DRAFT_SIZE), rerolls: 0, picksRemaining: 1 };
}
function finishDraft() {
ensureCardState(game);
game.cardDraft.pending = false;
game.cardDraft.choices = [];
game.cardDraft.picksRemaining = 0;
game.cardTargetPick = null;
ui.modal.classList.remove('visible', 'equipment-popover');
updatePanels();
}
function redrawChoices(count = BASE_DRAFT_SIZE) {
ensureCardState(game);
game.cardDraft.choices = dealCards(game, count);
}
function refillChoicesIfNeeded() {
ensureCardState(game);
if (!game.cardDraft.choices.length && game.cardDraft.picksRemaining > 0) redrawChoices(BASE_DRAFT_SIZE);
}
function completePickedCard(card) {
removeOneChoice(game, card);
game.cardDraft.picksRemaining = Math.max(0, (game.cardDraft.picksRemaining || 1) - 1);
game.cardTargetPick = null;
refillChoicesIfNeeded();
if (game.cardDraft.picksRemaining <= 0) finishDraft();
else showDraft();
}
function chooseExtraCards(card) {
removeOneChoice(game, card);
game.cardDraft.picksRemaining = Math.max(0, (game.cardDraft.picksRemaining || 1) - 1) + CARD_BALANCE.extraCardsAddedPicks;
game.cardDraft.choices.push(...dealCards(game, 2));
showDraft();
}
function chooseDud(card, buttonEl) {
shake(game, 34, 0.62);
if (document.body?.animate) {
document.body.animate([
{ transform: 'translate(0, 0)' },
{ transform: 'translate(-10px, 7px)' },
{ transform: 'translate(12px, -8px)' },
{ transform: 'translate(-8px, -5px)' },
{ transform: 'translate(9px, 6px)' },
{ transform: 'translate(0, 0)' }
], { duration: 260, easing: 'steps(5, end)' });
}
if (buttonEl) {
buttonEl.style.setProperty('--fling-x', `${260 + Math.random() * 260}px`);
buttonEl.style.setProperty('--fling-y', `${-260 - Math.random() * 170}px`);
buttonEl.style.setProperty('--fling-r', `${(Math.random() < 0.5 ? -1 : 1) * (52 + Math.random() * 38)}deg`);
buttonEl.classList.add('flung');
}
window.setTimeout(() => {
removeOneChoice(game, card);
refillChoicesIfNeeded();
showDraft();
}, 340);
}
function chooseCard(card, buttonEl = null) {
if (card.type === 'dud') return chooseDud(card, buttonEl);
if (card.id === 'extraCards') return chooseExtraCards(card);
if (card.type === 'equipmentUpgrade' || card.type === 'cellAction') return startMapTargetPicker(card);
applyInstantCard(game, card);
completePickedCard(card);
}
function startMapTargetPicker(card) {
const targets = targetsForCard(game, card);
if (!targets.length) return;
game.cardTargetPick = {
pending: true,
cardId: card.id,
mode: card.target === 'blockedCell' ? 'blockedCell' : 'equipment',
targetKeys: targets.map(targetKey),
remaining: card.id === 'dynamite' ? Math.min(3, targets.length) : 1
};
ui.modal.classList.remove('visible', 'equipment-popover');
updatePanels();
}
function applyDynamiteTarget(target) {
if (!target || target.type !== 'blockedCell') return false;
const k = key(target.col, target.row);
if (!game.blockedCells?.has(k)) return false;
game.blockedCells.delete(k);
const c = cellCenter(target.col, target.row);
eraseEffect(game, c.x, c.y);
shockwave(game, c.x, c.y, THEME.danger, 62);
sparkBurst(game, c.x, c.y, 18, THEME.warn);
smokeBurst(game, c.x, c.y, 8);
shake(game, 16, 0.35);
floating(game, c.x, c.y - 18, 'BOOM', THEME.danger);
refreshRoutingAfterEdit(game);
game.cardTargetPick.remaining = Math.max(0, (game.cardTargetPick.remaining || 1) - 1);
game.cardTargetPick.targetKeys = targetsForCard(game, 'dynamite').map(targetKey);
updatePanels();
return true;
}
function chooseTargetAtPoint(p) {
if (!game.cardTargetPick?.pending) return false;
const card = cardById(game.cardTargetPick.cardId);
const target = targetAtPoint(game, p);
if (!card || !target) {
const label = game.cardTargetPick.mode === 'blockedCell' ? 'SELECT BLOCKED CELL' : 'SELECT UPGRADE TARGET';
floating(game, p.x, p.y - 18, label, THEME.danger);
return true;
}
if (card.id === 'dynamite') {
if (!applyDynamiteTarget(target)) return true;
if ((game.cardTargetPick.remaining || 0) <= 0 || !targetsForCard(game, 'dynamite').length) completePickedCard(card);
return true;
}
applyEquipmentUpgrade(game, target);
completePickedCard(card);
return true;
}
function cancelTargetPick() {
if (!game.cardTargetPick?.pending) return;
game.cardTargetPick = null;
showDraft();
}
function doReroll() {
const cost = rerollCost(game);
if (game.cash < cost) return;
applyPenalty(game, cost);
game.stats.cardRerollCost = (game.stats.cardRerollCost || 0) + cost;
game.totals.cardRerollCost = (game.totals.cardRerollCost || 0) + cost;
game.cardDraft.rerolls += 1;
redrawChoices(BASE_DRAFT_SIZE);
showDraft();
}
function cardButton(card, index) {
const b = document.createElement('button');
b.type = 'button';
const kind = card.type === 'dud' ? 'dud' : (card.rarity === 'ultraRare' ? 'ultra-rare' : (card.rarity === 'rare' ? 'rare' : 'common'));
b.className = `card-choice ${kind}`;
const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'ultraRare' ? 'ULTRA RARE' : (card.rarity === 'rare' ? 'RARE' : 'COMMON'));
b.innerHTML = `<strong>${card.title}</strong><span>${rarity}</span><small>${cardDescription(game, card)}</small>`;
b.addEventListener('click', () => chooseCard(card, b));
b.dataset.index = String(index);
return b;
}
function showDraft() {
ensureCardState(game);
game.cardTargetPick = null;
if (!game.cardDraft?.pending) prepareDraft();
if (!game.cardDraft.choices?.length) redrawChoices(BASE_DRAFT_SIZE);
const choices = game.cardDraft.choices || [];
const cost = rerollCost(game);
const remaining = Math.max(1, game.cardDraft.picksRemaining || 1);
ui.modalTitle.textContent = 'Choose Upgrade Card';
ui.modalBody.innerHTML = `<p class="muted-card-note">Choose ${remaining} card${remaining === 1 ? '' : 's'} before Build phase. Dud cards can be flicked away without consuming a pick. Equipment cards target machines; Dynamite targets blocked cells.</p><div id="cardChoices" class="card-choices"></div>`;
const wrap = ui.modalBody.querySelector('#cardChoices');
choices.forEach((card, index) => wrap.appendChild(cardButton(card, index)));
ui.modalActions.innerHTML = '';
const reroll = document.createElement('button');
reroll.type = 'button';
reroll.className = 'facility-action warn reroll-button';
reroll.textContent = `Reroll ${yen(cost)}`;
reroll.disabled = game.cash < cost;
reroll.title = reroll.disabled ? 'Not enough cash' : `Reroll count today: ${(game.cardDraft.rerolls || 0) + 1}`;
reroll.addEventListener('click', doReroll);
ui.modalActions.appendChild(reroll);
ui.modal.classList.remove('equipment-popover');
ui.modal.classList.add('visible');
}
return { prepareDraft, showDraft, finishDraft, chooseTargetAtPoint, cancelTargetPick };
}

View file

@ -1,43 +1,85 @@
import { AUTO_SCANNER_COOLDOWN, GRID, THEME } from '../core/config.js';
import { GRID, THEME } from '../core/config.js';
import { createChick } from '../core/entities.js';
import { nextSpawnDelay } from '../core/state.js';
import { key, parseKey, pointToCell, cellCenter, randomBetween, yen } from '../core/utils.js';
import { scannerById, scannerBySlot, scannerCenter, routeFromFarmToScanner, outputRoute, destinationLabel, destinationColor, nearestConveyorKey, buildConveyorComponents, autoSideFor } from './routing.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice } from './economy.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus } from './economy.js';
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount } from './cards.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js';
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js';
import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js';
export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) {
function currentSpawnDelay(farm) {
return nextSpawnDelay(farm) / productionMultiplier(game);
return nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) * eggProductionDelayMultiplier(game) / productionMultiplier(game);
}
function spawnChick(farm) {
const data = routeFromFarmToScanner(game, farm, true);
const farmCenter = cellCenter(farm.col, farm.row);
if (!data) {
floating(game, farmCenter.x, farmCenter.y - 18, 'NO BELT', THEME.danger);
return false;
const maxOutputs = 1 + extraEggOutletCount(game);
const routes = [];
const usedStarts = new Set();
const maxAttempts = Math.max(maxOutputs * 5, 5);
for (let attempt = 0; attempt < maxAttempts && routes.length < maxOutputs; attempt += 1) {
const data = routeFromFarmToScanner(game, farm, true);
if (!data) break;
const start = data.route?.[0];
if (!start) continue;
const startKey = `${Math.round(start.x)}:${Math.round(start.y)}`;
if (usedStarts.has(startKey) && routes.length > 0) continue;
usedStarts.add(startKey);
routes.push(data);
}
const start = data.route[0];
const blocker = spawnTileBlocker(start);
if (blocker) {
blocker.stoppedTimer = 0.85;
return false;
if (!routes.length) {
suppressUnconnectedFarmSpawn(farm, farmCenter);
return true;
}
const chick = createChick(game, data);
if (chick.sex === 'poop') countPoopSpawned(game);
game.chicks.push(chick);
spawnPulse(game, farmCenter.x, farmCenter.y);
return true;
let spawned = 0;
let blocked = 0;
for (const data of routes) {
const start = data.route[0];
const blocker = spawnTileBlocker(start);
if (blocker) {
blocker.stoppedTimer = 0.85;
blocked += 1;
continue;
}
const chick = createChick(game, data);
if (chick.sex === 'poop') countPoopSpawned(game);
game.chicks.push(chick);
recordFarmProduction(game, farm);
spawned += 1;
}
if (spawned > 0) {
spawnPulse(game, farmCenter.x, farmCenter.y);
if (spawned > 1) floating(game, farmCenter.x, farmCenter.y - 28, `x${spawned}`, THEME.green);
return true;
}
return blocked === 0;
}
function suppressUnconnectedFarmSpawn(farm, farmCenter) {
if ((farm.noRouteNoticeCooldown || 0) <= 0) {
floating(game, farmCenter.x, farmCenter.y - 24, 'NO BELT: PAUSED', THEME.warn);
farm.noRouteNoticeCooldown = 2.5;
}
}
function spawnTileBlocker(start) {
return game.chicks.find(ch => ch.stage !== 'flying' && Math.hypot(ch.x - start.x, ch.y - start.y) < GRID.cell * 0.68) || null;
}
function updateRunning(dt, { closeFarmShutters, completeTurn }) {
updateRepairman(game, dt);
updateProcessingCooldowns(game, dt);
if (game.cleanupTimer !== null) {
game.cleanupTimer = Math.max(0, game.cleanupTimer - dt);
if (game.cleanupTimer <= 0) completeTurn();
return;
}
const producing = game.timeLeft > 0;
game.timeLeft = Math.max(0, game.timeLeft - dt);
if (producing && game.timeLeft <= 0) closeFarmShutters();
@ -47,6 +89,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
if (producing && game.timeLeft > 0) {
for (const farm of game.eggFarms) {
if (farm.noRouteNoticeCooldown > 0) farm.noRouteNoticeCooldown = Math.max(0, farm.noRouteNoticeCooldown - dt);
farm.nextSpawn -= dt;
if (farm.nextSpawn <= 0) {
const spawned = spawnChick(farm);
@ -64,9 +107,28 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
updateScannerQueues(dt);
updateCongestion();
checkCongestionExplosions();
if (game.timeLeft <= 0 && (game.chicks.length === 0 || game.shutdownTimeLeft <= 0)) completeTurn();
onGameOverCheck();
if (game.timeLeft > 0 || game.shutdownTimeLeft > 0) checkCongestionExplosions();
if (game.timeLeft <= 0) {
if (game.chicks.length === 0) completeTurn();
else if (game.shutdownTimeLeft <= 0) blowOffRemainingForCleanup();
}
}
function blowOffRemainingForCleanup() {
if (game.cleanupBlown) return;
game.cleanupBlown = true;
game.cleanupTimer = 1;
const victims = [...game.chicks];
for (const chick of victims) flyingDebris(game, chick.sex, chick.x, chick.y, -Math.PI / 2, true);
game.chicks = [];
for (const scanner of game.scanners) scanner.queue = [];
updateCongestion();
if (victims.length) {
const cx = victims.reduce((sum, chick) => sum + chick.x, 0) / victims.length;
const cy = victims.reduce((sum, chick) => sum + chick.y, 0) / victims.length;
shockwave(game, cx, cy, THEME.warn, 58);
floating(game, cx, cy - 24, 'CLEANUP', THEME.warn);
}
}
function updateRoutedChick(chick, index, dt) {
@ -104,19 +166,40 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return chick.targetIndex || 0;
}
function conveyorKeyFromPoint(p) {
const cell = pointToCell(p.x, p.y);
if (!cell) return null;
const k = key(cell.col, cell.row);
return game.conveyorTiles.has(k) ? k : null;
}
function speedForChick(chick) {
const k = conveyorKeyFromPoint(chick) || conveyorKeyFromPoint(chick.route?.[chick.targetIndex] || chick);
return currentConveyorSpeed(k);
}
function recordPassAtPoint(chick, p) {
const k = conveyorKeyFromPoint(p);
if (!k || chick.lastWearKey === k) return;
chick.lastWearKey = k;
recordConveyorPass(game, k);
}
function moveAlongRoute(chick, dt) {
let remaining = currentConveyorSpeed() * dt;
let remaining = speedForChick(chick) * dt;
while (remaining > 0 && chick.targetIndex < chick.route.length) {
const target = chick.route[chick.targetIndex];
const dx = target.x - chick.x, dy = target.y - chick.y;
const dist = Math.hypot(dx, dy);
if (dist <= 0.001) {
recordPassAtPoint(chick, target);
chick.targetIndex += 1;
continue;
}
if (remaining >= dist) {
chick.x = target.x;
chick.y = target.y;
recordPassAtPoint(chick, target);
chick.targetIndex += 1;
remaining -= dist;
} else {
@ -175,6 +258,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function updateScannerQueues(dt) {
for (const scanner of game.scanners) {
if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt);
if (scanner.keyPressTime > 0) scanner.keyPressTime = Math.max(0, scanner.keyPressTime - dt);
positionScannerQueue(scanner);
if (scanner.kind !== 'auto' || scanner.cooldown > 0 || !scanner.queue.length) continue;
const id = scanner.queue[0];
@ -184,7 +268,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
continue;
}
if (sortChickByIndex(index, autoSideFor(scanner, game.chicks[index]), true)) {
scanner.cooldown = AUTO_SCANNER_COOLDOWN;
recordAutoScan(game, scanner);
scanner.cooldown = autoScannerCooldownSeconds(scanner, game);
countAutoSorted(game);
}
}
@ -199,7 +284,10 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function sortSlot(slot, side) {
if (game.phase !== 'running') return;
const scanner = scannerBySlot(game, slot);
if (!scanner || !scanner.queue.length) return;
if (!scanner) return;
scanner.keyPressTime = 0.18;
scanner.keyPressSide = side;
if (!scanner.queue.length) return;
const index = game.chicks.findIndex(c => c.id === scanner.queue[0]);
if (index >= 0) sortChickByIndex(index, side, false);
}
@ -234,17 +322,24 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
function resolveMixer(index) {
const mixer = game.facilities.mixer;
if (mixer?.processingCooldown > 0) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
return;
}
const chick = takeChick(index);
if (!chick) return;
const { x, y } = chick;
countMixer(game);
recordFacilityProcess(game, 'mixer');
setFacilityCooldownAfterProcess(game, 'mixer');
if (chick.sex === 'poop') {
const penalty = applyMixerPoopFine(game);
countPoopDestination(game, 'mixer');
countMistake(game);
sludgeEffect(game, x, y);
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
onGameOverCheck();
return;
}
const amount = applyMixerIncome(game);
@ -252,7 +347,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
else countMistake(game);
meatEffect(game, x, y);
floating(game, x, y - 18, `+${yen(amount)}`, THEME.green);
onGameOverCheck();
}
function resolveTruck(index) {
@ -270,7 +364,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (isTarget) countCorrect(game);
else countMistake(game);
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
onGameOverCheck();
return;
}
if (chick.sex === 'female') {
@ -292,7 +385,6 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const penalty = applyWrongTruckFine(game);
countMistake(game);
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
onGameOverCheck();
} else {
countMistake(game);
floating(game, x, y - 18, 'REJECT', THEME.warn);
@ -300,18 +392,28 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
function resolveTrash(index) {
const trash = game.facilities.trash;
if (trash?.processingCooldown > 0) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
return;
}
const chick = takeChick(index);
if (!chick) return;
const { x, y } = chick;
shredEffect(game, x, y, chick.sex);
countTrash(game);
recordFacilityProcess(game, 'trash');
setFacilityCooldownAfterProcess(game, 'trash');
const bonus = applyShredderBonus(game);
const bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
if (chick.sex === 'poop') {
countPoopDestination(game, 'trash');
countCorrect(game);
floating(game, x, y - 18, 'CLEAN', THEME.green);
floating(game, x, y - 18, `CLEAN${bonusText}`, bonus.amount > 0 ? THEME.green : THEME.green);
} else {
countMistake(game);
floating(game, x, y - 18, 'WASTE', THEME.ink);
floating(game, x, y - 18, bonus.amount > 0 ? `WASTE${bonusText}` : 'WASTE', bonus.amount > 0 ? THEME.green : THEME.ink);
}
}

View file

@ -88,7 +88,9 @@ export function productionMultiplier(game) {
}
export function currentPoopRate(game) {
return Math.min(0.9, POOP_RATE * (game.contractActive?.poopMultiplier || 1));
const feedCards = Math.max(0, Number(game?.cardEffects?.hatchingFeed) || 0);
const feedMultiplier = Math.pow(1.03, feedCards);
return Math.min(0.95, POOP_RATE * (game.contractActive?.poopMultiplier || 1) * feedMultiplier);
}
export function truckTarget(game) {

View file

@ -1,6 +1,23 @@
import { ECONOMY, FACILITY_DEFS, INCOME_FACILITY_IDS } from '../core/config.js';
import { factoryValue, targetTruckCount, truckTarget } from './contracts.js';
function effectCount(game, id) {
return Math.max(0, Number(game?.cardEffects?.[id]) || 0);
}
function usedMachineActive(game) {
return !!game?.cardEffects?.usedMachineActive;
}
function resaleRate(game, sameBuild) {
if (sameBuild) return 1;
return Math.min(0.8, 0.5 + 0.1 * Math.min(3, effectCount(game, 'recyclingSubsidy')));
}
function explosionDamageMultiplier(game) {
return Math.pow(0.95, effectCount(game, 'safetyCover'));
}
export function equipmentBasePrice(objOrHit) {
const obj = objOrHit?.ref || objOrHit || {};
const type = objOrHit?.type || obj.type;
@ -11,8 +28,9 @@ export function equipmentBasePrice(objOrHit) {
return FACILITY_DEFS[obj.id]?.price || 0;
}
export function buildPrice(id) {
return FACILITY_DEFS[id]?.price || 0;
export function buildPrice(id, game = null) {
const base = FACILITY_DEFS[id]?.price || 0;
return usedMachineActive(game) ? Math.ceil(base * 0.5) : base;
}
export function facilityUpgradeCount(game, id) {
@ -44,19 +62,73 @@ export function maleTruckPenalty(game) {
return Math.ceil(baseFine * incomeMultiplier(game, 'truck'));
}
export function zundaTaxInfo(cash) {
export function shredderUpgradeCount(game) {
const raw = Math.max(0, (game.facilities?.trash?.level || 1) - 1);
return Math.min(ECONOMY.shredderBonus.maxCards, raw);
}
export function rawShredderUpgradeCount(game) {
return Math.max(0, (game.facilities?.trash?.level || 1) - 1);
}
export function shredderBonusMaxCards() {
return ECONOMY.shredderBonus.maxCards;
}
export function shredderBonusChance(gameOrCount) {
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
const capped = Math.min(ECONOMY.shredderBonus.maxCards, Math.max(0, count));
const percent = (Math.sqrt(capped) / ECONOMY.shredderBonus.probabilityPercentDivisor) * capped;
return Math.min(1, percent / 100);
}
export function shredderBonusExpectedPerItem(gameOrCount) {
return shredderBonusChance(gameOrCount);
}
export function rollShredderBonus(game) {
const cards = shredderUpgradeCount(game);
const chance = shredderBonusChance(cards);
const amount = cards > 0 && Math.random() < chance ? 1 : 0;
return { cards, chance, amount, expected: chance };
}
export function applyShredderBonus(game) {
const result = rollShredderBonus(game);
if (result.amount > 0) {
game.stats.shredderBonus = (game.stats.shredderBonus || 0) + result.amount;
game.totals.shredderBonus = (game.totals.shredderBonus || 0) + result.amount;
applyRevenue(game, result.amount);
}
return result;
}
export function zundaTaxInfo(cash, game = null) {
const rules = ECONOMY.zundaTax;
const legalWork = Math.max(0, game?.cardEffects?.legalWork || 0);
const exemption = rules.exemption + legalWork * rules.legalWorkExemptionBonus;
const maxRateCash = rules.maxRateCash + legalWork * rules.legalWorkExemptionBonus;
const profit = Math.max(0, Math.floor(Number(cash) || 0));
const taxable = Math.max(0, profit - rules.exemption);
if (taxable <= 0) return { profit, taxable: 0, rate: 0, ratePercent: 0, tax: 0, step: 0 };
const taxable = Math.max(0, profit - exemption);
if (taxable <= 0) return { profit, exemption, maxRateCash, taxable: 0, rate: 0, ratePercent: 0, tax: 0, step: 0, legalWork };
const step = Math.max(1, Math.ceil(taxable / rules.stepAmount));
const rate = Math.min(rules.maxRate, step * rules.stepRate);
const tax = Math.ceil(taxable * rate);
return { profit, taxable, rate, ratePercent: Math.round(rate * 100), tax, step };
return { profit, exemption, maxRateCash, taxable, rate, ratePercent: Math.round(rate * 100), tax, step, legalWork };
}
export function zundaTaxForCash(cash) {
return zundaTaxInfo(cash).tax;
export function zundaTaxForCash(cash, game = null) {
return zundaTaxInfo(cash, game).tax;
}
export function fairiesTributeInfo(game, turn = game.turn) {
const day = Math.max(1, Math.floor(Number(turn) || 1));
const base = Math.ceil(day * ECONOMY.fairiesTribute.perDay);
const flatteryCount = Math.max(0, game?.cardEffects?.fairiesFlatteryNext || game?.cardEffects?.flattery || 0);
const multiplier = flatteryCount > 0 ? Math.pow(0.5, flatteryCount) : 1;
const amount = Math.max(0, Math.ceil(base * multiplier));
const reduction = Math.max(0, base - amount);
return { day, base, flatteryCount, perCardReduction: Math.ceil(base * 0.5), reduction, amount };
}
export function resaleValueFor(hit, game) {
@ -64,11 +136,14 @@ export function resaleValueFor(hit, game) {
const meta = hit?.type === 'conveyor' ? game?.conveyorMeta?.get(hit.oldKey || obj.id) : obj;
const price = meta?.price || equipmentBasePrice(hit || obj);
const sameBuild = meta?.builtSession === game?.buildSession;
return { amount: Math.max(0, Math.ceil(price * (sameBuild ? 1 : 0.5))), sameBuild, price };
if (meta?.noRefund || obj?.noRefund) return { amount: 0, sameBuild, price, rate: 0, noRefund: true };
const rate = resaleRate(game, sameBuild);
return { amount: Math.max(0, Math.ceil(price * rate)), sameBuild, price, rate, noRefund: false };
}
export function explosionDamageForPrice(price) {
return Math.max(1, Math.ceil((Number(price) || 0) / ECONOMY.explosionDamageDivisor));
export function explosionDamageForPrice(price, game = null) {
const base = Math.max(1, Math.ceil((Number(price) || 0) / ECONOMY.explosionDamageDivisor));
return Math.max(1, Math.ceil(base * explosionDamageMultiplier(game)));
}
export function upgradeCostFor(obj) {
@ -87,6 +162,7 @@ export function positivePayout(_game, amount) {
}
export function applyCashDelta(game, delta) {
const before = game.cash;
game.cash += delta;
game.stats.profit += delta;
game.totals.profit += delta;
@ -113,6 +189,7 @@ 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;
@ -151,7 +228,7 @@ export function applyWrongTruckFine(game) {
}
export function applyExplosionDamage(game, price) {
const amount = explosionDamageForPrice(price);
const amount = explosionDamageForPrice(price, game);
game.stats.explosionDamage += amount;
game.totals.explosionDamage += amount;
applyPenalty(game, amount);
@ -171,7 +248,7 @@ export function settleTruckRevenue(game) {
}
export function collectZundaTax(game, basisCash = game.cash) {
const info = zundaTaxInfo(basisCash);
const info = zundaTaxInfo(basisCash, game);
if (info.tax <= 0) return info;
game.stats.zundaTax += info.tax;
game.totals.zundaTax += info.tax;
@ -179,6 +256,70 @@ export function collectZundaTax(game, basisCash = game.cash) {
return info;
}
export function collectFairiesTribute(game, turn = game.turn) {
const info = fairiesTributeInfo(game, turn);
if (game.cardEffects) {
game.cardEffects.fairiesFlatteryNext = 0;
if (game.cardEffects.flattery != null) delete game.cardEffects.flattery;
}
if (info.amount <= 0) return info;
game.stats.fairiesTribute = (game.stats.fairiesTribute || 0) + info.amount;
game.totals.fairiesTribute = (game.totals.fairiesTribute || 0) + info.amount;
applyPenalty(game, info.amount);
return info;
}
export function applyRescueLoanIfNeeded(game) {
if (!game?.cardEffects || game.cash >= 0) return { amount: 0, used: 0, remaining: Math.max(0, game?.cardEffects?.rescueLoanCharges || 0) };
let used = 0;
let amount = 0;
while (game.cash < 0 && (game.cardEffects.rescueLoanCharges || 0) > 0) {
game.cardEffects.rescueLoanCharges -= 1;
used += 1;
amount += 500;
game.stats.rescueLoan = (game.stats.rescueLoan || 0) + 500;
game.totals.rescueLoan = (game.totals.rescueLoan || 0) + 500;
applyRevenue(game, 500);
}
return { amount, used, remaining: Math.max(0, game.cardEffects.rescueLoanCharges || 0) };
}
export function collectChemicalWeaponSubsidy(game) {
const cards = effectCount(game, 'chemicalWeaponSubsidy');
const poopTruck = Math.max(0, game?.stats?.poopTruck || 0);
const blocks = Math.floor(poopTruck / 100);
const amount = cards > 0 && blocks > 0 ? blocks * 2000 * cards : 0;
if (amount > 0) {
game.stats.chemicalWeaponSubsidy = (game.stats.chemicalWeaponSubsidy || 0) + amount;
game.totals.chemicalWeaponSubsidy = (game.totals.chemicalWeaponSubsidy || 0) + amount;
applyRevenue(game, amount);
}
return { cards, poopTruck, blocks, amount };
}
export function collectLoanRepayments(game) {
const effects = game?.cardEffects;
if (!effects || !Array.isArray(effects.loans) || !effects.loans.length) return { amount: 0, paidLoans: 0, remainingLoans: 0 };
let amount = 0;
let paidLoans = 0;
const nextLoans = [];
for (const loan of effects.loans) {
if ((loan.daysRemaining || 0) <= 0) continue;
const payment = Math.max(0, Math.ceil(loan.dailyPayment || 400));
amount += payment;
paidLoans += 1;
const daysRemaining = Math.max(0, (loan.daysRemaining || 0) - 1);
if (daysRemaining > 0) nextLoans.push({ ...loan, daysRemaining });
}
effects.loans = nextLoans;
if (amount > 0) {
game.stats.loanRepayment = (game.stats.loanRepayment || 0) + amount;
game.totals.loanRepayment = (game.totals.loanRepayment || 0) + amount;
applyPenalty(game, amount);
}
return { amount, paidLoans, remainingLoans: nextLoans.length };
}
export function finalScore(game) {
const value = factoryValue(game);
const days = Math.max(1, game.totals.turnsCompleted || game.turn || 1);

View file

@ -43,10 +43,10 @@ export function smokeBurst(game, x, y, count = 10) {
game.effects.push({ type: 'smoke', priority: EFFECT_PRIORITY.ambient, x: x + randomBetween(-10, 10), y: y + randomBetween(-10, 10), vx: randomBetween(-26, 26), vy: randomBetween(-60, -12), life: randomBetween(.6, 1.2), maxLife: 1.2, size: randomBetween(8, 18) });
}
}
export function flyingDebris(game, sex, x, y, baseAngle = null) {
export function flyingDebris(game, sex, x, y, baseAngle = null, noDamage = false) {
const angle = baseAngle === null ? randomBetween(-Math.PI, Math.PI) : baseAngle + randomBetween(-0.9, 0.9);
const speed = randomBetween(260, 620);
game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8) });
game.effects.push({ type: 'flyingChick', priority: EFFECT_PRIORITY.critical, sex, x: x + randomBetween(-8, 8), y: y + randomBetween(-8, 8), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - randomBetween(80, 220), radius: sex === 'poop' ? 11 : 13, hit: new Set(), life: 4.2, maxLife: 4.2, spin: randomBetween(-8, 8), noDamage });
}
export function eraseEffect(game, x, y) {
game.effects.push({ type: 'erase', priority: EFFECT_PRIORITY.important, x, y, life: .45, maxLife: .45, size: 54 });
@ -66,7 +66,7 @@ export function updateEffects(game, dt, canvas, equipmentHitBoxes) {
e.x += (e.vx || 0) * dt; e.y += (e.vy || 0) * dt; e.vy = (e.vy || 0) + 150 * dt;
} else if (e.type === 'flyingChick') {
e.x += e.vx * dt; e.y += e.vy * dt; e.vy += 360 * dt;
handleFlyingChickDamage(game, e, equipmentHitBoxes);
if (!e.noDamage) handleFlyingChickDamage(game, e, equipmentHitBoxes);
const sx = e.x + game.view.x, sy = e.y + game.view.y;
if (sx < -100 || sy < -100 || sx > canvas.width + 100 || sy > canvas.height + 100) e.life = 0;
}

39
src/systems/gameEvents.js Normal file
View file

@ -0,0 +1,39 @@
const MAX_EVENTS = 160;
export function emitGameEvent(game, type, payload = {}) {
if (!game) return null;
if (!Array.isArray(game.eventLog)) game.eventLog = [];
const id = game.nextEventId || 1;
game.nextEventId = id + 1;
const event = {
id,
at: Date.now(),
day: game.turn || 1,
phase: game.phase || 'unknown',
type,
payload: sanitizePayload(payload)
};
game.eventLog.push(event);
if (game.eventLog.length > MAX_EVENTS) game.eventLog.splice(0, game.eventLog.length - MAX_EVENTS);
return event;
}
export function recentGameEvents(game, limit = 8) {
const events = Array.isArray(game?.eventLog) ? game.eventLog : [];
return events.slice(Math.max(0, events.length - limit)).reverse();
}
export function clearGameEvents(game) {
if (game) game.eventLog = [];
}
function sanitizePayload(payload) {
if (!payload || typeof payload !== 'object') return payload;
const out = {};
for (const [k, v] of Object.entries(payload)) {
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean' || v == null) out[k] = v;
else if (Array.isArray(v)) out[k] = v.slice(0, 12).map(x => typeof x === 'object' ? JSON.stringify(x).slice(0, 80) : x);
else out[k] = JSON.stringify(v).slice(0, 120);
}
return out;
}

View file

@ -16,7 +16,10 @@ export function snapshot(game) {
scanners: game.scanners.map(cleanScanner),
conveyorTiles: [...game.conveyorTiles],
conveyorMeta: [...game.conveyorMeta.entries()],
branchCounters: [...game.branchCounters.entries()]
branchCounters: [...game.branchCounters.entries()],
cardEffects: game.cardEffects,
cardDraft: game.cardDraft,
cardTargetPick: game.cardTargetPick
});
}
export function restore(game, text) {
@ -35,10 +38,19 @@ export function restore(game, text) {
game.conveyorTiles = new Set(data.conveyorTiles || []);
game.conveyorMeta = new Map(data.conveyorMeta || []);
game.branchCounters = new Map(data.branchCounters || []);
game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, flattery: 0 };
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
game.cardDraft = data.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;
game.cardTargetPick = data.cardTargetPick || null;
game.groupDrag = null;
game.selectionBox = null;
game.pan = null;
game.routingVersion = (game.routingVersion || 0) + 1;
game.factoryGraph = null;
game.factoryGraphCommittedVersion = -1;
}
export function record(game) {
if (game.phase !== 'build') return;
game.undoStack.push(snapshot(game));

302
src/systems/maintenance.js Normal file
View file

@ -0,0 +1,302 @@
import { BALANCE } from '../core/balance.js';
import { GRID, THEME } from '../core/config.js';
import { cellCenter, parseKey, key, yen } from '../core/utils.js';
import { spendCash } from './economy.js';
import { floating } from './effects.js';
const RULES = BALANCE.maintenance;
function effectCount(game, id) {
return Math.max(0, Number(game?.cardEffects?.[id]) || 0);
}
export function degradationUseMultiplier(game) {
const crowded = Math.min(1.4, 1 + 0.04 * Math.min(10, effectCount(game, 'crowdedFarming')));
const preventive = Math.pow(0.95, effectCount(game, 'preventiveMaintenance'));
return crowded * preventive;
}
export function durabilityMultiplier(game) {
return Math.pow(1.03, effectCount(game, 'durabilityCoating'));
}
export function durabilityCapFor(game, type, qualityMultiplier = 1) {
const base = RULES.durability[type] || 1000;
return Math.max(1, Math.ceil(base * Math.max(0.1, qualityMultiplier || 1) * durabilityMultiplier(game)));
}
export function repairmanDailyCost(game) {
const base = RULES.repairman.dailyCost;
return game?.cardEffects?.laborExploitation ? Math.ceil(base * 0.7) : base;
}
function qualityMultiplierOf(objOrMeta) {
return Math.max(0.1, Number(objOrMeta?.durabilityBaseMultiplier) || 1);
}
export function ensureMaintenanceState(game) {
if (!game.repairman) {
game.repairman = {
hiredForNextDay: false,
active: false,
x: GRID.x + GRID.cell * 0.5,
y: GRID.y + GRID.rows * GRID.cell + 84,
target: null,
repairedToday: 0
};
}
for (const [k, meta] of game.conveyorMeta || []) {
if (meta.uses == null) meta.uses = 0;
if (meta.maintenanceType == null) meta.maintenanceType = 'conveyor';
const desired = durabilityCapFor(game, 'conveyor', qualityMultiplierOf(meta));
if (meta.durability == null || meta.durability < desired) meta.durability = desired;
}
for (const farm of game.eggFarms || []) attachMaintenance(farm, 'eggFarm', game);
for (const scanner of game.scanners || []) {
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);
}
}
function attachMaintenance(obj, type, game = null) {
if (!obj) return;
if (!obj.maintenance) obj.maintenance = {};
obj.maintenance.type = type;
if (obj.maintenance.uses == null) obj.maintenance.uses = 0;
const desired = game ? durabilityCapFor(game, type, qualityMultiplierOf(obj)) : (RULES.durability[type] || 1000);
if (obj.maintenance.durability == null || obj.maintenance.durability < desired) obj.maintenance.durability = desired;
}
function usesOf(target) {
if (!target) return 0;
if (target.meta) return Math.max(0, target.meta.uses || 0);
return Math.max(0, target.maintenance?.uses || 0);
}
function durabilityOf(target) {
if (!target) return 1;
if (target.meta) return Math.max(1, target.meta.durability || RULES.durability.conveyor);
return Math.max(1, target.maintenance?.durability || RULES.durability[target.maintenance?.type] || 1000);
}
function setUses(target, uses) {
if (!target) return;
if (target.meta) target.meta.uses = Math.max(0, uses);
else if (target.maintenance) target.maintenance.uses = Math.max(0, uses);
}
export function wearRatio(target) {
return Math.max(0, Math.min(1, usesOf(target) / durabilityOf(target)));
}
export function remainingPercent(target) {
return Math.max(0, Math.round((1 - wearRatio(target)) * 100));
}
export function performanceFactor(target) {
const wear = wearRatio(target);
if (wear <= RULES.fullPerformanceUntilWear) return 1;
const t = (wear - RULES.fullPerformanceUntilWear) / (1 - RULES.fullPerformanceUntilWear);
return Math.max(RULES.minimumPerformance, 1 - t * (1 - RULES.minimumPerformance));
}
export function degradationPercent(target) {
return Math.round(wearRatio(target) * 100);
}
export function conveyorTarget(game, conveyorKey) {
const meta = game.conveyorMeta?.get(conveyorKey);
return meta ? { type: 'conveyor', key: conveyorKey, meta } : null;
}
export function conveyorSpeedFactorForKey(game, conveyorKey) {
ensureMaintenanceState(game);
const target = conveyorTarget(game, conveyorKey);
return target ? performanceFactor(target) : 1;
}
export function averageConveyorPerformance(game) {
ensureMaintenanceState(game);
let sum = 0;
let count = 0;
for (const k of game.conveyorTiles || []) {
sum += conveyorSpeedFactorForKey(game, k);
count += 1;
}
return count ? sum / count : 1;
}
export function delayMultiplier(target) {
return 1 / performanceFactor(target);
}
export function autoScannerDelayMultiplier(scanner) {
if (!scanner || scanner.kind !== 'auto') return 1;
attachMaintenance(scanner, 'autoScanner');
return delayMultiplier(scanner);
}
export function eggSpawnDelayMultiplier(farm) {
attachMaintenance(farm, 'eggFarm');
return delayMultiplier(farm);
}
export function facilityProcessingDelay(game, id) {
ensureMaintenanceState(game);
const f = game.facilities?.[id];
if (!f || !['mixer', 'trash'].includes(id)) return 0;
attachMaintenance(f, id);
const t = Math.max(0, delayMultiplier(f) - 1);
return t * (RULES.processingDelayMaxSeconds?.[id] || 1);
}
export function recordConveyorPass(game, conveyorKey) {
ensureMaintenanceState(game);
const meta = game.conveyorMeta?.get(conveyorKey);
if (!meta) return;
meta.uses = Math.min(meta.durability || RULES.durability.conveyor, (meta.uses || 0) + degradationUseMultiplier(game));
game.stats.conveyorPasses = (game.stats.conveyorPasses || 0) + 1;
}
export function recordFarmProduction(game, farm) {
ensureMaintenanceState(game);
attachMaintenance(farm, 'eggFarm');
farm.maintenance.uses = Math.min(farm.maintenance.durability, (farm.maintenance.uses || 0) + degradationUseMultiplier(game));
game.stats.eggProduced = (game.stats.eggProduced || 0) + 1;
}
export function recordAutoScan(game, scanner) {
ensureMaintenanceState(game);
if (!scanner || scanner.kind !== 'auto') return;
attachMaintenance(scanner, 'autoScanner');
scanner.maintenance.uses = Math.min(scanner.maintenance.durability, (scanner.maintenance.uses || 0) + degradationUseMultiplier(game));
}
export function recordFacilityProcess(game, id) {
ensureMaintenanceState(game);
const f = game.facilities?.[id];
if (!f || !['mixer', 'trash'].includes(id)) return;
attachMaintenance(f, id);
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game));
}
export function setFacilityCooldownAfterProcess(game, id) {
const f = game.facilities?.[id];
if (!f) return 0;
const delay = facilityProcessingDelay(game, id);
f.processingCooldown = Math.max(f.processingCooldown || 0, delay);
return delay;
}
export function updateProcessingCooldowns(game, dt) {
for (const id of ['mixer', 'trash']) {
const f = game.facilities?.[id];
if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
}
}
export function equipmentMaintenanceTargets(game) {
ensureMaintenanceState(game);
const targets = [];
for (const [k, meta] of game.conveyorMeta || []) targets.push({ type: 'conveyor', key: k, meta, label: `Belt ${k}`, center: cellCenter(parseKey(k).col, parseKey(k).row) });
for (const farm of game.eggFarms || []) targets.push({ type: 'eggFarm', ref: farm, label: `EGG #${farm.id}`, center: cellCenter(farm.col, farm.row) });
for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) });
for (const id of ['mixer', 'trash']) {
const f = game.facilities?.[id];
if (f) targets.push({ type: id, ref: f, label: f.shortName || f.name || id, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
}
return targets;
}
function targetWear(t) { return t.meta ? wearRatio(t) : wearRatio(t.ref); }
function targetUses(t) { return t.meta ? usesOf(t) : usesOf(t.ref); }
function targetDurability(t) { return t.meta ? durabilityOf(t) : durabilityOf(t.ref); }
function reduceTargetUses(t, amount) { setUses(t.meta ? t : t.ref, Math.max(0, targetUses(t) - amount)); }
function targetKey(t) { return t.type === 'conveyor' ? `conveyor:${t.key}` : `${t.type}:${t.ref?.id || t.ref?.type || t.type}`; }
export function hireRepairmanForNextDay(game) {
ensureMaintenanceState(game);
if (game.repairman.hiredForNextDay) return { ok: false, reason: 'Repairman already hired.' };
const cost = repairmanDailyCost(game);
if (game.cash < cost) return { ok: false, reason: `Need ${yen(cost - game.cash)} more.` };
spendCash(game, cost);
game.repairman.hiredForNextDay = true;
return { ok: true, cost };
}
export function activateRepairmanForDay(game) {
ensureMaintenanceState(game);
game.repairman.active = !!game.repairman.hiredForNextDay;
game.repairman.hiredForNextDay = false;
game.repairman.target = null;
game.repairman.repairedToday = 0;
}
export function deactivateRepairman(game) {
ensureMaintenanceState(game);
game.repairman.active = false;
game.repairman.target = null;
}
function resolveStoredTarget(game, stored) {
if (!stored) return null;
const targets = equipmentMaintenanceTargets(game);
return targets.find(t => targetKey(t) === stored.key) || null;
}
function chooseRepairTarget(game) {
let best = null;
for (const t of equipmentMaintenanceTargets(game)) {
const w = targetWear(t);
if (w <= 0.001) continue;
if (!best || w > targetWear(best)) best = t;
}
return best;
}
export function updateRepairman(game, dt) {
ensureMaintenanceState(game);
const r = game.repairman;
if (!r.active || game.phase !== 'running') return;
let target = resolveStoredTarget(game, r.target) || chooseRepairTarget(game);
if (!target) {
r.target = null;
return;
}
r.target = { key: targetKey(target), label: target.label };
const speed = RULES.repairman.walkSpeed;
const dx = target.center.x - r.x;
const dy = target.center.y - r.y;
const dist = Math.hypot(dx, dy);
if (dist > 8) {
const step = Math.min(dist, speed * dt);
r.x += dx / dist * step;
r.y += dy / dist * step;
return;
}
const repairWear = dt / (RULES.repairman.secondsPerOnePercent * 100);
const usesBefore = targetUses(target);
const usesDelta = repairWear * targetDurability(target);
reduceTargetUses(target, usesDelta);
const repairedPct = Math.max(0, (usesBefore - targetUses(target)) / targetDurability(target) * 100);
r.repairedToday = (r.repairedToday || 0) + repairedPct;
if (repairedPct > 0 && Math.random() < 0.02) floating(game, r.x, r.y - 28, 'REPAIR', THEME.green);
if (targetWear(target) <= 0.001) r.target = null;
}
export function worstDegradation(game) {
let worst = 0;
let label = 'none';
for (const t of equipmentMaintenanceTargets(game)) {
const w = targetWear(t);
if (w > worst) { worst = w; label = t.label; }
}
return { ratio: worst, percent: Math.round(worst * 100), label };
}
export function maintenanceSummary(game) {
const worst = worstDegradation(game);
const avgBeltPerformance = averageConveyorPerformance(game);
return { worst, avgBeltPerformance, repairman: game.repairman || null };
}

View file

@ -12,7 +12,8 @@ export function scannerById(game, id) { return game.scanners.find(s => s.id ===
export function scannerBySlot(game, slot) { return game.scanners.find(s => s.kind === 'manual' && s.slot === slot) || null; }
// -----------------------------------------------------------------------------
// Port definitions
// Port definitions. Ports prefer the exact connector cell, but also accept
// visually touching adjacent conveyor cells so drawn connections and routing match.
// -----------------------------------------------------------------------------
export function scannerConnector(scanner, type) {
return {
@ -36,8 +37,27 @@ function exactConveyorCell(game, point) {
return conveyorAt(game, point) ? [{ ...point }] : [];
}
// Kept for explicit non-port use. Ports and validation intentionally use exact
// cells only; visual adjacency is not treated as a connection.
function visualPortConveyorCells(game, point, blocked = [], preferred = []) {
if (!point || !inGrid(point.col, point.row)) return [];
const result = [];
const add = p => {
if (!p || !inGrid(p.col, p.row) || !game.conveyorTiles.has(key(p.col, p.row))) return;
if (blocked.some(b => b && sameCell(p, b))) return;
if (!result.some(x => sameCell(x, p))) result.push({ ...p, viaTolerance: !sameCell(p, point) });
};
add(point);
for (const p of preferred) add(p);
for (const p of adjacentCells(point)) add(p);
return result;
}
function graphPortConveyorCells(game, point, blocked = [], preferred = []) {
const cells = visualPortConveyorCells(game, point, blocked, preferred);
const exact = cells.filter(p => !p.viaTolerance);
return exact.length ? exact : cells;
}
// Kept for explicit non-port use.
export function exactOrAdjacentConveyorCells(game, point, blocked = [], preferred = []) {
if (!point || !inGrid(point.col, point.row)) return [];
const result = [];
@ -56,11 +76,13 @@ export function exactOrAdjacentConveyorCells(game, point, blocked = [], preferre
// Strict connection tests
// -----------------------------------------------------------------------------
export function scannerInputCells(game, scanner) {
return exactConveyorCell(game, scannerConnector(scanner, 'inputA'));
const connector = scannerConnector(scanner, 'inputA');
return visualPortConveyorCells(game, connector, [ { col: scanner.col, row: scanner.row } ]);
}
function outputStartCells(game, scanner, side) {
return exactConveyorCell(game, scannerConnector(scanner, side));
const connector = scannerConnector(scanner, side);
return visualPortConveyorCells(game, connector, [ { col: scanner.col, row: scanner.row } ]);
}
export function facilityEntryPoint(game, dest) {
@ -80,11 +102,11 @@ export function facilityEntryCell(game, dest) {
export function facilityEndpointCells(game, dest) {
const f = game.facilities[dest];
if (!f?.entry) return [];
return exactConveyorCell(game, f.entry);
return visualPortConveyorCells(game, f.entry);
}
export function portHasConveyor(game, port) {
return exactConveyorCell(game, port).length > 0;
return visualPortConveyorCells(game, port).length > 0;
}
export function scannerPortHasConveyor(game, scanner, type) {
@ -104,22 +126,179 @@ export function inputCellsForScanner(game, scanner) { return scannerInputCells(g
export function refreshRoutingAfterEdit(game) {
game.branchCounters?.clear?.();
game.routeCache = null;
game.routingVersion = (game.routingVersion || 0) + 1;
game.factoryGraph = null;
game.factoryGraphCommittedVersion = -1;
}
// -----------------------------------------------------------------------------
// FactoryGraph: build once after edits, then route and validate against this
// graph. The graph contains conveyor cells, exact ports, component membership,
// and cached path results. This makes the simulation read a single canonical
// connectivity model instead of re-interpreting the map in several systems.
// -----------------------------------------------------------------------------
function graphNodeKey(col, row) { return key(col, row); }
function graphCell(k) { return parseKey(k); }
function emptyFactoryGraph(game) {
return {
version: game.routingVersion || 0,
cells: new Set(),
adjacency: new Map(),
components: new Map(),
cellToComponent: new Map(),
scannerInputs: new Map(),
scannerPorts: new Map(),
facilityPorts: new Map(),
farmOutputs: new Map(),
pathCache: new Map(),
metrics: {
conveyorCells: 0,
edges: 0,
components: 0,
deadEnds: 0,
crosses: 0,
scannerPorts: 0,
connectedScannerPorts: 0,
facilityPorts: 0,
connectedFacilityPorts: 0,
farmOutputs: 0
},
issues: []
};
}
export function buildFactoryGraph(game) {
const graph = emptyFactoryGraph(game);
for (const k of game.conveyorTiles) {
graph.cells.add(k);
graph.adjacency.set(k, []);
}
for (const k of graph.cells) {
const p = graphCell(k);
for (const d of DIRS) {
const nk = graphNodeKey(p.col + d.dc, p.row + d.dr);
if (graph.cells.has(nk)) graph.adjacency.get(k).push({ key: nk, dir: d.name });
}
}
let edgePairs = 0;
for (const [k, ns] of graph.adjacency.entries()) {
edgePairs += ns.length;
const names = new Set(ns.map(n => n.dir));
if (ns.length === 1) graph.metrics.deadEnds += 1;
if (ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down')) graph.metrics.crosses += 1;
}
graph.metrics.conveyorCells = graph.cells.size;
graph.metrics.edges = Math.floor(edgePairs / 2);
buildGraphComponents(graph);
indexGraphPorts(game, graph);
graph.issues = validateFactoryGraph(game, graph, { includeRoutes: false });
return graph;
}
function buildGraphComponents(graph) {
let next = 1;
for (const k of graph.cells) {
if (graph.cellToComponent.has(k)) continue;
const id = next++;
const queue = [k];
const cells = [];
graph.cellToComponent.set(k, id);
while (queue.length) {
const cur = queue.shift();
cells.push(cur);
for (const n of graph.adjacency.get(cur) || []) {
if (graph.cellToComponent.has(n.key)) continue;
graph.cellToComponent.set(n.key, id);
queue.push(n.key);
}
}
graph.components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 });
}
graph.metrics.components = graph.components.size;
}
function indexGraphPorts(game, graph) {
for (const scanner of game.scanners) {
const ports = {};
for (const type of ['inputA', 'left', 'right']) {
const cell = scannerConnector(scanner, type);
const cells = graphPortConveyorCells(game, cell, [ { col: scanner.col, row: scanner.row } ])
.filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
const cellKeys = cells.map(p => graphNodeKey(p.col, p.row));
const connected = cellKeys.length > 0;
ports[type] = { type, cell, cells, keys: cellKeys, key: cellKeys[0] || null, connected };
graph.metrics.scannerPorts += 1;
if (connected) graph.metrics.connectedScannerPorts += 1;
if (type === 'inputA') for (const cellKey of cellKeys) graph.scannerInputs.set(cellKey, scanner.id);
}
graph.scannerPorts.set(scanner.id, ports);
}
for (const [id, f] of Object.entries(game.facilities)) {
if (!f.entry) continue;
const cells = graphPortConveyorCells(game, f.entry).filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
const cellKeys = cells.map(p => graphNodeKey(p.col, p.row));
const connected = cellKeys.length > 0;
graph.facilityPorts.set(id, { id, cell: { ...f.entry }, cells, keys: cellKeys, key: cellKeys[0] || null, connected });
graph.metrics.facilityPorts += 1;
if (connected) graph.metrics.connectedFacilityPorts += 1;
}
for (const farm of game.eggFarms) {
const outputs = adjacentCells({ col: farm.col, row: farm.row })
.map(p => graphNodeKey(p.col, p.row))
.filter(k => graph.cells.has(k));
graph.farmOutputs.set(farm.id, outputs);
graph.metrics.farmOutputs += outputs.length;
}
}
export function ensureFactoryGraph(game) {
const version = game.routingVersion || 0;
if (!game.factoryGraph || game.factoryGraph.version !== version) game.factoryGraph = buildFactoryGraph(game);
return game.factoryGraph;
}
export function commitFactoryGraphForDay(game) {
const graph = ensureFactoryGraph(game);
game.factoryGraphCommittedVersion = graph.version;
game.factoryGraphSnapshot = graphSummary(graph);
return graph;
}
export function graphSummary(graph) {
if (!graph) return null;
return {
version: graph.version,
conveyorCells: graph.metrics.conveyorCells,
edges: graph.metrics.edges,
components: graph.metrics.components,
deadEnds: graph.metrics.deadEnds,
crosses: graph.metrics.crosses,
scannerPorts: `${graph.metrics.connectedScannerPorts}/${graph.metrics.scannerPorts}`,
facilityPorts: `${graph.metrics.connectedFacilityPorts}/${graph.metrics.facilityPorts}`,
farmOutputs: graph.metrics.farmOutputs,
issues: [...(graph.issues || [])]
};
}
// -----------------------------------------------------------------------------
// Conveyor graph and pathfinding
// -----------------------------------------------------------------------------
export function getConveyorNeighbors(game, col, row) {
const result = [];
for (const d of DIRS) {
const nc = col + d.dc, nr = row + d.dr;
if (inGrid(nc, nr) && game.conveyorTiles.has(key(nc, nr))) result.push({ col: nc, row: nr, dir: d });
}
return result;
const graph = ensureFactoryGraph(game);
const k = graphNodeKey(col, row);
return (graph.adjacency.get(k) || []).map(n => ({ ...graphCell(n.key), dir: DIRS.find(d => d.name === n.dir) || { name: n.dir, dc: 0, dr: 0 } }));
}
export function getAdjacentConveyors(game, col, row) {
return adjacentCells({ col, row }).filter(p => game.conveyorTiles.has(key(p.col, p.row)));
const graph = ensureFactoryGraph(game);
return adjacentCells({ col, row }).filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
}
function dirBetween(a, b) {
@ -127,79 +306,67 @@ function dirBetween(a, b) {
return DIRS.find(d => d.dc === dc && d.dr === dr)?.name || null;
}
function isCross(game, col, row) {
const ns = getConveyorNeighbors(game, col, row);
const names = new Set(ns.map(n => n.dir.name));
function isCrossInGraph(graph, cellKey) {
const ns = graph.adjacency.get(cellKey) || [];
const names = new Set(ns.map(n => n.dir));
return ns.length === 4 && names.has('left') && names.has('right') && names.has('up') && names.has('down');
}
// A four-way cross conveyor is an overpass/crossing. Chicks never turn there.
export function bfsAllRoutes(game, start, isGoal) {
const startK = key(start.col, start.row);
if (!game.conveyorTiles.has(startK)) return [];
const graph = ensureFactoryGraph(game);
const startK = graphNodeKey(start.col, start.row);
if (!graph.cells.has(startK)) return [];
const cacheKey = `${startK}|${isGoal.cacheKey || 'dynamic'}`;
if (isGoal.cacheKey && graph.pathCache.has(cacheKey)) return graph.pathCache.get(cacheKey).map(path => path.map(p => ({ ...p })));
const startState = `${startK}|none`;
const queue = [{ col: start.col, row: start.row, incoming: 'none' }];
const queue = [{ key: startK, col: start.col, row: start.row, incoming: 'none' }];
const visited = new Set([startState]);
const parent = new Map();
const found = [];
while (queue.length) {
const cur = queue.shift();
const curK = key(cur.col, cur.row);
const stateK = `${curK}|${cur.incoming}`;
if (isGoal(cur)) found.push(stateK);
for (const n of getConveyorNeighbors(game, cur.col, cur.row)) {
const outDir = dirBetween(cur, n);
if (cur.incoming !== 'none' && isCross(game, cur.col, cur.row) && outDir !== cur.incoming) continue;
const nextStateK = `${key(n.col, n.row)}|${outDir}`;
const curStateK = `${cur.key}|${cur.incoming}`;
if (isGoal(cur)) found.push(curStateK);
for (const n of graph.adjacency.get(cur.key) || []) {
const next = graphCell(n.key);
const outDir = dirBetween(cur, next);
if (cur.incoming !== 'none' && isCrossInGraph(graph, cur.key) && outDir !== cur.incoming) continue;
const nextStateK = `${n.key}|${outDir}`;
if (visited.has(nextStateK)) continue;
visited.add(nextStateK);
parent.set(nextStateK, stateK);
queue.push({ col: n.col, row: n.row, incoming: outDir });
parent.set(nextStateK, curStateK);
queue.push({ key: n.key, col: next.col, row: next.row, incoming: outDir });
}
}
return found.map(goalState => {
const paths = found.map(goalState => {
const reversed = [];
let cursor = goalState;
while (cursor) {
const [cellK] = cursor.split('|');
reversed.push(parseKey(cellK));
reversed.push(graphCell(cellK));
if (cellK === startK) break;
cursor = parent.get(cursor);
}
return reversed.reverse();
});
if (isGoal.cacheKey) graph.pathCache.set(cacheKey, paths.map(path => path.map(p => ({ ...p }))));
return paths;
}
export function buildConveyorComponents(game) {
const graph = ensureFactoryGraph(game);
const components = new Map();
const cellToComponent = new Map();
let next = 1;
for (const k of game.conveyorTiles) {
if (cellToComponent.has(k)) continue;
const id = next++;
const queue = [parseKey(k)];
const cells = [];
cellToComponent.set(k, id);
while (queue.length) {
const cur = queue.shift();
const ck = key(cur.col, cur.row);
cells.push(ck);
for (const n of getConveyorNeighbors(game, cur.col, cur.row)) {
const nk = key(n.col, n.row);
if (cellToComponent.has(nk)) continue;
cellToComponent.set(nk, id);
queue.push({ col: n.col, row: n.row });
}
}
components.set(id, { id, cells, capacity: Math.max(1, cells.length), count: 0, ratio: 0 });
}
return { components, cellToComponent };
for (const [id, comp] of graph.components.entries()) components.set(id, { id, cells: [...comp.cells], capacity: comp.capacity, count: 0, ratio: 0 });
return { components, cellToComponent: new Map(graph.cellToComponent) };
}
export function nearestConveyorKey(game, x, y) {
const graph = ensureFactoryGraph(game);
let best = null;
let bestD = Infinity;
for (const k of game.conveyorTiles) {
for (const k of graph.cells) {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
const d = Math.hypot(c.x - x, c.y - y);
@ -229,9 +396,33 @@ function routeWithConnector(fromPoint, connector, cells, extraEnd = null) {
return points;
}
function routeToScannerCenter(cells, scanner) {
const points = routePoints(cells);
const input = scannerConnector(scanner, 'inputA');
if (input) appendPoint(points, cellCenter(input.col, input.row));
appendPoint(points, scannerCenter(scanner));
return points;
}
function routeWithScannerTarget(fromPoint, connector, cells, targetScanner) {
const points = [{ x: fromPoint.x, y: fromPoint.y }];
appendPoint(points, cellCenter(connector.col, connector.row));
for (const p of routePoints(cells)) appendPoint(points, p);
const input = scannerConnector(targetScanner, 'inputA');
if (input) appendPoint(points, cellCenter(input.col, input.row));
appendPoint(points, scannerCenter(targetScanner));
return points;
}
export function chooseRoundRobin(game, id, candidates, advance = false) {
if (!candidates.length) return null;
const sorted = [...candidates].sort((a, b) => {
const ba = a.roleBias ?? 0;
const bb = b.roleBias ?? 0;
if (ba !== bb) return ba - bb;
const la = a.cells?.length ?? a.route?.length ?? Number.POSITIVE_INFINITY;
const lb = b.cells?.length ?? b.route?.length ?? Number.POSITIVE_INFINITY;
if (la !== lb) return la - lb;
const ka = a.key || JSON.stringify(a.route || a.cells || a);
const kb = b.key || JSON.stringify(b.route || b.cells || b);
return ka.localeCompare(kb);
@ -254,8 +445,6 @@ export function scannerOutputs(scanner) {
export function autoSideFor(scanner, chick) {
const rules = scannerRules(scanner);
if (rules.left.match === chick.sex) return 'left';
if (rules.right.match === chick.sex) return 'right';
if (rules.left.match === 'default') return 'left';
return 'right';
}
@ -266,28 +455,29 @@ export function destinationColor(dest) {
export function isFacilityEndpoint(game, p, dest, connector) {
if (sameCell(p, connector)) return false;
const f = game.facilities[dest];
if (f?.entry) return facilityEndpointCells(game, dest).some(e => sameCell(p, e));
const degree = getConveyorNeighbors(game, p.col, p.row).length;
if (degree > 1) return false;
const entry = facilityEntryPoint(game, dest);
return !!entry && distance(cellCenter(p.col, p.row), entry) <= GRID.cell * 2.2;
const graph = ensureFactoryGraph(game);
const port = graph.facilityPorts.get(dest);
return !!port?.connected && (port.keys || [port.key]).includes(graphNodeKey(p.col, p.row));
}
export function routeFromFarmToScanner(game, farm, advance = false) {
const starts = getAdjacentConveyors(game, farm.col, farm.row);
const graph = ensureFactoryGraph(game);
const starts = (graph.farmOutputs.get(farm.id) || []).map(graphCell);
const candidates = [];
for (const start of starts) {
const routes = bfsAllRoutes(game, start, p => isInputConnector(game, p.col, p.row));
const isGoal = p => graph.scannerInputs.has(graphNodeKey(p.col, p.row));
isGoal.cacheKey = `farmInput:${start.col},${start.row}:v${graph.version}`;
const routes = bfsAllRoutes(game, start, isGoal);
for (const cells of routes) {
const last = cells[cells.length - 1];
const scanner = getInputScanner(game, last.col, last.row);
const scannerId = graph.scannerInputs.get(graphNodeKey(last.col, last.row));
const scanner = scannerById(game, scannerId);
if (!scanner) continue;
candidates.push({ key: `${scanner.id}:${last.col},${last.row}:${cells.length}`, scanner, cells });
}
}
const chosen = chooseRoundRobin(game, `farm:${farm.id}`, candidates, advance);
if (chosen) return { scannerId: chosen.scanner.id, route: routePoints(chosen.cells, scannerCenter(chosen.scanner)) };
if (chosen) return { scannerId: chosen.scanner.id, route: routeToScannerCenter(chosen.cells, chosen.scanner) };
const fallback = chooseRoundRobin(game, `farm:${farm.id}:output`, starts.map(p => ({ key: key(p.col, p.row), cells: [p] })), advance);
return fallback ? { scannerId: null, route: routePoints(fallback.cells) } : null;
}
@ -295,8 +485,10 @@ export function routeFromFarmToScanner(game, farm, advance = false) {
export function outputRoute(game, side, fromPoint, scannerId, advance = false) {
const scanner = scannerById(game, scannerId);
if (!scanner) return null;
const graph = ensureFactoryGraph(game);
const port = graph.scannerPorts.get(scanner.id)?.[side];
const connector = scannerConnector(scanner, side);
const starts = outputStartCells(game, scanner, side);
const starts = port?.connected ? (port.keys || [port.key]).filter(Boolean).map(graphCell) : [];
if (!connector || !starts.length) return null;
const dest = scannerOutputs(scanner)[side];
if (dest === 'scanner-role-1') return routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance);
@ -304,29 +496,38 @@ export function outputRoute(game, side, fromPoint, scannerId, advance = false) {
}
function routeToNextScanner(game, scanner, side, fromPoint, connector, starts, advance) {
const targets = game.scanners.filter(s => s.role === 1 && s.id !== scanner.id && inputCellsForScanner(game, s).length);
const graph = ensureFactoryGraph(game);
const targets = game.scanners.filter(s => s.id !== scanner.id && graph.scannerPorts.get(s.id)?.inputA?.connected);
const candidates = [];
for (const start of starts) {
for (const target of targets) {
const inputKeys = new Set(inputCellsForScanner(game, target).map(p => key(p.col, p.row)));
const routes = bfsAllRoutes(game, start, p => inputKeys.has(key(p.col, p.row)));
for (const cells of routes) {
candidates.push({ key: `start${key(start.col, start.row)}:s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells });
}
const targetKey = graph.scannerPorts.get(target.id)?.inputA?.key;
if (!targetKey) continue;
const targetKeys = (graph.scannerPorts.get(target.id)?.inputA?.keys || [targetKey]).filter(Boolean);
const isGoal = p => targetKeys.includes(graphNodeKey(p.col, p.row));
isGoal.cacheKey = `toScanner:${start.col},${start.row}:${target.id}:v${graph.version}`;
const routes = bfsAllRoutes(game, start, isGoal);
const roleBias = target.role === 1 ? 0 : 1;
for (const cells of routes) candidates.push({ key: `${roleBias}:start${key(start.col, start.row)}:s${target.id}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, target, cells, roleBias });
}
}
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:toRole1`, candidates, advance);
if (!chosen) return null;
return { destination: 'scanner', nextScannerId: chosen.target.id, route: routeWithConnector(fromPoint, connector, chosen.cells, scannerCenter(chosen.target)) };
return { destination: 'scanner', nextScannerId: chosen.target.id, route: routeWithScannerTarget(fromPoint, connector, chosen.cells, chosen.target) };
}
function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest, advance) {
const graph = ensureFactoryGraph(game);
const port = graph.facilityPorts.get(dest);
if (!port?.connected) return null;
const candidates = [];
for (const start of starts) {
const routes = bfsAllRoutes(game, start, p => isFacilityEndpoint(game, p, dest, connector));
for (const cells of routes) {
candidates.push({ key: `start${key(start.col, start.row)}:${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells });
}
const goalKeys = (port.keys || [port.key]).filter(Boolean);
if (!goalKeys.length) continue;
const isGoal = p => goalKeys.includes(graphNodeKey(p.col, p.row));
isGoal.cacheKey = `toFacility:${start.col},${start.row}:${dest}:v${graph.version}`;
const routes = bfsAllRoutes(game, start, isGoal);
for (const cells of routes) candidates.push({ key: `start${key(start.col, start.row)}:${dest}:${cells.length}:${key(cells[cells.length - 1].col, cells[cells.length - 1].row)}`, cells });
}
const chosen = chooseRoundRobin(game, `scanner:${scanner.id}:${side}:${dest}`, candidates, advance);
if (!chosen) return null;
@ -334,40 +535,88 @@ function routeToFacility(game, scanner, side, fromPoint, connector, starts, dest
}
// -----------------------------------------------------------------------------
// Connection validation
// Connection validation: uses FactoryGraph as the single source of truth.
// -----------------------------------------------------------------------------
function outputRouteReachesFacility(game, scanner, visited = new Set()) {
if (!scanner || visited.has(scanner.id)) return false;
visited.add(scanner.id);
for (const side of ['left', 'right']) {
const plan = outputRoute(game, side, scannerCenter(scanner), scanner.id, false);
if (!plan) continue;
if (['mixer', 'trash', 'truck'].includes(plan.destination)) return true;
if (plan.destination === 'scanner') {
const next = scannerById(game, plan.nextScannerId);
if (outputRouteReachesFacility(game, next, visited)) return true;
}
}
return false;
}
export function minimumStartConnectionIssues(game) {
const graph = ensureFactoryGraph(game);
for (const farm of game.eggFarms || []) {
const route = routeFromFarmToScanner(game, farm, false);
if (!route?.scannerId) continue;
const scanner = scannerById(game, route.scannerId);
if (outputRouteReachesFacility(game, scanner)) return [];
}
const farmCount = game.eggFarms?.length || 0;
const exitCount = ['mixer', 'trash', 'truck'].filter(id => graph.facilityPorts.get(id)?.connected).length;
if (!farmCount) return ['Build at least one EGG before starting the next day.'];
if (!graph.metrics.farmOutputs) return ['Connect at least one EGG to a conveyor.'];
if (!game.scanners?.length) return ['Build at least one scanner and connect it to an EGG route.'];
if (!exitCount) return ['Connect at least one receiver exit: Mixer, Shredder, or Truck.'];
return ['Connect at least one EGG through a scanner to any valid exit.'];
}
function scannerName(scanner) {
if (scanner.kind === 'manual') return `S${(scanner.slot ?? 0) + 1}`;
return `Auto Scanner #${scanner.id}`;
}
export function facilityConnectionIssues(game) {
export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), options = {}) {
const includeRoutes = options.includeRoutes !== false;
const issues = [];
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
for (const id of MACHINE_FACILITY_IDS) {
const f = game.facilities[id];
if (!f) {
issues.push(`${labels[id] || id} is missing`);
continue;
}
if (!facilityEndpointCells(game, id).length) issues.push(`${labels[id] || id} receiver has no conveyor`);
if (!graph.facilityPorts.get(id)?.connected) issues.push(`${labels[id] || id} receiver has no conveyor`);
}
for (const scanner of game.scanners) {
if (!scannerPortHasConveyor(game, scanner, 'inputA')) issues.push(`${scannerName(scanner)} input has no conveyor`);
if (!scannerPortHasConveyor(game, scanner, 'left')) issues.push(`${scannerName(scanner)} left output has no conveyor`);
if (!scannerPortHasConveyor(game, scanner, 'right')) issues.push(`${scannerName(scanner)} right output has no conveyor`);
const ports = graph.scannerPorts.get(scanner.id) || {};
if (!ports.inputA?.connected) issues.push(`${scannerName(scanner)} input has no conveyor`);
if (!ports.left?.connected) issues.push(`${scannerName(scanner)} left output has no conveyor`);
if (!ports.right?.connected) issues.push(`${scannerName(scanner)} right output has no conveyor`);
if (!includeRoutes) continue;
for (const side of ['left', 'right']) {
if (scannerPortHasConveyor(game, scanner, side) && !outputRoute(game, side, scannerCenter(scanner), scanner.id)) {
issues.push(`${scannerName(scanner)} ${side} route is incomplete`);
}
if (ports[side]?.connected && !outputRoute(game, side, scannerCenter(scanner), scanner.id)) issues.push(`${scannerName(scanner)} ${side} route is incomplete`);
}
}
for (const farm of game.eggFarms) {
if (!routeFromFarmToScanner(game, farm)) issues.push(`Egg Farm #${farm.id} has no scanner route`);
if (includeRoutes) {
for (const farm of game.eggFarms) {
const route = routeFromFarmToScanner(game, farm);
if (!route?.scannerId) issues.push(`Egg Farm #${farm.id} has no scanner route`);
}
} else {
for (const farm of game.eggFarms) {
if (!(graph.farmOutputs.get(farm.id) || []).length) issues.push(`Egg Farm #${farm.id} has no output conveyor`);
}
}
return [...new Set(issues)];
}
export function facilityConnectionIssues(game) {
return minimumStartConnectionIssues(game);
}
export function factoryReady(game) {
return facilityConnectionIssues(game).length === 0;
}

View file

@ -159,6 +159,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (origin.type !== 'facility') continue;
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
const { entry, side } = nearestGridEdge(nextCenter);
if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true;
const draft = { ...origin.obj, entry: { ...entry }, side };
layoutFacilityOnEdge(draft, entry, side);
candidateRects.push({ id: origin.id, rect: rectOfFacility(draft) });
@ -191,6 +192,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (origin.type === 'facility') continue;
const col = origin.col + dcol, row = origin.row + drow;
if (!pointInGrid(col, row)) return fail('Selection outside grid');
if (game.blockedCells?.has?.(key(col, row))) return fail('Selection hits blocked ground');
const tk = key(col, row);
if (targetCells.has(tk)) return fail('Selection overlap');
if (cellOccupiedByNonSelected(col, row, selectedTokens)) return fail('Cell occupied');
@ -208,7 +210,7 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
for (const o of conveyorOrigins) {
const newK = key(o.col + dcol, o.row + drow);
game.conveyorTiles.add(newK);
game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null });
game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null, uses: 0 });
o.currentKey = newK;
}
for (const origin of game.groupDrag.origins) {

View file

@ -1,17 +1,33 @@
import { BUILD_TOOL_IDS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js';
import { BUILD_TOOL_IDS, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js';
import { yen } from '../core/utils.js';
import { TEXT } from '../core/text.js';
import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js';
import { buildPrice, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, upgradedMixerPrice, zundaTaxInfo } from './economy.js';
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, zundaTaxInfo } from './economy.js';
import { truckTarget } from './contracts.js';
import { conveyorSpeedForGame } from './cards.js';
import { maintenanceSummary, repairmanDailyCost } from './maintenance.js';
export function createUISystem({ game, ui, build, startGame, activeQueuedChick }) {
function displaySpeed() { return Math.round(Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED)); }
export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) {
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
}
function displaySpeed() { return Math.round(conveyorSpeedForGame(game)); }
function noAffordableBuildTools() {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return false;
let hasBuildableOption = false;
for (const id of BUILD_TOOL_IDS) {
if (MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id]) continue;
hasBuildableOption = true;
if (game.cash >= buildPrice(id, game)) return false;
}
return hasBuildableOption;
}
function updatePanels() {
updateToolButtons();
updateBuildStatus();
updateFacilityPanel();
updateContractPanel();
updateTurnSummary();
updateHistoryButtons();
@ -25,10 +41,12 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
for (const id of BUILD_TOOL_IDS) {
const btn = ui.buttons[id];
if (!btn) continue;
const price = buildPrice(id);
const price = buildPrice(id, game);
const priceSpan = btn.querySelector('span');
if (priceSpan) priceSpan.textContent = id === 'conveyor' ? `${yen(price)} / tile` : yen(price);
const uniqueAlreadyBuilt = MACHINE_FACILITY_IDS.includes(id) && !!game.facilities[id];
const unaffordable = game.cash < price;
btn.disabled = game.phase !== 'build' || unaffordable || uniqueAlreadyBuilt;
btn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || unaffordable || uniqueAlreadyBuilt;
if (btn.disabled && game.buildTool === id) game.buildTool = null;
btn.classList.toggle('unaffordable', unaffordable);
btn.classList.toggle('already-built', uniqueAlreadyBuilt);
@ -38,12 +56,29 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
? `${FACILITY_DEFS[id]?.name || id} already exists`
: '';
}
if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build';
if (ui.buttons.erase) ui.buttons.erase.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
if (ui.hireRepairmanButton) {
const hired = !!game.repairman?.hiredForNextDay;
const cost = repairmanDailyCost(game);
const blocked = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || hired || game.cash < cost;
ui.hireRepairmanButton.disabled = blocked;
ui.hireRepairmanButton.classList.toggle('active', hired);
ui.hireRepairmanButton.classList.toggle('unaffordable', game.cash < cost);
ui.hireRepairmanButton.title = hired ? 'Repairman is hired for the next production phase.' : `Hire for ${yen(cost)} for one day. Repairs 3% durability per second.`;
}
const cashout = noAffordableBuildTools();
ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout);
ui.buttons.erase?.classList.toggle('cashout-emphasis', cashout);
}
function updateBuildStatus() {
const cashout = noAffordableBuildTools();
ui.buildStatus.textContent = game.phase === 'build'
? TEXT.status.build(game.buildTool)
? (game.cardTargetPick?.pending
? 'Select an upgrade target on the map.'
: (game.cardDraft?.pending
? 'Choose upgrade card before building.'
: (cashout ? 'No affordable equipment. Start the next day or sell equipment to recover cash.' : TEXT.status.build(game.buildTool))))
: TEXT.status.sorting;
}
@ -53,18 +88,27 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
}
function updateTurnSummary() {
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)).length;
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length;
const issues = facilityConnectionIssues(game);
const tax = zundaTaxInfo(game.cash);
const tax = zundaTaxInfo(game.cash, 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;
ui.turnSummary.innerHTML = [
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | 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> | taxable ${yen(tax.taxable)} × ${tax.ratePercent}%`,
`Belt: <b>${displaySpeed()}px/s fixed</b> | Farms connected: <b>${connected}/${game.eggFarms.length}</b>`,
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | taxable ${yen(tax.taxable)} × ${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
lastTribute != null
? `Fairies tribute paid before Build: <b>-${yen(lastTribute)}</b>${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}`
: `Fairies tribute before Build: <b>-${yen(tribute.amount)}</b>${tribute.reduction ? ` | Flattery -${yen(tribute.reduction)}` : ''}`,
`Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Repairman: <b>${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}</b>`,
`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;
@ -93,20 +137,6 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
<span>Output x${offer.productionMultiplier} / Poop x${offer.poopMultiplier}</span>
</div>`;
}
function updateFacilityPanel() {
const obj = build.selectedObject();
if (!obj) {
ui.facilityPanel.className = 'facility-panel-empty';
ui.facilityPanel.innerHTML = TEXT.status.equipmentPanelEmpty;
return;
}
ui.facilityPanel.className = 'facility-card';
const lines = build.selectedInfoLines(obj);
if (obj.type === 'facility' && obj.id === 'trash') lines.push('Items are shredded; no stored contents are visualized.');
ui.facilityPanel.innerHTML = `<h3>${build.selectedTitle(obj)}</h3>${lines.map(x => `<p>${x}</p>`).join('')}<p><b>Upgrade choices appear in the clicked equipment menu.</b></p>`;
}
function updateUI() {
updateToolButtons();
ui.shell.classList.toggle('phase-running', game.phase === 'running');
@ -134,9 +164,9 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
ui.buttons.s1Right.disabled = game.phase !== 'running' || !activeQueuedChick(0);
ui.buttons.s2Left.disabled = game.phase !== 'running' || !activeQueuedChick(1);
ui.buttons.s2Right.disabled = game.phase !== 'running' || !activeQueuedChick(1);
const nextTax = zundaTaxInfo(game.cash).tax;
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA TAX ${yen(nextTax)}` : TEXT.actions.nextDay;
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !factoryReady(game);
const nextTax = zundaTaxInfo(game.cash, game).tax;
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA ${yen(nextTax)}` : TEXT.actions.nextDay;
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
}
function updatePriorityStrip() {
@ -144,7 +174,7 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
const target = truckTarget(game).toUpperCase();
ui.targetBrief.textContent = `TRUCK TARGET: ${target}`;
ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s`;
ui.speedBrief.title = `Conveyor speed is fixed by day. Cap: ${CONVEYOR_SPEED_MAX}px/s.`;
ui.speedBrief.title = `Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${CONVEYOR_SPEED_MAX}px/s.`;
const active = game.contractActive;
const offer = game.contractOffer;
if (active) {
@ -171,59 +201,40 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
function showTitle() {
ui.modalTitle.textContent = TEXT.versionTitle;
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon">M</span><strong>Male</strong><b>-></b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon">F</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon">P</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect all receiver ports before Next Day. One day is one run.</p>`;
ui.modalBody.innerHTML = `<div class="route-cards"><div class="route-card"><span class="route-icon male" aria-label="male chick">♂</span><strong>Male</strong><b>-></b><em>Mixer</em><small>S1: A</small></div><div class="route-card"><span class="route-icon female" aria-label="female chick">♀</span><strong>Female</strong><b>-></b><em>Truck</em><small>S1: D, then S2: right</small></div><div class="route-card"><span class="route-icon poop" aria-label="poop">💩</span><strong>Poop</strong><b>-></b><em>Shredder</em><small>S1: D, then S2: left</small></div></div><p>Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.</p>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button(TEXT.actions.startGame, startGame, 'primary-button'));
ui.modal.classList.remove('equipment-popover');
ui.modal.classList.remove('equipment-popover', 'gameover-modal');
ui.modal.classList.add('visible');
}
function showTurnResult(r) {
ui.modalTitle.textContent = `Day ${r.turn} Result`;
const contractHtml = r.contract ? `
<div class="result-section-title">Limited Contract</div>
<div class="result-grid">
<div><strong>Contract</strong><span>${r.contract.title}</span></div>
<div><strong>Rule</strong><span>${r.contract.route}</span></div>
<div><strong>Progress</strong><span>${r.contract.count} / ${r.contract.targetAmount}</span></div>
<div><strong>Bonus</strong><span>${r.contract.success ? '+' + yen(r.contract.bonus) : 'Failed'}</span></div>
</div>` : '<div class="result-section-title muted-title">No limited contract active this day.</div>';
const contractBonus = r.contract?.bonus || 0;
const dailyEarned = r.revenue;
ui.modalTitle.textContent = `Day ${r.turn} Settlement`;
ui.modalBody.innerHTML = `
<div class="result-section-title">Shipment / Daily Revenue</div>
<div class="result-grid">
<div><strong>Truck target</strong><span>${r.ship.target.toUpperCase()}</span></div>
<div><strong>Truck formula</strong><span>${r.ship.targetCount} × ${yen(r.ship.unitPrice)} = ${yen(r.ship.base)}</span></div>
<div><strong>Final truck revenue</strong><span>${yen(r.ship.adjusted)}</span></div>
<div><strong>Mixer income</strong><span>${yen(r.mixerRevenue)} / unit ${yen(upgradedMixerPrice(game))}</span></div>
<div><strong>Poop fine</strong><span>Mixer ${yen(r.mixerPoopFine)} / Shipment ${yen(r.truckPoopFine)}</span></div>
<div><strong>Wrong truck fine</strong><span>${yen(r.maleTruckFine)}</span></div>
<div><strong>ZUNDA TAX</strong><span>Opening tax paid on Next Day: -${yen(r.zundaTax || 0)}</span></div>
<div><strong>Explosion penalty</strong><span>${yen(r.explosionDamage)}</span></div>
<div><strong>Daily earned formula</strong><span>(${yen(r.mixerRevenue)} + ${yen(r.ship.adjusted)} + ${yen(contractBonus)}) ÷ 1 day = ${yen(dailyEarned)}</span></div>
<div><strong>Net profit formula</strong><span>${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}</span></div>
<div><strong>Cash</strong><span>${yen(r.cash)}</span></div>
</div>
${contractHtml}`;
<p class="formula-box compact">${yen(r.revenue)} - ${yen(r.penalty)} = ${yen(r.profit)}</p>
<p><strong>Final profit: ${yen(r.profit)}</strong></p>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button(TEXT.actions.buildPhase, hideModal, 'primary-button'));
ui.modal.classList.remove('equipment-popover');
ui.modalActions.appendChild(button('Choose Upgrade Card', beginCardDraft || hideModal, 'primary-button'));
ui.modal.classList.remove('equipment-popover', 'gameover-modal');
ui.modal.classList.add('visible');
}
function showGameOver() {
const processed = game.totals.processed;
const accuracy = processed ? Math.round(game.totals.correct / processed * 100) : 0;
const fs = finalScore(game);
ui.modalTitle.textContent = TEXT.phases.gameover;
ui.modalBody.innerHTML = `<p>Your cash went negative.</p>
<div class="formula-box"><strong>Final score formula</strong><br>Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base ÷ Days) = ceil(${yen(fs.base)} ÷ ${fs.days}) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>`;
ui.modalBody.innerHTML = `<p class="gameover-reason">Your cash went negative.</p>
<div class="result-grid gameover-summary"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div></div>
<details class="gameover-details"><summary>Score breakdown</summary>
<div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value × 0.5 + Contract bonus × 0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base ÷ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct×5 - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid gameover-breakdown"><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>
</details>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button(TEXT.actions.restart, startGame, 'primary-button'));
ui.modalActions.appendChild(button(TEXT.actions.restart || 'Restart', startGame, 'primary-button restart-button'));
ui.modal.classList.remove('equipment-popover');
ui.modal.classList.add('visible');
ui.modal.classList.add('visible', 'gameover-modal');
}
function button(text, onClick, className = 'primary-button') {
@ -236,16 +247,13 @@ export function createUISystem({ game, ui, build, startGame, activeQueuedChick }
}
function hideModal() {
ui.modal.classList.remove('visible', 'equipment-popover');
ui.modal.classList.remove('visible', 'equipment-popover', 'gameover-modal');
ui.modal.style.removeProperty('--popover-x');
ui.modal.style.removeProperty('--popover-y');
}
function checkGameOver() {
if (game.phase !== 'gameover' && game.cash < 0) {
game.phase = 'gameover';
showGameOver();
}
// Bankruptcy is intentionally checked only in completeTurn(), after all day-end revenue, tribute, and rescue-loan effects are settled.
}
return { updatePanels, updateUI, showTitle, showTurnResult, showGameOver, hideModal, checkGameOver };

View file

@ -58,9 +58,7 @@ h1, h2, p { margin: 0; }
.tool-button.danger.active { background: #ffdada; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); }
.primary-button { padding: 10px 12px; background: var(--green); color: var(--white); white-space: nowrap; }
.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; cursor: not-allowed; }
.mini-box, .facility-panel-empty, .facility-card { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; }
.facility-card h3 { color: var(--ink); font-size: 12px; margin: 0 0 6px; }
.facility-card p { margin: 4px 0; }
.mini-box { border: 3px solid var(--line); background: var(--white); padding: 12px; line-height: 1.45; color: var(--muted); font-size: 11px; }
.facility-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.facility-action { padding: 7px 9px; font-size: 9px; }
.facility-action.good { background: var(--green-soft); }
@ -180,3 +178,285 @@ h1, h2, p { margin: 0; }
margin: 6px 0;
font-size: 9px;
}
/* v15 pre-build card draft */
.card-choices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 220px));
justify-content: center;
align-items: stretch;
gap: 10px;
margin-top: 14px;
}
.card-choice {
min-height: 132px;
border: 3px solid var(--line);
background: #f7fff5;
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.16);
padding: 12px;
text-align: left;
cursor: pointer;
font-weight: 900;
}
.card-choice:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 rgba(16,32,21,.18); }
.card-choice strong { display: block; font-size: 14px; text-transform: uppercase; line-height: 1.2; }
.card-choice span { display: inline-block; margin: 8px 0; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; letter-spacing: .1em; }
.card-choice small { display: block; color: var(--muted); font-size: 11px; line-height: 1.35; }
.card-choice.rare { background: #fff0d1; box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(16,32,21,.16); }
.card-choice.ultra-rare { background: #ffe6f0; box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(16,32,21,.18); }
.card-choice.ultra-rare span { background: #c42424; color: #fff; }
.muted-card-note { color: var(--muted); font-weight: 700; }
.card-targets { display: grid; gap: 8px; margin-top: 14px; }
.card-target-button { border: 3px solid var(--line); background: #f7fff5; padding: 10px 12px; text-align: left; cursor: pointer; font-weight: 900; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
.card-target-button:hover { background: var(--green-soft); }
/* v19 readability pass */
body { font-size: 16px; }
.hud-card span { font-size: 11px; }
.hud-card strong { font-size: clamp(18px, 1.5vw, 25px); }
.priority-strip > div { font-size: 14px; }
.panel-head h1 { font-size: 18px; }
.panel-head p { font-size: 11px; }
.build-panel h2 { font-size: 13px; }
.tool-button strong { font-size: 14px; }
.tool-button span { font-size: 11px; }
.mini-box, .contract-card { font-size: 12px; }
.sort-button { font-size: 14px; }
.modal-card h2 { font-size: 29px; }
.card-choice strong { font-size: 16px; }
.card-choice small { font-size: 13px; }
.tool-button.unaffordable, .tool-button.already-built { opacity: .42; filter: grayscale(.7); }
.hover-tooltip {
position: fixed;
z-index: 30;
width: min(320px, calc(100vw - 20px));
display: none;
border: 3px solid var(--line);
background: rgba(255,255,255,.96);
color: var(--ink);
box-shadow: 6px 6px 0 rgba(16,32,21,.18);
padding: 10px;
pointer-events: none;
line-height: 1.35;
}
.hover-tooltip.visible { display: block; }
.hover-tooltip strong { display: block; font-size: 15px; text-transform: uppercase; margin-bottom: 5px; }
.hover-tooltip em { display: block; font-style: normal; color: var(--green); font-weight: 900; margin-bottom: 7px; }
.hover-tooltip span { display: block; color: var(--muted); font-size: 12px; }
.card-choice.dud {
background: #eeeeee;
color: #777;
filter: grayscale(1);
border-style: dashed;
box-shadow: 5px 5px 0 rgba(16,32,21,.10);
}
.card-choice.dud span { background: #f8f8f8; }
.card-choice.flung {
pointer-events: none;
transform: translate(var(--fling-x, 480px), var(--fling-y, -320px)) rotate(var(--fling-r, 68deg)) scale(.68);
opacity: 0;
transition: transform .34s cubic-bezier(.18,.84,.26,.99), opacity .28s ease-in;
}
.route-card .route-icon.male,
.route-card .route-icon.female,
.route-card .route-icon.poop {
display: inline-grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 999px;
border: 3px solid var(--line, #102015);
background: var(--white, #fff);
line-height: 1;
}
.route-card .route-icon.male { color: #2477ff; background: #d9ecff; }
.route-card .route-icon.female { color: #d62a85; background: #ffe0f0; }
.route-card .route-icon.poop { color: #7b4a23; background: #fff0d1; font-size: 24px; }
.restart-button { min-width: 160px; }
.restart-fallback-note { margin-top: 12px; color: var(--muted, #526456); font-weight: 700; }
/* v24.4 hover-only build-button flavor / cashout guidance / compact game-over */
.tool-button .tool-flavor {
display: block;
max-height: 0;
opacity: 0;
overflow: hidden;
margin-top: 0;
color: var(--muted);
font-size: 8px;
line-height: 1.22;
text-transform: none;
letter-spacing: 0;
transition: max-height .12s ease, opacity .12s ease, margin-top .12s ease;
}
.tool-button:hover .tool-flavor {
max-height: 42px;
opacity: 1;
margin-top: 5px;
}
.large-tools .tool-button { min-height: 58px; }
.large-tools .tool-button:hover { min-height: 78px; }
.history-tools .tool-button.small { min-height: 40px; }
.primary-button.cashout-emphasis,
.tool-button.cashout-emphasis {
opacity: 1 !important;
filter: none !important;
animation: cashoutPulse 0.9s ease-in-out infinite alternate;
}
.primary-button.cashout-emphasis {
box-shadow: inset 0 0 0 4px #fff0d1, 6px 6px 0 rgba(16,32,21,.22);
}
.tool-button.cashout-emphasis {
background: #fff0d1;
box-shadow: inset 0 0 0 3px var(--danger), 6px 6px 0 rgba(16,32,21,.20);
}
@keyframes cashoutPulse {
from { transform: translate(0, 0); }
to { transform: translate(-1px, -1px); }
}
.modal.gameover-modal { padding: 12px; }
.modal.gameover-modal .modal-card {
width: min(640px, 92vw);
max-height: min(82vh, 720px);
overflow: auto;
padding: 16px;
}
.modal.gameover-modal .modal-card h2 {
font-size: 22px;
margin-bottom: 8px;
}
.gameover-reason { font-weight: 900; color: var(--danger); margin-bottom: 8px; }
.gameover-summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
.gameover-breakdown {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
.modal.gameover-modal .result-grid div { padding: 7px; }
.modal.gameover-modal .result-grid strong { font-size: 9px; margin-bottom: 3px; }
.modal.gameover-modal .result-grid span { font-size: 12px; }
.gameover-details {
margin-top: 10px;
border: 3px solid var(--line);
background: #f7fff5;
padding: 8px;
}
.gameover-details summary {
cursor: pointer;
font-weight: 900;
text-transform: uppercase;
}
.modal.gameover-modal .formula-box.compact { font-size: 9px; line-height: 1.35; }
@media (max-width: 720px) {
.gameover-summary, .gameover-breakdown { grid-template-columns: 1fr 1fr; }
}
/* v27.0 cleanup and stable build-menu tooltip pass */
body { font-size: 18px; }
.build-panel { width: clamp(330px, 26vw, 420px); }
.panel-head h1 { font-size: 20px; }
.panel-head h1 span { font-size: 12px; }
.panel-head p { font-size: 13px; line-height: 1.35; }
.build-panel h2 { font-size: 14px; }
.priority-strip > div { font-size: 15px; }
.hud-card span { font-size: 12px; }
.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); }
.mini-box, .contract-card { font-size: 14px; line-height: 1.45; }
.compact-rules .mini-box { font-size: 13px; }
.tool-button {
position: relative;
overflow: visible;
min-height: 62px;
padding: 10px;
}
.large-tools .tool-button { min-height: 62px; }
.large-tools .tool-button:hover { min-height: 62px; }
.tool-button strong { font-size: 15px; line-height: 1.15; }
.tool-button span { font-size: 12px; }
.tool-button .tool-flavor {
display: none;
position: absolute;
z-index: 60;
left: 0;
top: calc(100% + 7px);
width: min(300px, calc(100vw - 40px));
max-height: none;
opacity: 1;
overflow: visible;
margin: 0;
padding: 10px 12px;
border: 3px solid var(--line);
background: rgba(255,255,255,.98);
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
font-size: 13px;
line-height: 1.35;
text-transform: none;
letter-spacing: 0;
pointer-events: none;
transition: none;
}
.tool-button .tool-flavor::before {
content: '';
position: absolute;
left: 16px;
top: -10px;
width: 16px;
height: 16px;
border-left: 3px solid var(--line);
border-top: 3px solid var(--line);
background: rgba(255,255,255,.98);
transform: rotate(45deg);
}
.tool-button:hover .tool-flavor {
display: block;
max-height: none;
opacity: 1;
margin: 0;
}
.primary-button { font-size: 16px; padding: 13px 16px; }
#nextTurnButton { min-height: 56px; font-size: 18px; }
@keyframes cashoutPulse {
from { box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(16,32,21,.18); filter: brightness(1); }
to { box-shadow: inset 0 0 0 5px #fff0d1, 4px 4px 0 rgba(16,32,21,.20); filter: brightness(1.08); }
}
.card-choices { grid-template-columns: repeat(auto-fill, minmax(230px, 270px)); gap: 14px; }
.card-choice { min-height: 164px; padding: 15px; }
.card-choice strong { font-size: 18px; }
.card-choice span { font-size: 11px; }
.card-choice small { font-size: 15px; line-height: 1.4; }
.muted-card-note { font-size: 15px; line-height: 1.45; }
.modal-card { width: min(980px, 96vw); }
.modal-card p, .modal-card li { font-size: 16px; }
.modal-actions .reroll-button {
order: 99;
flex: 1 1 100%;
min-height: 64px;
margin-top: 8px;
font-size: 20px;
text-align: center;
justify-content: center;
background: #fff0d1;
box-shadow: inset 0 0 0 4px var(--green), 6px 6px 0 rgba(16,32,21,.20);
}
.modal-actions .reroll-button:disabled { box-shadow: 4px 4px 0 rgba(16,32,21,.12); }
.hover-tooltip strong { font-size: 17px; }
.hover-tooltip span { font-size: 14px; }
.sort-button { font-size: 16px; min-height: 56px; }
.control-dock { grid-template-columns: repeat(4, 172px); }
@media (max-width: 1180px) {
.build-panel { width: auto; }
.control-dock { grid-template-columns: repeat(2, 172px); }
}