This commit is contained in:
33333-33333 2026-06-11 14:54:21 +09:00
commit b13e07a074
29 changed files with 166 additions and 4276 deletions

View file

@ -1,114 +0,0 @@
# Balance Patch Notes v27.14
## Event system
The old shipment-goal terminology has been replaced by one-day Events in player-facing UI and balance notes. Internal names are mostly event-based; a few generic helper concepts are preserved only where they are not player-facing.
Events start appearing from Day 8 with a 10% chance. An offered event is forced and applies only to the next running day.
Implemented events:
1. Premium Female Shipment
- Female -> Truck event.
- Target = estimated total production x 0.3.
- Success pays an event bonus.
2. Contaminant Sample Export
- Poop is valid Truck cargo.
- All poop income is doubled.
- Target = estimated total production x 0.3.
- Success pays an event bonus.
3. Health Inspection
- Poop mistake fines are doubled.
- Shredding enough poop pays an event bonus.
4. Conveyor Jam Warning
- Jam explosion threshold becomes 85% congestion.
- Finishing with zero explosions pays an event bonus.
5. Power Saving Order
- Conveyor speed x0.8.
- Auto Scanner cooldown x1.2.
- Settlement pays a flat subsidy.
6. Overtime Day
- Day length becomes 80 seconds.
- No extra effect.
7. Short Shift
- Day length becomes 40 seconds.
- Next Fairies tribute is reduced by 10%.
8. Chick Boom
- Egg interval x0.65.
- Poop rate +5 percentage points.
9. Rotten Feed
- Poop rate +20 percentage points.
- Shredder poop payout x1.5.
10. Maintenance Audit
- No penalty.
- If no maintained machine is below 50% remaining durability, settlement pays a bonus.
## Related balance behavior
- Shredder Upgrade payout is affected by Event poop/shredder multipliers.
- COMPOSTER payout is also affected by Event shredder payout multipliers.
- Poop target Truck events no longer fine poop when poop is the valid cargo.
- Chemical Weapons Subsidy remains 70 poop / 1200 yen.
- Fairies tribute is paid on Next Day press.
- Normal Loan card and repayment state are removed. Emergency Loan remains.
- The old tax system is removed. Fairies tribute is the only recurring day-start levy.
## v27.14 consistency/debug changes
- Debug UI restored intentionally for balancing builds. HTML/CSS/JS include comments stating it should not be removed during cleanup unless explicitly requested.
- Debug card selector is grouped by gameplay genre.
- Debug single-card grant supports selecting the target for target-consuming build-phase cards.
- Debug All Cards still uses automatic valid targets.
- Removed unused special weight logic left over from old SPARE PARTS BIN behavior.
- Removed unused Egg Farm cash upgrade cost data; Egg upgrades are card-based.
- Shredder Upgrade now sums upgrades across all Shredder facilities, not only the original primary Shredder.
- Maintenance and repair target collection now includes additional Mixer/Shredder facilities.
- Maintenance Audit low-durability checks now include additional Mixer/Shredder facilities.
- Fixed duplicate Emergency Loan button in the bankruptcy decision modal.
- Fixed stale debug-removal CSS fragments left from the previous audit pass.
- Fixed corrupted score-breakdown formula text.
## v27.15 dark factory visual patch
- Wired `assets/dark_factory/facilities/` sprites into canvas rendering.
- Added pixelated rendering for facility sprites and dark factory grid/floor colors.
- Changed CSS theme from green/white panels to dark industrial panels with amber/red/blue accents.
- Moved UI emphasis: status HUD across the top, event/status strip down the left, build/equipment panel on the right, sorting controls at the bottom.
- Kept user-supplied chick/poop slots unmodified. Chick art is not auto-requested; existing poop compatibility path remains.
- Debug UI remains intentionally present for balancing builds.
## v27.17 verified visual patch
- Versioned CSS and JavaScript launch filenames prevent the old visual bundle from being reused from cache.
- Removed all player-facing remnants of wage-based Repairman hiring.
- Maintenance Room remains a 2×2 buildable object and dispatches one Repairman per room.
- Conveyor slats now animate perpendicular to travel, with blue-green rubber on Boost Conveyors.
- DEBUG collapsed width reduced to 58 px.
- Facility sprite scale aligned to grid-object pixel density.
- Scanner ducts, blocked-cell coverage, chick sex colors/symbols, metal/grime decoration, and card texture icons verified in browser.
## v27.18 UI and routing patch
- Moved event, belt speed, combo, and truck target indicators to a non-overlapping lower-left group.
- Reworked T-junction branch mode as explicit output selection: random or a valid fixed exit.
- Lightened floor tiles, added deterministic green-fluid stains, increased conveyor arrow contrast, and centered blocked-cell X marks.
- Converted card icons into translucent background watermarks and normalized modal button styling.
- Replaced Maintenance Room art with storage/break-room pixel art.
## v27.19 land/UI/dynamite patch
- Starting blocked ratio: 3%.
- Purchased-lot blocked ratio: base 3% + 0.5 percentage points per purchase, max 10%.
- Dynamite target selection is map-driven and consumes the card correctly after any blast.
- Duplicate event/status information removed.

View file

@ -1,20 +0,0 @@
# v27.19 verification
## Automated checks
- All source JavaScript files pass `node --check`.
- Production bundle `dist/game-v27.19.js` builds successfully.
- Initial blocked-cell generation: 6 / 200 cells = 3%.
- Purchased-lot ratios: purchase 1 = 3.5%, purchase 2 = 4.0%, purchase 14+ = 10% cap.
- A 10x10 lot at the cap generates 10 blocked cells.
- Dynamite empty-space click leaves target mode active.
- Clicking a chosen blocked cell removes that exact cell and consumes one charge.
- Escape after at least one blast finishes and consumes the card, preventing reuse.
- Browser check: no console/page errors, duplicate right-side event panel absent, Status contains no event/belt/combo/truck-target duplicates.
- Browser check: NEXT DAY Fairies fee remains inside the button.
## Visual checks
- Green floor residue is sparse and splattered.
- Blocked cells use subdued black/yellow hazard striping.
- Initial Status reports 6 blocked cells.

View file

@ -1,19 +1,17 @@
# Chick Sorter v27.19 — Land and UI Patch
# Chick Sorter
Serve this folder with a static HTTP server and open `index.html`.
The launch page uses cache-busted files:
The launch page uses the unversioned app assets:
- `styles-v27.19.css?build=2719`
- `dist/game-v27.19.js?build=2719`
A `v27.19 VERIFIED` badge appears near the upper-right when the current build is loaded.
- `styles.css`
- `dist/game.js`
## Build
Run `npm install` once, then `npm run build` after editing JavaScript or `styles.css`.
Run `npm install` once, then `npm run build` after editing JavaScript or `styles.css`. Without a local Node/npm toolchain, `dist/game.js` remains a source-module wrapper for local static serving.
## v27.19 changes
## Current changes
- Initial no-build tiles are 3% of the starting 20x10 land.
- Each purchased 10x10 lot increases its own no-build ratio by 0.5 percentage points, capped at 10%.

View file

@ -5,10 +5,7 @@ This patch adds a **dark factory** art set without changing runtime references y
## Folder guide
- `assets/dark_factory/facilities/` : new factory equipment sprites
- `assets/dark_factory/previews/` : visual overview sheet
- `assets/ui/` : future UI asset buckets (buttons / panels / icons / debug)
- `assets/chicks/` : reserved for user-supplied chick sprites
- `assets/poop/` : reserved for user-supplied poop sprites
- `assets/_legacy/` : copied backup of older loose files
- `assets/images/` : user-supplied chick and poop sprites used by the renderer
- `assets/images/` : left untouched for compatibility with the current build
## Notes

View file

@ -1,3 +0,0 @@
# Image assets
`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`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1 +0,0 @@
Reserved for user-supplied chick sprites. Recommended base: 16x16 world sprites / 32x32 UI icons.\n

View file

@ -1 +0,0 @@
Reserved for user-supplied poop sprites. Recommended base: 16x16 world sprites / 32x32 UI icons.\n

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

View file

@ -1 +0,0 @@
Future UI asset buckets. Nothing is wired yet. Use 32x32 as the base size for non-grid UI objects.\n

59
dist/game-v27.17.js vendored

File diff suppressed because one or more lines are too long

59
dist/game-v27.18.js vendored

File diff suppressed because one or more lines are too long

51
dist/game-v27.19.js vendored

File diff suppressed because one or more lines are too long

61
dist/game.js vendored

File diff suppressed because one or more lines are too long

View file

@ -6,13 +6,13 @@
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Chick Sorter v27.19 — Land and UI Patch</title>
<link rel="stylesheet" href="./styles-v27.19.css?build=2719" />
<title>Chick Sorter v27.20 — Card and Expansion Patch</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app">
<section id="gameShell" class="game-shell" data-build="v27.19" aria-label="Game screen">
<div class="build-stamp" aria-label="loaded build">v27.19 VERIFIED</div>
<section id="gameShell" class="game-shell" data-build="v27.20" aria-label="Game screen">
<div class="build-stamp" aria-label="loaded build">v27.20 VERIFIED</div>
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
<div class="hud hud-top-left" aria-label="Run status">
@ -40,7 +40,7 @@
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v27.19</span></h1>
<h1>CHICK SORTER <span>v27.20</span></h1>
<p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
@ -110,6 +110,6 @@
</div>
</div>
<script type="module" src="./dist/game-v27.19.js?build=2719"></script>
<script type="module" src="./dist/game.js"></script>
</body>
</html>

4
package-lock.json generated
View file

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

View file

@ -1,11 +1,11 @@
{
"name": "zunda-shiwake",
"version": "27.19.0",
"version": "27.20.0",
"private": true,
"type": "module",
"scripts": {
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "npm run clean && node -e \"require('fs').copyFileSync('styles.css', 'styles-v27.19.css')\" && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game-v27.19.js"
"build": "npm run clean && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game.js"
},
"devDependencies": {
"esbuild": "^0.25.0"

View file

@ -1,7 +1,7 @@
// Central balance sheet for tuning gameplay.
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
export const BALANCE = {
version: 'v27.19 LAND + UI PATCH',
version: 'v27.20 CARD + EXPANSION PATCH',
time: {
daySeconds: 60,
farmShutdownGraceSeconds: 10
@ -24,9 +24,9 @@ export const BALANCE = {
}
},
map: {
blockedRatio: 0.03,
blockedExpansionIncrement: 0.005,
blockedRatioMax: 0.10
blockedRatio: 0.00,
blockedExpansionIncrement: 0.0075,
blockedRatioMax: 0.12
},
economy: {
income: {
@ -253,7 +253,7 @@ export const BALANCE = {
rarity: 'rare',
type: 'cellAction',
target: 'blockedCell',
description: 'Choose and remove up to 3 unbuildable cells.',
description: 'Choose and remove up to 10 unbuildable cells.',
tags: ['RISK', 'RARE', 'ONE-SHOT']
},
{

View file

@ -25,7 +25,7 @@ export function resetOwnedCells(game) {
game.ownedChunks = new Set();
game.gridChunkOriginY = 0;
game.gridExpansionPurchases = 0;
game.gridExpansionPurchasesByDirection = { right: 0, up: 0 };
game.gridExpansionPurchasesByDirection = { right: 0, up: 0, down: 0 };
for (let chunkY = 0; chunkY < INITIAL_CHUNKS_Y; chunkY += 1) {
for (let chunkX = 0; chunkX < INITIAL_CHUNKS_X; chunkX += 1) game.ownedChunks.add(chunkKey(chunkX, chunkY));
}
@ -83,15 +83,17 @@ export function expansionLots(game) {
const { chunkX, chunkY } = parseChunkKey(k);
const right = { chunkX: chunkX + 1, chunkY, direction: 'right' };
const up = { chunkX, chunkY: chunkY - 1, direction: 'up' };
for (const candidate of [right, up]) {
if (candidate.chunkX < 0 || candidate.chunkY > 0) continue;
const down = { chunkX, chunkY: chunkY + 1, direction: 'down' };
for (const candidate of [right, up, down]) {
if (candidate.chunkX < 0) continue;
const ck = chunkKey(candidate.chunkX, candidate.chunkY);
if (owned.has(ck) || candidates.has(ck)) continue;
candidates.set(ck, lotFromChunk(game, candidate.chunkX, candidate.chunkY, candidate.direction));
}
}
const directionOrder = { right: 0, up: 1, down: 2 };
return [...candidates.values()].sort((a, b) => {
const dir = (a.direction === b.direction) ? 0 : (a.direction === 'right' ? -1 : 1);
const dir = (directionOrder[a.direction] ?? 9) - (directionOrder[b.direction] ?? 9);
if (dir) return dir;
if (a.chunkY !== b.chunkY) return a.chunkY - b.chunkY;
return a.chunkX - b.chunkX;

View file

@ -21,7 +21,7 @@ function mulberry32(seed) {
}
export function blockedRatioForExpansionPurchase(purchaseCount, { base = 0.03, increment = 0.005, max = 0.10 } = {}) {
export function blockedRatioForExpansionPurchase(purchaseCount, { base = 0.00, increment = 0.0075, max = 0.12 } = {}) {
const count = Math.max(0, Math.floor(Number(purchaseCount) || 0));
return Number(Math.min(max, base + count * increment).toFixed(6));
}

View file

@ -122,7 +122,7 @@ function refreshDebugTargetSelect() {
}
const auto = document.createElement('option');
auto.value = '__auto__';
auto.textContent = card?.id === 'dynamite' ? 'Auto: first 3 blocked cells' : 'Auto: first valid target';
auto.textContent = card?.id === 'dynamite' ? 'Auto: first 10 blocked cells' : 'Auto: first valid target';
ui.debug.targetSelect.appendChild(auto);
for (const target of targets) {
const option = document.createElement('option');

View file

@ -150,15 +150,15 @@ function floorCellHash(col, row) {
}
function drawFloorSlime(ctx, x, y, col, row) {
const h = floorCellHash(col, row);
// Sparse, deterministic splashes: about 3% of owned floor cells.
if (h % 31 !== 0) return;
// Sparse, deterministic splashes: roughly 1.5% of owned floor cells.
if (h % 67 !== 0) return;
const ox = 10 + ((h >>> 4) % Math.max(1, GRID.cell - 22));
const oy = 10 + ((h >>> 9) % Math.max(1, GRID.cell - 22));
const angle = ((h >>> 14) % 16) * Math.PI / 16;
ctx.save();
ctx.globalAlpha = 0.46;
ctx.fillStyle = '#4d9638';
ctx.strokeStyle = 'rgba(30,66,27,.48)';
ctx.globalAlpha = 0.38;
ctx.fillStyle = '#4a8d37';
ctx.strokeStyle = 'rgba(30,66,27,.34)';
ctx.lineWidth = 1;
// Small irregular core rather than a large puddle.
@ -175,16 +175,16 @@ function drawFloorSlime(ctx, x, y, col, row) {
ctx.stroke();
// Several separated droplets produce a splattered silhouette.
const droplets = 3 + ((h >>> 24) % 4);
const droplets = 4 + ((h >>> 24) % 5);
for (let i = 0; i < droplets; i += 1) {
const a = angle + i * 2.17 + ((h >>> (i + 8)) & 3) * .18;
const dist = 8 + ((h >>> (i + 11)) & 7);
const radius = 1.1 + ((h >>> (i + 17)) & 3) * .45;
const dist = 10 + ((h >>> (i + 11)) & 10);
const radius = 0.9 + ((h >>> (i + 17)) & 3) * .35;
ctx.beginPath();
ctx.arc(x + ox + Math.cos(a) * dist, y + oy + Math.sin(a) * dist * .72, radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = 'rgba(151,218,101,.40)';
ctx.fillStyle = 'rgba(151,218,101,.26)';
ctx.beginPath();
ctx.arc(x + ox - 2, y + oy - 2, 1.7, 0, Math.PI * 2);
ctx.fill();
@ -248,36 +248,32 @@ function drawGrid(ctx, game) {
const c = cellCenter(p.col, p.row);
const x = c.x - GRID.cell / 2;
const y = c.y - GRID.cell / 2;
// Muted floor-level hazard striping. It remains readable without dominating machines.
// Muted floor-level warning striping that sits close to the floor tone range.
ctx.save();
ctx.beginPath();
ctx.rect(Math.round(x + 1), Math.round(y + 1), GRID.cell - 2, GRID.cell - 2);
ctx.clip();
ctx.fillStyle = 'rgba(42,45,48,.42)';
ctx.fillStyle = 'rgba(70,72,76,.16)';
ctx.fillRect(Math.round(x), Math.round(y), GRID.cell, GRID.cell);
ctx.lineWidth = 8;
ctx.strokeStyle = 'rgba(196,163,79,.38)';
for (let stripe = -GRID.cell; stripe < GRID.cell * 2; stripe += 16) {
ctx.lineWidth = 6;
ctx.strokeStyle = 'rgba(165,145,73,.24)';
for (let stripe = -GRID.cell; stripe < GRID.cell * 2; stripe += 14) {
ctx.beginPath();
ctx.moveTo(x + stripe, y + GRID.cell);
ctx.lineTo(x + stripe + GRID.cell, y);
ctx.stroke();
}
ctx.lineWidth = 8;
ctx.strokeStyle = 'rgba(20,23,26,.32)';
for (let stripe = -GRID.cell + 8; stripe < GRID.cell * 2; stripe += 16) {
ctx.strokeStyle = 'rgba(27,29,33,.22)';
for (let stripe = -GRID.cell + 7; stripe < GRID.cell * 2; stripe += 14) {
ctx.beginPath();
ctx.moveTo(x + stripe, y + GRID.cell);
ctx.lineTo(x + stripe + GRID.cell, y);
ctx.stroke();
}
ctx.restore();
ctx.strokeStyle = 'rgba(17,19,22,.72)';
ctx.lineWidth = 2;
ctx.strokeRect(Math.round(x) + 1, Math.round(y) + 1, GRID.cell - 2, GRID.cell - 2);
ctx.strokeStyle = 'rgba(224,194,112,.24)';
ctx.strokeStyle = 'rgba(20,23,26,.42)';
ctx.lineWidth = 1;
ctx.strokeRect(Math.round(x) + 4, Math.round(y) + 4, GRID.cell - 8, GRID.cell - 8);
ctx.strokeRect(Math.round(x) + 1.5, Math.round(y) + 1.5, GRID.cell - 3, GRID.cell - 3);
}
ctx.restore();
}
@ -304,7 +300,8 @@ function drawExpansionOffers(ctx, game) {
ctx.fillStyle = affordable ? THEME.green : THEME.danger;
ctx.font = '900 16px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(lot.direction === 'right' ? 'BUY RIGHT LOT' : 'BUY UPPER LOT', cx, cy - 12);
const lotLabel = lot.direction === 'right' ? 'BUY RIGHT LOT' : (lot.direction === 'down' ? 'BUY LOWER LOT' : 'BUY UPPER LOT');
ctx.fillText(lotLabel, cx, cy - 12);
ctx.fillStyle = THEME.ink;
ctx.font = '900 16px ui-monospace, monospace';
ctx.fillText(`${yen(cost)} / 10×10`, cx, cy + 15);
@ -663,27 +660,47 @@ 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)) {
drawDirtOverlay(ctx, c.x - 29, c.y - 29, 58, 58, farm);
const outlets = 1 + Math.max(0, Number(farm.extraEggOutlet) || 0);
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x + 9, c.y + 12, 30, 18, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`O${outlets}`, c.x + 24, c.y + 24);
drawSelection(ctx, game, 'eggFarm', farm.id, c.x, c.y, 68, 68);
ctx.restore(); return;
ctx.fillStyle = '#5a6470';
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3;
rect(ctx, c.x - 27, c.y - 27, 54, 54, true, true);
ctx.fillStyle = '#6f5130';
ctx.strokeStyle = '#2f2112';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.ellipse(c.x, c.y + 3, 19, 12, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = '#a77b4b';
ctx.lineWidth = 2;
for (let i = -16; i <= 16; i += 6) {
ctx.beginPath();
ctx.moveTo(c.x - 18, c.y + i * 0.1);
ctx.quadraticCurveTo(c.x + i, c.y - 10 + Math.abs(i) * 0.2, c.x + 18, c.y + i * 0.08);
ctx.stroke();
}
const eggs = [{ x: -8, y: 1 }, { x: 0, y: -3 }, { x: 8, y: 2 }];
for (const egg of eggs) {
ctx.fillStyle = '#e8e2cf';
ctx.strokeStyle = '#8d8475';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(c.x + egg.x, c.y + egg.y, 4.8, 6.2, 0.2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
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 19px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText('EGG', c.x, c.y - 4);
const outlets = 1 + Math.max(0, Number(farm.extraEggOutlet) || 0);
ctx.font = '900 11px ui-monospace, monospace'; ctx.fillStyle = THEME.ink; ctx.fillText(`L${farm.level} O${outlets}`, c.x, c.y + 13); ctx.fillText(`${farm.nextSpawn.toFixed(1)}s`, c.x, c.y + 26);
ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x + 8, c.y + 11, 30, 18, true, true);
ctx.fillStyle = THEME.ink; ctx.font = '900 10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.fillText(`O${outlets}`, c.x + 23, c.y + 23);
ctx.fillStyle = '#d5e35a'; ctx.font = '900 9px ui-monospace, monospace'; ctx.fillText(`L${farm.level}`, c.x - 13, c.y - 15);
const shutter = Math.max(0, Math.min(1, farm.shutterProgress || 0));
if (shutter > 0) {
const h = 56 * shutter;
ctx.fillStyle = THEME.ink;
rect(ctx, c.x - 28, c.y - 28, 56, h, true, false);
ctx.strokeStyle = THEME.white; ctx.lineWidth = 2;
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(); }
const h = 54 * shutter;
ctx.fillStyle = 'rgba(10,12,15,.86)';
rect(ctx, c.x - 27, c.y - 27, 54, h, true, false);
ctx.strokeStyle = 'rgba(230,237,242,.64)'; ctx.lineWidth = 2;
for (let yy = c.y - 23; yy < c.y - 27 + h; yy += 8) { ctx.beginPath(); ctx.moveTo(c.x - 23, yy); ctx.lineTo(c.x + 23, yy); ctx.stroke(); }
if (shutter >= 1) { ctx.fillStyle = THEME.white; ctx.font = '900 12px ui-monospace, monospace'; ctx.fillText('SHUT', c.x, c.y + 4); }
}
drawDirtOverlay(ctx, c.x - 28, c.y - 28, 56, 56, farm);
@ -952,11 +969,16 @@ function drawMixer(ctx, game, m = game.facilities.mixer) {
ctx.fillText(`MIXER L${m.level}`, m.x + 14, m.y + 18);
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + 14, m.y + 34);
const drew = drawFacilityTexture(ctx, assets.mixerCompact.loaded ? assets.mixerCompact : assets.mixer, m, 2.05);
if (!drew) {
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 + m.h / 2 + 6, 11 + i * 8, 0, Math.PI * 1.5); ctx.stroke(); }
}
ctx.fillStyle = '#1a2028'; ctx.strokeStyle = '#87bfff'; ctx.lineWidth = 4;
rect(ctx, m.x + 44, m.y + 34, 76, 50, true, true);
ctx.strokeStyle = '#4aa3ff'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(m.x + 82, m.y + 58, 15, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(m.x + 82, m.y + 43); ctx.lineTo(m.x + 82, m.y + 73); ctx.stroke();
ctx.beginPath(); ctx.moveTo(m.x + 69, m.y + 56); ctx.lineTo(m.x + 95, m.y + 60); ctx.stroke();
ctx.fillStyle = '#59687a'; ctx.strokeStyle = '#0d1014';
rect(ctx, m.x + 56, m.y + 24, 12, 10, true, true);
rect(ctx, m.x + 96, m.y + 24, 12, 10, true, true);
ctx.fillStyle = '#7ea9dc'; ctx.fillRect(m.x + 48, m.y + 42, 68, 5);
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m);
drawSelection(ctx, game, 'facility', m.id, m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
@ -969,11 +991,23 @@ function drawTrash(ctx, game, t = game.facilities.trash) {
drawFacilityDetails(ctx, t, THEME.wasteGreen);
ctx.fillStyle = THEME.white; ctx.font = '900 14px ui-monospace, monospace'; ctx.textAlign = 'left';
ctx.fillText(`SHREDDER L${t.level}`, t.x + 14, t.y + 18);
const drew = drawFacilityTexture(ctx, assets.shredderCompact.loaded ? assets.shredderCompact : assets.shredder, t, 1.85);
if (!drew) {
ctx.fillStyle = '#0a0b0e';
for (let i = 0; i < 7; i += 1) ctx.fillRect(t.x + 40 + i * 12, t.y + 42, 6, Math.max(20, t.h - 72));
ctx.fillStyle = '#181d20'; ctx.strokeStyle = '#9fd473'; ctx.lineWidth = 4;
rect(ctx, t.x + 38, t.y + 36, 92, 22, true, true);
ctx.fillStyle = '#262d30'; ctx.strokeStyle = '#0d1014';
rect(ctx, t.x + 46, t.y + 58, 76, 18, true, true);
ctx.fillStyle = '#111418';
for (let i = 0; i < 7; i += 1) {
const tx = t.x + 51 + i * 10;
ctx.beginPath();
ctx.moveTo(tx, t.y + 58);
ctx.lineTo(tx + 5, t.y + 68);
ctx.lineTo(tx + 10, t.y + 58);
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = '#6f7e8d';
rect(ctx, t.x + 54, t.y + 27, 16, 9, true, true);
rect(ctx, t.x + 98, t.y + 27, 16, 9, true, true);
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', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);

View file

@ -61,7 +61,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function resolveExpansionRequest(request = 'right') {
if (request && typeof request === 'object' && Number.isFinite(request.chunkX) && Number.isFinite(request.chunkY)) return request;
const direction = ['right', 'up'].includes(request) ? request : 'right';
const direction = ['right', 'up', 'down'].includes(request) ? request : 'right';
return expansionLot(game, direction);
}
@ -78,7 +78,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
record(game);
spendCash(game, cost);
if (!game.gridExpansionPurchasesByDirection) game.gridExpansionPurchasesByDirection = { right: 0, up: 0 };
if (!game.gridExpansionPurchasesByDirection) game.gridExpansionPurchasesByDirection = { right: 0, up: 0, down: 0 };
const originY = game.gridChunkOriginY || 0;
if (offer.chunkY < originY) {
const chunksToAdd = originY - offer.chunkY;
@ -93,9 +93,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
game.gridExpansionPurchasesByDirection[direction] = (game.gridExpansionPurchasesByDirection[direction] || 0) + 1;
syncGridSizeToGame();
const blockedRatio = blockedRatioForExpansionPurchase(game.gridExpansionPurchases, {
base: BALANCE.map.blockedRatio ?? 0.03,
increment: BALANCE.map.blockedExpansionIncrement ?? 0.005,
max: BALANCE.map.blockedRatioMax ?? 0.10
base: BALANCE.map.blockedRatio ?? 0.00,
increment: BALANCE.map.blockedExpansionIncrement ?? 0.0075,
max: BALANCE.map.blockedRatioMax ?? 0.12
});
const blocked = generateBlockedCellsInRect({
seed: `${game.runSeed}:grid-expansion:${lot.chunkX},${lot.chunkY}:${game.gridExpansionPurchases}`,

View file

@ -119,7 +119,7 @@ export function debugGrantCard(game, cardOrId, options = {}) {
.map(String));
const selected = requestedKeys.size
? targets.filter(t => requestedKeys.has(targetKey(t)))
: targets.slice(0, 3);
: targets.slice(0, 10);
if (!selected.length) return { ok: false, reason: `Selected target is no longer valid for ${card.title}.` };
for (const target of selected.slice(0, 3)) {
game.blockedCells.delete(key(target.col, target.row));
@ -129,7 +129,7 @@ export function debugGrantCard(game, cardOrId, options = {}) {
sparkBurst(game, c.x, c.y, 8, THEME.warn);
}
refreshRoutingAfterEdit(game);
return { ok: true, reason: `${card.title} removed ${Math.min(3, selected.length)} blocked cell(s).` };
return { ok: true, reason: `${card.title} removed ${Math.min(10, selected.length)} blocked cell(s).` };
}
return { ok: false, reason: `${card.title} cannot be debug-granted.` };
}
@ -507,7 +507,7 @@ function cardDescription(game, card) {
if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.';
if (card.id === 'laborExploitation') return 'Repairman dispatch paperwork is streamlined. Repair rooms keep operating; 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 === 'dynamite') return 'Choose any blocked tiles on the map and destroy up to 3. Empty-space clicks are ignored; press Esc to finish after at least one blast.';
if (card.id === 'dynamite') return 'Choose any blocked tiles on the map and destroy up to 10. Empty-space clicks are ignored; press Esc to finish after at least one blast.';
return card.description;
}
@ -610,7 +610,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
cardId: card.id,
mode: card.target === 'blockedCell' ? 'blockedCell' : 'equipment',
targetKeys: targets.map(targetKey),
remaining: card.id === 'dynamite' ? Math.min(3, targets.length) : 1,
remaining: card.id === 'dynamite' ? Math.min(10, targets.length) : 1,
used: 0
};
ui.modal.classList.remove('visible', 'equipment-popover');
@ -721,8 +721,10 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
function cardIconHtml(card) {
const icon = cardIconFor(card);
if (icon.type === 'image') return `<div class="card-icon image"><img src="${icon.src}" alt="${icon.alt || ''}" /></div>`;
return `<div class="card-icon glyph" aria-hidden="true">${icon.text || '◆'}</div>`;
if (icon.type === 'image') {
return `<div class="card-badge-icon image"><img src="${icon.src}" alt="${icon.alt || ''}" /></div><div class="card-icon image" aria-hidden="true"><img src="${icon.src}" alt="" /></div>`;
}
return `<div class="card-badge-icon glyph" aria-hidden="true">${icon.text || '◆'}</div><div class="card-icon glyph" aria-hidden="true">${icon.text || '◆'}</div>`;
}
function cardButton(card, index) {

View file

@ -1,143 +0,0 @@
import { TURN_SECONDS, POOP_RATE, CONTRACT_EVENT_CHANCE, CONTRACT_EVENT_FIRST_TURN } from '../core/config.js';
import { getSpawnRange } from '../core/state.js';
import { applyRevenue } from './economy.js';
const CONTRACT_TARGETS = [
{
target: 'female',
title: 'Premium Female Shipment',
route: 'Female -> Truck',
note: 'Only female chicks are valid truck cargo this turn.'
},
{
target: 'male',
title: 'Male Export Trial',
route: 'Male -> Truck',
note: 'Male chicks are temporarily valid truck cargo. Male truck fines are suspended for valid male cargo.'
},
{
target: 'poop',
title: 'Contaminant Sample Export',
route: 'Poop -> Truck',
note: 'Poop is temporarily valid truck cargo. Shipped poop does not soil this contract shipment.'
}
];
export function factoryValue(game) {
return [
...game.eggFarms.map(f => f.price || 0),
...game.scanners.map(s => s.price || 0),
...Object.values(game.facilities).map(f => f.price || 0),
...[...game.conveyorMeta.values()].map(m => m.price || 0)
].reduce((a, b) => a + b, 0);
}
export function estimatedChicksPerTurn(game, productionMultiplier = 1) {
return game.eggFarms.reduce((sum, farm) => {
const [min, max] = getSpawnRange(farm);
const avg = Math.max(0.1, (min + max) / 2);
return sum + (TURN_SECONDS / avg) * productionMultiplier;
}, 0);
}
export function rollContractOffer(game) {
// No irregular events during the first seven running turns.
// Offers created after Turn 7 apply to Turn 8 or later.
const nextTurn = game.turn + 1;
if (nextTurn < CONTRACT_EVENT_FIRST_TURN) return null;
if (Math.random() > CONTRACT_EVENT_CHANCE) return null;
const template = CONTRACT_TARGETS[Math.floor(Math.random() * CONTRACT_TARGETS.length)];
const productionMultiplier = 2;
const poopMultiplier = 3;
const estimate = estimatedChicksPerTurn(game, productionMultiplier);
const targetAmount = Math.max(1, Math.ceil(estimate * 0.9));
const rate = Math.round((0.08 + Math.random() * 0.08) * 100) / 100;
const rewardByFactory = Math.floor(factoryValue(game) * rate);
const rewardByCash = Math.floor(Math.max(0, game.cash) * 0.25);
const reward = Math.max(rewardByFactory, rewardByCash);
return {
id: `contract-${game.turn}-${Date.now()}-${Math.floor(Math.random() * 9999)}`,
...template,
forced: true,
productionMultiplier,
poopMultiplier,
targetAmount,
estimatedOutput: Math.round(estimate),
rewardRate: rate,
reward,
rewardByFactory,
rewardByCash,
createdTurn: game.turn,
oneTurnOnly: true
};
}
export function activateAcceptedContract(game) {
// v10.1: limited events are forced. If an event is shown during Build phase,
// it automatically applies to the next turn; there is no accept/skip step.
game.contractActive = game.contractOffer ? { ...game.contractOffer } : null;
game.contractOffer = null;
}
export function clearActiveContract(game) {
game.contractActive = null;
}
export function productionMultiplier(game) {
return game.contractActive?.productionMultiplier || 1;
}
export function currentPoopRate(game) {
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) {
return game.contractActive?.target || 'female';
}
export function targetTruckCount(stats, target) {
if (target === 'male') return stats.truckMale || 0;
if (target === 'poop') return stats.poopTruck || 0;
return stats.truckFemale || 0;
}
export function isTargetTruckCargo(game, sex) {
return truckTarget(game) === sex;
}
export function shouldFineMaleTruck(game) {
return truckTarget(game) !== 'male';
}
export function resolveContract(game) {
const contract = game.contractActive;
if (!contract) return null;
const count = targetTruckCount(game.stats, contract.target);
const success = count >= contract.targetAmount;
const bonus = success ? contract.reward : 0;
if (bonus > 0) {
applyRevenue(game, bonus);
game.stats.contractBonus = bonus;
game.totals.contractBonus += bonus;
game.totals.contractSuccess += 1;
} else {
game.stats.contractBonus = 0;
game.totals.contractFailed += 1;
}
const result = {
title: contract.title,
target: contract.target,
route: contract.route,
targetAmount: contract.targetAmount,
count,
success,
bonus,
reward: contract.reward,
productionMultiplier: contract.productionMultiplier,
poopMultiplier: contract.poopMultiplier
};
game.stats.contractResult = result;
return result;
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -263,7 +263,7 @@ h1, h2, p { margin: 0; }
/* v15 pre-build card draft */
.card-choices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 220px));
grid-template-columns: repeat(auto-fit, minmax(190px, 220px));
justify-content: center;
align-items: stretch;
gap: 10px;
@ -532,7 +532,7 @@ body { font-size: 18px; }
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-choices { grid-template-columns: repeat(auto-fit, minmax(230px, 270px)); gap: 14px; }
.card-choice { min-height: 164px; padding: 15px; }
.card-choice strong { font-size: 18px; }
.card-choice span { font-size: 11px; }
@ -613,9 +613,10 @@ body { font-size: 20px; }
display: none;
position: absolute;
z-index: 90;
right: 0;
top: calc(100% + 8px);
width: min(320px, calc(100vw - 40px));
right: calc(100% + 10px);
top: 50%;
transform: translateY(-50%);
width: min(320px, 34vw);
max-height: none;
opacity: 1;
overflow: visible;
@ -636,11 +637,11 @@ body { font-size: 20px; }
.large-tools .tool-button .tool-flavor::before {
content: '';
position: absolute;
right: 18px;
top: -10px;
right: -10px;
top: calc(50% - 8px);
width: 16px;
height: 16px;
border-left: 3px solid var(--line);
border-right: 3px solid var(--line);
border-top: 3px solid var(--line);
background: rgba(255,255,255,.98);
transform: rotate(45deg);
@ -1201,6 +1202,35 @@ body {
.facility-action:disabled,
.card-target-button:disabled { opacity: .42; filter: grayscale(.85); box-shadow: 3px 3px 0 rgba(0,0,0,.45); }
/* Keep the translucent watermark, but also show a readable foreground icon badge. */
.card-badge-icon {
position: absolute;
z-index: 4;
left: 14px;
top: 14px;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 3px solid rgba(7,8,10,.95);
background: rgba(16,19,25,.88);
box-shadow: inset 0 0 0 1px rgba(255,184,39,.18), 3px 3px 0 rgba(0,0,0,.48);
image-rendering: pixelated;
}
.card-badge-icon img {
width: 30px;
height: 30px;
object-fit: contain;
image-rendering: pixelated;
filter: drop-shadow(1px 1px 0 rgba(0,0,0,.7));
}
.card-badge-icon.glyph {
color: #ffcf68;
font-size: 22px;
line-height: 1;
text-shadow: 2px 2px 0 rgba(0,0,0,.55);
}
/* Icons are now translucent background art rather than separate foreground badges. */
.card-choice {
display: block;
@ -1209,7 +1239,7 @@ body {
isolation: isolate;
}
.card-choice::before { z-index: 1; }
.card-copy { position: relative; z-index: 3; padding-right: 6px; }
.card-copy { position: relative; z-index: 3; padding-right: 6px; padding-left: 54px; }
.card-icon {
position: absolute;
z-index: 0;