Compare commits

...

5 commits

Author SHA1 Message Date
6097bac9d5 aa 2026-06-12 13:48:35 +09:00
22e6366dc2 twe 2026-06-11 22:49:46 +09:00
b13e07a074 tweak 2026-06-11 14:54:21 +09:00
368de90c6c visual 2026-06-11 14:34:37 +09:00
ced8f251d4 before visual update 2026-06-11 01:09:49 +09:00
61 changed files with 6463 additions and 1133 deletions

12
FUTURE_IDEAS.md Normal file
View file

@ -0,0 +1,12 @@
# Future Ideas
These items are intentionally not implemented in v29.00.
- Capacitor or battery storage for smoothing biomass and coal voltage pulses.
- Transformer or power-combiner station for a late-game managed bus where generators can be combined.
- Editable machine power priorities.
- Advanced power statistics such as average voltage, brownout count, utilization, and loss heat maps.
- Additional wire tiers beyond regular wire at 0.5V per tile and insulated wire at 0.15V per tile.
- Zunda balancing controls such as shorter duration, consumption cooldown, reduced zunda-only quality conversion, or farm appetite limits.
- Repair-access visualization for reachable cells and suggested footbridge locations.
- Power-aware card ideas after the base power economy is measured.

134
README.md
View file

@ -1,46 +1,114 @@
# Chick Sorter v27.0 Cleanup Build
# Chick Sorter v29.00
Open `index.html` in a browser.
## v29.00 systems
Production uses the committed `dist/game.js` bundle. Edit source files under
`src/`, then run `npm install` once and `npm run build` after JavaScript
changes to regenerate the bundle.
Power is a simplified deterministic voltage network. Manual Scanners, Egg Farms, regular Conveyors, Boost Conveyors, Power Wires, Insulated Power Wires, Footbridges, the Starter Generator, Biomass Generators, and Coal Generators do not require electricity. Mixers need 10V and 4V Voltage Load, Shredders need 8V and 3V Voltage Load, Shipping Trucks need 6V and 2V Voltage Load, Auto Scanners need 8V and 2V Voltage Load, Sausage Machines need 12V and 5V Voltage Load, Maintenance Rooms need 6V and 1V Voltage Load, and Inbound Trucks need 5V and 1V Voltage Load.
## Static server cache setup
Regular Power Wire costs 10 yen per tile and loses 0.5V per traversed tile. Insulated Power Wire costs 22 yen per tile and loses 0.15V per traversed tile. Wire branches do not divide voltage, loops do not increase voltage, and separate generators are not summed. Each generator admits machine loads deterministically; its bus voltage is generator output minus admitted Voltage Load.
Serve `index.html`, the bundled JavaScript, and CSS with cache validation
instead of import query strings. This prevents stale files while keeping reloads
fast.
Every new game includes a free 2x2 Starter Generator with 25V output and free initial regular wiring to the initial Mixer, Shredder, and Shipping Truck. The starter generator cannot be sold and has no build button.
Use the example in `deploy/` for your HTTP server:
Biomass Generators are 3x3, cost 520 yen, and accept male chicks, female chicks, and poop. Each accepted fuel item adds +5V for 2 seconds. Coal Generators are 2x3, cost 680 yen, and accept coal only. Each coal adds +15V for 3 seconds. Fuel pulses in one generator stack additively and are cleared by day end.
- nginx: include `deploy/nginx-cache.conf` inside the server block.
- Apache: copy or include `deploy/apache-cache.htaccess` from the site root.
- Caddy: adapt `deploy/Caddyfile-cache`.
Inbound Trucks cost 350 yen and can import coal and zunda mochi. Coal costs 2 yen each, zunda mochi costs 4 yen each, and each truck can order up to 80 combined items per day. Imports are charged at day start, distributed over the 60-second day with deterministic jitter, retained while the output is blocked, and not refunded if undelivered by day end.
After deploying, purge any CDN or reverse-proxy cache once.
Egg Farm production is capped by the theoretical capacity of one Boost Conveyor, derived from live conveyor speed divided by minimum item spacing. Production above the cap becomes quality overflow using `1 + 0.45 * overflowRatio^0.85`, clamped from 1.0x to 2.5x. Female shipments and sausage shipments use continuous quality multipliers; meat inherits chick quality and sausages use the arithmetic mean of two meat inputs. Mixer blocked-output fallback remains exactly +5 yen and ignores quality.
## Current baseline
Zunda mochi on a conveyor cell orthogonally adjacent to an Egg Farm is consumed by that farm. Each item adds 8 seconds of boost up to 30 seconds. While boosted, the farm production interval is multiplied by 0.55, with the existing 0.25 second safe minimum.
- 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.
Repair workers use four-directional grid pathing. They can walk on owned, unobstructed ground; they cannot walk through machines or uncovered conveyor cells, and power wires do not block them. Workers exit maintenance rooms from a reachable perimeter cell and repair from a reachable orthogonally adjacent work cell.
## Controls
Footbridges cost 90 yen and live on an overlay layer with wires. They cover 1x3 or 3x1 cells; press R while the Footbridge tool is active to rotate placement. They allow repair-worker pathing over conveyor cells without changing conveyor transport, direction editing, or branch behavior.
- 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.
Serve this folder with a static HTTP server and open `index.html`.
## Cleanup in this build
The launch page uses:
- 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.
- `styles.css`
- `dist/game.js`
## Build
Run `npm install` once, then `npm run build` after editing JavaScript.
## Current gameplay specification (v29.00)
### Preserved boost conveyor rendering behavior
- Fixed a runtime `ReferenceError` caused by an undeclared `boost` variable in the animated conveyor renderer.
- Added a runtime smoke test for both normal and boost conveyor drawing.
### Input and motion fixes (v28.20)
- Clicking either end of a conveyor run now reverses the complete run, including the clicked terminal tile.
- Conveyor stripe animation uses the same effective pixels per second as chicks on each tile. Boost Conveyor is therefore exactly 2× the normal animation speed before other modifiers.
- NEXT DAY no longer rebuilds its child markup every frame, preventing lost clicks during pointer press/release.
- Explosion debris horizontal velocity is reduced to 82% while vertical launch energy is unchanged.
### Land expansion
- Each purchase adds a 10×10 lot.
- First expansion costs JPY 850.
- Later prices use linear growth: `850 × (1 + 0.75 × previous purchases)`, rounded down to JPY 50.
- Expected prices: JPY 850, 1,450, 2,100, 2,750, 3,400...
### Map obstacles
- Initial no-build ratio: 0%.
- Each purchased lot adds 0.75 percentage points to that lot's obstacle ratio.
- Obstacle ratio is capped at 12%.
### Scanners
- Scanner footprint: 2×2 grid cells.
- Lv1 Auto Scanner cooldown: 1.5 seconds, about 40 items/day.
- This equals about 2.1 average Lv1 Egg Farms.
- Auto Scanner local and global tuning each multiply cooldown by 0.95 per upgrade.
### Mixer / Sausage production chain
- Mixer remains an external edge facility.
- When mounted along a horizontal map edge, its chick inlet is on the top of the body and its meat outlet is on the bottom.
- When mounted along a vertical map edge, its chick inlet is on the left of the body and its meat outlet is on the right.
- The inlet and outlet use separate receiver cells, leaving the cell between them open.
- A non-poop chick processed by Mixer creates 1 meat when the outlet conveyor is present and clear.
- If meat cannot leave the outlet because no conveyor is connected or the outlet cell is occupied, Mixer falls back to a fixed direct sale of JPY 5. Mixer upgrades affect sausage value, not this fallback.
- Sausage Machine costs JPY 380 and occupies exactly 3×3 owned grid cells.
- Its single meat inlet is one cell left of the middle row; its single sausage outlet is one cell right of the middle row.
- Placement is rejected if the 3×3 body or either port would be outside owned, usable grid space.
- It consumes 2 meat and outputs 1 sausage.
- Sausages are accepted by Truck regardless of the live-cargo event target.
- Base sausage sale value is JPY 26.
- MIXER Improvement and SAUSAGE MACHINE Improvement each multiply sausage value by 1.10 per installed level.
### Conveyor branches
- Fixed branch direction is shown by a triangle rather than an `RT`/direction abbreviation.
- A branch never offers a direction that points back toward an incoming conveyor.
- Branch selection is unavailable unless at least two outgoing conveyors remain after incoming directions are removed.
### Camera controls
- Build phase: right- or middle-drag pans the camera.
- Factory-running phase: left-, right-, or middle-drag pans the camera.
- Mouse wheel zoom remains available during both phases.
### Shredder upgrades
- SHREDDER Improvement uses a nonlinear scrap lottery.
- Upgrade count increases payout approximately with `1.6 × cards^1.25`.
- It also increases hit chance from 28%, by 1.8 percentage points per upgrade, capped at 82%.
- Winning payouts roll 1×, 2×, 4×, or 10×.
- Big-win and jackpot odds increase as more SHREDDER Improvement cards are installed.
### Card eligibility
- Equipment-targeted cards are excluded when no valid target exists.
- Equipment-specific passive cards can declare facility requirements and are excluded while those facilities are absent.
### Other verified rules
- Floor slime is deterministic decoration.
- Dynamite removes up to 3 selected blocked cells.
- Bankruptcy is checked only after day-end settlement.

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

View file

@ -0,0 +1,100 @@
{
"theme": "dark_factory",
"grid_unit_px": 16,
"ui_unit_px": 32,
"palette_note": "restricted palette / coarse pixel art",
"facilities": [
{
"id": "conveyor",
"files": [
"conveyor_straight_h_16.png",
"conveyor_straight_v_16.png",
"conveyor_turn_ne_16.png",
"conveyor_turn_nw_16.png",
"conveyor_turn_se_16.png",
"conveyor_turn_sw_16.png"
],
"size": "16x16",
"folder": "facilities/conveyor"
},
{
"id": "boost_conveyor",
"files": [
"boost_conveyor_straight_h_16.png",
"boost_conveyor_straight_v_16.png",
"boost_conveyor_turn_ne_16.png",
"boost_conveyor_turn_nw_16.png",
"boost_conveyor_turn_se_16.png",
"boost_conveyor_turn_sw_16.png"
],
"size": "16x16",
"folder": "facilities/conveyor"
},
{
"id": "egg_farm",
"files": [
"egg_farm_16.png"
],
"size": "16x16",
"folder": "facilities/machines"
},
{
"id": "manual_scanner",
"files": [
"manual_scanner_32x48.png"
],
"size": "32x48",
"footprint": "2x3 tiles",
"folder": "facilities/machines"
},
{
"id": "auto_scanner",
"files": [
"auto_scanner_32x48.png"
],
"size": "32x48",
"footprint": "2x3 tiles",
"folder": "facilities/machines"
},
{
"id": "mixer",
"files": [
"mixer_32.png"
],
"size": "32x32",
"folder": "facilities/machines"
},
{
"id": "shredder",
"files": [
"shredder_32.png"
],
"size": "32x32",
"folder": "facilities/machines"
},
{
"id": "truck",
"files": [
"truck_32.png"
],
"size": "32x32",
"folder": "facilities/vehicles"
},
{
"id": "repairman",
"files": [
"repairman_16.png"
],
"size": "16x16",
"folder": "facilities/utility"
},
{
"id": "obstacle_crate",
"files": [
"obstacle_crate_16.png"
],
"size": "16x16",
"folder": "facilities/utility"
}
]
}

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.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 378 B

Before After
Before After

View file

@ -0,0 +1,903 @@
# Codex Implementation Prompt — Chick Sorter v29.00
Implement the next major version of the attached **Chick Sorter v28.21 source project**.
The objective is to add a deterministic power-network system, fuel-based generation, inbound logistics, egg-farm quality overflow, and realistic repair-worker navigation while preserving every feature and regression fix already present in v28.21.
Treat this prompt as the authoritative implementation brief. Do not expand the mandatory scope with optional backlog items unless they are explicitly marked as required.
---
## 1. Non-negotiable engineering rules
1. Do not edit the minified production bundle directly.
2. Modify source files under `src/` and the appropriate source HTML/CSS/README files, then rebuild the production bundle.
3. Preserve all v28.21 behavior and regression fixes.
4. Keep all tunable values in the existing centralized balance/configuration module.
5. Prefer isolated modules for new systems, including:
- `src/systems/power.js`
- `src/systems/imports.js`
- `src/systems/farmQuality.js` if farm-quality logic cannot remain cleanly isolated in the existing farm module.
6. Do not add texture assets. Draw new equipment with Canvas geometry, symbols, labels, and simple temporary colors.
7. Update the version to `29.0.0`, the visible version to `v29.00`, and the bundle cache key to `game.js?v=29.00` or the equivalent used by the project.
8. Implement the work in stages. After every stage, run relevant syntax checks and tests.
9. Do not silently discard transport items because of congestion, power loss, or invalid machine state.
10. Update README so every documented value matches the actual implementation constants.
After each implementation stage, report:
- files changed;
- behavior implemented;
- tests run and results;
- unresolved assumptions or defects.
---
## 2. Preserve all existing v28.21 features
Do not regress any of the following:
- land expansion starts at 850 yen and uses the current reduced linear increase model;
- the level-1 automatic scanner occupies 2x2 cells and processes approximately 2.1 level-1 farms;
- shredder upgrades scale nonlinearly and retain high random variance;
- the mixer emits meat through its configured output and pays a fixed fallback revenue of exactly +5 yen when meat cannot be emitted;
- the 3x3 sausage machine consumes two meat items and emits one sausage;
- sausage revenue is collected by the shipping truck;
- equipment-specific cards are excluded from the card pool when the relevant equipment does not exist;
- README matches implemented obstacle and balance behavior;
- mixer input/output ports, sausage-machine placement, conveyor branching rules, triangle direction indicators, and running-phase camera panning remain intact;
- clicking an edge conveyor reverses the connected conveyor line correctly;
- Boost Conveyor animation is twice the regular conveyor animation speed;
- conveyor animation speed stays synchronized with actual transported-item movement, including modifiers and wear;
- NEXT DAY works on a single click;
- explosion horizontal impulse remains reduced to the v28.20 value;
- normal and Boost Conveyor drawing never throws `boost is not defined` or another undefined-variable error.
Keep the existing internal truck ID for compatibility. Rename only the player-facing label of the current truck to **Shipping Truck**.
---
# Mandatory v29.00 systems
## 3. Power model
### 3.1 Equipment exempt from electricity
The following operate without voltage:
- manual scanner;
- egg farm;
- regular conveyor;
- Boost Conveyor;
- regular power wire;
- insulated power wire;
- footbridge;
- starter generator;
- biomass generator;
- coal generator.
Every other active machine requires sufficient received voltage.
When a machine is under-voltage:
- stop accepting or processing new items;
- do not remove an item from an upstream conveyor until acceptance is confirmed;
- pause processing timers rather than resetting them;
- retain internal queues and pending outputs;
- resume normally after voltage is restored.
### 3.2 Voltage terminology
This is an intentionally simplified game system, not a physical electrical simulation.
Each powered machine has:
- `minVoltage`: minimum received voltage required to operate;
- `drawV`: its contribution to generator load sag;
- `relayDropV`: optional extra route loss only if the implementation allows a power route to pass through a declared machine relay node.
Use the player-facing term **Voltage Load** for `drawV`.
### 3.3 Generator load sag
A pure path-loss system would allow one 25V generator to power unlimited parallel machines. Prevent that with source load sag.
For each source:
```text
busVoltage = currentGeneratorOutputV - sum(drawV of admitted machines)
receivedVoltage(machine) = busVoltage - routeLossV(machine)
```
Admit a machine only when adding its load keeps every already admitted machine at or above its own `minVoltage`.
Different generators do not add their voltages together in v29.00.
A machine is assigned to at most one generator.
Choose and admit sources deterministically:
1. calculate the best route from every available generator;
2. prefer the source with the highest open-circuit received voltage;
3. admit higher `powerPriority` first;
4. then lower route loss;
5. then earlier build order;
6. then stable equipment ID.
Use default `powerPriority = 50`.
Use priority 100 for the initial mixer, shredder, and shipping truck.
Repeat assignment until stable, with a hard maximum of eight passes.
### 3.4 Power routing
Power wires form a four-directional graph.
- Regular Power Wire loses **0.5V per traversed tile**.
- Insulated Power Wire loses **0.15V per traversed tile**.
- Wire types connect directly to each other.
- Branches do not divide voltage.
- Loops must never increase voltage.
- Separate generators are not summed.
- Active fuel pulses in the same fuel generator are summed.
- Stop exploring a path at 0V or below.
- Use a maximum-priority Dijkstra-style propagation or an equivalent monotonic minimum-loss algorithm.
Do not run a complete graph search every render frame. Recalculate after structural changes and at no more than 10Hz while timed generator pulses change.
Recommended public functions:
```js
ensurePowerState(game)
markPowerDirty(game)
updatePowerSystem(game, dt)
recalculatePowerNetwork(game)
receivedVoltageFor(game, equipment)
isEquipmentPowered(game, equipment)
powerSummary(game)
```
### 3.5 Power layer
Power wires use an overlay layer separate from equipment and conveyor occupancy.
Wires may overlap:
- regular conveyors;
- Boost Conveyors;
- footbridges.
Wires may not occupy:
- unowned cells;
- obstacle cells;
- equipment body cells, unless a future explicit relay contact supports it.
Equipment receives power through an adjacent wire touching a declared power contact.
When Power Overlay is off, wires should not interfere with normal conveyor clicking.
### 3.6 Power-wire equipment
#### Regular Power Wire
- ID: `powerWire`
- Price: 10 yen per tile
- Loss: 0.5V per tile
- Temporary art: thin dark line, brighter in Power Overlay mode
#### Insulated Power Wire
- ID: `insulatedPowerWire`
- Price: 22 yen per tile
- Loss: 0.15V per tile
- Temporary art: thicker line with a light outer casing
- Replacing one wire type with the other should be a direct operation that charges or refunds the price difference.
### 3.7 Powered-machine values
Place these values in the centralized balance table.
| Equipment | Minimum voltage | Voltage load | Relay drop |
|---|---:|---:|---:|
| Mixer | 10V | 4V | 4V |
| Shredder | 8V | 3V | 3V |
| Shipping Truck | 6V | 2V | 2V |
| Automatic Scanner | 8V | 2V | 2V |
| Sausage Machine | 12V | 5V | 5V |
| Maintenance Room | 6V | 1V | 1V |
| Inbound Truck | 5V | 1V | 1V |
### 3.8 Starter generator and initial wiring
Add one starter generator to every new game.
- ID: `starterGenerator`
- Size: 2x2
- Continuous output: 25V
- Exists from game start
- Cannot be sold
- Cannot be built again
- May be moved during build phase
- Requires no electricity
- Temporary art: dark body, lightning symbol, and `25V` label
A new game must also contain free regular power wires connecting the starter generator to:
- the initial mixer;
- the initial shredder;
- the initial shipping truck.
All three machines must be powered on day one without player intervention.
Implementation requirements:
1. reserve the generator footprint and required route cells before obstacle generation;
2. create default equipment first;
3. run a deterministic helper such as `createStarterPowerLayout(game)`;
4. route through owned, non-obstacle cells;
5. allow wires to cross conveyor cells because wires are on a separate layer;
6. never route through equipment bodies;
7. charge zero money for the starter generator and initial wires;
8. validate the completed layout;
9. fail loudly in development if valid initial routing cannot be created.
The initial mixer, shredder, and shipping truck create 9V total load, leaving a 16V bus before path loss.
### 3.9 Biomass generator
- ID: `biomassGenerator`
- Size: 3x3
- Price: 520 yen
- Inputs: three ports at left-center, top-center, and bottom-center
- Accepts male chicks, female chicks, and poop
- Rejects meat, sausage, coal, and zunda mochi
- Each accepted item adds +5V for 2 seconds
- Multiple active pulses in the same generator stack additively
- Three overlapping inputs therefore provide +15V during the overlap
- Clear pulses at day end
- Requires no electricity
- Temporary art: leaf/flame symbol and current output label
### 3.10 Coal generator
- ID: `coalGenerator`
- Size: 2x3
- Price: 680 yen
- One coal input at left-center
- Each coal adds +15V for 3 seconds
- Multiple active pulses in the same generator stack additively
- Reject all non-coal items
- Clear pulses at day end
- Requires no electricity
### 3.11 Power UI
Add a build-phase **POWER OVERLAY** toggle.
Display:
- wire type;
- generator output;
- assigned source;
- source bus voltage after admitted load;
- route loss;
- received voltage;
- minimum required voltage;
- Voltage Load;
- under-voltage state.
Example equipment tooltip:
```text
Power: 9.5V / 10V
Source: Starter Generator
Source bus: 15.5V
Route loss: 6.0V
Voltage Load: 4V
Status: UNDER VOLTAGE
```
Show a red crossed-lightning symbol on an under-voltage machine during operation.
Warn before NEXT DAY when machines are unpowered, but do not block starting the day.
---
## 4. Inbound logistics
### 4.1 Inbound Truck
Add a new edge machine.
- ID: `inboundTruck`
- Price: 350 yen
- Multiple inbound trucks may be built
- One output port faces the grid
- Requires 5V and contributes 1V Voltage Load
- Imports coal and zunda mochi
- Clicking it opens a per-truck configuration panel
Store:
```js
inboundOrder: {
coalPerDay: 0,
zundaMochiPerDay: 0
}
```
Daily limits per truck:
- coal: 0–60;
- zunda mochi: 0–30;
- combined total: 80 items.
Purchase prices:
- coal: **2 yen each**;
- zunda mochi: **4 yen each**.
Charge the complete order cost at the beginning of the day.
### 4.2 Delivery timing
Distribute imports across the 60-second operating day.
For `n` items:
```js
baseTime = ((i + 0.5) / n) * TURN_SECONDS
```
Apply deterministic or seeded jitter within ±15% of the base interval.
Merge coal and zunda schedules and dispatch in timestamp order.
If the output is blocked:
- retain the item in the truck queue;
- do not delete it;
- resume release when space becomes available.
If the truck is under-voltage, pause release timing and resume after power restoration.
Items still unreleased at day end are undelivered, do not carry over, and are not refunded.
### 4.3 New transport types
Add transport-item types compatible with the current entity structure:
```js
sex: 'coal'
sex: 'zundaMochi'
```
Temporary art:
- coal: small black rounded square;
- zunda mochi: small green circle or diamond.
Manual and automatic scanners must reject both items without deleting them.
---
## 5. Egg-farm output cap and product quality
### 5.1 Design goal
Farm upgrades currently reach a point where farms produce faster than even a Boost Conveyor can remove items.
From v29.00 onward:
- production speed up to the theoretical capacity of one Boost Conveyor increases item count;
- theoretical production beyond that capacity no longer increases item count;
- the excess becomes a diminishing product-quality multiplier.
Never derive quality from real congestion, a blocked output, queue size, or deliberate obstruction.
### 5.2 Derive Boost Conveyor capacity
Do not introduce a duplicate hard-coded throughput constant.
Derive capacity from the live movement model, for example:
```js
boostOutputCapacityPerSecond =
BALANCE.conveyor.boostSpeedPxPerSecond /
BALANCE.transport.minimumItemSpacingPx;
```
Use the actual variable names from the project.
Changing Boost Conveyor speed or minimum spacing must automatically change the farm cap.
### 5.3 Farm formula
For each farm:
```text
R = theoretical production rate after permanent upgrades, cards, events, and zunda boost
C = theoretical maximum output rate of one Boost Conveyor
actualSpawnRate = min(R, C)
overflowRatio = max(0, R / C - 1)
qualityMultiplier = clamp(1 + 0.45 * overflowRatio^0.85, 1.0, 2.5)
```
Store the formula constants in centralized balance data.
Expected behavior:
| Theoretical rate | Actual item rate | Approximate quality |
|---:|---:|---:|
| 0.8C | 0.8C | 1.00x |
| 1.0C | 1.0C | 1.00x |
| 1.5C | 1.0C | about 1.25x |
| 2.0C | 1.0C | 1.45x |
| 3.0C | 1.0C | about 1.81x |
| 5.0C | 1.0C | about 2.46x |
Snapshot quality when an item is spawned:
```js
sourceQuality: 1.0
```
### 5.4 Quality inheritance
- A female chick shipped directly multiplies shipping revenue by its `sourceQuality`.
- A male chick processed into meat transfers its quality to that meat item.
- A sausage made from two meat items uses the arithmetic mean of the two meat-quality values.
- Shipping sausage multiplies its base value by sausage quality.
- Poop has no quality multiplier.
- Biomass power output ignores quality.
- Coal and zunda have no quality.
- The mixer blocked-output fallback remains exactly +5 yen and ignores quality.
### 5.5 Farm UI
Show:
- theoretical production rate;
- actual capped output rate;
- Boost Conveyor cap;
- current quality multiplier;
- quality grade.
Display-only grades:
- 1.00–1.14x: Standard
- 1.15–1.34x: Select
- 1.35–1.74x: Premium
- 1.75x and above: Luxury
Revenue must use the continuous multiplier, not grade buckets.
If the connected belt is slower than the farm's actual capped output, show:
```text
OUTPUT BELT BELOW FARM CAPACITY
```
---
## 6. Zunda mochi farm boost
A zunda mochi item reaching a conveyor cell orthogonally adjacent to an egg farm is consumed by that farm.
- Accept from any of the four adjacent directions.
- Each item adds 8 seconds to the boost timer.
- Maximum stored boost time: 30 seconds.
- While active, production interval multiplier: 0.55.
- Multiple items extend duration but do not multiply the speed factor.
- Clear remaining boost time at day end.
- Keep the existing safe minimum production interval of 0.25 seconds.
- Zunda changes theoretical rate `R`.
- If zunda raises `R` above the Boost Conveyor cap, the excess becomes quality rather than extra item count.
Record realized zunda value in daily statistics.
Do not add all of the following balancing restrictions at once. They are tuning controls only if playtests show zunda is dominant:
- reduce duration from 8 seconds to 6 seconds;
- add a per-farm consumption cooldown;
- reduce only the zunda-derived quality-conversion efficiency;
- add a per-farm daily appetite limit.
---
## 7. Repair-worker navigation and footbridges
### 7.1 Collision rules
Replace direct straight-line repair-worker movement with four-directional A* or BFS grid pathfinding.
A worker may enter a cell only when it is:
- owned;
- not an obstacle;
- not occupied by equipment, a farm, scanner, or maintenance room;
- not occupied by a conveyor unless a footbridge covers that conveyor cell.
Power wires do not block walking.
### 7.2 Footbridge
- ID: `footbridge`
- Price: 90 yen
- Size: 1x3 or 3x1
- Rotatable
- May overlap conveyor and power-wire layers
- May not overlap obstacles, unowned cells, or equipment bodies
- Affects repair-worker movement only
- Must not alter conveyor transport, direction editing, or branch selection
- Temporary art: translucent deck with side rails
### 7.3 Path caching
Each worker should store:
```js
path
pathIndex
pathVersion
```
Maintain `game.walkabilityVersion` and increment it whenever construction changes passability.
Recalculate only when:
- target changes;
- walkability version changes;
- current path becomes invalid.
Do not run pathfinding every frame.
### 7.4 Repair position
Workers repair from a reachable cell orthogonally adjacent to the target footprint.
They must not stand inside the target machine.
- Generate adjacent candidate work cells.
- Choose the nearest reachable candidate.
- Repair conveyors from an adjacent walkable cell or a valid footbridge cell.
- Repair edge equipment from the nearest reachable grid-side work cell.
- Skip unreachable targets.
- Show `NO REPAIR ACCESS` in details for unreachable equipment.
- Workers must exit the maintenance room from a valid perimeter cell rather than its center.
---
## 8. Layer selection and editing
Because conveyors, power wires, and footbridges may overlap:
- equipment selection has highest priority;
- with Power Overlay enabled, wires participate in hit testing;
- with Power Overlay disabled, conveyors take priority and wires are not directly clickable;
- footbridges must remain selectable without breaking conveyor editing;
- erase one layer at a time in this order:
1. footbridge;
2. power wire;
3. conveyor;
- optionally support Shift+Erase to remove all overlay layers on one cell.
Do not regress edge-conveyor reversal or conveyor branch selection rules.
---
## 9. Power gates for existing machines
Add power checks at these points:
- automatic scanner before queue processing;
- mixer before acceptance and processing;
- shredder before acceptance and processing;
- shipping truck before acceptance;
- sausage machine before acceptance, processing, and output;
- inbound truck before item release;
- maintenance room before worker dispatch and while repair work is progressing.
Do not remove an item from the upstream conveyor before the destination confirms that it is powered and has capacity.
---
## 10. Card behavior and durability
Preserve equipment-dependent card filtering.
Do not add generator, wire, inbound-truck, or footbridge cards in v29.00.
Repairable new equipment:
- starter generator;
- biomass generator;
- coal generator;
- inbound truck.
No wear in v29.00:
- regular wire;
- insulated wire;
- footbridge.
Generator wear must not reduce output voltage in this version. Use the existing normal delay/maintenance model only if required.
---
## 11. Statistics and UI
Add daily statistics for:
- coal ordered, released, consumed, and undelivered;
- zunda ordered, released, consumed, and undelivered;
- import purchase cost;
- fuel consumed by generator type;
- maximum output per generator;
- under-voltage seconds per machine;
- generator-source assignment changes;
- zunda boost seconds per farm;
- farm theoretical rate;
- farm actual output rate;
- average and maximum quality multiplier;
- revenue added by quality;
- unreachable repair-target count.
Build controls:
- POWER WIRE
- INSULATED WIRE
- BIOMASS GEN
- COAL GEN
- INBOUND TRUCK
- FOOTBRIDGE
- POWER OVERLAY
Do not add a build button for the starter generator.
Inbound Truck panel:
- coal per day;
- zunda mochi per day;
- combined total;
- daily purchase cost;
- average release interval;
- current output queue.
Editable only in build phase.
Top-level power summary example:
```text
POWER: Starter 25V / 3 powered / 1 under-voltage
```
Do not display a summed `TOTAL V`, because independent generators are not combined.
---
## 12. README requirements
Document the implemented values and behavior for:
- machines that do and do not require electricity;
- generator load sag;
- regular-wire loss of 0.5V per tile;
- insulated-wire loss of 0.15V per tile;
- non-splitting branches;
- non-additive separate generators;
- machine minimum voltage and Voltage Load;
- starter, biomass, and coal generators;
- free initial generator and wiring;
- inbound orders and item prices;
- zunda farm boost;
- Boost Conveyor production cap;
- quality overflow and inheritance;
- repair-worker collision rules;
- footbridges;
- temporary geometric art for new machines.
Every README number must come from or match the centralized balance values.
---
## 13. Required tests
Retain all v28.21 tests and add a dedicated v29.00 test suite.
### Power
1. One regular wire tile loses 0.5V.
2. Ten regular wire tiles lose 5V.
3. One insulated wire tile loses 0.15V.
4. Ten insulated wire tiles lose 1.5V.
5. Mixed paths calculate exact combined loss.
6. Branches do not divide voltage.
7. Loops never increase voltage.
8. Separate generators are not summed.
9. Three overlapping biomass pulses produce +15V.
10. One coal pulse produces +15V for 3 seconds.
11. Starter mixer, shredder, and shipping truck create 9V total load.
12. A machine that would invalidate the admitted source set remains unpowered.
13. Under-voltage machines do not consume items.
14. Paused processing resumes after power restoration.
15. New games include the starter generator and initial wires.
16. Initial mixer, shredder, and shipping truck are powered on day one.
17. Starter power equipment costs zero.
18. Initial power cells do not overlap obstacles or equipment bodies.
### Imports
19. Coal costs 2 yen each.
20. Zunda mochi costs 4 yen each.
21. Sixty coal items schedule at approximately one-second intervals.
22. Six items schedule at approximately ten-second intervals.
23. A blocked inbound output retains its item.
24. Coal and zunda cannot enter scanners.
25. Order cost is charged correctly at day start.
26. Under-voltage pauses release without losing queued imports.
### Farm quality
27. Spawn count is unchanged below the Boost Conveyor capacity.
28. Spawn rate is capped at the derived Boost Conveyor capacity.
29. Quality is exactly 1.0x at or below the cap.
30. Quality increases monotonically above the cap.
31. Quality never exceeds 2.5x.
32. Blocking farm output does not raise quality.
33. Female shipping revenue uses source quality.
34. Meat inherits chick quality.
35. Sausage quality equals the arithmetic mean of its two meat inputs.
36. Mixer fallback remains exactly +5 yen regardless of quality.
37. Poop and generator output ignore farm quality.
38. Changing Boost Conveyor movement speed changes the derived cap without editing another constant.
### Zunda
39. A farm consumes adjacent zunda mochi.
40. One item adds 8 seconds up to 30 seconds.
41. Zunda changes theoretical farm rate.
42. Zunda cannot increase count beyond the Boost Conveyor cap.
43. Zunda overflow increases quality through the same formula.
44. Multiple zunda items extend duration but do not multiply the speed factor.
### Repair workers
45. Workers cannot pass through equipment.
46. Workers cannot walk on uncovered conveyor cells.
47. Workers can cross conveyor cells covered by a footbridge.
48. Workers repair from an adjacent cell.
49. Unreachable machines are skipped and flagged.
50. Construction edits invalidate and recalculate cached paths.
### Regression and build
51. Every v28.21 test passes.
52. `npm run build` succeeds.
53. Generated output contains no `boost is not defined` regression.
54. NEXT DAY enters the running phase with one click.
55. Regular and Boost Conveyor drawing does not throw.
56. Edge-conveyor reversal still works.
57. Conveyor item movement remains synchronized with conveyor animation.
58. A new factory runs without manual power edits.
59. The final ZIP contains source, updated README, and rebuilt production files.
---
## 14. Implementation order
Implement in this order, with a working checkpoint after each stage:
1. balance constants, IDs, equipment definitions, temporary drawing;
2. starter-generator placement reservation and free initial wiring;
3. wire overlay and route-loss calculation;
4. generator load admission and machine power gates;
5. biomass and coal generators;
6. inbound truck and imported transport items;
7. egg-farm throughput cap and quality inheritance;
8. zunda integration;
9. footbridge and repair-worker pathfinding;
10. UI, statistics, and README;
11. full regression tests, production build, and release ZIP.
Do not implement all systems as one large patch.
---
## 15. Completion criteria
The implementation is complete only when:
- a new game visibly includes a starter generator and connected wires;
- the initial mixer, shredder, and shipping truck work on day one;
- long wire runs reduce received voltage;
- insulated wire loses exactly 0.15V per tile;
- adding powered machines creates source load and can cause under-voltage;
- under-voltage never deletes or silently consumes items;
- fuel inputs create timed additive output pulses inside each generator;
- coal and zunda imports are distributed across the day;
- farm output count cannot exceed one Boost Conveyor's theoretical transport capacity;
- production above that cap increases product quality rather than count;
- deliberate congestion never creates quality;
- quality survives the female shipping and meat-to-sausage chains;
- repair workers avoid machines and uncovered conveyors;
- footbridges permit repair-worker conveyor crossings;
- README values match implementation values;
- all old and new tests pass;
- the production bundle builds successfully.
---
# Optional future-design backlog — do not implement in v29.00
Record these ideas in a short `FUTURE_IDEAS.md` or an equivalent non-executable design note. Do not implement them unless explicitly requested.
## A. Capacitor / battery
Store short biomass and coal voltage pulses and release them gradually to reduce intermittent under-voltage.
Potential parameters:
- storage capacity;
- maximum charge rate;
- maximum discharge rate;
- round-trip efficiency;
- player-selectable reserve threshold.
## B. Transformer or power-combiner station
Allow multiple generators to feed one managed bus only through a dedicated late-game machine. This would intentionally replace the v29.00 rule that independent generator voltages are never summed.
## C. Editable power priorities
Allow Low, Normal, and High priority per machine so nonessential production shuts down before critical logistics during shortages.
## D. Advanced power statistics
Possible additions:
- average received voltage;
- brownout count;
- energy/fuel cost per shipped item;
- source utilization percentage;
- unused generator output;
- voltage-loss heat map.
## E. Additional wire tiers
Possible late-game wire types with lower loss, higher purchase price, or limited placement requirements. Do not reduce Insulated Power Wire below its required 0.15V-per-tile value in v29.00.
## F. Zunda balancing controls
Only if telemetry shows zunda is dominant:
- shorter duration;
- farm consumption cooldown;
- reduced zunda-only contribution to quality overflow;
- daily appetite limit.
Change one control at a time and measure the result.
## G. Repair-access visualization
A build-mode overlay showing:
- reachable worker cells;
- planned repair paths;
- machines with no adjacent work cell;
- recommended footbridge locations.
## H. Power-aware card ideas
Possible future cards after the base system is stable:
- reduced machine Voltage Load;
- lower regular-wire loss;
- longer fuel pulses;
- improved biomass voltage per item;
- temporary emergency generator output;
- capacitor efficiency.
Do not add these cards in v29.00 because the base power economy must be measured first.

Binary file not shown.

103
dist/game.js vendored

File diff suppressed because one or more lines are too long

View file

@ -3,21 +3,24 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chick Sorter v27.6</title>
<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 v29.00</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app">
<section id="gameShell" class="game-shell" aria-label="Game screen">
<section id="gameShell" class="game-shell" data-build="v29.00" aria-label="Game screen">
<div class="build-stamp" aria-label="loaded build">v29.00</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">
<div class="hud-card cash"><span>CASH</span><strong id="money">・・250</strong></div>
<div class="hud-card cash"><span>CASH</span><strong id="money">¥250</strong></div>
<div class="hud-card"><span>TIME</span><strong id="timeLeft">60.0s</strong></div>
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">・・0</strong></div>
<div class="hud-card profit"><span>NET</span><strong id="turnProfit">¥0</strong></div>
<div class="hud-card"><span>DAY</span><strong id="turn">1</strong></div>
<div class="hud-card compact"><span>PHASE</span><strong id="phaseLabel">Title</strong></div>
<div class="hud-card combo"><span>COMBO</span><strong id="comboCount">0</strong></div>
</div>
<div class="hud hud-top-right" aria-label="Day result details">
@ -27,17 +30,16 @@
<div class="hud-card"><span>POOP</span><strong id="poopCount">0</strong></div>
</div>
<div class="priority-strip" aria-label="Important day info">
<div id="eventBrief" class="event-brief">NO ACTIVE EVENT</div>
<div id="targetBrief" class="target-brief">TRUCK TARGET: FEMALE</div>
<div id="speedBrief" class="speed-brief">BELT: 52 px/s</div>
<div class="left-bottom-status" aria-label="Important day info">
<div id="eventBrief" class="event-brief status-chip">NO ACTIVE EVENT</div>
<div id="speedBrief" class="speed-brief status-chip">BELT: 52 px/s</div>
<div class="combo-brief status-chip"><span>COMBO</span><strong id="comboCount">0</strong></div>
</div>
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v27.6</span></h1>
<p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
<h1>CHICK SORTER <span>v29.00</span></h1>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
</div>
@ -46,21 +48,29 @@
<section class="equipment-section">
<h2>Add / Edit Equipment</h2>
<div class="large-tools">
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>・・30 / tile</span></button>
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>・・50 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>・・250</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥360 / 3.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>・・260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>・・450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>・・240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Truck</strong><span>・・450</span></button>
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>¥75 / tile</span></button>
<button id="buildPowerWireButton" class="tool-button" type="button"><strong>Power Wire</strong><span>¥10 / tile</span></button>
<button id="buildInsulatedPowerWireButton" class="tool-button" type="button"><strong>Insulated Wire</strong><span>¥22 / tile</span></button>
<button id="powerOverlayButton" class="tool-button small" type="button"><strong>Power Overlay</strong><span>Show wires</span></button>
<button id="buildFootbridgeButton" class="tool-button" type="button"><strong>Footbridge</strong><span>¥90</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥180 / 1.5s cooldown</span></button>
<button id="buildManualScannerButton" class="tool-button" type="button"><strong>Manual Scanner</strong><span>¥260</span></button>
<button id="buildMixerButton" class="tool-button" type="button"><strong>Mixer</strong><span>¥450</span></button>
<button id="buildTrashButton" class="tool-button" type="button"><strong>Waste Shredder</strong><span>¥240</span></button>
<button id="buildTruckButton" class="tool-button" type="button"><strong>Shipping Truck</strong><span>¥450</span></button>
<button id="buildInboundTruckButton" class="tool-button" type="button"><strong>Inbound Truck</strong><span>¥350</span></button>
<button id="buildSausageMakerButton" class="tool-button" type="button"><strong>Sausage Machine</strong><span>¥380 / 3×3</span></button>
<button id="buildBiomassGeneratorButton" class="tool-button" type="button"><strong>Biomass Gen</strong><span>¥520 / 3×3</span></button>
<button id="buildCoalGeneratorButton" class="tool-button" type="button"><strong>Coal Gen</strong><span>¥680 / 2×3</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</span></button>
<button id="hireRepairmanButton" class="tool-button repair" type="button"><strong>Hire Repairman</strong><span>・・100 / next day</span></button>
<button id="buildMaintenanceRoomButton" class="tool-button repair" type="button"><strong>Maintenance Room</strong><span>¥220 / 2×2</span></button>
<div class="history-tools"><button id="undoButton" class="tool-button small" type="button"><strong>Undo</strong><span>Ctrl+Z</span></button><button id="redoButton" class="tool-button small" type="button"><strong>Redo</strong><span>Ctrl+Y</span></button></div>
</div>
<div id="buildStatus" class="mini-box">Build tools unlock after each day.</div>
</section>
<section class="rules-section compact-rules">
<section class="rules-section compact-rules">
<h2>Status</h2>
<div id="turnSummary" class="mini-box"></div>
</section>
@ -77,19 +87,25 @@
</div>
<div id="hoverTooltip" class="hover-tooltip" aria-hidden="true"></div>
<!-- DEBUG UI is intentionally kept for this active balancing build. Do not remove during cleanup/audit passes unless explicitly requested. -->
<details id="debugPanel" class="debug-panel">
<summary>DBG</summary>
<div class="debug-body">
<label class="debug-check"><input id="debugInfiniteCash" type="checkbox" /> Infinite money</label>
<label class="debug-day">Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
<button id="debugSetDayButton" type="button">Set Day</button>
<div class="debug-row">
<label class="debug-day">Day <input id="debugDayInput" type="number" min="1" step="1" value="1" /></label>
<button id="debugSetDayButton" type="button">Set Day</button>
</div>
<button id="debugZeroTimerButton" type="button">Timer 0</button>
<label class="debug-card">Card <select id="debugCardSelect"></select></label>
<button id="debugGrantCardButton" type="button">Grant Card</button>
<button id="debugGrantAllCardsButton" type="button">All Cards</button>
<label class="debug-card">Card <select id="debugCardSelect"></select></label>
<label class="debug-target">Target <select id="debugTargetSelect"></select></label>
<label class="debug-event">Next Event <select id="debugEventSelect"></select></label>
<button id="debugGrantCardButton" type="button">Grant Selected</button>
<div id="debugReadout" class="debug-readout"></div>
</div>
</details>
</section>
</main>
@ -101,6 +117,6 @@
</div>
</div>
<script type="module" src="./dist/game.js"></script>
<script type="module" src="./dist/game.js?v=29.00"></script>
</body>
</html>

58
package-lock.json generated
View file

@ -1,19 +1,19 @@
{
"name": "zunda-shiwake",
"version": "27.10.0",
"version": "29.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zunda-shiwake",
"version": "27.10.0",
"version": "29.0.0",
"devDependencies": {
"esbuild": "^0.25.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
@ -30,7 +30,7 @@
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
@ -47,7 +47,7 @@
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
@ -64,7 +64,7 @@
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
@ -81,7 +81,7 @@
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
@ -98,7 +98,7 @@
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
@ -114,7 +114,7 @@
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
@ -131,7 +131,7 @@
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
@ -148,7 +148,7 @@
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
@ -165,7 +165,7 @@
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
@ -182,7 +182,7 @@
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
@ -199,7 +199,7 @@
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
@ -216,7 +216,7 @@
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
@ -233,7 +233,7 @@
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
@ -250,7 +250,7 @@
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
@ -267,7 +267,7 @@
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
@ -284,7 +284,7 @@
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
@ -301,7 +301,7 @@
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
@ -318,7 +318,7 @@
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
@ -335,7 +335,7 @@
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
@ -352,7 +352,7 @@
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
@ -369,7 +369,7 @@
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
@ -386,7 +386,7 @@
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
@ -403,7 +403,7 @@
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
@ -420,7 +420,7 @@
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
@ -437,7 +437,7 @@
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
@ -453,7 +453,7 @@
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/esbuild/-/esbuild-0.25.12.tgz",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,

View file

@ -1,11 +1,12 @@
{
"name": "zunda-shiwake",
"version": "27.6.0",
"version": "29.0.0",
"private": true,
"type": "module",
"scripts": {
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "npm run clean && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game.js"
"build": "npm run clean && node scripts/build.mjs",
"test": "node -e \"const fs=require('fs'); const cp=require('child_process'); if (fs.existsSync('test_v28_21.mjs')) cp.execFileSync(process.execPath, ['test_v28_21.mjs'], { stdio: 'inherit' }); cp.execFileSync(process.execPath, ['test_v29_00.mjs'], { stdio: 'inherit' });\""
},
"devDependencies": {
"esbuild": "^0.25.0"

15
scripts/build.mjs Normal file
View file

@ -0,0 +1,15 @@
import { build } from 'esbuild';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
await build({
absWorkingDir: root,
entryPoints: [resolve(root, 'src/game.js')],
bundle: true,
format: 'esm',
target: 'es2020',
minify: true,
outfile: resolve(root, 'dist/game.js')
});

View file

@ -1,7 +1,7 @@
// Central balance sheet for tuning gameplay.
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
export const BALANCE = {
version: 'v27.6 direct conveyor chick movement',
version: 'v29.00',
time: {
daySeconds: 60,
farmShutdownGraceSeconds: 10
@ -18,44 +18,62 @@ export const BALANCE = {
expansion: {
colsPerPurchase: 10,
rowsPerPurchase: 10,
costBase: 1000,
costMultiplier: 4 / 3,
truncateUnit: 100
costBase: 850,
costGrowthRate: 0.75,
truncateUnit: 50
}
},
map: {
blockedRatio: 0.075
blockedRatio: 0.00,
blockedExpansionIncrement: 0.0075,
blockedRatioMax: 0.12
},
economy: {
income: {
mixer: 5,
truck: 10
truck: 10,
sausage: 26
},
poopFine: 10,
zundaTax: {
exemption: 500,
legalWorkExemptionBonus: 250,
maxRateCash: 10000,
stepAmount: 500,
stepRate: 0.05,
maxRate: 0.95
},
fairiesTribute: {
perDay: 10
perDay: 10,
profitExemption: 300,
legalWorkExemptionBonus: 250,
profitRate: 0.12
},
incomeUpgradeRate: 1.10,
explosionDamageDivisor: 30,
maleTruckFinePerHalfDay: 30,
shredderBonus: {
maxCards: 30,
probabilityPercentDivisor: 2
payoutCoefficient: 1.6,
payoutExponent: 1.25,
baseHitChance: 0.28,
hitChancePerUpgrade: 0.018,
maxHitChance: 0.82,
jackpotBaseChance: 0.01,
jackpotChancePerUpgrade: 0.0025,
jackpotMaxChance: 0.08,
bigWinBaseChance: 0.07,
bigWinChancePerUpgrade: 0.0015,
bigWinMaxChance: 0.12,
doubleChance: 0.20,
jackpotMultiplier: 10,
bigWinMultiplier: 4,
doubleMultiplier: 2
},
chemicalWeaponSubsidy: {
poopPerBlock: 70,
yenPerBlock: 1200
}
},
production: {
poopRate: 0.08,
autoScannerCooldown: 3.5,
autoScannerCooldown: 1.5,
autoScannerUpgradeRate: 0.95,
autoScannerCardUpgradeRate: 0.95,
autoScannerMinCooldown: 0.5,
poopRateCardBonus: 0.04,
eggSpawnRanges: [
[1.95, 4.35],
[1.55, 3.45],
@ -66,7 +84,49 @@ export const BALANCE = {
conveyor: {
baseSpeed: 52,
maxSpeed: 300,
bearingSpeedMultiplier: 1.075
bearingSpeedMultiplier: 1.075,
minimumItemSpacingPx: 25
},
power: {
recalcHz: 10,
maxAssignmentPasses: 8,
wires: {
powerWire: { id: 'powerWire', name: 'Power Wire', shortName: 'WIRE', price: 10, lossV: 0.5 },
insulatedPowerWire: { id: 'insulatedPowerWire', name: 'Insulated Wire', shortName: 'INS WIRE', price: 22, lossV: 0.15 }
},
exempt: ['manualScanner', 'eggFarm', 'conveyor', 'boostConveyor', 'powerWire', 'insulatedPowerWire', 'footbridge', 'starterGenerator', 'biomassGenerator', 'coalGenerator'],
machines: {
mixer: { minVoltage: 10, drawV: 4, relayDropV: 4, powerPriority: 100 },
trash: { minVoltage: 8, drawV: 3, relayDropV: 3, powerPriority: 100 },
truck: { minVoltage: 6, drawV: 2, relayDropV: 2, powerPriority: 100 },
autoScanner: { minVoltage: 8, drawV: 2, relayDropV: 2, powerPriority: 50 },
sausageMaker: { minVoltage: 12, drawV: 5, relayDropV: 5, powerPriority: 50 },
maintenanceRoom: { minVoltage: 6, drawV: 1, relayDropV: 1, powerPriority: 50 },
inboundTruck: { minVoltage: 5, drawV: 1, relayDropV: 1, powerPriority: 50 }
},
generators: {
starterGenerator: { outputV: 25 },
biomassGenerator: { baseOutputV: 0, pulseVoltage: 5, pulseSeconds: 2 },
coalGenerator: { baseOutputV: 0, pulseVoltage: 15, pulseSeconds: 3 }
}
},
imports: {
combinedLimitPerTruck: 80,
prices: { coal: 2, zundaMochi: 4 },
jitterRatio: 0.15
},
farmQuality: {
overflowCoefficient: 0.45,
overflowExponent: 0.85,
minMultiplier: 1,
maxMultiplier: 2.5,
zundaSeconds: 8,
zundaMaxSeconds: 30,
zundaIntervalMultiplier: 0.55,
safeMinIntervalSeconds: 0.25
},
effects: {
explosionHorizontalVelocityMultiplier: 0.82
},
cards: {
definitions: [
@ -88,13 +148,22 @@ export const BALANCE = {
description: 'Choose one AUTO SCANNER on the map and raise it by 1 level.',
tags: ['SCANNER', 'ACTIVE']
},
{
id: 'autoScannerTuning',
title: 'AUTO SCANNER Tuning',
rarity: 'common',
type: 'instant',
description: 'All AUTO SCANNER cooldowns use default cooldown x0.95 per copy.',
requiresFacility: 'autoScanner',
tags: ['SCANNER', 'PASSIVE']
},
{
id: 'upgradeMixer',
title: 'MIXER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'mixer',
description: 'Choose MIXER on the map and raise it by 1 level.',
description: 'Choose one MIXER. Each level improves meat quality and raises sausage sale value by 10%.',
tags: ['ECONOMY', 'ACTIVE']
},
{
@ -106,21 +175,31 @@ export const BALANCE = {
description: 'Choose TRUCK on the map and raise it by 1 level.',
tags: ['ECONOMY', 'ACTIVE']
},
{
id: 'upgradeSausageMaker',
title: 'SAUSAGE MACHINE Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'sausageMaker',
description: 'Choose one SAUSAGE MACHINE. Each level raises sausage sale value by 10%.',
tags: ['ECONOMY', 'SAUSAGE', 'ACTIVE']
},
{
id: 'upgradeTrash',
title: 'SHREDDER Improvement',
rarity: 'common',
type: 'equipmentUpgrade',
target: 'trash',
description: 'Shred poops for a little money. Max 30 upgrades.',
description: 'Adds a nonlinear scrap-lottery upgrade. More copies raise both payout and win chance; max 30.',
tags: ['POOP', 'ACTIVE']
},
{
id: 'crowdedFarming',
title: 'Crowded Farming Contract',
title: 'Crowded Farming',
rarity: 'common',
type: 'instant',
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.',
requiresFacility: 'eggFarm',
tags: ['EGG', 'RISK', 'PASSIVE']
},
{
@ -129,8 +208,17 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'EGG production interval -4%. Poop rate +3%. No card limit.',
requiresFacility: 'eggFarm',
tags: ['EGG', 'POOP', 'PASSIVE']
},
{
id: 'poopRateBooster',
title: 'Poop Culture',
rarity: 'common',
type: 'instant',
description: 'Base poop rate +4 percentage points. No card limit.',
tags: ['POOP', 'PASSIVE']
},
{
id: 'safetyCover',
title: 'Safety Cover',
@ -145,6 +233,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'All equipment deterioration speed -5%. No card limit.',
requiresAnyFacility: ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'sausageMaker'],
tags: ['MAINTENANCE', 'PASSIVE']
},
{
@ -161,6 +250,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'All equipment maximum durability +3%. No card limit.',
requiresAnyFacility: ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'sausageMaker'],
tags: ['MAINTENANCE', 'PASSIVE']
},
{
@ -169,6 +259,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'Conveyor speed +7.5%.',
requiresFacility: 'conveyor',
tags: ['CONVEYOR', 'PASSIVE']
},
{
@ -177,9 +268,8 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'Restores 2% durability to every machine. A box of almost-compatible parts.',
tags: ['MAINTENANCE', 'ACTIVE'],
weightBase: 100,
weightLossPerCopy: 2
requiresAnyFacility: ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'sausageMaker'],
tags: ['MAINTENANCE', 'ACTIVE']
},
{
id: 'dudRefund',
@ -203,6 +293,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'Turns shredder waste into suspicious fertilizer. Gain JPY 3 per copy when SHREDDER processes poop.',
requiresFacility: 'trash',
tags: ['POOP', 'ECONOMY', 'PASSIVE']
},
{
@ -211,6 +302,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'Manual and Auto Scanner queue spacing -15%. No card limit.',
requiresAnyFacility: ['manualScanner', 'autoScanner'],
tags: ['SCANNER', 'PASSIVE']
},
{
@ -236,7 +328,7 @@ export const BALANCE = {
rarity: 'rare',
type: 'cellAction',
target: 'blockedCell',
description: 'Remove any 3 unbuildable cells',
description: 'Choose and remove up to 3 unbuildable cells.',
tags: ['RISK', 'RARE', 'ONE-SHOT']
},
{
@ -263,14 +355,6 @@ export const BALANCE = {
description: 'Next Fairies tribute -50%.',
tags: ['ECONOMY', 'RARE', 'ONE-SHOT']
},
{
id: 'loan',
title: 'Loan',
rarity: 'rare',
type: 'instant',
description: 'Gain JPY 300 per elapsed day now. Pay JPY 400 per day for that many days starting tomorrow.',
tags: ['ECONOMY', 'RISK', 'RARE']
},
{
id: 'extraCards',
title: 'Extra Cards',
@ -284,7 +368,7 @@ export const BALANCE = {
title: 'Legal Work',
rarity: 'rare',
type: 'instant',
description: 'ZUNDA TAX exemption +¥250. The 95% cap point also moves up by ¥250.',
description: 'Fairies tribute profit exemption +JPY 250.',
tags: ['ECONOMY', 'RARE', 'PASSIVE']
},
{
@ -292,7 +376,7 @@ export const BALANCE = {
title: 'Chemical Weapons Subsidy',
rarity: 'ultraRare',
type: 'instant',
description: 'Each day, gain JPY 2000 per 100 poop shipped by truck. No card limit.',
description: 'Each day, gain JPY 1200 per 70 poop shipped by truck. No card limit.',
tags: ['POOP', 'ULTRA RARE', 'PASSIVE']
},
{
@ -300,7 +384,7 @@ export const BALANCE = {
title: 'Motivational Exploitation',
rarity: 'ultraRare',
type: 'instant',
description: 'Repairman labor cost -30%. The repairman smiles with bloodshot eyes. One time only.',
description: 'Repair room work time -30%. The repairman smiles with bloodshot eyes. One time only.',
tags: ['MAINTENANCE', 'ULTRA RARE', 'PASSIVE']
},
{
@ -316,8 +400,9 @@ export const BALANCE = {
bearing: 0,
legalWork: 0,
fairiesFlatteryNext: 0,
autoScannerTuning: 0,
poopRateBooster: 0,
crowdedFarming: 0,
extraEggOutlet: 0,
hatchingFeed: 0,
safetyCover: 0,
recyclingSubsidy: 0,
@ -332,8 +417,7 @@ export const BALANCE = {
scannerQueueSpacing: 0,
laborExploitation: 0,
usedMachineActive: false,
rescueLoanCharges: 0,
loans: []
rescueLoanCharges: 0
},
commonWeight: 8,
rareWeight: 2,
@ -348,8 +432,8 @@ export const BALANCE = {
multiplier: 100
}
},
contracts: {
eventChance: 0.70,
events: {
eventChance: 0.10,
firstTurn: 8
},
maintenance: {
@ -360,14 +444,19 @@ export const BALANCE = {
eggFarm: 475,
autoScanner: 850,
mixer: 775,
trash: 950
trash: 950,
sausageMaker: 825,
starterGenerator: 1000,
biomassGenerator: 900,
coalGenerator: 900,
inboundTruck: 850
},
processingDelayMaxSeconds: {
mixer: 1.20,
trash: 1.00
trash: 1.00,
sausageMaker: 0.90
},
repairman: {
dailyCost: 100,
secondsPerOnePercent: 0.5,
walkSpeed: 90
}
@ -381,16 +470,28 @@ export const BALANCE = {
id: 'boostConveyor', type: 'conveyor', name: 'Boost Conveyor', shortName: 'BOOST BELT', price: 75,
buildable: true, upgradeable: false, speedMultiplier: 2
},
powerWire: {
id: 'powerWire', type: 'powerWire', name: 'Power Wire', shortName: 'WIRE', price: 10,
buildable: true, upgradeable: false
},
insulatedPowerWire: {
id: 'insulatedPowerWire', type: 'powerWire', name: 'Insulated Power Wire', shortName: 'INS WIRE', price: 22,
buildable: true, upgradeable: false
},
footbridge: {
id: 'footbridge', type: 'footbridge', name: 'Footbridge', shortName: 'BRIDGE', price: 90,
buildable: true, upgradeable: false, footprint: { cols: 1, rows: 3 }
},
eggFarm: {
id: 'eggFarm', type: 'eggFarm', name: 'Egg Farm', shortName: 'EGG', price: 250,
buildable: true, upgradeable: true, maxLevel: 4, upgradeCosts: [null, 300, 720, 1600]
buildable: true, upgradeable: true, maxLevel: 4
},
manualScanner: {
id: 'manualScanner', type: 'scanner', kind: 'manual', name: 'Manual Scanner', shortName: 'Manual Scanner', price: 260,
buildable: true, upgradeable: false
},
autoScanner: {
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 360,
id: 'autoScanner', type: 'scanner', kind: 'auto', name: 'Auto Scanner', shortName: 'Auto Scanner', price: 180,
buildable: true, upgradeable: false
},
mixer: {
@ -402,8 +503,32 @@ export const BALANCE = {
buildable: true, upgradeable: true, body: { w: 180, h: 100 }
},
truck: {
id: 'truck', type: 'facility', name: 'Truck', shortName: 'TRUCK', price: 450,
id: 'truck', type: 'facility', name: 'Shipping Truck', shortName: 'SHIP TRUCK', price: 450,
buildable: true, upgradeable: true, incomeKey: 'truck', body: { w: 176, h: 138 }
},
sausageMaker: {
id: 'sausageMaker', type: 'facility', name: 'Sausage Machine', shortName: 'SAUSAGE', price: 380,
buildable: true, upgradeable: true, incomeKey: 'sausage', footprint: { cols: 3, rows: 3 }, body: { w: 138, h: 138 }, outputProduct: 'sausage'
},
starterGenerator: {
id: 'starterGenerator', type: 'facility', name: 'Starter Generator', shortName: 'STARTER', price: 0,
buildable: false, upgradeable: false, footprint: { cols: 2, rows: 2 }, body: { w: 92, h: 92 }
},
biomassGenerator: {
id: 'biomassGenerator', type: 'facility', name: 'Biomass Generator', shortName: 'BIOMASS', price: 520,
buildable: true, upgradeable: false, footprint: { cols: 3, rows: 3 }, body: { w: 138, h: 138 }
},
coalGenerator: {
id: 'coalGenerator', type: 'facility', name: 'Coal Generator', shortName: 'COAL GEN', price: 680,
buildable: true, upgradeable: false, footprint: { cols: 2, rows: 3 }, body: { w: 92, h: 138 }
},
inboundTruck: {
id: 'inboundTruck', type: 'facility', name: 'Inbound Truck', shortName: 'INBOUND', price: 350,
buildable: true, upgradeable: false, body: { w: 176, h: 120 }
},
maintenanceRoom: {
id: 'maintenanceRoom', type: 'maintenanceRoom', name: 'Maintenance Room', shortName: 'REPAIR ROOM', price: 220,
buildable: true, upgradeable: false, footprint: { cols: 2, rows: 2 }
}
}
};

View file

@ -9,10 +9,12 @@ export const ECONOMY = BALANCE.economy;
export const FARM_SHUTDOWN_GRACE_SECONDS = BALANCE.time.farmShutdownGraceSeconds;
export const AUTO_SCANNER_COOLDOWN = BALANCE.production.autoScannerCooldown;
export const AUTO_SCANNER_UPGRADE_RATE = BALANCE.production.autoScannerUpgradeRate;
export const AUTO_SCANNER_CARD_UPGRADE_RATE = BALANCE.production.autoScannerCardUpgradeRate || BALANCE.production.autoScannerUpgradeRate;
export const AUTO_SCANNER_MIN_COOLDOWN = BALANCE.production.autoScannerMinCooldown;
export const CONTRACT_EVENT_CHANCE = BALANCE.contracts.eventChance;
export const CONTRACT_EVENT_FIRST_TURN = BALANCE.contracts.firstTurn;
export const DAILY_EVENT_CHANCE = BALANCE.events.eventChance;
export const DAILY_EVENT_FIRST_TURN = BALANCE.events.firstTurn;
export const POOP_RATE = BALANCE.production.poopRate;
export const POOP_RATE_CARD_BONUS = BALANCE.production.poopRateCardBonus || 0;
export const CONVEYOR_SPEED = BALANCE.conveyor.baseSpeed;
export const CONVEYOR_SPEED_MAX = BALANCE.conveyor.maxSpeed;
export const BEARING_SPEED_MULTIPLIER = BALANCE.conveyor.bearingSpeedMultiplier;
@ -24,9 +26,12 @@ 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', 'boostConveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck'];
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
export const INCOME_FACILITY_IDS = ['mixer', 'truck'];
export const BUILD_TOOL_IDS = ['conveyor', 'boostConveyor', 'powerWire', 'insulatedPowerWire', 'footbridge', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck', 'inboundTruck', 'sausageMaker', 'biomassGenerator', 'coalGenerator', 'maintenanceRoom'];
export const EDGE_FACILITY_IDS = ['mixer', 'trash', 'truck', 'inboundTruck'];
export const GRID_FACILITY_IDS = ['sausageMaker', 'starterGenerator', 'biomassGenerator', 'coalGenerator'];
export const MACHINE_FACILITY_IDS = [...EDGE_FACILITY_IDS, ...GRID_FACILITY_IDS];
export const REQUIRED_RECEIVER_FACILITY_IDS = ['mixer', 'trash', 'truck'];
export const INCOME_FACILITY_IDS = ['mixer', 'truck', 'sausageMaker'];
export const BUILD_COSTS = Object.fromEntries(Object.entries(FACILITY_DEFS).map(([id, def]) => [id, def.price]));
@ -38,20 +43,22 @@ export const DIRS = [
];
export const THEME = {
bg: '#eefbea',
ink: '#102015',
muted: '#526456',
green: '#22b94f',
greenSoft: '#b7f5c6',
mixerBlue: '#2477ff',
truckPink: '#ff6aa8',
wasteGreen: '#22b94f',
danger: '#c42424',
warn: '#f0a020',
white: '#ffffff',
bg: '#111318',
ink: '#090a0d',
muted: '#8f9bab',
green: '#7bd84f',
greenSoft: '#273c24',
mixerBlue: '#4aa3ff',
truckPink: '#ffb33e',
wasteGreen: '#88d35a',
meatRed: '#d96b72',
sausageOrange: '#d78b3f',
danger: '#e34a36',
warn: '#ffb827',
white: '#e6edf2',
poop: '#7b4a23',
male: '#62a9ff',
female: '#ff8bc6'
male: '#4aa3ff',
female: '#ff6fae'
};
export const EFFECT_PRIORITY = {

View file

@ -1,6 +1,7 @@
import { BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
import { isOwnedCell } from './gridExpansion.js';
import { nextSpawnDelay } from './state.js';
import { currentPoopRate } from '../systems/contracts.js';
import { currentPoopRate } from '../systems/events.js';
const DEFAULT_MANUAL_KEYS = [
[{ key: 'a', code: 'KeyA', label: 'A' }, { key: 'd', code: 'KeyD', label: 'D' }],
@ -49,7 +50,45 @@ export function createScanner(game, col, row, kind) {
};
}
export function nearestGridEdge(point) {
function distanceToSegment(point, a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
const t = len2 > 0 ? Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / len2)) : 0;
const x = a.x + dx * t;
const y = a.y + dy * t;
return Math.hypot(point.x - x, point.y - y);
}
function ownedBoundaryEdges(game) {
const edges = [];
const cells = game?.ownedCells?.size
? [...game.ownedCells].map(k => {
const [col, row] = String(k).split(',').map(Number);
return { col, row };
})
: Array.from({ length: GRID.cols * GRID.rows }, (_, i) => ({ col: i % GRID.cols, row: Math.floor(i / GRID.cols) }));
for (const cell of cells) {
const x0 = GRID.x + cell.col * GRID.cell;
const y0 = GRID.y + cell.row * GRID.cell;
const x1 = x0 + GRID.cell;
const y1 = y0 + GRID.cell;
if (!isOwnedCell(game, cell.col - 1, cell.row)) edges.push({ side: 'left', entry: cell, a: { x: x0, y: y0 }, b: { x: x0, y: y1 } });
if (!isOwnedCell(game, cell.col + 1, cell.row)) edges.push({ side: 'right', entry: cell, a: { x: x1, y: y0 }, b: { x: x1, y: y1 } });
if (!isOwnedCell(game, cell.col, cell.row - 1)) edges.push({ side: 'top', entry: cell, a: { x: x0, y: y0 }, b: { x: x1, y: y0 } });
if (!isOwnedCell(game, cell.col, cell.row + 1)) edges.push({ side: 'bottom', entry: cell, a: { x: x0, y: y1 }, b: { x: x1, y: y1 } });
}
return edges;
}
export function nearestGridEdge(point, game = null) {
const boundary = ownedBoundaryEdges(game);
if (boundary.length) {
const best = boundary
.map(edge => ({ ...edge, distance: distanceToSegment(point, edge.a, edge.b) }))
.sort((a, b) => a.distance - b.distance)[0];
return { side: best.side, entry: { ...best.entry } };
}
const colFloat = (point.x - GRID.x) / GRID.cell;
const rowFloat = (point.y - GRID.y) / GRID.cell;
const col = Math.max(0, Math.min(GRID.cols - 1, Math.round(colFloat - 0.5)));
@ -65,20 +104,88 @@ export function nearestGridEdge(point) {
return { side, entry: { col, row: GRID.rows - 1 } };
}
function facilityKind(facility) { return facility?.baseId || facility?.id || ''; }
export function syncGridFacilityGeometry(facility) {
if (!facility?.gridPlaced) return facility;
const def = FACILITY_DEFS[facilityKind(facility)] || {};
facility.wCells = Math.max(1, facility.wCells || def.footprint?.cols || 3);
facility.hCells = Math.max(1, facility.hCells || def.footprint?.rows || 3);
facility.x = GRID.x + facility.col * GRID.cell;
facility.y = GRID.y + facility.row * GRID.cell;
facility.w = facility.wCells * GRID.cell;
facility.h = facility.hCells * GRID.cell;
if (facilityKind(facility) === 'sausageMaker') {
facility.entry = { col: facility.col - 1, row: facility.row + Math.floor(facility.hCells / 2) };
facility.side = 'grid';
}
if (facilityKind(facility) === 'biomassGenerator' || facilityKind(facility) === 'coalGenerator') {
facility.entry = { col: facility.col - 1, row: facility.row + Math.floor(facility.hCells / 2) };
facility.side = 'grid';
}
return facility;
}
export function gridFacilityFootprintCells(facility) {
if (!facility?.gridPlaced) return [];
const cells = [];
for (let dr = 0; dr < (facility.hCells || 3); dr += 1) {
for (let dc = 0; dc < (facility.wCells || 3); dc += 1) cells.push({ col: facility.col + dc, row: facility.row + dr });
}
return cells;
}
function orientEdgeFacility(facility, side) {
if (facilityKind(facility) !== 'mixer') return;
const baseW = facility.baseW || facility.w || 168;
const baseH = facility.baseH || facility.h || 110;
facility.baseW = baseW;
facility.baseH = baseH;
const horizontal = side === 'top' || side === 'bottom';
facility.w = horizontal ? Math.max(baseW, baseH) : Math.min(baseW, baseH);
facility.h = horizontal ? Math.min(baseW, baseH) : Math.max(baseW, baseH);
}
export function facilityBodyPortPoint(facility, type = 'input') {
if (!facility) return null;
const kind = facilityKind(facility);
if (kind === 'mixer') {
const horizontal = facility.side === 'top' || facility.side === 'bottom';
if (horizontal) return type === 'input'
? { x: facility.x + facility.w / 2, y: facility.y }
: { x: facility.x + facility.w / 2, y: facility.y + facility.h };
return type === 'input'
? { x: facility.x, y: facility.y + facility.h / 2 }
: { x: facility.x + facility.w, y: facility.y + facility.h / 2 };
}
if (kind === 'sausageMaker' && facility.gridPlaced) {
return type === 'input'
? { x: facility.x, y: facility.y + facility.h / 2 }
: { x: facility.x + facility.w, y: facility.y + facility.h / 2 };
}
return { x: facility.x + facility.w / 2, y: facility.y + facility.h / 2 };
}
export function layoutFacilityOnEdge(facility, entry, side) {
const center = { x: GRID.x + entry.col * GRID.cell + GRID.cell / 2, y: GRID.y + entry.row * GRID.cell + GRID.cell / 2 };
const edge = { left: GRID.x, right: GRID.x + GRID.cols * GRID.cell, top: GRID.y, bottom: GRID.y + GRID.rows * GRID.cell };
orientEdgeFacility(facility, side);
const cell = {
x0: GRID.x + entry.col * GRID.cell,
y0: GRID.y + entry.row * GRID.cell,
x1: GRID.x + (entry.col + 1) * GRID.cell,
y1: GRID.y + (entry.row + 1) * GRID.cell
};
const center = { x: (cell.x0 + cell.x1) / 2, y: (cell.y0 + cell.y1) / 2 };
facility.entry = { ...entry };
facility.side = side;
if (side === 'left') { facility.x = edge.left - facility.w - 18; facility.y = center.y - facility.h / 2; }
else if (side === 'right') { facility.x = edge.right + 18; facility.y = center.y - facility.h / 2; }
else if (side === 'top') { facility.x = center.x - facility.w / 2; facility.y = edge.top - facility.h - 18; }
else { facility.x = center.x - facility.w / 2; facility.y = edge.bottom + 18; }
if (side === 'left') { facility.x = cell.x0 - facility.w - 18; facility.y = center.y - facility.h / 2; }
else if (side === 'right') { facility.x = cell.x1 + 18; facility.y = center.y - facility.h / 2; }
else if (side === 'top') { facility.x = center.x - facility.w / 2; facility.y = cell.y0 - facility.h - 18; }
else { facility.x = center.x - facility.w / 2; facility.y = cell.y1 + 18; }
return facility;
}
export function createFacility(game, id, p, price) {
const { entry, side } = nearestGridEdge(p);
const { entry, side } = nearestGridEdge(p, game);
const def = FACILITY_DEFS[id];
const spec = def
? { name: def.name, w: def.body?.w || 168, h: def.body?.h || 110, price: def.price }
@ -86,14 +193,54 @@ export function createFacility(game, id, p, price) {
const f = {
type: 'facility', id,
name: spec.name,
x: 0, y: 0, w: spec.w, h: spec.h,
x: 0, y: 0, w: spec.w, h: spec.h, baseW: spec.w, baseH: spec.h,
level: 1,
price: price || spec.price,
builtSession: game.buildSession
};
if (id === 'inboundTruck') f.inboundOrder = { coalPerDay: 0, zundaMochiPerDay: 0 };
return layoutFacilityOnEdge(f, entry, side);
}
export function createGridFacility(game, id, col, row, price) {
const def = FACILITY_DEFS[id];
const f = {
type: 'facility', id,
name: def?.name || id,
baseId: id,
col, row,
wCells: def?.footprint?.cols || (id === 'sausageMaker' ? 3 : 1),
hCells: def?.footprint?.rows || (id === 'sausageMaker' ? 3 : 1),
gridPlaced: true,
level: 1,
price: price || def?.price || 0,
builtSession: game.buildSession
};
if (id === 'biomassGenerator' || id === 'coalGenerator') f.fuelPulses = [];
return syncGridFacilityGeometry(f);
}
export function maintenanceRoomFootprint(room) {
if (!room) return [];
return [
{ col: room.col, row: room.row },
{ col: room.col + 1, row: room.row },
{ col: room.col, row: room.row + 1 },
{ col: room.col + 1, row: room.row + 1 }
];
}
export function createMaintenanceRoom(game, col, row) {
return {
type: 'maintenanceRoom', id: game.nextId++, col, row,
price: BUILD_COSTS.maintenanceRoom,
builtSession: game.buildSession,
wCells: 2, hCells: 2
};
}
export function rollChickSex(game) {
return Math.random() < currentPoopRate(game) ? 'poop' : (Math.random() < 0.5 ? 'male' : 'female');
}

View file

@ -15,17 +15,20 @@ function parseChunkKey(value) {
export function expansionCost(game) {
const cfg = BALANCE.grid.expansion || {};
const purchaseCount = Math.max(1, (game.gridExpansionPurchases || 0) + 1);
const raw = (cfg.costBase || 1000) * purchaseCount * (cfg.costMultiplier || (4 / 3));
const unit = Math.max(1, cfg.truncateUnit || 100);
const base = Math.max(0, Number(cfg.costBase) || 850);
const growth = Math.max(0, Number(cfg.costGrowthRate) || 0.75);
const raw = base * (1 + growth * (purchaseCount - 1));
const unit = Math.max(1, cfg.truncateUnit || 50);
return Math.floor(raw / unit) * unit;
}
export function resetOwnedCells(game) {
game.ownedCells = new Set();
game.ownedChunks = new Set();
game.gridChunkOriginX = 0;
game.gridChunkOriginY = 0;
game.gridExpansionPurchases = 0;
game.gridExpansionPurchasesByDirection = { right: 0, up: 0 };
game.gridExpansionPurchasesByDirection = { left: 0, 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));
}
@ -41,7 +44,8 @@ export function ensureOwnedChunks(game) {
const originY = game.gridChunkOriginY || 0;
for (const k of game.ownedCells) {
const p = parseKey(k);
const chunkX = Math.floor(p.col / GRID_CHUNK_SIZE);
const originX = game.gridChunkOriginX || 0;
const chunkX = Math.floor(p.col / GRID_CHUNK_SIZE) + originX;
const chunkY = Math.floor(p.row / GRID_CHUNK_SIZE) + originY;
game.ownedChunks.add(chunkKey(chunkX, chunkY));
}
@ -63,13 +67,14 @@ export function countOwnedCells(game) {
}
export function lotFromChunk(game, chunkX, chunkY, direction = 'up') {
const originX = game.gridChunkOriginX || 0;
const originY = game.gridChunkOriginY || 0;
return {
direction,
key: chunkKey(chunkX, chunkY),
chunkX,
chunkY,
colStart: chunkX * GRID_CHUNK_SIZE,
colStart: (chunkX - originX) * GRID_CHUNK_SIZE,
rowStart: (chunkY - originY) * GRID_CHUNK_SIZE,
cols: GRID_CHUNK_SIZE,
rows: GRID_CHUNK_SIZE
@ -81,17 +86,19 @@ export function expansionLots(game) {
const candidates = new Map();
for (const k of owned) {
const { chunkX, chunkY } = parseChunkKey(k);
const left = { chunkX: chunkX - 1, chunkY, direction: 'left' };
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 [left, right, up, down]) {
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 = { left: 0, right: 1, up: 2, down: 3 };
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;
@ -155,6 +162,24 @@ export function shiftRowIndexedMap(map, rowDelta) {
return next;
}
export function shiftColIndexedSet(set, colDelta) {
const next = new Set();
for (const k of set || []) {
const p = parseKey(k);
next.add(key(p.col + colDelta, p.row));
}
return next;
}
export function shiftColIndexedMap(map, colDelta) {
const next = new Map();
for (const [k, v] of map || []) {
const p = parseKey(k);
next.set(key(p.col + colDelta, p.row), v);
}
return next;
}
export function ownChunk(game, chunkX, chunkY) {
ensureOwnedChunks(game).add(chunkKey(chunkX, chunkY));
}

View file

@ -20,6 +20,12 @@ function mulberry32(seed) {
};
}
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));
}
export function createRunSeed() {
return `RUN-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`.toUpperCase();
}
@ -38,7 +44,7 @@ export function generateBlockedCells({ seed, targetRatio = 0.20, reserved = [] }
const j = Math.floor(rng() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
const target = Math.round(GRID.cols * GRID.rows * targetRatio);
const target = Math.round(GRID.cols * GRID.rows * targetRatio + 1e-9);
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
}
@ -60,6 +66,6 @@ export function generateBlockedCellsInRect({ seed, colStart = 0, colEnd = GRID.c
const j = Math.floor(rng() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
const target = Math.round((c1 - c0) * (r1 - r0) * targetRatio);
const target = Math.round((c1 - c0) * (r1 - r0) * targetRatio + 1e-9);
return new Set(candidates.slice(0, Math.min(target, candidates.length)).map(c => c.k));
}

View file

@ -21,15 +21,16 @@ export function newTurnStats() {
pendingTruckRevenue: 0,
mixerRevenue: 0,
truckRevenue: 0,
sausageRevenue: 0,
meatProduced: 0,
sausageProduced: 0,
sausageShipped: 0,
chickShipmentIncome: 0,
poopShipmentIncome: 0,
manualComboBonus: 0,
manualComboSuccess: 0,
manualComboFailure: 0,
repairWorkerWages: 0,
zundaTax: 0,
fairiesTribute: 0,
loanRepayment: 0,
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
cardRerollCost: 0,
@ -43,10 +44,34 @@ export function newTurnStats() {
shredderBonus: 0,
correct: 0,
mistakes: 0,
contractBonus: 0,
contractResult: null,
eventBonus: 0,
explosions: 0,
eventResult: null,
conveyorPasses: 0,
eggProduced: 0
eggProduced: 0,
coalOrdered: 0,
coalReleased: 0,
coalConsumed: 0,
coalUndelivered: 0,
zundaOrdered: 0,
zundaReleased: 0,
zundaConsumed: 0,
zundaUndelivered: 0,
zundaBoostSeconds: 0,
zundaBoostSecondsByFarm: {},
importPurchaseCost: 0,
biomassFuelConsumed: 0,
maxGeneratorOutput: {},
qualityRevenue: 0,
farmTheoreticalRate: 0,
farmActualOutputRate: 0,
qualityMultiplierSum: 0,
qualityMultiplierCount: 0,
maxQualityMultiplier: 1,
generatorAssignmentChanges: 0,
underVoltageSeconds: 0,
underVoltageSecondsByMachine: {},
unreachableRepairTargets: 0
};
}
@ -64,18 +89,19 @@ export function newTotalStats() {
poopTruck: 0,
poopTrash: 0,
poopMixer: 0,
meatProduced: 0,
sausageProduced: 0,
sausageShipped: 0,
explosionDamage: 0,
shredderBonus: 0,
contractBonus: 0,
zundaTax: 0,
eventBonus: 0,
explosions: 0,
fairiesTribute: 0,
loanRepayment: 0,
chemicalWeaponSubsidy: 0,
rescueLoan: 0,
manualComboBonus: 0,
manualComboSuccess: 0,
manualComboFailure: 0,
repairWorkerWages: 0,
cardRerollCost: 0,
dudRefundIncome: 0,
composterIncome: 0,
@ -83,10 +109,25 @@ export function newTotalStats() {
mixerPoopFine: 0,
truckPoopFine: 0,
maleTruckFine: 0,
contractSuccess: 0,
contractFailed: 0,
eventSuccess: 0,
eventFailed: 0,
conveyorPasses: 0,
eggProduced: 0
eggProduced: 0,
coalOrdered: 0,
coalReleased: 0,
coalConsumed: 0,
coalUndelivered: 0,
zundaOrdered: 0,
zundaReleased: 0,
zundaConsumed: 0,
zundaUndelivered: 0,
zundaBoostSeconds: 0,
importPurchaseCost: 0,
biomassFuelConsumed: 0,
qualityRevenue: 0,
generatorAssignmentChanges: 0,
underVoltageSeconds: 0,
unreachableRepairTargets: 0
};
}
@ -106,11 +147,13 @@ export function createGame() {
gridCols: BALANCE.grid.cols,
gridRows: BALANCE.grid.rows,
gridExpansionPurchases: 0,
gridExpansionPurchasesByDirection: { right: 0, up: 0 },
gridExpansionPurchasesByDirection: { left: 0, right: 0, up: 0, down: 0 },
gridChunkOriginX: 0,
gridChunkOriginY: 0,
ownedCells: new Set(),
blockedCells: new Set(),
buildTool: null,
footbridgeOrientation: 'vertical',
buildSession: 0,
undoStack: [],
redoStack: [],
@ -126,11 +169,20 @@ export function createGame() {
floatingTexts: [],
shake: { time: 0, strength: 0 },
truckCargo: [],
shippedCargo: [],
conveyorTiles: new Set(),
conveyorMeta: new Map(),
powerWires: new Map(),
footbridges: new Map(),
powerOverlay: false,
power: { dirty: true, elapsed: 0, routes: new Map(), assignments: new Map(), sources: new Map(), underVoltage: new Set() },
imports: { schedules: new Map() },
walkabilityVersion: 0,
eggFarms: [],
scanners: [],
facilities: {},
maintenanceRooms: [],
repairmen: [],
branchCounters: new Map(),
routingVersion: 0,
factoryGraph: null,
@ -138,15 +190,28 @@ export function createGame() {
factoryGraphSnapshot: null,
mixerHalfTimer: 0,
congestion: new Map(),
componentLookup: new Map(),
frameId: 0,
frameCache: {
frameId: -1,
routingVersion: -1,
chickRevision: 0,
spatialRevision: -1,
spatial: new Map(),
congestionFrame: -1,
componentChicks: new Map(),
manualMonitorSignature: '',
draw: {}
},
lastExplodedComponent: new Map(),
contractOffer: null,
contractActive: null,
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, crowdedFarming: 0, extraEggOutlet: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, sparePartsBin: 0, dudRefund: 0, freeReroll: 0, composter: 0, scannerQueueSpacing: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0, loans: [] },
eventOffer: null,
eventActive: null,
pendingFairiesTributeMultiplier: 1,
cardEffects: { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, autoScannerTuning: 0, poopRateBooster: 0, crowdedFarming: 0, hatchingFeed: 0, safetyCover: 0, recyclingSubsidy: 0, chemicalWeaponSubsidy: 0, preventiveMaintenance: 0, dudFilter: 0, durabilityCoating: 0, sparePartsBin: 0, dudRefund: 0, freeReroll: 0, composter: 0, scannerQueueSpacing: 0, laborExploitation: 0, usedMachineActive: false, rescueLoanCharges: 0 },
cardDraft: { pending: false, choices: [], rerolls: 0, picksRemaining: 0 },
cardTargetPick: null,
manualCombo: { count: 0, lastBonus: 0 },
pendingRepairWorkerWages: 0,
repairman: { hiredForNextDay: false, active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + BALANCE.grid.rows * GRID.cell + 84, target: null, repairedToday: 0 },
repairman: { active: false, x: GRID.x + GRID.cell * 0.5, y: GRID.y + BALANCE.grid.rows * GRID.cell + 84, target: null, repairedToday: 0 },
stats: newTurnStats(),
lastResult: null,
totals: newTotalStats()
@ -173,7 +238,14 @@ function facilityBodyForEntry(id, entry, side) {
else if (side === 'right') { x = g.right + 18; y = c.y - spec.h / 2; }
else if (side === 'bottom') { x = c.x - spec.w / 2; y = g.bottom + 18; }
else if (side === 'top') { x = c.x - spec.w / 2; y = g.top - spec.h - 18; }
return { type: 'facility', id, name: spec.name, x, y, w: spec.w, h: spec.h, level: 1, price: spec.price, entry: { ...entry }, side };
const facility = { type: 'facility', id, name: spec.name, x, y, w: spec.w, h: spec.h, baseW: spec.w, baseH: spec.h, level: 1, price: spec.price, entry: { ...entry }, side };
if (id === 'mixer' && (side === 'left' || side === 'right')) {
facility.w = Math.min(spec.w, spec.h);
facility.h = Math.max(spec.w, spec.h);
if (side === 'left') { facility.x = g.left - facility.w - 18; facility.y = c.y - facility.h / 2; }
else { facility.x = g.right + 18; facility.y = c.y - facility.h / 2; }
}
return facility;
}
export function defaultFacilities() {
@ -205,7 +277,7 @@ const INITIAL_SCANNERS = Object.freeze([
]);
const INITIAL_EGG_FARMS = Object.freeze([
{ type: 'eggFarm', id: 1, col: 1, row: 0, level: 1, extraEggOutlet: 0, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
{ type: 'eggFarm', id: 1, col: 1, row: 0, level: 1, nextSpawn: 2, lastInterval: 2, spawnCounter: 0, price: BUILD_COSTS.eggFarm, builtSession: null }
]);
function addConveyorTile(game, col, row, options = {}) {
@ -265,9 +337,7 @@ function scannerBodyCells(scanner) {
{ col: scanner.col, row: scanner.row - 1 },
{ col: scanner.col + 1, row: scanner.row - 1 },
{ col: scanner.col, row: scanner.row },
{ col: scanner.col + 1, row: scanner.row },
{ col: scanner.col, row: scanner.row + 1 },
{ col: scanner.col + 1, row: scanner.row + 1 }
{ col: scanner.col + 1, row: scanner.row }
].filter(p => inGrid(p.col, p.row));
}
@ -292,9 +362,73 @@ function protectedInitialCells(game) {
for (const p of farmOutputCells(farm)) add(p);
}
for (const f of Object.values(game.facilities)) add(f.entry);
for (const f of Object.values(game.facilities)) {
if (f.gridPlaced) {
for (let dr = 0; dr < (f.hCells || 1); dr += 1) for (let dc = 0; dc < (f.wCells || 1); dc += 1) add({ col: f.col + dc, row: f.row + dr });
}
}
return reserved;
}
function addPowerWire(game, col, row, kind = 'powerWire', free = false) {
if (!inGrid(col, row)) return;
game.powerWires.set(key(col, row), {
kind,
price: free ? 0 : (BALANCE.power.wires[kind]?.price || 0),
builtSession: free ? null : game.buildSession
});
}
function installStarterGenerator(game) {
const def = FACILITY_DEFS.starterGenerator;
const starter = {
type: 'facility',
id: 'starterGenerator',
baseId: 'starterGenerator',
name: def.name,
col: 6,
row: 6,
wCells: def.footprint.cols,
hCells: def.footprint.rows,
gridPlaced: true,
level: 1,
price: 0,
builtSession: null,
unsellable: true
};
starter.x = GRID.x + starter.col * GRID.cell;
starter.y = GRID.y + starter.row * GRID.cell;
starter.w = starter.wCells * GRID.cell;
starter.h = starter.hCells * GRID.cell;
game.facilities.starterGenerator = starter;
}
function createStarterPowerLayout(game) {
game.powerWires = new Map();
const routeCells = [
[5, 6], [4, 6], [3, 6], [2, 6], [1, 6], [0, 6], [0, 5], [0, 4],
[8, 7], [8, 8], [9, 8], [10, 8], [11, 8], [12, 8], [12, 9],
[13, 8], [14, 8], [15, 8], [16, 8], [17, 8], [18, 8], [18, 9]
];
for (const [col, row] of routeCells) addPowerWire(game, col, row, 'powerWire', true);
}
function validateStarterPowerLayout(game) {
const bodyCells = new Set();
for (const f of Object.values(game.facilities || {})) {
if (!f.gridPlaced) continue;
for (let dr = 0; dr < (f.hCells || 1); dr += 1) {
for (let dc = 0; dc < (f.wCells || 1); dc += 1) bodyCells.add(key(f.col + dc, f.row + dr));
}
}
for (const k of game.powerWires.keys()) {
if (game.blockedCells.has(k) || bodyCells.has(k)) throw new Error(`Invalid starter power wire at ${k}`);
}
for (const id of ['starterGenerator', 'mixer', 'trash', 'truck']) {
if (!game.facilities[id]) throw new Error(`Missing starter power facility: ${id}`);
}
}
function installInitialConveyorBackbone(game) {
const cells = INITIAL_CONVEYOR_BACKBONE.map(([col, row]) => ({ col, row }));
for (const { col, row } of cells) addConveyorTile(game, col, row);
@ -320,13 +454,17 @@ export function resetLayout(game) {
game.repairman.y = GRID.y + GRID.rows * GRID.cell + 84;
game.repairman.target = null;
game.repairman.active = false;
game.repairman.hiredForNextDay = false;
}
game.conveyorTiles.clear();
game.conveyorMeta.clear();
game.powerWires = new Map();
game.footbridges = new Map();
game.maintenanceRooms = [];
game.repairmen = [];
game.blockedCells = new Set();
installInitialConveyorBackbone(game);
game.facilities = defaultFacilities();
installStarterGenerator(game);
game.scanners = INITIAL_SCANNERS.map(scanner => ({ ...scanner, keys: scanner.keys ? { left: { ...scanner.keys.left }, right: { ...scanner.keys.right } } : null, queue: [] }));
game.eggFarms = INITIAL_EGG_FARMS.map(farm => {
const nextSpawn = nextSpawnDelay(farm);
@ -337,11 +475,17 @@ export function resetLayout(game) {
// 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.
// when roughly 3% of the initial map is blocked.
createStarterPowerLayout(game);
const reserved = protectedInitialCells(game);
for (const k of game.powerWires.keys()) reserved.add(k);
game.blockedCells = generateBlockedCells({ seed: game.runSeed, targetRatio: BALANCE.map.blockedRatio, reserved: [...reserved] });
scrubBlockedCellsFromInitialBackbone(game);
installInitialConveyorBackbone(game);
createStarterPowerLayout(game);
validateStarterPowerLayout(game);
game.power = { dirty: true, elapsed: 0, routes: new Map(), assignments: new Map(), sources: new Map(), underVoltage: new Set() };
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
game.routingVersion = (game.routingVersion || 0) + 1;
game.factoryGraph = null;
game.factoryGraphCommittedVersion = -1;

View file

@ -28,12 +28,15 @@ export const TEXT = {
sorting: 'Sorting: use scanner keys. Build after clearing the day.',
allPortsConnected: 'Start condition met: one EGG route reaches an exit.',
equipmentPanelEmpty: 'Hover equipment in Build phase.',
eventPanelRunning: 'Forced events appear in Build phase.'
},
routeLabels: {
mixer: 'MIXER',
truck: 'TRUCK',
inboundTruck: 'INBOUND',
trash: 'WASTE',
sausageMaker: 'SAUSAGE',
biomassGenerator: 'BIOMASS',
coalGenerator: 'COAL GEN',
scanner: 'NEXT',
'scanner-role-1': 'NEXT',
input: 'IN'
@ -67,18 +70,43 @@ export function buildToolFlavorText(id) {
conveyor: 'A narrow green belt. Routes decide whether chicks live, ship, or become invoices.',
boostConveyor: 'Moves chicks twice as fast. Wears down like a normal belt.',
eggFarm: 'A tiny gatehouse producing questionable eggs on schedule.',
autoScanner: 'Slower than hands without upgrades.',
autoScanner: 'Processes about 40 items per day at Lv1, roughly 2.1 Lv1 farms.',
manualScanner: 'A manual checkpoint. The operator is the algorithm.',
mixer: 'Male chicks become revenue here. Do not feed it poop.',
mixer: 'Makes meat when OUT is clear; otherwise falls back to direct sale.',
trash: 'A polite shredder for poop and other regrets.',
truck: 'Ships the target cargo. Wrong cargo still leaves a paper trail.'
truck: 'Shipping Truck. Ships target chicks and all sausages.',
powerWire: 'Overlay wire. Loses 0.5V per tile.',
insulatedPowerWire: 'Overlay wire. Loses 0.15V per tile.',
footbridge: 'Lets repair workers cross conveyor cells.',
inboundTruck: 'Imports coal and zunda mochi during the day.',
biomassGenerator: 'Fuel generator. Chicks and poop add timed voltage.',
coalGenerator: 'Fuel generator. Coal adds timed voltage.',
sausageMaker: 'A 3×3 grid machine. Left inlet takes 2 meat; right outlet emits 1 sausage.',
maintenanceRoom: '2×2 repair bay. Each room dispatches one repairman during the day.'
};
return flavors[id] || '';
}
function buildToolIconSrc(id) {
const icons = {
conveyor: './assets/dark_factory/facilities/conveyor/conveyor_straight_h_16.png',
boostConveyor: './assets/dark_factory/facilities/conveyor/boost_conveyor_straight_h_16.png',
eggFarm: './assets/dark_factory/facilities/machines/egg_farm_16.png?v=dark_factory',
autoScanner: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png',
manualScanner: './assets/dark_factory/facilities/machines/manual_scanner_32x48.png',
mixer: './assets/dark_factory/facilities/machines/mixer_32.png',
trash: './assets/dark_factory/facilities/machines/shredder_32.png',
truck: './assets/dark_factory/facilities/vehicles/truck_32.png',
sausageMaker: './assets/dark_factory/facilities/machines/mixer_32.png',
maintenanceRoom: './assets/dark_factory/facilities/utility/maintenance_room_32.png'
};
return icons[id] || '';
}
export function buildToolButtonHtml(id) {
const flavor = buildToolFlavorText(id);
return `<strong>${equipmentName(id)}</strong><span>${buildToolPriceText(id)}</span>${flavor ? `<small class="tool-flavor">${flavor}</small>` : ''}`;
const icon = buildToolIconSrc(id);
return `${icon ? `<img class="tool-icon" src="${icon}" alt="" aria-hidden="true" />` : ''}<strong>${equipmentName(id)}</strong><span class="tool-price">${buildToolPriceText(id)}</span>${flavor ? `<small class="tool-flavor">${flavor}</small>` : ''}`;
}
export function routeLabel(dest) {

View file

@ -1,19 +1,21 @@
import { GRID, TURN_SECONDS, THEME } from './core/config.js';
import { GRID, THEME } from './core/config.js';
import { buildToolButtonHtml } from './core/text.js';
import { createGame, resetLayout, newTurnStats } from './core/state.js';
import { clamp, pointToCell, cellCenter, key, yen } from './core/utils.js';
import { expansionLots, lotBounds } from './core/gridExpansion.js';
import { commitFactoryGraphForDay, facilityConnectionIssues, scannerConnector } from './systems/routing.js';
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, collectLoanRepayments, collectZundaTax, settleTruckRevenue } from './systems/economy.js';
import { commitFactoryGraphForDay, disconnectedBuildWarnings, facilityConnectionIssues, scannerConnector } from './systems/routing.js';
import { applyRevenue, collectChemicalWeaponSubsidy, collectFairiesTribute, settleTruckRevenue } from './systems/economy.js';
import { undo, redo } from './systems/history.js';
import { drawAll } from './render/draw.js';
import { createBuildSystem } from './systems/buildSystem.js';
import { createUISystem } from './systems/uiSystem.js';
import { createChickSystem } from './systems/chickSystem.js';
import { CARD_DEFS, conveyorSpeedForGame, createCardSystem, debugGrantCard } from './systems/cards.js';
import { CARD_DEFS, cardById, conveyorSpeedForGame, createCardSystem, debugCardTargets, debugGrantCard } from './systems/cards.js';
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
import { rollContractOffer, activateAcceptedContract, clearActiveContract, resolveContract } from './systems/contracts.js';
import { ensureMaintenanceState, hireRepairmanForNextDay, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
import { EVENT_TARGETS, createEventOffer, rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
import { ensureMaintenanceState, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
import { updatePowerSystem, recalculatePowerNetwork } from './systems/power.js';
import { startInboundOrders, settleUndeliveredImports } from './systems/imports.js';
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
@ -21,13 +23,12 @@ const ctx = canvas.getContext('2d');
const ui = {
shell: document.getElementById('gameShell'),
money: document.getElementById('money'), turn: document.getElementById('turn'), timeLeft: document.getElementById('timeLeft'), phase: document.getElementById('phaseLabel'), comboCount: document.getElementById('comboCount'),
eventBrief: document.getElementById('eventBrief'), targetBrief: document.getElementById('targetBrief'), speedBrief: document.getElementById('speedBrief'),
eventBrief: document.getElementById('eventBrief'), speedBrief: document.getElementById('speedBrief'),
turnProfit: document.getElementById('turnProfit'), mixerCount: document.getElementById('mixerCount'), truckFemaleCount: document.getElementById('truckFemaleCount'), truckMaleCount: document.getElementById('truckMaleCount'), poopCount: document.getElementById('poopCount'),
buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'), contractPanel: document.getElementById('contractPanel'),
buildStatus: document.getElementById('buildStatus'), turnSummary: document.getElementById('turnSummary'),
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
hoverTooltip: document.getElementById('hoverTooltip'),
manualScannerMonitor: document.getElementById('manualScannerMonitor'),
hireRepairmanButton: document.getElementById('hireRepairmanButton'),
debug: {
panel: document.getElementById('debugPanel'),
infiniteCash: document.getElementById('debugInfiniteCash'),
@ -35,13 +36,15 @@ const ui = {
setDay: document.getElementById('debugSetDayButton'),
zeroTimer: document.getElementById('debugZeroTimerButton'),
cardSelect: document.getElementById('debugCardSelect'),
targetSelect: document.getElementById('debugTargetSelect'),
eventSelect: document.getElementById('debugEventSelect'),
grantCard: document.getElementById('debugGrantCardButton'),
grantAllCards: document.getElementById('debugGrantAllCardsButton'),
readout: document.getElementById('debugReadout')
},
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'), boostConveyor: document.getElementById('buildBoostConveyorButton'), 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')
nextTurn: document.getElementById('nextTurnButton'), conveyor: document.getElementById('buildConveyorButton'), boostConveyor: document.getElementById('buildBoostConveyorButton'), powerWire: document.getElementById('buildPowerWireButton'), insulatedPowerWire: document.getElementById('buildInsulatedPowerWireButton'), powerOverlay: document.getElementById('powerOverlayButton'), footbridge: document.getElementById('buildFootbridgeButton'), eggFarm: document.getElementById('buildEggFarmButton'), autoScanner: document.getElementById('buildAutoScannerButton'), manualScanner: document.getElementById('buildManualScannerButton'), mixer: document.getElementById('buildMixerButton'), trash: document.getElementById('buildTrashButton'), truck: document.getElementById('buildTruckButton'), inboundTruck: document.getElementById('buildInboundTruckButton'), sausageMaker: document.getElementById('buildSausageMakerButton'), biomassGenerator: document.getElementById('buildBiomassGeneratorButton'), coalGenerator: document.getElementById('buildCoalGeneratorButton'), maintenanceRoom: document.getElementById('buildMaintenanceRoomButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
}
};
@ -50,25 +53,203 @@ function initializeStaticText() {
const buttonToolMap = {
conveyor: 'conveyor',
boostConveyor: 'boostConveyor',
powerWire: 'powerWire',
insulatedPowerWire: 'insulatedPowerWire',
footbridge: 'footbridge',
eggFarm: 'eggFarm',
autoScanner: 'autoScanner',
manualScanner: 'manualScanner',
mixer: 'mixer',
trash: 'trash',
truck: 'truck'
truck: 'truck',
inboundTruck: 'inboundTruck',
sausageMaker: 'sausageMaker',
biomassGenerator: 'biomassGenerator',
coalGenerator: 'coalGenerator',
maintenanceRoom: 'maintenanceRoom'
};
for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) {
if (ui.buttons[buttonKey]) ui.buttons[buttonKey].innerHTML = buildToolButtonHtml(toolId);
}
}
// DEBUG UI is intentionally part of this non-production balancing build.
// Do not remove it during consistency cleanup/audit passes unless explicitly requested.
function debugCategoryForCard(card) {
const tags = new Set(card.tags || []);
if (['upgradeEgg', 'crowdedFarming', 'hatchingFeed', 'extraEggOutlet'].includes(card.id) || tags.has('EGG')) return 'Production / EGG';
if (['upgradeTrash', 'poopRateBooster', 'composter', 'chemicalWeaponSubsidy'].includes(card.id) || tags.has('POOP')) return 'Poop / Shredder';
if (['upgradeAutoScanner', 'autoScannerTuning', 'scannerQueueSpacing'].includes(card.id) || tags.has('SCANNER')) return 'Scanner';
if (['bearing', 'dynamite'].includes(card.id) || tags.has('CONVEYOR')) return 'Conveyor / Map';
if (['safetyCover', 'preventiveMaintenance', 'durabilityCoating', 'sparePartsBin', 'laborExploitation'].includes(card.id) || tags.has('MAINTENANCE')) return 'Maintenance / Risk';
if (['dudFilter', 'dudRefund', 'freeReroll', 'extraCards'].includes(card.id) || tags.has('CARD')) return 'Card Engine';
if (tags.has('ULTRA RARE')) return 'Ultra Rare';
if (tags.has('ECONOMY') || ['upgradeMixer', 'upgradeTruck', 'upgradeSausageMaker', 'recyclingSubsidy', 'usedMachine', 'newMachine', 'flattery', 'legalWork', 'rescueLoan'].includes(card.id)) return 'Economy / Shipment';
return 'Other';
}
function initializeDebugPanel() {
if (!ui.debug?.panel) return;
if (ui.debug.cardSelect) {
ui.debug.cardSelect.innerHTML = CARD_DEFS
.map(card => `<option value="${card.id}">${card.title}</option>`)
.join('');
if (!ui.debug?.panel || !ui.debug.cardSelect) return;
const order = ['Production / EGG', 'Poop / Shredder', 'Scanner', 'Conveyor / Map', 'Maintenance / Risk', 'Card Engine', 'Economy / Shipment', 'Ultra Rare', 'Other'];
const groups = new Map(order.map(label => [label, []]));
for (const card of CARD_DEFS) {
const label = debugCategoryForCard(card);
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(card);
}
ui.debug.cardSelect.innerHTML = '';
for (const label of order) {
const cards = groups.get(label) || [];
if (!cards.length) continue;
const optGroup = document.createElement('optgroup');
optGroup.label = label;
for (const card of cards) {
const option = document.createElement('option');
option.value = card.id;
const rarity = card.rarity === 'ultraRare' ? 'UR' : card.rarity === 'rare' ? 'R' : 'C';
option.textContent = `[${rarity}] ${card.title}`;
optGroup.appendChild(option);
}
ui.debug.cardSelect.appendChild(optGroup);
}
ui.debug.cardSelect.addEventListener('change', refreshDebugTargetSelect);
initializeDebugEventSelect();
refreshDebugTargetSelect();
}
function initializeDebugEventSelect() {
if (!ui.debug?.eventSelect) return;
ui.debug.eventSelect.innerHTML = '';
const roll = document.createElement('option');
roll.value = '__roll__';
roll.textContent = 'Roll normally';
ui.debug.eventSelect.appendChild(roll);
const none = document.createElement('option');
none.value = '__none__';
none.textContent = 'No event';
ui.debug.eventSelect.appendChild(none);
for (const event of EVENT_TARGETS) {
const option = document.createElement('option');
option.value = event.key;
option.textContent = event.title;
ui.debug.eventSelect.appendChild(option);
}
ui.debug.eventSelect.value = game.debugNextEventKey || '__roll__';
ui.debug.eventSelect.addEventListener('change', debugSetNextEvent);
}
function debugNextEventOffer() {
const key = game.debugNextEventKey || '__roll__';
if (key === '__none__') return null;
if (key === '__roll__') return rollEventOffer(game);
return createEventOffer(game, key);
}
function refreshDebugTargetSelect() {
if (!ui.debug?.targetSelect) return;
const id = ui.debug.cardSelect?.value;
const card = cardById(id);
const targets = card ? debugCardTargets(game, card) : [];
ui.debug.targetSelect.innerHTML = '';
if (!targets.length) {
const option = document.createElement('option');
option.value = '__none__';
option.textContent = card ? 'No target required' : 'No card selected';
ui.debug.targetSelect.appendChild(option);
ui.debug.targetSelect.disabled = true;
return;
}
const auto = document.createElement('option');
auto.value = '__auto__';
auto.textContent = card?.id === 'dynamite' ? 'Auto: first 10 blocked cells' : 'Auto: first valid target';
ui.debug.targetSelect.appendChild(auto);
for (const target of targets) {
const option = document.createElement('option');
option.value = target.key;
option.textContent = target.label;
ui.debug.targetSelect.appendChild(option);
}
ui.debug.targetSelect.disabled = false;
}
function debugSetDay() {
const day = Math.max(1, Math.floor(Number(ui.debug.dayInput?.value) || 1));
game.turn = day;
if (ui.debug.dayInput) ui.debug.dayInput.value = String(day);
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `DAY ${day}`, THEME.green);
uiSystem.updatePanels();
uiSystem.updateUI();
refreshDebugTargetSelect();
updateDebugReadout(`Day set to ${day}.`);
}
function debugSetNextEvent() {
game.debugNextEventKey = ui.debug.eventSelect?.value || '__roll__';
if (game.phase === 'build') {
game.eventOffer = debugNextEventOffer();
uiSystem.updatePanels();
uiSystem.updateUI();
}
const label = ui.debug.eventSelect?.selectedOptions?.[0]?.textContent || 'Roll normally';
updateDebugReadout(`Next event: ${label}.`);
}
function debugZeroTimer() {
const wasClearing = game.phase === 'running' && (game.timeLeft <= 0 || game.shutdownTimeLeft > 0 || game.cleanupTimer !== null);
game.timeLeft = 0;
if (game.cleanupTimer !== null) game.cleanupTimer = 0;
if (wasClearing) game.shutdownTimeLeft = 0;
else if (game.phase === 'running') closeFarmShutters();
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, 'TIMER 0', THEME.warn);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout('Timer set to 0.');
}
function debugGrantSelectedCard() {
const id = ui.debug.cardSelect?.value;
const targetKey = ui.debug.targetSelect?.disabled ? undefined : ui.debug.targetSelect?.value;
const result = debugGrantCard(game, id, { targetKey });
if (ui.debug.grantCard) {
ui.debug.grantCard.textContent = result.ok ? 'Granted' : 'Failed';
window.setTimeout(() => { if (ui.debug.grantCard) ui.debug.grantCard.textContent = 'Grant Selected'; }, 900);
}
chicks?.updateCongestion?.(game.frameId || 0);
uiSystem.updatePanels();
uiSystem.updateUI();
refreshDebugTargetSelect();
updateDebugReadout(result.reason);
}
function debugGrantAllCards() {
const results = CARD_DEFS.map(card => debugGrantCard(game, card, { targetKey: '__auto__' }));
const ok = results.filter(result => result.ok).length;
chicks?.updateCongestion?.(game.frameId || 0);
uiSystem.updatePanels();
uiSystem.updateUI();
refreshDebugTargetSelect();
updateDebugReadout(`Granted ${ok}/${CARD_DEFS.length} cards.`);
}
function applyDebugState() {
if (ui.debug?.infiniteCash?.checked && game.cash < 999999) {
game.cash = 999999;
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
}
}
function updateDebugReadout(message = '') {
if (!ui.debug?.readout) return;
ui.debug.readout.textContent = [
message,
`D${game.turn}`,
yen(game.cash),
`Combo ${game.manualCombo?.count || 0}`,
`Event ${game.eventActive?.title || game.eventOffer?.title || 'none'}`,
`Next ${ui.debug?.eventSelect?.selectedOptions?.[0]?.textContent || 'Roll normally'}`,
`Rescue ${game.cardEffects?.rescueLoanCharges || 0}`,
`Chem ${game.cardEffects?.chemicalWeaponSubsidy || 0}`
].filter(Boolean).join(' | ');
}
const game = createGame();
@ -90,70 +271,12 @@ function startGame() {
resetCameraToFactoryStart();
ensureMaintenanceState(game);
uiSystem.hideModal();
chicks.updateCongestion();
chicks.updateCongestion(game.frameId || 0);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout();
}
function debugSetDay() {
const day = Math.max(1, Math.floor(Number(ui.debug.dayInput?.value) || 1));
game.turn = day;
if (ui.debug.dayInput) ui.debug.dayInput.value = String(day);
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `DAY ${day}`, THEME.green);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(`Day set to ${day}.`);
}
function debugZeroTimer() {
game.timeLeft = 0;
if (game.phase === 'running') closeFarmShutters();
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, 'TIMER 0', THEME.warn);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout('Timer set to 0.');
}
function debugGrantSelectedCard() {
const id = ui.debug.cardSelect?.value;
const result = debugGrantCard(game, id);
if (ui.debug.grantCard) {
ui.debug.grantCard.textContent = result.ok ? 'Granted' : 'Grant failed';
window.setTimeout(() => { if (ui.debug.grantCard) ui.debug.grantCard.textContent = 'Grant Card'; }, 900);
}
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(result.reason);
}
function debugGrantAllCards() {
const results = CARD_DEFS.map(card => debugGrantCard(game, card));
const ok = results.filter(result => result.ok).length;
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout(`Granted ${ok}/${CARD_DEFS.length} cards.`);
}
function applyDebugState() {
if (ui.debug?.infiniteCash?.checked && game.cash < 999999) {
game.cash = 999999;
game.totals.maxCash = Math.max(game.totals.maxCash, game.cash);
}
}
function updateDebugReadout(message = '') {
if (!ui.debug?.readout) return;
ui.debug.readout.textContent = [
message,
`D${game.turn}`,
yen(game.cash),
`Combo ${game.manualCombo?.count || 0}`,
`Rescue ${game.cardEffects?.rescueLoanCharges || 0}`,
`Chem ${game.cardEffects?.chemicalWeaponSubsidy || 0}`
].filter(Boolean).join(' | ');
}
function startNextTurn() {
if (game.phase !== 'build') return;
if (game.cardDraft?.pending || game.cardTargetPick?.pending) {
@ -163,24 +286,27 @@ function startNextTurn() {
}
const issues = facilityConnectionIssues(game);
if (issues.length) {
focusFirstStartIssue();
build.fail(`Cannot start: ${issues[0]}`);
uiSystem.updatePanels();
return;
}
ensureMaintenanceState(game);
commitFactoryGraphForDay(game);
const powerState = recalculatePowerNetwork(game);
if (powerState.underVoltage?.size) {
floating(game, canvas.width / 2 - game.view.x, 136 - game.view.y, `${powerState.underVoltage.size} UNDER-VOLTAGE`, THEME.warn);
}
activateRepairmanForDay(game);
const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const zunda = collectZundaTax(game, zundaBasisProfit);
const loanRepayment = collectLoanRepayments(game);
game.lastStartFees = { zundaTax: zunda.tax || 0, loanRepayment: loanRepayment.amount || 0, fairiesTribute: 0 };
if (zunda.tax > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `ZUNDA TAX -${yen(zunda.tax)}`, THEME.danger);
if (loanRepayment.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `LOAN -${yen(loanRepayment.amount)}`, THEME.danger);
const priorProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const tribute = collectFairiesTribute(game, Math.max(1, game.turn || 1), priorProfit);
game.lastStartFees = { fairiesTribute: tribute.amount || 0, tribute };
if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger);
// Negative cash during the next production day is allowed; bankruptcy is checked only at day-end settlement before Build phase.
activateAcceptedContract(game);
activateEventOffer(game);
game.phase = 'running';
game.turn += 1;
game.timeLeft = TURN_SECONDS;
game.timeLeft = eventDaySeconds(game);
game.shutdownTimeLeft = 0;
game.cleanupTimer = null;
game.cleanupBlown = false;
@ -189,47 +315,38 @@ function startNextTurn() {
game.floatingTexts = [];
game.shake = { time: 0, strength: 0 };
game.truckCargo = [];
game.shippedCargo = [];
resetCameraToFactoryStart();
game.stats = newTurnStats();
game.stats.zundaTax = 0;
game.stats.loanRepayment = game.lastStartFees?.loanRepayment || 0;
game.stats.repairWorkerWages = game.pendingRepairWorkerWages || 0;
const carriedStartCosts = game.stats.loanRepayment + game.stats.repairWorkerWages;
if (carriedStartCosts > 0) {
game.stats.penalty += carriedStartCosts;
game.stats.profit -= carriedStartCosts;
startInboundOrders(game);
game.stats.fairiesTribute = game.lastStartFees?.fairiesTribute || 0;
if (game.stats.fairiesTribute > 0) {
game.stats.penalty += game.stats.fairiesTribute;
game.stats.profit -= game.stats.fairiesTribute;
}
game.pendingRepairWorkerWages = 0;
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; }
for (const farm of game.eggFarms) { farm.nextSpawn = chicks.currentSpawnDelay(farm); farm.lastInterval = farm.nextSpawn; farm.shutterProgress = 0; farm.shutterSparked = false; farm.zundaBoostTime = 0; }
game.buildTool = null;
game.selected = null;
game.groupDrag = null;
game.selectionBox = null;
game.pan = null;
chicks.updateCongestion();
chicks.updateCongestion(game.frameId || 0);
uiSystem.updatePanels();
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;
settleUndeliveredImports(game);
for (const facility of Object.values(game.facilities || {})) {
if (facility.fuelPulses) facility.fuelPulses = [];
}
for (const farm of game.eggFarms || []) farm.zundaBoostTime = 0;
const ship = settleTruckRevenue(game);
const contract = resolveContract(game);
const activeEvent = game.eventActive ? { ...game.eventActive } : null;
const eventResult = resolveEvent(game);
const chemical = collectChemicalWeaponSubsidy(game);
if (ship.adjusted > 0) {
const truck = game.facilities.truck;
@ -237,15 +354,13 @@ function completeTurn() {
const y = truck ? truck.y + 26 : 96 - game.view.y;
floating(game, x, y, `${ship.target === 'poop' ? 'POOP SHIP' : 'SHIP'} +${yen(ship.adjusted)}`, THEME.green);
}
if (contract?.bonus > 0) floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `CONTRACT +${yen(contract.bonus)}`, THEME.green);
if (eventResult?.bonus > 0) floating(game, canvas.width / 2 - game.view.x, 96 - game.view.y, `EVENT +${yen(eventResult.bonus)}`, THEME.green);
if (chemical.amount > 0) floating(game, canvas.width / 2 - game.view.x, 116 - game.view.y, `CHEM SUBSIDY +${yen(chemical.amount)}`, THEME.green);
clearActiveContract(game);
const settlementDay = Math.max(1, (game.totals.turnsCompleted || 0) + 1);
const tribute = collectFairiesTribute(game, settlementDay);
game.lastBuildFees = { fairiesTribute: tribute.amount || 0, reduction: tribute.reduction || 0, turn: settlementDay };
if (tribute.amount > 0) floating(game, canvas.width / 2 - game.view.x, 146 - game.view.y, `FAIRIES -${yen(tribute.amount)}`, THEME.danger);
clearActiveEvent(game);
game.totals.turnsCompleted += 1;
game.lastResult = { ...game.stats, ship, contract, chemical, rescue: { amount: 0, used: 0, remaining: game.cardEffects?.rescueLoanCharges || 0 }, fairiesTribute: tribute, turn: game.turn, cash: game.cash };
const tributeMultiplier = fairiesTributeMultiplierFromEvent(activeEvent);
if (tributeMultiplier !== 1) game.pendingFairiesTributeMultiplier = tributeMultiplier;
game.lastResult = { ...game.stats, ship, event: eventResult, chemical, rescue: { amount: 0, used: 0, remaining: game.cardEffects?.rescueLoanCharges || 0 }, fairiesTribute: { amount: game.stats.fairiesTribute || 0 }, tributeMultiplier, turn: game.turn, cash: game.cash };
if (game.cash < 0) {
if ((game.cardEffects?.rescueLoanCharges || 0) > 0) {
game.phase = 'loanDecision';
@ -260,6 +375,7 @@ function completeTurn() {
function enterBuildPhase() {
game.phase = 'build';
game.truckCargo = [];
game.shippedCargo = [];
game.chicks = [];
game.shutdownTimeLeft = 0;
game.cleanupTimer = null;
@ -271,7 +387,7 @@ function enterBuildPhase() {
for (const scanner of game.scanners) scanner.queue = [];
for (const farm of game.eggFarms) { farm.shutterProgress = 0; farm.shutterSparked = false; }
deactivateRepairman(game);
game.contractOffer = rollContractOffer(game);
game.eventOffer = debugNextEventOffer();
if (cardSystem) cardSystem.prepareDraft();
game.cardTargetPick = null;
game.buildTool = null;
@ -317,11 +433,14 @@ function update(timestamp) {
if (!game.lastTimestamp) game.lastTimestamp = timestamp;
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
game.lastTimestamp = timestamp;
applyDebugState();
game.frameId = (game.frameId || 0) + 1;
chicks.beginFrame?.(game.frameId);
updatePowerSystem(game, dt);
if (game.phase === 'running') chicks.updateRunning(dt, { closeFarmShutters, completeTurn });
updateEffects(game, dt, canvas, build.equipmentHitBoxes);
chicks.updateCongestion();
chicks.updateCongestion(game.frameId);
drawAll(ctx, canvas, game, { activeQueuedChick: chicks.activeQueuedChick, selectedObject: build.selectedObject, selectedTitle: build.selectedTitle });
applyDebugState();
uiSystem.updateUI();
updateManualScannerMonitor();
updateDebugReadout();
@ -385,6 +504,43 @@ function resetCameraToFactoryStart() {
game.view.y = 190 - anchor.y;
clampCamera();
}
function focusCameraOnWorldPoint(point) {
if (!point) return false;
ensureView();
const s = viewScale();
game.view.x = canvas.width / 2 - point.x * s;
game.view.y = canvas.height / 2 - point.y * s;
clampCamera();
return true;
}
function warningFocusPoint(warning) {
const ref = warning?.ref;
if (!ref) return null;
if (warning.type === 'facility') return { x: ref.x + ref.w / 2, y: ref.y + ref.h / 2 };
if (warning.type === 'eggFarm') return cellCenter(ref.col, ref.row);
if (warning.type === 'scanner') return { x: cellCenter(ref.col, ref.row).x + GRID.cell / 2, y: cellCenter(ref.col, ref.row).y };
if (warning.type === 'maintenanceRoom') return { x: cellCenter(ref.col, ref.row).x + GRID.cell / 2, y: cellCenter(ref.col, ref.row).y + GRID.cell / 2 };
return null;
}
function focusFirstStartIssue() {
const warnings = disconnectedBuildWarnings(game);
const warning = warnings.find(item => item.type === 'facility') || warnings[0] || fallbackStartIssue();
const point = warningFocusPoint(warning);
if (focusCameraOnWorldPoint(point) && warning) game.selected = { type: warning.type, id: warning.id };
}
function fallbackStartIssue() {
const facility = Object.values(game.facilities || {}).find(f => f?.entry && game.conveyorTiles.has(key(f.entry.col, f.entry.row)))
|| Object.values(game.facilities || {})[0];
if (facility) return { type: 'facility', id: facility.id, ref: facility };
const scanner = game.scanners?.find(s => s);
if (scanner) return { type: 'scanner', id: scanner.id, ref: scanner };
const farm = game.eggFarms?.[0];
return farm ? { type: 'eggFarm', id: farm.id, ref: farm } : null;
}
function startPan(event) { const p = rawCanvasPoint(event); ensureView(); game.pan = { start: p, viewX: game.view.x, viewY: game.view.y, moved: false }; }
function updatePan(event) {
if (!game.pan) return;
@ -456,6 +612,7 @@ function clickSelect(event) {
const obj = build.selectedObject();
if (obj?.type === 'scanner' && obj.kind === 'manual') build.showManualScannerMenu(obj);
if (obj?.type === 'scanner' && obj.kind === 'auto') build.showAutoScannerMenu(obj);
if (obj?.type === 'facility' && (obj.baseId || obj.id) === 'inboundTruck') build.showInboundTruckMenu(obj);
}
function cancelBuildAction() {
@ -481,7 +638,7 @@ function activeQueuedForScanner(scanner) {
function chickDisplay(chick) {
if (!chick) return '<span class="scanner-current empty">WAIT</span>';
const label = chick.sex === 'poop' ? '💩 POOP' : chick.sex === 'male' ? '♂ MALE' : '♀ FEMALE';
const label = chick.sex === 'poop' ? 'POOP' : chick.sex === 'male' ? 'M MALE' : 'F FEMALE';
return `<span class="scanner-current ${chick.sex}">${label}</span>`;
}
@ -492,7 +649,7 @@ function miniScannerGrid(scanner, chick) {
const inputKey = inputCell ? key(inputCell.col, inputCell.row) : null;
const facilityEntryCells = new Set(Object.values(game.facilities || {}).map(f => f.entry ? key(f.entry.col, f.entry.row) : null).filter(Boolean));
const activeSex = chick?.sex || '';
const activeLabel = activeSex === 'poop' ? '💩' : activeSex === 'male' ? '♂' : activeSex === 'female' ? '♀' : '';
const activeLabel = activeSex === 'poop' ? 'P' : activeSex === 'male' ? 'M' : activeSex === 'female' ? 'F' : '';
const cells = [];
for (let row = scanner.row - 2; row <= scanner.row + 2; row += 1) {
for (let col = scanner.col - 2; col <= scanner.col + 2; col += 1) {
@ -517,10 +674,10 @@ function miniScannerGrid(scanner, chick) {
label = 'P';
} else if (game.conveyorTiles.has(k)) {
classes.push('belt');
label = '•';
label = '.';
} else if (game.blockedCells?.has?.(k)) {
classes.push('blocked');
label = '×';
label = 'X';
}
cells.push(`<span class="${classes.join(' ')}">${label}</span>`);
}
@ -536,6 +693,26 @@ function scannerIsOffscreen(scanner) {
function updateManualScannerMonitor() {
if (!ui.manualScannerMonitor) return;
const cache = game.frameCache || (game.frameCache = {});
const view = game.view || {};
const signature = [
game.phase,
Math.round((view.x || 0) * 10),
Math.round((view.y || 0) * 10),
Math.round((view.scale || 1) * 100),
game.chicks.length,
game.scanners.map(scanner => [
scanner.id,
scanner.kind,
scanner.col,
scanner.row,
scanner.queue?.join(',') || '',
scanner.keyPressSide || '',
Math.ceil((scanner.keyPressTime || 0) * 10)
].join(':')).join('|')
].join(';');
if (cache.manualMonitorSignature === signature) return;
cache.manualMonitorSignature = signature;
if (game.phase !== 'running') {
ui.manualScannerMonitor.classList.remove('visible');
ui.manualScannerMonitor.innerHTML = '';
@ -562,8 +739,8 @@ function updateManualScannerMonitor() {
</div>
${miniScannerGrid(scanner, chick)}
<div class="scanner-monitor-actions">
<button type="button" data-scanner-id="${scanner.id}" data-side="left"${disabled}>${escapeHtml(left)} → ${leftDest}</button>
<button type="button" data-scanner-id="${scanner.id}" data-side="right"${disabled}>${escapeHtml(right)} → ${rightDest}</button>
<button type="button" data-scanner-id="${scanner.id}" data-side="left"${disabled}>${escapeHtml(left)} -> ${leftDest}</button>
<button type="button" data-scanner-id="${scanner.id}" data-side="right"${disabled}>${escapeHtml(right)} -> ${rightDest}</button>
</div>
</section>`;
}).join('');
@ -582,11 +759,15 @@ ui.manualScannerMonitor?.addEventListener('click', event => {
canvas.addEventListener('contextmenu', event => event.preventDefault());
canvas.addEventListener('wheel', zoomAt, { passive: false });
canvas.addEventListener('pointerdown', event => {
if (game.phase !== 'build') return;
if (!['build', 'running'].includes(game.phase)) return;
if (event.button === 2 || event.button === 1 || (game.phase === 'running' && event.button === 0)) {
canvas.setPointerCapture(event.pointerId);
startPan(event);
return;
}
if (game.phase !== 'build' || event.button !== 0) return;
canvas.setPointerCapture(event.pointerId);
const world = canvasPoint(event);
if (event.button === 2) { startPan(event); return; }
if (event.button !== 0) return;
if (game.cardTargetPick?.mode === 'autoScannerMenu') { cancelBuildAction(); return; }
if (game.cardTargetPick?.pending) { cardSystem.chooseTargetAtPoint(world); return; }
const expansionOffer = build.expansionOfferAtPoint?.(world);
@ -620,9 +801,9 @@ canvas.addEventListener('pointerdown', event => {
build.startSelectionBox(event);
});
canvas.addEventListener('pointermove', event => {
if (game.pan && (event.buttons & 7)) updatePan(event);
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);
if ((game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') && (event.buttons & 1)) build.continueConveyorDrag(pointToCell(canvasPoint(event).x, canvasPoint(event).y));
@ -634,7 +815,7 @@ canvas.addEventListener('pointerup', event => {
if (!game.groupDrag.committed) clickSelect(event);
build.finishGroupDrag();
}
if (event.button === 2 && game.pan && !game.pan.moved && game.buildTool) cancelBuildAction();
if ((event.button === 2 || event.button === 1) && game.pan && !game.pan.moved && game.buildTool && game.phase === 'build') cancelBuildAction();
if (game.buildTool === 'conveyor' || game.buildTool === 'boostConveyor') build.endConveyorDrag?.();
game.groupDrag = null; game.pan = null;
try { canvas.releasePointerCapture(event.pointerId); } catch (_) { /* noop */ }
@ -648,13 +829,12 @@ document.addEventListener('pointerdown', event => {
}, true);
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.boostConveyor.addEventListener('click', () => build.setBuildTool('boostConveyor')); 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.nextTurn.addEventListener('click', startNextTurn); ui.buttons.conveyor.addEventListener('click', () => build.setBuildTool('conveyor')); ui.buttons.boostConveyor.addEventListener('click', () => build.setBuildTool('boostConveyor')); ui.buttons.powerWire?.addEventListener('click', () => build.setBuildTool('powerWire')); ui.buttons.insulatedPowerWire?.addEventListener('click', () => build.setBuildTool('insulatedPowerWire')); ui.buttons.powerOverlay?.addEventListener('click', () => { game.powerOverlay = !game.powerOverlay; uiSystem.updatePanels(); }); ui.buttons.footbridge?.addEventListener('click', () => build.setBuildTool('footbridge')); 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.inboundTruck?.addEventListener('click', () => build.setBuildTool('inboundTruck')); ui.buttons.sausageMaker.addEventListener('click', () => build.setBuildTool('sausageMaker')); ui.buttons.biomassGenerator?.addEventListener('click', () => build.setBuildTool('biomassGenerator')); ui.buttons.coalGenerator?.addEventListener('click', () => build.setBuildTool('coalGenerator')); ui.buttons.maintenanceRoom.addEventListener('click', () => build.setBuildTool('maintenanceRoom')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
ui.debug.setDay?.addEventListener('click', debugSetDay);
ui.debug.zeroTimer?.addEventListener('click', debugZeroTimer);
ui.debug.grantCard?.addEventListener('click', debugGrantSelectedCard);
ui.debug.grantAllCards?.addEventListener('click', debugGrantAllCards);
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } });
ui.buttons.undo.addEventListener('click', () => { if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); refreshDebugTargetSelect(); } }); ui.buttons.redo.addEventListener('click', () => { if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); refreshDebugTargetSelect(); } });
function scannerKeyMatches(scanner, side, event) {
const binding = scanner.keys?.[side];
if (!binding) return false;
@ -672,6 +852,13 @@ window.addEventListener('keydown', event => {
}
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && name === 'z') { event.preventDefault(); if (undo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
if (game.phase === 'build' && (event.ctrlKey || event.metaKey) && (name === 'y' || (event.shiftKey && name === 'z'))) { event.preventDefault(); if (redo(game)) { clampCamera(); uiSystem.updatePanels(); uiSystem.updateUI(); } }
if (game.phase === 'build' && game.buildTool === 'footbridge' && name === 'r') {
event.preventDefault();
game.footbridgeOrientation = game.footbridgeOrientation === 'horizontal' ? 'vertical' : 'horizontal';
floating(game, canvas.width / 2 - game.view.x, 86 - game.view.y, `FOOTBRIDGE ${game.footbridgeOrientation.toUpperCase()}`, THEME.green);
uiSystem.updatePanels();
return;
}
if (event.key === 'Escape' && game.cardTargetPick?.pending) {
event.preventDefault();
if (game.cardTargetPick.mode === 'autoScannerMenu') cancelBuildAction();
@ -685,4 +872,4 @@ build = createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels: () =>
chicks = createChickSystem({ game, currentConveyorSpeed, onGameOverCheck: () => uiSystem?.checkGameOver() });
cardSystem = createCardSystem({ game, ui, onUpdatePanels: () => uiSystem?.updatePanels() });
uiSystem = createUISystem({ game, ui, build, startGame, beginCardDraft: () => cardSystem.showDraft(), activeQueuedChick: chicks.activeQueuedChick });
initializeStaticText(); initializeDebugPanel(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); updateDebugReadout(); requestAnimationFrame(update);
initializeStaticText(); initializeDebugPanel(); resetLayout(game); ensureMaintenanceState(game); chicks.updateCongestion(game.frameId || 0); uiSystem.updatePanels(); uiSystem.updateUI(); uiSystem.showTitle(); updateDebugReadout(); requestAnimationFrame(update);

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,20 @@
import { BALANCE } from '../core/balance.js';
import { DIRS, MACHINE_FACILITY_IDS, GRID, THEME } from '../core/config.js';
import { DIRS, EDGE_FACILITY_IDS, GRID_FACILITY_IDS, MACHINE_FACILITY_IDS, GRID, THEME, ECONOMY } from '../core/config.js';
import { getSpawnRange } from '../core/state.js';
import { createEggFarm, createScanner, createFacility } from '../core/entities.js';
import { generateBlockedCellsInRect } from '../core/mapGen.js';
import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js';
import { createEggFarm, createScanner, createFacility, createGridFacility, createMaintenanceRoom, maintenanceRoomFootprint, gridFacilityFootprintCells, syncGridFacilityGeometry } from '../core/entities.js';
import { generateBlockedCellsInRect, blockedRatioForExpansionPurchase } from '../core/mapGen.js';
import { expansionCost, expansionLot, expansionLots, lotFromChunk, lotBounds, lotContainsPoint, cellsInLot, isOwnedCell, countOwnedCells, shiftColIndexedSet, shiftColIndexedMap, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.js';
import { key, parseKey, pointToCell, cellCenter, yen, directionNameBetweenCells } from '../core/utils.js';
import { TEXT, equipmentName } from '../core/text.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js';
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, gridFacilityAt, facilityInputCell, facilityOutputCell, facilityOutputHasConveyor, routeFromFarmToScanner, refreshRoutingAfterEdit, disconnectedBuildWarnings } from './routing.js';
import { buildPrice, equipmentBasePrice, incomeMultiplier, mixerPoopPenalty, refundCash, spendCash, truckPoopPenalty, upgradedMixerPrice, upgradedTruckPrice, resaleValueFor, shredderUpgradeCount, shredderBonusIncome, shredderBonusChance, shredderBonusMaxCards, upgradedSausagePrice } 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';
import { markPowerDirty, powerDetailsFor } from './power.js';
import { boostOutputCapacityPerSecond, farmTheoreticalRatePerSecond, qualityForRate, qualityGrade } from './farmQuality.js';
export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanels }) {
function updatePanels() { if (onUpdatePanels) onUpdatePanels(); }
@ -22,7 +24,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
let conveyorDragStartCell = null;
let conveyorDragMoved = false;
const BRANCH_MODES = ['random', 'up', 'right', 'down', 'left'];
const BRANCH_LABELS = { random: 'RND', up: 'UP', right: 'RT', down: 'DN', left: 'LF' };
const BRANCH_LABELS = { random: 'RND', up: '▲', right: '▶', down: '▼', left: '◀' };
const BRANCH_MARKER = { w: 40, h: 40, y: 0 };
function gridExpansionCost() { return expansionCost(game); }
@ -42,10 +44,33 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
game.blockedCells = shiftRowIndexedSet(game.blockedCells, rowDelta);
game.conveyorTiles = shiftRowIndexedSet(game.conveyorTiles, rowDelta);
game.conveyorMeta = shiftRowIndexedMap(game.conveyorMeta, rowDelta);
game.powerWires = shiftRowIndexedMap(game.powerWires || new Map(), rowDelta);
game.footbridges = shiftRowIndexedMap(game.footbridges || new Map(), rowDelta);
for (const farm of game.eggFarms || []) farm.row += rowDelta;
for (const scanner of game.scanners || []) scanner.row += rowDelta;
for (const facility of Object.values(game.facilities || {})) {
if (facility.entry) facility.entry.row += rowDelta;
if (facility.gridPlaced) { facility.row += rowDelta; syncGridFacilityGeometry(facility); }
else if (facility.entry) facility.entry.row += rowDelta;
}
}
function shiftWorldColsRight(colDelta) {
if (!colDelta) return;
GRID.x -= colDelta * GRID.cell;
GRID.cols += colDelta;
game.gridChunkOriginX = (game.gridChunkOriginX || 0) - Math.floor(colDelta / 10);
game.ownedCells = shiftColIndexedSet(game.ownedCells, colDelta);
game.blockedCells = shiftColIndexedSet(game.blockedCells, colDelta);
game.conveyorTiles = shiftColIndexedSet(game.conveyorTiles, colDelta);
game.conveyorMeta = shiftColIndexedMap(game.conveyorMeta, colDelta);
game.powerWires = shiftColIndexedMap(game.powerWires || new Map(), colDelta);
game.footbridges = shiftColIndexedMap(game.footbridges || new Map(), colDelta);
for (const farm of game.eggFarms || []) farm.col += colDelta;
for (const scanner of game.scanners || []) scanner.col += colDelta;
for (const room of game.maintenanceRooms || []) room.col += colDelta;
for (const facility of Object.values(game.facilities || {})) {
if (facility.gridPlaced) { facility.col += colDelta; syncGridFacilityGeometry(facility); }
else if (facility.entry) facility.entry.col += colDelta;
}
}
@ -56,12 +81,36 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function expansionOfferAtPoint(p) {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null;
return expansionLots(game).find(lot => lot && lotContainsPoint(lot, p)) || null;
return expansionLots(game).map(lot => expansionLotStatus(lot)).find(lot => lot && lotContainsPoint(lot, p)) || null;
}
function lotRect(lot) {
const b = lotBounds(lot);
return { x: b.x, y: b.y, w: b.w, h: b.h };
}
function facilityBlocksExpansionLot(lot) {
const rect = lotRect(lot);
return Object.values(game.facilities || {}).some(f => {
const kind = f?.baseId || f?.id;
if (!EDGE_FACILITY_IDS.includes(kind) || f.gridPlaced) return false;
return rectsOverlap(rect, rectOfFacility(f), 0);
});
}
function expansionLotStatus(lot) {
if (!lot) return null;
const blockedByFacility = facilityBlocksExpansionLot(lot);
return {
...lot,
disabled: blockedByFacility,
disabledReason: blockedByFacility ? 'Move edge facilities before buying this lot.' : ''
};
}
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 = ['left', 'right', 'up', 'down'].includes(request) ? request : 'right';
return expansionLot(game, direction);
}
@ -69,6 +118,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const offer = resolveExpansionRequest(request);
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return { ok: false, reason: 'Cannot expand now.' };
if (!offer) return { ok: false, reason: 'Invalid expansion lot.' };
if (facilityBlocksExpansionLot(offer)) {
const reason = 'Move edge facilities before buying this lot.';
fail(reason);
return { ok: false, reason };
}
const direction = offer.direction || 'right';
const cost = gridExpansionCost();
if (game.cash < cost) {
@ -78,8 +132,13 @@ 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 = { left: 0, right: 0, up: 0, down: 0 };
const originY = game.gridChunkOriginY || 0;
const originX = game.gridChunkOriginX || 0;
if (offer.chunkX < originX) {
const chunksToAdd = originX - offer.chunkX;
shiftWorldColsRight(chunksToAdd * (BALANCE.grid.expansion?.colsPerPurchase || 10));
}
if (offer.chunkY < originY) {
const chunksToAdd = originY - offer.chunkY;
shiftWorldRowsDown(chunksToAdd * (BALANCE.grid.expansion?.rowsPerPurchase || 10));
@ -92,13 +151,18 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
game.gridExpansionPurchases = (game.gridExpansionPurchases || 0) + 1;
game.gridExpansionPurchasesByDirection[direction] = (game.gridExpansionPurchasesByDirection[direction] || 0) + 1;
syncGridSizeToGame();
const blockedRatio = blockedRatioForExpansionPurchase(game.gridExpansionPurchases, {
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}`,
colStart: lot.colStart,
colEnd: lot.colStart + lot.cols,
rowStart: lot.rowStart,
rowEnd: lot.rowStart + lot.rows,
targetRatio: BALANCE.map.blockedRatio
targetRatio: blockedRatio
});
for (const k of blocked) game.blockedCells.add(k);
refreshRoutingAfterEdit(game);
@ -106,7 +170,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const b = lotBounds(lot);
floating(game, b.x + b.w / 2, b.y + b.h / 2, `${direction.toUpperCase()} +${addedCells} CELLS -${yen(cost)}`, THEME.green);
updatePanels();
return { ok: true, direction, cost, addedCells, blocked: blocked.size, chunkX: lot.chunkX, chunkY: lot.chunkY };
return { ok: true, direction, cost, addedCells, blocked: blocked.size, blockedRatio, chunkX: lot.chunkX, chunkY: lot.chunkY };
}
function pointInGrid(col, row) { return col >= 0 && col < GRID.cols && row >= 0 && row < GRID.rows && isOwnedCell(game, col, row); }
@ -129,10 +193,46 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (existingFarm && !(moving?.type === 'eggFarm' && moving.ref?.id === existingFarm.id)) return true;
const existingScanner = scannerAt(game, col, row);
if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
const existingRoom = maintenanceRoomAt(game, col, row);
if (existingRoom && !(moving?.type === 'maintenanceRoom' && moving.ref?.id === existingRoom.id)) return true;
const existingGridFacility = gridFacilityAt(game, col, row);
if (existingGridFacility && !(moving?.type === 'facility' && moving.ref?.id === existingGridFacility.id)) return true;
if (game.conveyorTiles.has(k) && !(moving?.type === 'conveyor' && moving.oldKey === k)) return true;
return false;
}
function facilityPortAt(col, row, moving = null) {
return Object.values(game.facilities || {}).find(facility => {
if (moving?.type === 'facility' && moving.ref?.id === facility.id) return false;
const input = facilityInputCell(game, facility);
const output = facilityOutputCell(game, facility);
return (input && input.col === col && input.row === row) || (output && output.col === col && output.row === row);
}) || null;
}
function maintenanceRoomAt(gameRef, col, row) {
return (gameRef.maintenanceRooms || []).find(room => maintenanceRoomFootprint(room).some(cell => cell.col === col && cell.row === row)) || null;
}
function maintenanceRoomPlacementBlocked(col, row, moving = null) {
const draft = { col, row };
for (const cell of maintenanceRoomFootprint(draft)) {
if (!pointInGrid(cell.col, cell.row) || isBlockedCell(cell.col, cell.row)) return true;
const k = key(cell.col, cell.row);
const existingFarm = farmAt(game, cell.col, cell.row);
if (existingFarm) return true;
const existingScanner = scannerAt(game, cell.col, cell.row);
if (existingScanner) return true;
const existingRoom = maintenanceRoomAt(game, cell.col, cell.row);
if (existingRoom && !(moving?.type === 'maintenanceRoom' && moving.ref?.id === existingRoom.id)) return true;
const existingGridFacility = gridFacilityAt(game, cell.col, cell.row);
if (existingGridFacility) return true;
if (facilityPortAt(cell.col, cell.row, moving)) return true;
if (game.conveyorTiles.has(k)) return true;
}
return false;
}
function scannerPlacementBlocked(col, row, moving = null) {
const draft = { col, row };
for (const cell of scannerFootprintCells(draft)) {
@ -142,17 +242,49 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (existingFarm) return true;
const existingScanner = scannerAt(game, cell.col, cell.row);
if (existingScanner && !(moving?.type === 'scanner' && moving.ref?.id === existingScanner.id)) return true;
const existingGridFacility = gridFacilityAt(game, cell.col, cell.row);
if (existingGridFacility) return true;
if (facilityPortAt(cell.col, cell.row, moving)) return true;
if (game.conveyorTiles.has(k)) return true;
}
return false;
}
function gridFacilityPlacementBlocked(id, col, row, moving = null) {
const def = BALANCE.facilities[id] || {};
const draft = { baseId: id, id, gridPlaced: true, col, row, wCells: def.footprint?.cols || (id === 'sausageMaker' ? 3 : 1), hCells: def.footprint?.rows || (id === 'sausageMaker' ? 3 : 1) };
for (const cell of gridFacilityFootprintCells(draft)) {
if (!pointInGrid(cell.col, cell.row) || isBlockedCell(cell.col, cell.row) || isEquipmentCell(cell.col, cell.row, moving) || facilityPortAt(cell.col, cell.row, moving)) return true;
}
if (id === 'sausageMaker' || id === 'biomassGenerator' || id === 'coalGenerator') {
const input = { col: col - 1, row: row + 1 };
const output = { col: col + (draft.wCells || 1), row: row + 1 };
const biomassPorts = [
input,
{ col: col + 1, row: row - 1 },
{ col: col + 1, row: row + (draft.hCells || 3) }
];
const ports = id === 'sausageMaker' ? [input, output] : (id === 'biomassGenerator' ? biomassPorts : [input]);
for (const port of ports) {
if (!pointInGrid(port.col, port.row) || isBlockedCell(port.col, port.row)) return true;
const portGridFacility = gridFacilityAt(game, port.col, port.row);
if (portGridFacility && portGridFacility.id !== moving?.ref?.id) return true;
if (farmAt(game, port.col, port.row) || scannerAt(game, port.col, port.row) || maintenanceRoomAt(game, port.col, port.row)) return true;
if (facilityPortAt(port.col, port.row, moving)) return true;
}
}
return false;
}
function selectedObject() {
if (!game.selected) return null;
if (game.selected.type === 'eggFarm') return game.eggFarms.find(f => f.id === game.selected.id) || null;
if (game.selected.type === 'scanner') return game.scanners.find(s => s.id === game.selected.id) || null;
if (game.selected.type === 'conveyor') return game.conveyorTiles.has(game.selected.id) ? { type: 'conveyor', id: game.selected.id } : null;
if (game.selected.type === 'powerWire') return game.powerWires?.has(game.selected.id) ? { type: 'powerWire', id: game.selected.id } : null;
if (game.selected.type === 'footbridge') return game.footbridges?.has(game.selected.id) ? { type: 'footbridge', id: game.selected.id } : null;
if (game.selected.type === 'facility') return game.facilities[game.selected.id] || null;
if (game.selected.type === 'maintenanceRoom') return (game.maintenanceRooms || []).find(r => r.id === game.selected.id) || null;
return null;
}
@ -161,12 +293,17 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (obj.type === 'eggFarm') return `${equipmentName('eggFarm')} #${obj.id}`;
if (obj.type === 'scanner') return `${obj.kind === 'auto' ? equipmentName('autoScanner') : equipmentName('manualScanner')} #${obj.id}`;
if (obj.type === 'conveyor') return game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor' ? equipmentName('boostConveyor') : equipmentName('conveyor');
if (obj.type === 'powerWire') return game.powerWires?.get(obj.id)?.kind === 'insulatedPowerWire' ? equipmentName('insulatedPowerWire') : equipmentName('powerWire');
if (obj.type === 'footbridge') return equipmentName('footbridge');
if (obj.type === 'facility') return obj.name;
if (obj.type === 'maintenanceRoom') return `${equipmentName('maintenanceRoom')} #${obj.id}`;
return 'Equipment';
}
function equipmentPrice(hit) {
if (hit?.type === 'conveyor') return game.conveyorMeta?.get(hit.oldKey || hit.ref?.id)?.price || equipmentBasePrice(hit);
if (hit?.type === 'powerWire') return game.powerWires?.get(hit.oldKey || hit.ref?.id)?.price || 0;
if (hit?.type === 'footbridge') return game.footbridges?.get(hit.oldKey || hit.ref?.id)?.price || BALANCE.facilities.footbridge.price;
return equipmentBasePrice(hit);
}
@ -188,6 +325,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function lastProtectedSaleReason(hit) {
const obj = hit?.ref || hit || {};
if (obj.unsellable || (obj.baseId || obj.id) === 'starterGenerator') return 'Starter Generator cannot be sold.';
if (hit?.type === 'eggFarm' || obj.type === 'eggFarm') {
return game.eggFarms.length <= 1 ? 'Cannot sell the last EGG FARM.' : '';
}
@ -240,6 +378,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return dirs.includes(incoming);
}
function deadEndReverseDir(cell, dirs) {
const neighbors = conveyorNeighborOutDirs(cell);
if (neighbors.length !== 1) return null;
const only = neighbors[0];
return dirs.includes(only) && neighborPointsIntoCell(cell, only) ? only : null;
}
function reachableOutDirsForKey(k) {
if (!game.conveyorTiles.has(k)) return [];
const cell = parseKey(k);
@ -249,7 +394,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const explicitReachable = [...new Set([...explicit, meta?.dir].filter(Boolean))]
.filter(dir => neighbors.includes(dir));
const inferredExits = neighbors;
const reachable = explicitReachable.length ? explicitReachable : inferredExits;
const candidateReachable = explicitReachable.length ? explicitReachable : inferredExits;
const reverseDeadEnd = deadEndReverseDir(cell, candidateReachable);
const reachable = explicitReachable.length
? explicitReachable.filter(dir => dir !== reverseDeadEnd)
: inferredExits.filter(dir => dir !== reverseDeadEnd);
if (meta) {
meta.outDirs = explicit.filter(dir => neighbors.includes(dir));
if (meta.dir && !neighbors.includes(meta.dir)) meta.dir = meta.outDirs[0] || null;
@ -262,20 +411,21 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (!game.conveyorTiles.has(k)) return [];
const cell = parseKey(k);
const connected = conveyorNeighborOutDirs(cell);
if (!isBranchCell(cell)) return connected.length ? connected : reachableOutDirsForKey(k);
if (!isBranchCell(cell)) return reachableOutDirsForKey(k);
return connected;
}
function branchSelectableEntranceDirsForKey(k) {
function branchSelectableExitDirsForKey(k) {
const cell = parseKey(k);
const connected = branchExitDirsForKey(k);
if (connected.length < 3) return [];
const meta = game.conveyorMeta.get(k) || {};
const physicalEntrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
return connected.filter(dir => {
const entrances = new Set(physicalEntrances);
entrances.add(dir);
return connected.filter(item => !entrances.has(item)).length >= 2;
});
const physicalExits = connected.filter(dir => !physicalEntrances.has(dir));
if (physicalEntrances.size) return physicalExits.length >= 2 ? physicalExits : [];
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(dir => connected.includes(dir));
return explicit.length >= 2 ? explicit : connected;
}
function markConveyorDirection(from, to) {
@ -295,6 +445,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
function cycleSingleConveyorDirection(cell) {
// A true isolated tile has no neighboring belt. An end tile has one neighbor
// and must reverse the whole connected run rather than rotating by itself.
if (conveyorNeighborCells(cell).length === 0) return cycleIsolatedConveyorDirection(cell);
const path = conveyorRunPathFrom(cell);
if (path.length < 2) return cycleIsolatedConveyorDirection(cell);
record(game);
@ -309,7 +462,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const k = key(cell.col, cell.row);
const meta = game.conveyorMeta.get(k);
if (!meta || isBranchCell(cell)) return false;
const choices = DIRS.map(d => d.name);
const blockedReverseDir = deadEndReverseDir(cell, DIRS.map(d => d.name));
const choices = DIRS.map(d => d.name).filter(dir => dir !== blockedReverseDir);
const current = meta.dir && choices.includes(meta.dir) ? meta.dir : null;
const next = choices[(Math.max(-1, choices.indexOf(current)) + 1) % choices.length];
record(game);
@ -382,6 +536,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
meta.outDirs = [dir];
meta.branchMode = 'random';
}
// The last belt has no following belt in the run, but it still needs an
// explicit outward direction. Without this, clicking the end tile only
// reversed the interior tiles and left the clicked end visually unchanged.
if (target.length >= 2) {
const previous = target[target.length - 2];
const terminal = target[target.length - 1];
if (!isBranchCell(terminal)) {
const terminalMeta = game.conveyorMeta.get(key(terminal.col, terminal.row));
const terminalDir = directionNameBetweenCells(previous, terminal);
if (terminalMeta && terminalDir) {
terminalMeta.dir = terminalDir;
terminalMeta.outDirs = [terminalDir];
terminalMeta.branchMode = 'random';
}
}
}
}
function conveyorToolDef() {
@ -394,7 +564,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const k = key(col, row);
if (game.conveyorTiles.has(k)) return { ok: false, exists: true };
if (isBlockedCell(col, row)) return { ok: false, reason: 'Cannot build on blocked ground.' };
if (farmAt(game, col, row) || scannerAt(game, col, row)) return { ok: false, reason: TEXT.fail.cellOccupied };
if (isEquipmentCell(col, row)) return { ok: false, reason: TEXT.fail.cellOccupied };
const toolDef = conveyorToolDef();
const kind = toolDef.id || 'conveyor';
const cost = buildPrice(kind, game);
@ -492,6 +662,50 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
conveyorDragRecordedDirectionEdit = false;
}
function overlayPlacementBlocked(col, row) {
return !pointInGrid(col, row) || isBlockedCell(col, row) || farmAt(game, col, row) || scannerAt(game, col, row) || maintenanceRoomAt(game, col, row) || gridFacilityAt(game, col, row);
}
function buildPowerWireCell(cell, kind) {
if (!cell) return { ok: false, reason: TEXT.fail.outOfGrid };
if (overlayPlacementBlocked(cell.col, cell.row)) return { ok: false, reason: 'Power wire cannot cross equipment or blocked ground.' };
const k = key(cell.col, cell.row);
const price = BALANCE.power.wires[kind]?.price || buildPrice(kind, game);
const existing = game.powerWires?.get(k);
const existingPrice = existing ? (BALANCE.power.wires[existing.kind]?.price || 0) : 0;
const delta = price - existingPrice;
if (delta > 0 && game.cash < delta) return { ok: false, reason: TEXT.fail.notEnoughCash };
record(game);
if (delta > 0) spendCash(game, delta);
if (delta < 0) refundCash(game, -delta);
game.powerWires = game.powerWires || new Map();
game.powerWires.set(k, { kind, price, builtSession: game.buildSession });
markPowerDirty(game);
game.selected = { type: 'powerWire', id: k };
updatePanels();
return { ok: true };
}
function buildFootbridgeCell(cell) {
if (!cell) return fail(TEXT.fail.outOfGrid);
const vertical = (game.footbridgeOrientation || 'vertical') !== 'horizontal';
const cells = vertical
? [cell.row - 1, cell.row, cell.row + 1].map(row => ({ col: cell.col, row }))
: [cell.col - 1, cell.col, cell.col + 1].map(col => ({ col, row: cell.row }));
if (cells.some(part => overlayPlacementBlocked(part.col, part.row))) return fail('Footbridge cannot cross equipment or blocked ground.');
if (cells.some(part => game.footbridges?.has(key(part.col, part.row)))) return fail('Footbridge already covers that cell.');
const cost = buildPrice('footbridge', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
const bridgeId = `footbridge:${game.nextId++}`;
record(game);
spendCash(game, cost);
game.footbridges = game.footbridges || new Map();
for (const part of cells) game.footbridges.set(key(part.col, part.row), { kind: 'footbridge', bridgeId, orientation: vertical ? 'vertical' : 'horizontal', price: cost, builtSession: game.buildSession });
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
game.selected = { type: 'footbridge', id: key(cell.col, cell.row) };
updatePanels();
}
function buildAtCell(cell) {
if (!cell) return fail(TEXT.fail.outOfGrid);
const { col, row } = cell;
@ -501,7 +715,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
refreshRoutingAfterEdit(game);
return;
}
if (game.buildTool === 'powerWire' || game.buildTool === 'insulatedPowerWire') {
const result = buildPowerWireCell(cell, game.buildTool);
if (!result.ok) return fail(result.reason || TEXT.fail.cellOccupied);
return;
}
if (game.buildTool === 'footbridge') return buildFootbridgeCell(cell);
if (isBlockedCell(col, row)) return fail('Cannot build on blocked ground.');
if (facilityPortAt(col, row) && !['conveyor', 'boostConveyor'].includes(game.buildTool)) return fail('Machine port must remain open for a conveyor.');
if (game.buildTool === 'maintenanceRoom') {
if (maintenanceRoomPlacementBlocked(col, row)) return fail(TEXT.fail.cellOccupied);
return buildMaintenanceRoom(col, row);
}
if (GRID_FACILITY_IDS.includes(game.buildTool)) {
if (gridFacilityPlacementBlocked(game.buildTool, col, row)) return fail('3x3 machine or its ports do not fit here.');
return buildGridFacility(col, row, game.buildTool);
}
if ((game.buildTool === 'manualScanner' || game.buildTool === 'autoScanner') ? scannerPlacementBlocked(col, row) : isEquipmentCell(col, row)) return fail(TEXT.fail.cellOccupied);
if (game.buildTool === 'eggFarm') buildFarm(col, row);
else if (game.buildTool === 'manualScanner') buildScanner(col, row, 'manual');
@ -510,8 +739,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function buildAtPoint(p) {
const cell = pointToCell(p.x, p.y);
if (['conveyor', 'boostConveyor', 'eggFarm', 'manualScanner', 'autoScanner'].includes(game.buildTool)) return buildAtCell(cell);
if (MACHINE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
if (['conveyor', 'boostConveyor', 'powerWire', 'insulatedPowerWire', 'footbridge', 'eggFarm', 'manualScanner', 'autoScanner', 'maintenanceRoom', ...GRID_FACILITY_IDS.filter(id => id !== 'starterGenerator')].includes(game.buildTool)) return buildAtCell(cell);
if (EDGE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
}
function buildFarm(col, row) {
@ -542,6 +771,46 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
floating(game, cellCenter(col, row).x, cellCenter(col, row).y, `-${yen(cost)}`, THEME.ink);
}
function buildMaintenanceRoom(col, row) {
const cost = buildPrice('maintenanceRoom', game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
if (maintenanceRoomPlacementBlocked(col, row)) return fail(TEXT.fail.cellOccupied);
record(game);
spendCash(game, cost);
const room = applyBuildQuality(createMaintenanceRoom(game, col, row));
room.price = cost;
game.maintenanceRooms = game.maintenanceRooms || [];
game.maintenanceRooms.push(room);
markPowerDirty(game);
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
game.selected = { type: 'maintenanceRoom', id: room.id };
const center = roomCenter(room);
floating(game, center.x, center.y, `-${yen(cost)}`, THEME.ink);
}
function roomCenter(room) {
const first = cellCenter(room.col, room.row);
return { x: first.x + GRID.cell / 2, y: first.y + GRID.cell / 2 };
}
function buildGridFacility(col, row, id) {
const cost = buildPrice(id, game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
if (gridFacilityPlacementBlocked(id, col, row)) return fail('3x3 machine or its ports do not fit here.');
const f = applyBuildQuality(createGridFacility(game, id, col, row, cost));
const storageId = game.facilities[id] ? `${id}:${game.nextId++}` : id;
f.baseId = id;
f.id = storageId;
record(game);
spendCash(game, cost);
game.facilities[storageId] = f;
refreshRoutingAfterEdit(game);
markPowerDirty(game);
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
game.selected = { type: 'facility', id: storageId };
floating(game, f.x + f.w / 2, f.y + f.h / 2, `-${yen(cost)}`, THEME.ink);
}
function buildFacility(p, id) {
const cost = buildPrice(id, game);
if (game.cash < cost) return fail(TEXT.fail.notEnoughCash);
@ -550,11 +819,16 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
f.baseId = id;
f.id = storageId;
if (f.entry && isBlockedCell(f.entry.col, f.entry.row)) return fail('Receiver cell is blocked.');
if (f.entry && (farmAt(game, f.entry.col, f.entry.row) || scannerAt(game, f.entry.col, f.entry.row) || maintenanceRoomAt(game, f.entry.col, f.entry.row) || gridFacilityAt(game, f.entry.col, f.entry.row))) {
return fail('Receiver cell must remain free of equipment. A conveyor may occupy it.');
}
if (facilityOverlaps(f)) return fail(TEXT.fail.facilityOverlap);
record(game);
spendCash(game, cost);
game.facilities[storageId] = f;
refreshRoutingAfterEdit(game);
markPowerDirty(game);
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
game.selected = { type: 'facility', id: storageId };
floating(game, p.x, p.y - 14, `-${yen(cost)}`, THEME.ink);
}
@ -573,7 +847,24 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const saleReason = lastProtectedSaleReason(hit);
if (saleReason) return fail(saleReason);
record(game);
if (hit.type === 'conveyor') {
if (hit.type === 'footbridge') {
const c = parseKey(hit.oldKey);
const center = cellCenter(c.col, c.row);
refundOrSell(hit, center.x, center.y);
const bridgeId = game.footbridges.get(hit.oldKey)?.bridgeId;
for (const [k, bridge] of game.footbridges || new Map()) {
if (!bridgeId || bridge.bridgeId === bridgeId) game.footbridges.delete(k);
}
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
eraseEffect(game, center.x, center.y);
} else if (hit.type === 'powerWire') {
const c = parseKey(hit.oldKey);
const center = cellCenter(c.col, c.row);
refundOrSell(hit, center.x, center.y);
game.powerWires.delete(hit.oldKey);
markPowerDirty(game);
eraseEffect(game, center.x, center.y);
} else if (hit.type === 'conveyor') {
const c = parseKey(hit.oldKey);
const center = cellCenter(c.col, c.row);
refundOrSell(hit, center.x, center.y);
@ -581,27 +872,38 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
game.conveyorMeta.delete(hit.oldKey);
eraseEffect(game, center.x, center.y);
refreshRoutingAfterEdit(game);
}
if (hit.type === 'eggFarm') {
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
} else if (hit.type === 'eggFarm') {
const c = cellCenter(hit.ref.col, hit.ref.row);
refundOrSell(hit, c.x, c.y);
game.eggFarms = game.eggFarms.filter(f => f.id !== hit.ref.id);
eraseEffect(game, c.x, c.y);
refreshRoutingAfterEdit(game);
}
if (hit.type === 'scanner') {
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
} else if (hit.type === 'scanner') {
const c = scannerCenter(hit.ref);
refundOrSell(hit, c.x, c.y);
game.scanners = game.scanners.filter(s => s.id !== hit.ref.id);
eraseEffect(game, c.x, c.y);
refreshRoutingAfterEdit(game);
}
if (hit.type === 'facility') {
markPowerDirty(game);
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
} else if (hit.type === 'facility') {
const f = hit.ref;
refundOrSell(hit, f.x + f.w / 2, f.y + f.h / 2);
delete game.facilities[f.id];
eraseEffect(game, f.x + f.w / 2, f.y + f.h / 2);
refreshRoutingAfterEdit(game);
markPowerDirty(game);
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
} else if (hit.type === 'maintenanceRoom') {
const room = hit.ref;
const center = roomCenter(room);
refundOrSell(hit, center.x, center.y);
game.maintenanceRooms = (game.maintenanceRooms || []).filter(r => r.id !== room.id);
eraseEffect(game, center.x, center.y);
markPowerDirty(game);
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
}
game.selected = null;
game.multiSelected = [];
@ -623,7 +925,11 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (farm) return { type: 'eggFarm', ref: farm };
const scanner = scannerAt(game, cell.col, cell.row);
if (scanner) return { type: 'scanner', ref: scanner };
const room = maintenanceRoomAt(game, cell.col, cell.row);
if (room) return { type: 'maintenanceRoom', ref: room };
const k = key(cell.col, cell.row);
if (game.footbridges?.has(k)) return { type: 'footbridge', oldKey: k, ref: { type: 'footbridge', id: k } };
if (game.powerOverlay && game.powerWires?.has(k)) return { type: 'powerWire', oldKey: k, ref: { type: 'powerWire', id: k } };
if (game.conveyorTiles.has(k)) return { type: 'conveyor', oldKey: k, ref: { type: 'conveyor', id: k } };
return null;
}
@ -638,9 +944,28 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const obj = selectedObject();
if (!obj) return;
if (obj.type === 'conveyor') eraseAtPoint(cellCenter(...Object.values(parseKey(obj.id))));
else if (obj.type === 'powerWire') {
const cell = parseKey(obj.id);
const meta = game.powerWires?.get(obj.id);
if (!meta) return;
record(game);
const center = cellCenter(cell.col, cell.row);
refundOrSell({ type: 'powerWire', oldKey: obj.id, ref: { id: obj.id, price: meta.price || 0, builtSession: meta.builtSession } }, center.x, center.y);
game.powerWires.delete(obj.id);
markPowerDirty(game);
eraseEffect(game, center.x, center.y);
game.selected = null;
game.multiSelected = [];
updatePanels();
}
else if (obj.type === 'footbridge') {
const cell = parseKey(obj.id);
eraseAtPoint(cellCenter(cell.col, cell.row));
}
else if (obj.type === 'eggFarm') eraseAtPoint(cellCenter(obj.col, obj.row));
else if (obj.type === 'scanner') eraseAtPoint(scannerCenter(obj));
else if (obj.type === 'facility') eraseAtPoint({ x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 });
else if (obj.type === 'maintenanceRoom') eraseAtPoint(roomCenter(obj));
}
function modalButton(text, onClick, className = 'primary-button') {
@ -724,6 +1049,47 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
ui.modal.classList.add('visible', 'equipment-popover');
}
function showInboundTruckMenu(truck) {
if (!truck || (truck.baseId || truck.id) !== 'inboundTruck') return;
truck.inboundOrder = truck.inboundOrder || { coalPerDay: 0, zundaMochiPerDay: 0 };
const limit = BALANCE.imports.combinedLimitPerTruck;
const coal = Math.max(0, Math.floor(truck.inboundOrder.coalPerDay || 0));
const zunda = Math.max(0, Math.floor(truck.inboundOrder.zundaMochiPerDay || 0));
const cost = coal * BALANCE.imports.prices.coal + zunda * BALANCE.imports.prices.zundaMochi;
const total = coal + zunda;
const avgInterval = total > 0 ? (BALANCE.time.daySeconds / total).toFixed(1) : 'none';
ui.modalTitle.textContent = `Inbound Truck #${truck.id}`;
ui.modalBody.innerHTML = `
<div class="equipment-menu-lines compact">
<label>Coal per day <input id="inboundCoalInput" type="number" min="0" max="${limit}" step="1" value="${coal}" /></label>
<label>Zunda mochi per day <input id="inboundZundaInput" type="number" min="0" max="${limit}" step="1" value="${zunda}" /></label>
<p>Combined total: <b>${total}/${limit}</b></p>
<p>Daily purchase cost: <b>${yen(cost)}</b></p>
<p>Average release interval: <b>${avgInterval}${total > 0 ? 's' : ''}</b></p>
<p>Output queue: <b>${truck.inboundQueue?.length || 0}</b></p>
</div>`;
ui.modalActions.innerHTML = '';
const applyButton = modalButton('Apply', () => {
if (game.phase !== 'build') return;
const nextCoal = Math.max(0, Math.floor(Number(document.getElementById('inboundCoalInput')?.value) || 0));
const nextZunda = Math.max(0, Math.floor(Number(document.getElementById('inboundZundaInput')?.value) || 0));
const total = nextCoal + nextZunda;
const scale = total > limit ? limit / total : 1;
record(game);
truck.inboundOrder = {
coalPerDay: Math.floor(nextCoal * scale),
zundaMochiPerDay: Math.floor(nextZunda * scale)
};
showInboundTruckMenu(truck);
updatePanels();
}, 'primary-button');
applyButton.disabled = game.phase !== 'build';
ui.modalActions.appendChild(applyButton);
ui.modalActions.appendChild(modalButton('Close', hideModal, 'facility-action'));
positionEquipmentPopover(truck);
ui.modal.classList.add('visible', 'equipment-popover');
}
function manualKeyConflicts(scanner) {
const own = [
{ side: 'left', binding: scanner.keys?.left },
@ -798,6 +1164,14 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
ui.modalActions.innerHTML = '';
if (obj.type === 'scanner' && obj.kind === 'auto') ui.modalActions.appendChild(modalButton('Auto Menu', () => showAutoScannerMenu(obj), 'facility-action warn'));
if (obj.type === 'scanner' && obj.kind === 'manual') ui.modalActions.appendChild(modalButton('Set Keys', () => showManualScannerMenu(obj), 'facility-action warn'));
if (obj.type === 'conveyor' && isBranchKey(obj.id) && branchSelectableExitDirsForKey(obj.id).length >= 2) {
const mode = game.conveyorMeta.get(obj.id)?.branchMode || 'random';
ui.modalActions.appendChild(modalButton(`Branch: ${branchModeLabel(mode)}`, () => {
cycleBranchModeForKey(obj.id);
game.selected = { type: 'conveyor', id: obj.id };
showSelectedMenu();
}, 'facility-action warn branch-action'));
}
const hit = obj.type === 'conveyor' ? { type: 'conveyor', oldKey: obj.id, ref: obj } : { type: obj.type, ref: obj };
const saleReason = lastProtectedSaleReason(hit);
if (saleReason) {
@ -829,6 +1203,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
boxes.push({ id: `facility:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: f.x, y: f.y, w: f.w, h: f.h });
if (f.entry) { const c = cellCenter(f.entry.col, f.entry.row); boxes.push({ id: `facilityPort:${id}`, price: equipmentPrice({ type: 'facility', ref: f }), x: c.x - 18, y: c.y - 18, w: 36, h: 36 }); }
}
for (const room of game.maintenanceRooms || []) { const c = roomCenter(room); boxes.push({ id: `maintenanceRoom:${room.id}`, price: equipmentPrice({ type: 'maintenanceRoom', ref: room }), x: c.x - GRID.cell, y: c.y - GRID.cell, w: GRID.cell * 2, h: GRID.cell * 2 }); }
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 - 50, y: c.y - 73, w: 100, h: 146 }); }
for (const k of game.conveyorTiles) {
@ -837,6 +1212,18 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const meta = game.conveyorMeta.get(k) || {};
boxes.push({ id: `conveyor:${k}`, price: meta.price || buildPrice(meta.kind === 'boostConveyor' ? 'boostConveyor' : 'conveyor', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 });
}
if (game.powerOverlay) {
for (const [k, wire] of game.powerWires || new Map()) {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
boxes.push({ id: `powerWire:${k}`, price: wire.price || 0, x: c.x - 18, y: c.y - 18, w: 36, h: 36 });
}
}
for (const [k, bridge] of game.footbridges || new Map()) {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
boxes.push({ id: `footbridge:${k}`, price: bridge.price || buildPrice('footbridge', game), x: c.x - 22, y: c.y - 22, w: 44, h: 44 });
}
return boxes;
}
@ -856,7 +1243,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function branchSwitcherAtPoint(p) {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return null;
for (const k of game.conveyorTiles) {
if (!isBranchKey(k)) continue;
if (!isBranchKey(k) || branchSelectableExitDirsForKey(k).length < 2) continue;
const cell = parseKey(k);
const c = cellCenter(cell.col, cell.row);
const left = c.x - BRANCH_MARKER.w / 2;
@ -869,19 +1256,19 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
function cycleBranchModeAtPoint(p) {
const k = branchSwitcherAtPoint(p);
if (!k) return false;
cycleBranchModeForKey(k);
return true;
return cycleBranchModeForKey(k);
}
function cycleBranchModeForKey(k) {
const meta = game.conveyorMeta.get(k);
if (!meta) return false;
const reachable = branchSelectableEntranceDirsForKey(k);
const reachable = branchSelectableExitDirsForKey(k);
if (reachable.length < 2) return false;
record(game);
const current = BRANCH_MODES.includes(meta.branchMode) ? meta.branchMode : 'random';
const modes = ['random', ...DIRS.map(d => d.name).filter(mode => reachable.includes(mode))];
meta.branchMode = modes[(Math.max(0, modes.indexOf(current)) + 1) % modes.length];
refreshRoutingAfterEdit(game);
game.selected = null;
game.multiSelected = [];
updatePanels();
@ -899,6 +1286,12 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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)}`);
const cap = boostOutputCapacityPerSecond(game);
const theoretical = obj.theoreticalRate || farmTheoreticalRatePerSecond(game, obj, obj.lastInterval || 1);
const quality = qualityForRate(theoretical, cap);
lines.push(`Rate: ${theoretical.toFixed(2)}/s theoretical | ${Math.min(theoretical, cap).toFixed(2)}/s actual`);
lines.push(`Boost cap: ${cap.toFixed(2)}/s | Quality: x${quality.toFixed(2)} ${qualityGrade(quality)}`);
if (obj.zundaBoostTime > 0) lines.push(`Zunda boost: ${obj.zundaBoostTime.toFixed(1)}s`);
} else if (obj.type === 'scanner') {
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
lines.push(`Sale value: ${yen(resaleValueFor({ type: obj.type, ref: obj }, game).amount)}`);
@ -908,7 +1301,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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');
const power = powerDetailsFor(game, obj);
if (power) appendPowerLines(lines, power);
} else if (obj.type === 'conveyor') {
const meta = game.conveyorMeta.get(obj.id);
const comp = game.componentLookup?.get(obj.id);
@ -919,20 +1313,48 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
lines.push(`Congestion: ${c ? Math.floor(c.ratio * 100) : 0}%`);
lines.push(`Durability: ${remainingPercent({ meta })}% | Speed x${performanceFactor({ meta }).toFixed(2)}`);
if (isBranchKey(obj.id)) {
lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`);
lines.push('Use the Branch button below or the on-belt branch badge.');
const selectableExits = branchSelectableExitDirsForKey(obj.id);
if (selectableExits.length >= 2) {
lines.push(`Branch mode: ${branchModeLabel(meta?.branchMode || 'random')}`);
lines.push('Click the Branch button or the on-belt triangle to cycle RANDOM / fixed exit.');
} else {
lines.push('Branch mode: unavailable — fewer than two outgoing conveyors.');
}
} else {
lines.push('Branch mode: needs 3+ conveyor connections.');
}
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'conveyor', oldKey: obj.id, ref: obj }, game).amount)}`);
} else if (obj.type === 'powerWire') {
const meta = game.powerWires?.get(obj.id);
const def = BALANCE.power.wires[meta?.kind || 'powerWire'];
lines.push(`Price: ${yen(meta?.price || def.price)}`);
lines.push(`Cell: ${obj.id}`);
lines.push(`Type: ${def.name} | Loss ${def.lossV}V/tile`);
} else if (obj.type === 'footbridge') {
const meta = game.footbridges?.get(obj.id);
lines.push(`Price: ${yen(meta?.price || buildPrice('footbridge', game))}`);
lines.push(`Cell: ${obj.id}`);
lines.push(`Orientation: ${meta?.orientation || 'vertical'}`);
lines.push('Repair workers may cross this conveyor cell.');
} else if (obj.type === 'maintenanceRoom') {
lines.push(`Price: ${yen(equipmentPrice({ type: 'maintenanceRoom', ref: obj }))}`);
lines.push(`Sale value: ${yen(resaleValueFor({ type: 'maintenanceRoom', ref: obj }, game).amount)}`);
lines.push('Size: 2×2 grid cells');
lines.push('Dispatch: 1 repairman during each production day');
const power = powerDetailsFor(game, obj);
if (power) appendPowerLines(lines, power);
} else if (obj.type === 'facility') {
lines.push(`Price: ${yen(equipmentPrice(obj))}`);
{ 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}`);
lines.push(['mixer', 'truck', 'trash', 'sausageMaker'].includes(facilityKind) ? `Level: ${obj.level} / no cap` : `Level: ${obj.level}`);
if (facilityKind === 'truck') lines.push('Durability: none | Always normal');
if (['mixer', 'trash'].includes(facilityKind)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj).toFixed(2)}s`);
if (['mixer', 'trash', 'sausageMaker'].includes(facilityKind)) lines.push(`Durability: ${remainingPercent(obj)}% | Extra delay ${facilityProcessingDelay(game, obj).toFixed(2)}s`);
if (facilityKind === 'mixer') {
lines.push(`Income: ${yen(upgradedMixerPrice(game))} per chick | upgrade x${incomeMultiplier(game, 'mixer').toFixed(3)}`);
lines.push(`Output: 1 meat per non-poop chick | meat buffer ${obj.meatBuffer || 0}`);
lines.push(`Meat quality: x${incomeMultiplier(game, 'mixer').toFixed(3)} to sausage value`);
const out = facilityOutputCell(game, obj);
lines.push(`OUT: ${out ? `${out.col},${out.row}` : 'none'} | conveyor ${facilityOutputHasConveyor(game, obj) ? 'connected' : 'missing'}`);
lines.push(`Fallback sale: ${yen(ECONOMY.income.mixer)} when OUT is missing or blocked`);
lines.push(`Poop fine: -${yen(mixerPoopPenalty(game))}`);
}
if (facilityKind === 'truck') {
@ -941,16 +1363,35 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
if (facilityKind === 'trash') {
const cards = shredderUpgradeCount(game);
const income = shredderBonusIncome(cards);
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.`);
lines.push(`Expected poop bonus: ${yen(income)} | hit chance ${Math.round(chance * 100)}%`);
lines.push('Winning payouts roll 1x / 2x / 4x / 10x, with jackpot odds rising per card.');
lines.push(`With enough upgrades, poop can out-earn male/female routes.`);
}
if (facilityKind === 'sausageMaker') {
const input = facilityInputCell(game, obj);
const out = facilityOutputCell(game, obj);
lines.push('Size: 3×3 grid cells | IN left / OUT right');
lines.push(`Recipe: 2 meat -> 1 sausage | meat buffer ${obj.meatBuffer || 0} | sausage buffer ${obj.sausageBuffer || 0}`);
lines.push(`Sausage sale value: ${yen(upgradedSausagePrice(game))}`);
lines.push(`IN: ${input ? `${input.col},${input.row}` : 'none'} | OUT: ${out ? `${out.col},${out.row}` : 'none'} | conveyor ${facilityOutputHasConveyor(game, obj) ? 'connected' : 'missing'}`);
}
if (obj.entry) lines.push(`Receiver: edge cell ${obj.entry.col},${obj.entry.row} (${obj.side})`);
const power = powerDetailsFor(game, obj);
if (power) appendPowerLines(lines, power);
}
return lines;
}
function appendPowerLines(lines, power) {
lines.push(`Power: ${power.receivedV.toFixed(1)}V / ${power.minVoltage}V`);
lines.push(`Source: ${power.sourceName} | Source bus: ${power.sourceBusV.toFixed(1)}V`);
lines.push(`Route loss: ${power.routeLossV.toFixed(1)}V | Voltage Load: ${power.drawV}V`);
if (power.receivedV < power.minVoltage) lines.push('Status: UNDER VOLTAGE');
}
function disconnectedWarningFor(obj) {
if (!obj || game.phase !== 'build') return '';
const match = disconnectedBuildWarnings(game).find(item => item.type === obj.type && item.id === obj.id);
@ -964,11 +1405,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
if (obj.type === 'conveyor' && game.conveyorMeta.get(obj.id)?.kind === 'boostConveyor') return 'Moves chicks twice as fast. Wears down like a normal belt.';
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 'Slower than hands without upgrades.';
if (obj.type === 'scanner' && obj.kind === 'auto') return 'A compact 2×2 scanner sized for roughly 2.1 level-1 farms.';
if (obj.type === 'scanner') return 'A manual checkpoint. The operator is the algorithm.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'mixer') return 'Male chicks become revenue here. Do not feed it poop.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'mixer') return 'Chicks become meat when OUT is clear; a blocked OUT falls back to direct sale.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'trash') return 'Poop goes in. Sometimes coins come out, for reasons best left unaudited.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'truck') return 'The shipping endpoint. Correct cargo pays; wrong cargo complains.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'truck') return 'The shipping endpoint. Correct cargo and sausages pay; wrong cargo complains.';
if (obj.type === 'facility' && (obj.baseId || obj.id) === 'sausageMaker') return 'A 3×3 in-grid machine with one meat inlet on the left and one sausage outlet on the right.';
if (obj.type === 'maintenanceRoom') return 'A cramped 2×2 storage and break room with lockers, spare parts, a bench and one dispatched repairman.';
return 'Factory equipment.';
}
@ -985,8 +1428,8 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
cycleSingleConveyorDirection,
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey,
removeSelected, switchScannerRole, showManualScannerMenu,
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey, branchSelectableExitDirsForKey,
removeSelected, switchScannerRole, showManualScannerMenu, showInboundTruckMenu,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes,
routeFromFarmToScanner

View file

@ -1,7 +1,7 @@
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
import { AUTO_SCANNER_COOLDOWN, AUTO_SCANNER_UPGRADE_RATE, AUTO_SCANNER_CARD_UPGRADE_RATE, AUTO_SCANNER_MIN_COOLDOWN, BEARING_SPEED_MULTIPLIER, CARD_BALANCE, CARD_DEFAULT_EFFECTS, CARD_DEFS as CARD_DEFINITIONS, CONVEYOR_SPEED, CONVEYOR_SPEED_MAX, ECONOMY, EGG_SPAWN_RANGES, GRID, THEME } from '../core/config.js';
import { nextSpawnDelay } from '../core/state.js';
import { cellCenter, key, yen } from '../core/utils.js';
import { applyPenalty, applyRevenue, upgradedMixerPrice, upgradedTruckPrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { applyPenalty, applyRevenue, upgradedTruckPrice, upgradedSausagePrice, shredderUpgradeCount, rawShredderUpgradeCount, shredderBonusIncome, shredderBonusChance, shredderBonusMaxCards } from './economy.js';
import { floating, shake, eraseEffect, shockwave, sparkBurst, smokeBurst } from './effects.js';
import { averageConveyorPerformance, autoScannerDelayMultiplier, ensureMaintenanceState, repairAllEquipment } from './maintenance.js';
import { scannerCenter, refreshRoutingAfterEdit } from './routing.js';
@ -19,7 +19,7 @@ function effectCount(game, id) {
}
export function ensureCardState(game) {
if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS, loans: [] };
if (!game.cardEffects) game.cardEffects = { ...CARD_DEFAULT_EFFECTS };
for (const [k, v] of Object.entries(CARD_DEFAULT_EFFECTS)) {
if (game.cardEffects[k] == null) game.cardEffects[k] = Array.isArray(v) ? [] : v;
}
@ -27,7 +27,6 @@ export function ensureCardState(game) {
game.cardEffects.fairiesFlatteryNext = Math.max(0, game.cardEffects.fairiesFlatteryNext || 0) + Math.max(0, game.cardEffects.flattery || 0);
delete game.cardEffects.flattery;
}
if (!Array.isArray(game.cardEffects.loans)) game.cardEffects.loans = [];
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
if (!game.cardDraft) game.cardDraft = { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
if (game.cardDraft.picksRemaining == null) game.cardDraft.picksRemaining = game.cardDraft.pending ? 1 : 0;
@ -54,13 +53,19 @@ export function conveyorSpeedForGame(game, conveyorKey = null) {
const speed = CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, effects.bearing || 0));
const beltMultiplier = conveyorKey ? Math.max(0.1, Number(game?.conveyorMeta?.get?.(conveyorKey)?.speedMultiplier) || 1) : 1;
const perf = conveyorKey ? 1 : averageConveyorPerformance(game);
return Math.min(CONVEYOR_SPEED_MAX, speed * perf) * beltMultiplier;
const eventMult = Math.max(0.01, Number(game?.eventActive?.conveyorSpeedMultiplier) || 1);
return Math.min(CONVEYOR_SPEED_MAX, speed * perf) * beltMultiplier * eventMult;
}
export function autoScannerCooldownSeconds(scanner, game = null) {
const upgrades = Math.max(0, (scanner?.level || 1) - 1);
const base = Math.max(AUTO_SCANNER_MIN_COOLDOWN, AUTO_SCANNER_COOLDOWN * Math.pow(AUTO_SCANNER_UPGRADE_RATE, upgrades));
return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner)) : base;
const localUpgrades = Math.max(0, (scanner?.level || 1) - 1);
const globalUpgrades = game ? effectCount(game, 'autoScannerTuning') : 0;
const tunedBase = AUTO_SCANNER_COOLDOWN
* Math.pow(AUTO_SCANNER_UPGRADE_RATE, localUpgrades)
* Math.pow(AUTO_SCANNER_CARD_UPGRADE_RATE, globalUpgrades);
const base = Math.max(AUTO_SCANNER_MIN_COOLDOWN, tunedBase);
const eventMult = game ? Math.max(0.01, Number(game?.eventActive?.autoScannerCooldownMultiplier) || 1) : 1;
return game ? Math.max(AUTO_SCANNER_MIN_COOLDOWN, base * autoScannerDelayMultiplier(scanner) * eventMult) : base;
}
export function rerollCost(game) {
@ -73,35 +78,58 @@ export function cardById(id) {
return CARD_DEFS.find(card => card.id === id) || null;
}
export function debugGrantCard(game, cardOrId) {
export function debugCardTargets(game, cardOrId) {
const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId;
ensureCardState(game);
if (!card || !(card.type === 'equipmentUpgrade' || card.type === 'cellAction')) return [];
return targetsForCard(game, card).map(target => ({
key: targetKey(target),
label: targetLabel(game, target),
type: target.type
}));
}
export function debugGrantCard(game, cardOrId, options = {}) {
const card = typeof cardOrId === 'string' ? cardById(cardOrId) : cardOrId;
ensureCardState(game);
if (!card || card.type === 'dud') return { ok: false, reason: 'Card not found.' };
if (card.id === 'extraCards') {
game.cardDraft = game.cardDraft || { pending: false, choices: [], rerolls: 0, picksRemaining: 0 };
game.cardDraft.choices.push(...dealCards(game, 2));
return { ok: true, reason: `${card.title} added draft cards.` };
return { ok: true, reason: `${card.title} added 2 draft cards.` };
}
if (card.type === 'instant') {
applyInstantCard(game, card);
return { ok: true, reason: `${card.title} granted.` };
}
if (card.type === 'equipmentUpgrade') {
const target = targetsForCard(game, card)[0];
if (!target) return { ok: false, reason: `No valid target for ${card.title}.` };
const targets = targetsForCard(game, card);
if (!targets.length) return { ok: false, reason: `No valid target for ${card.title}.` };
const requestedKey = options.targetKey && options.targetKey !== '__auto__' ? String(options.targetKey) : '';
const target = requestedKey ? targets.find(t => targetKey(t) === requestedKey) : targets[0];
if (!target) return { ok: false, reason: `Selected target is no longer valid for ${card.title}.` };
applyEquipmentUpgrade(game, target);
return { ok: true, reason: `${card.title} applied.` };
return { ok: true, reason: `${card.title} applied to ${targetLabel(game, target)}.` };
}
if (card.type === 'cellAction' && card.target === 'blockedCell') {
const targets = targetsForCard(game, card).slice(0, 3);
const targets = targetsForCard(game, card);
if (!targets.length) return { ok: false, reason: 'No blocked cells to remove.' };
for (const target of targets) {
const requestedKeys = new Set((Array.isArray(options.targetKeys) ? options.targetKeys : [options.targetKey])
.filter(k => k && k !== '__auto__')
.map(String));
const selected = requestedKeys.size
? targets.filter(t => requestedKeys.has(targetKey(t)))
: targets.slice(0, 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));
const c = cellCenter(target.col, target.row);
eraseEffect(game, c.x, c.y);
shockwave(game, c.x, c.y, THEME.danger, 42);
sparkBurst(game, c.x, c.y, 8, THEME.warn);
}
refreshRoutingAfterEdit(game);
return { ok: true, reason: `${card.title} removed ${targets.length} blocked cells.` };
return { ok: true, reason: `${card.title} removed ${Math.min(10, selected.length)} blocked cell(s).` };
}
return { ok: false, reason: `${card.title} cannot be debug-granted.` };
}
@ -118,13 +146,15 @@ function targetLabel(game, target) {
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.baseId || target.id) === 'mixer') {
const beforeLevel = target.level;
const before = upgradedMixerPrice(game);
if (target.type === 'facility' && ['mixer', 'sausageMaker'].includes(target.baseId || target.id)) {
const kind = target.baseId || target.id;
const beforeLevel = target.level || 1;
const before = upgradedSausagePrice(game);
target.level = beforeLevel + 1;
const after = upgradedMixerPrice(game);
const after = upgradedSausagePrice(game);
target.level = beforeLevel;
return `MIXER L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`;
const label = kind === 'mixer' ? 'MIXER' : 'SAUSAGE MACHINE';
return `${label} L${target.level} -> L${target.level + 1} | sausage ${yen(before)} -> ${yen(after)}`;
}
if (target.type === 'facility' && (target.baseId || target.id) === 'truck') {
const beforeLevel = target.level;
@ -134,12 +164,12 @@ function targetLabel(game, target) {
target.level = beforeLevel;
return `TRUCK L${target.level} -> L${target.level + 1} | ${yen(before)} -> ${yen(after)}`;
}
if (target.type === 'facility' && target.id === 'trash') {
if (target.type === 'facility' && (target.baseId || target.id) === 'trash') {
const before = shredderUpgradeCount(game);
const after = Math.min(shredderBonusMaxCards(), before + 1);
const beforePercent = (shredderBonusChance(before) * 100).toFixed(2);
const afterPercent = (shredderBonusChance(after) * 100).toFixed(2);
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | ${beforePercent}% -> ${afterPercent}% per item`;
const beforeIncome = shredderBonusIncome(before);
const afterIncome = shredderBonusIncome(after);
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | expected ${yen(beforeIncome)} -> ${yen(afterIncome)} per poop`;
}
return `${target.name || target.id} L${target.level || 1} -> L${(target.level || 1) + 1}`;
}
@ -163,6 +193,7 @@ export function targetsForCard(game, cardOrId) {
if (card.target === 'autoScanner') return game.scanners.filter(s => s.kind === 'auto');
if (card.target === 'mixer') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'mixer');
if (card.target === 'truck') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'truck');
if (card.target === 'sausageMaker') return Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'sausageMaker');
if (card.target === 'trash') return rawShredderUpgradeCount(game) < shredderBonusMaxCards()
? Object.values(game.facilities || {}).filter(f => (f.baseId || f.id) === 'trash')
: [];
@ -185,24 +216,34 @@ function cardAtCap(game, card) {
}
function repairmanCardVisible(game) {
const r = game.repairman || {};
return !!(r.active || r.hiredForNextDay || game.pendingRepairWorkerWages > 0);
return !!((game.maintenanceRooms || []).length || (game.repairmen || []).length || game.repairman?.active);
}
function facilityRequirementMet(game, id) {
if (id === 'conveyor') return (game.conveyorTiles?.size || 0) > 0;
if (id === 'autoScanner') return (game.scanners || []).some(s => s.kind === 'auto');
if (id === 'manualScanner') return (game.scanners || []).some(s => s.kind === 'manual');
if (id === 'eggFarm') return (game.eggFarms || []).length > 0;
return Object.values(game.facilities || {}).some(f => (f.baseId || f.id) === id);
}
function cardRequirementsMet(game, card) {
if (card.requiresFacility && !facilityRequirementMet(game, card.requiresFacility)) return false;
if (Array.isArray(card.requiresAnyFacility) && !card.requiresAnyFacility.some(id => facilityRequirementMet(game, id))) return false;
return true;
}
function availableCards(game) {
return CARD_DEFS.filter(card => {
if (card.id === 'laborExploitation' && !repairmanCardVisible(game)) return false;
if (!cardRequirementsMet(game, card)) return false;
if (cardAtCap(game, card)) return false;
if ((card.type === 'equipmentUpgrade' || card.type === 'cellAction') && targetsForCard(game, card).length <= 0) return false;
return true;
});
}
function cardWeight(game, card) {
if (card.weightBase != null) {
const held = effectCount(game, card.id);
return Math.max(0, Number(card.weightBase) - Math.max(0, Number(card.weightLossPerCopy) || 0) * held);
}
function cardWeight(_game, card) {
if (card.rarity === 'ultraRare') return ULTRA_RARE_WEIGHT;
if (card.rarity === 'rare') return RARE_WEIGHT;
return COMMON_WEIGHT;
@ -290,7 +331,7 @@ function boundsForTarget(target) {
}
if (target.type === 'scanner') {
const c = scannerCenter(target);
return { x: c.x - 54, y: c.y - 77, w: 108, h: 154, cx: c.x, cy: c.y };
return { x: c.x - 48, y: c.y - 48, w: 96, h: 96, 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 };
@ -355,9 +396,11 @@ function applyInstantCard(game, card) {
const effects = ensureCardState(game);
const inc = id => { effects[id] = Math.max(0, effects[id] || 0) + 1; };
if (card.id === 'bearing') inc('bearing');
if (card.id === 'autoScannerTuning') inc('autoScannerTuning');
if (card.id === 'legalWork') inc('legalWork');
if (card.id === 'crowdedFarming') effects.crowdedFarming = Math.min(10, (effects.crowdedFarming || 0) + 1);
if (card.id === 'hatchingFeed') inc('hatchingFeed');
if (card.id === 'poopRateBooster') inc('poopRateBooster');
if (card.id === 'safetyCover') inc('safetyCover');
if (card.id === 'recyclingSubsidy') effects.recyclingSubsidy = Math.min(3, (effects.recyclingSubsidy || 0) + 1);
if (card.id === 'chemicalWeaponSubsidy') inc('chemicalWeaponSubsidy');
@ -383,13 +426,6 @@ function applyInstantCard(game, card) {
inc('rescueLoanCharges');
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, 'RESCUE HELD', THEME.green);
}
if (card.id === 'loan') {
const day = Math.max(1, Math.floor(Number(game.turn) || 1));
const amount = 300 * day;
applyRevenue(game, amount);
effects.loans.push({ daysRemaining: day, dailyPayment: 400, borrowedOnTurn: game.turn || 1 });
floating(game, GRID.x + GRID.cols * GRID.cell / 2, GRID.y - 30, `LOAN +${yen(amount)}`, THEME.green);
}
}
function formatPercent(value) {
@ -406,13 +442,14 @@ function incomeAtLevel(base, level) {
}
function incomeUpgradePreview(game, id, base) {
const targets = targetsForCard(game, id === 'mixer' ? 'upgradeMixer' : 'upgradeTruck');
const target = targets[0];
const cardId = id === 'mixer' ? 'upgradeMixer' : id === 'sausageMaker' ? 'upgradeSausageMaker' : 'upgradeTruck';
const target = targetsForCard(game, cardId)[0];
if (!target) return { before: incomeAtLevel(base, 1), after: incomeAtLevel(base, 2) };
const before = id === 'mixer' ? upgradedMixerPrice(game) : upgradedTruckPrice(game);
const price = () => id === 'truck' ? upgradedTruckPrice(game) : upgradedSausagePrice(game);
const before = price();
const beforeLevel = target.level || 1;
target.level = beforeLevel + 1;
const after = id === 'mixer' ? upgradedMixerPrice(game) : upgradedTruckPrice(game);
const after = price();
target.level = beforeLevel;
return { before, after };
}
@ -431,8 +468,12 @@ function cardDescription(game, card) {
return `Choose one AUTO SCANNER. Level +1. Cooldown ${before.toFixed(2)}s -> ${after.toFixed(2)}s.`;
}
if (card.id === 'upgradeMixer') {
const preview = incomeUpgradePreview(game, 'mixer', ECONOMY.income.mixer);
return `Choose one MIXER. Earning +10%. Male-chick income ${yen(preview.before)} -> ${yen(preview.after)}.`;
const preview = incomeUpgradePreview(game, 'mixer', ECONOMY.income.sausage);
return `Choose one MIXER. Meat quality +10%. Sausage sale value ${yen(preview.before)} -> ${yen(preview.after)}.`;
}
if (card.id === 'upgradeSausageMaker') {
const preview = incomeUpgradePreview(game, 'sausageMaker', ECONOMY.income.sausage);
return `Choose one SAUSAGE MACHINE. Product value +10%. Sausage sale value ${yen(preview.before)} -> ${yen(preview.after)}.`;
}
if (card.id === 'upgradeTruck') {
const preview = incomeUpgradePreview(game, 'truck', ECONOMY.income.truck);
@ -441,7 +482,11 @@ function cardDescription(game, card) {
if (card.id === 'upgradeTrash') {
const before = shredderUpgradeCount(game);
const after = Math.min(shredderBonusMaxCards(), before + 1);
return `SHREDDER level +1. Bonus chance per item ${formatPercent(shredderBonusChance(before))} -> ${formatPercent(shredderBonusChance(after))}.`;
const beforeIncome = shredderBonusIncome(before);
const afterIncome = shredderBonusIncome(after);
const beforeChance = Math.round(shredderBonusChance(before) * 100);
const afterChance = Math.round(shredderBonusChance(after) * 100);
return `SHREDDER level +1. Expected value ${yen(beforeIncome)} -> ${yen(afterIncome)} per poop; hit chance ${beforeChance}% -> ${afterChance}%.`;
}
if (card.id === 'bearing') {
const before = Math.min(CONVEYOR_SPEED_MAX, CONVEYOR_SPEED * Math.pow(BEARING_SPEED_MULTIPLIER, Math.max(0, e.bearing || 0)));
@ -450,7 +495,7 @@ function cardDescription(game, card) {
}
if (card.id === 'legalWork') {
const before = Math.max(0, e.legalWork || 0);
return `ZUNDA TAX exemption +${yen(250)}. After pick: exemption bonus ${yen(250 * (before + 1))}.`;
return `Fairies tribute profit exemption +${yen(250)}. After pick: exemption bonus ${yen(250 * (before + 1))}.`;
}
if (card.id === 'crowdedFarming') {
const next = Math.min(10, (e.crowdedFarming || 0) + 1);
@ -461,10 +506,12 @@ function cardDescription(game, card) {
const held = Math.max(0, Number(targets[0]?.farm?.extraEggOutlet) || 0);
return `Choose one EGG. Output ports ${1 + held} -> ${2 + held}. Max 4 ports per EGG.`;
}
if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`;
if (card.id === 'hatchingFeed') return `Held ${e.hatchingFeed || 0}. After pick: EGG interval ${formatPercent(Math.pow(0.96, (e.hatchingFeed || 0) + 1))}, poop rate multiplier ${formatPercent(Math.pow(1.03, (e.hatchingFeed || 0) + 1))}.`;
if (card.id === 'poopRateBooster') return `Held ${e.poopRateBooster || 0}. After pick: base poop rate +${Math.round((e.poopRateBooster || 0) + 1) * 4} percentage points.`;
if (card.id === 'autoScannerTuning') return `Held ${e.autoScannerTuning || 0}. After pick: all AUTO SCANNER default cooldown ${formatPercent(Math.pow(AUTO_SCANNER_CARD_UPGRADE_RATE, (e.autoScannerTuning || 0) + 1))}.`;
if (card.id === 'safetyCover') return `Held ${e.safetyCover || 0}. After pick: explosion damage ${formatPercent(Math.pow(0.95, (e.safetyCover || 0) + 1))}.`;
if (card.id === 'recyclingSubsidy') return `Held ${e.recyclingSubsidy || 0}/3. After pick: normal SELL refund ${Math.round(Math.min(0.8, 0.5 + 0.1 * ((e.recyclingSubsidy || 0) + 1)) * 100)}%.`;
if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(2000 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 100 truck-shipped poop each day.`;
if (card.id === 'chemicalWeaponSubsidy') return `Held ${e.chemicalWeaponSubsidy || 0}. After pick: ${yen(1200 * ((e.chemicalWeaponSubsidy || 0) + 1))} per 70 truck-shipped poop each day.`;
if (card.id === 'preventiveMaintenance') return `Held ${e.preventiveMaintenance || 0}. After pick: deterioration speed ${formatPercent(Math.pow(0.95, (e.preventiveMaintenance || 0) + 1))}.`;
if (card.id === 'dudFilter') {
const base = Math.max(0, Math.min(1, CARD_BALANCE.dudChancePerCard || 0));
@ -475,7 +522,7 @@ function cardDescription(game, card) {
return `Held ${held}. DUD chance per card ${formatPercent(before)} -> ${formatPercent(after)}.`;
}
if (card.id === 'durabilityCoating') return `Held ${e.durabilityCoating || 0}. After pick: maximum durability ${formatPercent(Math.pow(1.03, (e.durabilityCoating || 0) + 1))}.`;
if (card.id === 'sparePartsBin') return `Held ${e.sparePartsBin || 0}. Immediately restores 2% durability to every maintained machine. Offer weight: ${Math.max(0, 100 - 2 * Math.max(0, e.sparePartsBin || 0))}.`;
if (card.id === 'sparePartsBin') return `Held ${e.sparePartsBin || 0}. Immediately restores 2% durability to every maintained machine. Common card weight.`;
if (card.id === 'dudRefund') return `Held ${e.dudRefund || 0}. After pick: DUD clicks award ${yen(50 * ((e.dudRefund || 0) + 1))}.`;
if (card.id === 'freeReroll') return `Held ${e.freeReroll || 0}. After pick: each card screen starts with ${(e.freeReroll || 0) + 1} free reroll${(e.freeReroll || 0) + 1 === 1 ? '' : 's'}.`;
if (card.id === 'composter') return `Held ${e.composter || 0}. After pick: SHREDDER poop grants ${yen(3 * ((e.composter || 0) + 1))}.`;
@ -483,12 +530,9 @@ function cardDescription(game, card) {
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 === '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 === '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.`;
}
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.';
return card.description;
}
@ -591,7 +635,8 @@ 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');
updatePanels();
@ -611,6 +656,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
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.used = Math.max(0, game.cardTargetPick.used || 0) + 1;
game.cardTargetPick.targetKeys = targetsForCard(game, 'dynamite').map(targetKey);
updatePanels();
return true;
@ -620,15 +666,21 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
if (!game.cardTargetPick?.pending) return false;
const card = cardById(game.cardTargetPick.cardId);
const target = targetAtPoint(game, p);
if (!card || !target) {
if (!card) {
cancelTargetPick();
return true;
}
if (card.id === 'dynamite') {
// Empty-space clicks do nothing: the player may freely choose any highlighted tile.
if (!target) return true;
if (!applyDynamiteTarget(target)) return true;
if ((game.cardTargetPick.remaining || 0) <= 0 || !targetsForCard(game, 'dynamite').length) completePickedCard(card);
return true;
}
if (!target) {
cancelTargetPick();
return true;
}
applyEquipmentUpgrade(game, target);
completePickedCard(card);
return true;
@ -636,6 +688,15 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
function cancelTargetPick() {
if (!game.cardTargetPick?.pending) return;
const activePick = game.cardTargetPick;
const card = cardById(activePick.cardId);
// Once a dynamite charge has been used, Escape finishes the card instead of
// returning a partially consumed card to the draft. Before the first blast,
// Escape still cancels normally.
if (card?.id === 'dynamite' && (activePick.used || 0) > 0) {
completePickedCard(card);
return;
}
game.cardTargetPick = null;
showDraft();
}
@ -659,6 +720,39 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
showDraft();
}
function cardIconFor(card) {
const tags = new Set(card.tags || []);
const byTarget = {
eggFarm: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png?v=dark_factory', alt: 'EGG' },
eggOutlet: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png?v=dark_factory', alt: 'EGG' },
autoScanner: { type: 'image', src: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png', alt: 'SCAN' },
mixer: { type: 'image', src: './assets/dark_factory/facilities/machines/mixer_32.png', alt: 'MIX' },
truck: { type: 'image', src: './assets/dark_factory/facilities/vehicles/truck_32.png', alt: 'TRUCK' },
trash: { type: 'image', src: './assets/dark_factory/facilities/machines/shredder_32.png', alt: 'SHR' },
sausageMaker: { type: 'glyph', text: 'S' },
blockedCell: { type: 'image', src: './assets/dark_factory/facilities/utility/obstacle_crate_16.png', alt: 'BLOCK' }
};
if (byTarget[card.target]) return byTarget[card.target];
if (card.id === 'bearing' || tags.has('CONVEYOR')) return { type: 'image', src: './assets/dark_factory/facilities/conveyor/conveyor_straight_h_16.png', alt: 'BELT' };
if (tags.has('SCANNER')) return { type: 'image', src: './assets/dark_factory/facilities/machines/auto_scanner_32x48.png', alt: 'SCAN' };
if (tags.has('EGG')) return { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png?v=dark_factory', alt: 'EGG' };
if (tags.has('POOP')) return { type: 'image', src: './assets/images/poop.png', alt: 'POOP' };
if (tags.has('MAINTENANCE')) return { type: 'image', src: './assets/dark_factory/facilities/utility/repairman_16.png', alt: 'FIX' };
if (tags.has('CARD')) return { type: 'image', src: './assets/dark_factory/facilities/utility/maintenance_room_32.png', alt: 'CARD' };
if (tags.has('ECONOMY')) return { type: 'image', src: './assets/dark_factory/facilities/vehicles/truck_32.png', alt: 'YEN' };
if (tags.has('RISK')) return { type: 'glyph', text: '!' };
return { type: 'glyph', text: '*' };
}
function cardIconHtml(card) {
const icon = cardIconFor(card);
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) {
const b = document.createElement('button');
b.type = 'button';
@ -666,7 +760,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
b.className = `card-choice ${kind}`;
const rarity = card.type === 'dud' ? 'MISS' : (card.rarity === 'ultraRare' ? 'ULTRA RARE' : (card.rarity === 'rare' ? 'RARE' : 'COMMON'));
const tags = [rarity, ...(card.tags || []).filter(tag => tag !== rarity)].slice(0, 5);
b.innerHTML = `<strong>${card.title}</strong><div class="card-tags">${tags.map(tag => `<span>${tag}</span>`).join('')}</div><small>${cardDescription(game, card)}</small>`;
b.innerHTML = `${cardIconHtml(card)}<div class="card-copy"><strong>${card.title}</strong><div class="card-tags">${tags.map(tag => `<span>${tag}</span>`).join('')}</div><small>${cardDescription(game, card)}</small></div>`;
b.addEventListener('click', () => chooseCard(card, b));
b.dataset.index = String(index);
return b;

View file

@ -2,23 +2,99 @@ import { DIRS, 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, inGrid } from '../core/utils.js';
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, nearestConveyorKey, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, applyShredderBonus, applyRevenue } from './economy.js';
import { scannerById, scannerBySlot, scannerCenter, scannerConnector, facilityOutputCell, facilityOutputHasConveyor, buildConveyorComponents, autoSideFor, ensureFactoryGraph } from './routing.js';
import { applyMixerIncome, applyMixerPoopFine, applyTruckPoopFine, applyWrongTruckFine, upgradedTruckPrice, upgradedSausagePrice, applyShredderBonus, applyRevenue } from './economy.js';
import { autoScannerCooldownSeconds, eggProductionDelayMultiplier, extraEggOutletCount, scannerQueueSpacingMultiplier } from './cards.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck } from './contracts.js';
import { productionMultiplier, truckTarget, isTargetTruckCargo, shouldFineMaleTruck, shredderPayoutMultiplier, congestionExplosionThreshold, congestionWarningThreshold } from './events.js';
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDestination, countPoopSpawned, countTrash, countTruckCargo } from './stats.js';
import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.js';
import { isEquipmentPowered, addFuelPulse, markPowerDirty } from './power.js';
import { updateInboundTrucks } from './imports.js';
import { addZundaBoost, actualSpawnIntervalForRate, boostOutputCapacityPerSecond, farmTheoreticalRatePerSecond, qualityForRate } from './farmQuality.js';
export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck }) {
function frameCache() {
if (!game.frameCache) {
game.frameCache = {
frameId: -1,
routingVersion: -1,
chickRevision: 0,
spatialRevision: -1,
spatial: new Map(),
congestionFrame: -1,
componentChicks: new Map(),
manualMonitorSignature: '',
draw: {}
};
}
return game.frameCache;
}
function markChicksChanged() {
const cache = frameCache();
cache.chickRevision = (cache.chickRevision || 0) + 1;
cache.congestionFrame = -1;
}
function markPositionsChanged() {
frameCache().congestionFrame = -1;
}
function beginFrame(frameId) {
const cache = frameCache();
if (cache.frameId === frameId) return;
cache.frameId = frameId;
cache.spatialRevision = -1;
cache.congestionFrame = -1;
}
function spatialKeyForPoint(x, y) {
return `${Math.floor(x / GRID.cell)},${Math.floor(y / GRID.cell)}`;
}
function buildSpatialIndex() {
const cache = frameCache();
if (cache.spatialRevision === cache.chickRevision) return cache.spatial;
const spatial = new Map();
for (const chick of game.chicks) {
if (chick.stage === 'flying') continue;
const k = spatialKeyForPoint(chick.x, chick.y);
if (!spatial.has(k)) spatial.set(k, []);
spatial.get(k).push(chick);
}
cache.spatial = spatial;
cache.spatialRevision = cache.chickRevision;
return spatial;
}
function nearbyChicks(target) {
const spatial = buildSpatialIndex();
const sx = Math.floor(target.x / GRID.cell);
const sy = Math.floor(target.y / GRID.cell);
const result = [];
for (let y = sy - 1; y <= sy + 1; y += 1) {
for (let x = sx - 1; x <= sx + 1; x += 1) {
const list = spatial.get(`${x},${y}`);
if (list) result.push(...list);
}
}
return result;
}
function currentSpawnDelay(farm) {
return nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) * eggProductionDelayMultiplier(game) / productionMultiplier(game);
const base = nextSpawnDelay(farm) * eggSpawnDelayMultiplier(farm) * eggProductionDelayMultiplier(game) / productionMultiplier(game);
const rate = farmTheoreticalRatePerSecond(game, farm, base);
farm.theoreticalRate = rate;
farm.actualOutputRate = Math.min(rate, boostOutputCapacityPerSecond(game));
farm.qualityMultiplier = qualityForRate(rate, boostOutputCapacityPerSecond(game));
return Math.max(0.25, actualSpawnIntervalForRate(rate, boostOutputCapacityPerSecond(game)));
}
function spawnChick(farm) {
const farmCenter = cellCenter(farm.col, farm.row);
const maxOutputs = 1 + extraEggOutletCount(game, farm);
const starts = farmOutputStarts(farm, maxOutputs);
const starts = farmOutputStarts(farm, maxOutputs).slice(0, 1);
if (!starts.length) {
suppressUnconnectedFarmSpawn(farm, farmCenter);
return true;
@ -34,8 +110,16 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
continue;
}
const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] });
const theoretical = farm.theoreticalRate || farmTheoreticalRatePerSecond(game, farm, farm.lastInterval || currentSpawnDelay(farm));
chick.sourceQuality = qualityForRate(theoretical, boostOutputCapacityPerSecond(game));
game.stats.farmTheoreticalRate = Math.max(game.stats.farmTheoreticalRate || 0, theoretical);
game.stats.farmActualOutputRate = Math.max(game.stats.farmActualOutputRate || 0, Math.min(theoretical, boostOutputCapacityPerSecond(game)));
game.stats.qualityMultiplierSum = (game.stats.qualityMultiplierSum || 0) + chick.sourceQuality;
game.stats.qualityMultiplierCount = (game.stats.qualityMultiplierCount || 0) + 1;
game.stats.maxQualityMultiplier = Math.max(game.stats.maxQualityMultiplier || 1, chick.sourceQuality);
if (chick.sex === 'poop') countPoopSpawned(game);
game.chicks.push(chick);
markChicksChanged();
recordFarmProduction(game, farm);
spawned += 1;
}
@ -65,12 +149,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
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;
return nearbyChicks(start).find(ch => 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);
updateInboundTrucks(game, dt, markChicksChanged);
flushFacilityOutputs();
if (game.cleanupTimer !== null) {
game.cleanupTimer = Math.max(0, game.cleanupTimer - dt);
if (game.cleanupTimer <= 0) completeTurn();
@ -87,6 +173,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);
if (farm.zundaBoostTime > 0) farm.zundaBoostTime = Math.max(0, farm.zundaBoostTime - dt);
farm.nextSpawn -= dt;
if (farm.nextSpawn <= 0) {
const spawned = spawnChick(farm);
@ -100,10 +187,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
chick.bob += dt * 7;
if (chick.stoppedTimer > 0) chick.stoppedTimer = Math.max(0, chick.stoppedTimer - dt);
if (chick.stage === 'queued') continue;
if (consumeAdjacentZunda(chick, i)) continue;
updateRoutedChick(chick, i, dt);
}
updateScannerQueues(dt);
updateCongestion();
updateCongestion(game.frameId);
checkCongestionExplosions();
if (game.timeLeft <= 0) {
if (game.chicks.length === 0) completeTurn();
@ -119,7 +207,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
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();
markChicksChanged();
updateCongestion(game.frameId);
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;
@ -153,9 +242,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
function targetBlockedByChick(chick, target) {
if (!target) return false;
for (const other of game.chicks) {
for (const other of nearbyChicks(target)) {
if (chick && other.id === chick.id) continue;
if (other.stage === 'flying') continue;
if (Math.hypot(other.x - target.x, other.y - target.y) < GRID.cell * 0.55) return true;
}
return false;
@ -175,9 +263,15 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const explicit = [...new Set(dirs)].filter(dir => DIRS.some(d => d.name === dir));
const cell = parseKey(cellKey);
const inferred = DIRS
.filter(d => movementOptionFromDir(cell, d.name))
.filter(d => movementOptionFromDir(cell, d.name, null))
.map(d => d.name);
const cleanExplicit = explicit.filter(dir => inferred.includes(dir));
const connected = DIRS
.filter(d => game.conveyorTiles.has(key(cell.col + d.dc, cell.row + d.dr)))
.map(d => d.name);
if (connected.length === 1 && (cleanExplicit.length ? cleanExplicit.includes(connected[0]) : true) && neighborPointsIntoCell(cell, connected[0])) {
return cleanExplicit.filter(dir => dir !== connected[0]);
}
const inferredExits = inferred;
return cleanExplicit.length ? cleanExplicit : inferredExits;
}
@ -187,7 +281,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
function scannerAtBodyCell(col, row) {
return game.scanners.find(scanner => scanner.col <= col && col <= scanner.col + 1 && scanner.row - 1 <= row && row <= scanner.row + 1) || null;
return game.scanners.find(scanner => scanner.col <= col && col <= scanner.col + 1 && scanner.row - 1 <= row && row <= scanner.row) || null;
}
function scannerReceivingFrom(cell, dirName) {
@ -203,18 +297,36 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return Object.entries(game.facilities || {}).find(([, f]) => f?.entry && f.entry.col === cell.col && f.entry.row === cell.row) || null;
}
function gridGeneratorReceivingFrom(cell, dirName) {
for (const facility of Object.values(game.facilities || {})) {
const kind = facilityKind(facility);
if (!facility.gridPlaced || !['biomassGenerator', 'coalGenerator'].includes(kind)) continue;
const ports = kind === 'biomassGenerator'
? [
{ col: facility.col - 1, row: facility.row + 1, dir: 'right' },
{ col: facility.col + 1, row: facility.row - 1, dir: 'down' },
{ col: facility.col + 1, row: facility.row + (facility.hCells || 3), dir: 'up' }
]
: [{ col: facility.col - 1, row: facility.row + 1, dir: 'right' }];
if (ports.some(port => port.col === cell.col && port.row === cell.row && port.dir === dirName)) return facility;
}
return null;
}
function facilityKind(f) {
return f?.baseId || f?.id || '';
}
function dirExitsToFacility(facility, dirName) {
if (facility?.gridPlaced && facilityKind(facility) === 'sausageMaker') return dirName === 'right';
if (facility?.gridPlaced && ['biomassGenerator', 'coalGenerator'].includes(facilityKind(facility))) return dirName === 'right';
return (facility.side === 'left' && dirName === 'left')
|| (facility.side === 'right' && dirName === 'right')
|| (facility.side === 'top' && dirName === 'up')
|| (facility.side === 'bottom' && dirName === 'down');
}
function movementOptionFromDir(cell, dirName) {
function movementOptionFromDir(cell, dirName, item = null) {
const d = dirByName(dirName);
if (!d) return null;
const next = { col: cell.col + d.dc, row: cell.row + d.dr };
@ -222,7 +334,9 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return { type: 'conveyor', dirName, target: cellCenter(next.col, next.row), nextCell: next };
}
const scanner = scannerReceivingFrom(cell, dirName);
if (scanner) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
if (scanner && !['meat', 'sausage', 'coal', 'zundaMochi'].includes(item?.sex)) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
const generator = gridGeneratorReceivingFrom(cell, dirName);
if (generator) return { type: 'facility', dirName, facilityId: generator.id, facility: generator, target: cellCenter(cell.col, cell.row) };
const facilityPair = facilityAtEntryCell(cell);
if (facilityPair && dirExitsToFacility(facilityPair[1], dirName)) {
return { type: 'facility', dirName, facilityId: facilityPair[0], facility: facilityPair[1], target: cellCenter(cell.col, cell.row) };
@ -249,15 +363,13 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const connected = conveyorNeighborDirNames(cell);
if (connected.length < 3) return dirs;
const entrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && connected.includes(branchMode)) {
const selectedEntrances = new Set(entrances);
selectedEntrances.add(branchMode);
if (connected.filter(dir => !selectedEntrances.has(dir)).length >= 2) entrances.add(branchMode);
}
const exits = connected.filter(dir => !entrances.has(dir));
if (exits.length) return exits;
const fallback = dirs.filter(dir => !entrances.has(dir));
return fallback.length ? fallback : dirs;
const physicalExits = connected.filter(dir => !entrances.has(dir));
let candidates = entrances.size
? physicalExits
: [...new Set(dirs.filter(dir => connected.includes(dir)))];
if (!candidates.length && !entrances.size) candidates = connected;
if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && candidates.length >= 2 && candidates.includes(branchMode)) return [branchMode];
return candidates;
}
function optionAvailableForBranch(chick, opt) {
@ -265,7 +377,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
function pickMovementOption(chick, cell, dirs, branchMode = 'random') {
const candidates = dirs.map(dir => movementOptionFromDir(cell, dir)).filter(Boolean);
const candidates = dirs.map(dir => movementOptionFromDir(cell, dir, chick)).filter(Boolean);
if (!candidates.length) return null;
const moving = candidates.filter(opt => opt.type !== 'facility');
const empty = moving.filter(opt => !targetBlockedByChick(chick, opt.target));
@ -303,6 +415,9 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (kind === 'mixer') resolveMixer(index, option.facility);
else if (kind === 'truck') resolveTruck(index, option.facility);
else if (kind === 'trash') resolveTrash(index, option.facility);
else if (kind === 'sausageMaker') resolveSausageMaker(index, option.facility);
else if (kind === 'biomassGenerator' || kind === 'coalGenerator') resolveGenerator(index, option.facility);
else if (kind === 'inboundTruck') { const chick = game.chicks[index]; if (chick) chick.stoppedTimer = 0.25; }
else removeChick(index, 'DONE');
return;
}
@ -311,6 +426,19 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
setSingleSegmentRoute(chick, option.target);
}
function consumeAdjacentZunda(chick, index) {
if (chick.sex !== 'zundaMochi') return false;
const cell = pointToCell(chick.x, chick.y);
if (!cell) return false;
const farm = game.eggFarms.find(candidate => Math.abs(candidate.col - cell.col) + Math.abs(candidate.row - cell.row) === 1);
if (!farm) return false;
addZundaBoost(game, farm);
takeChick(index);
const c = cellCenter(farm.col, farm.row);
floating(game, c.x, c.y - 24, 'ZUNDA BOOST', THEME.green);
return true;
}
function conveyorKeyFromPoint(p) {
const cell = pointToCell(p.x, p.y);
if (!cell) return null;
@ -347,10 +475,12 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
recordPassAtPoint(chick, target);
chick.targetIndex += 1;
remaining -= dist;
markPositionsChanged();
} else {
chick.x += dx / dist * remaining;
chick.y += dy / dist * remaining;
remaining = 0;
markPositionsChanged();
}
}
}
@ -361,6 +491,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
chick.queueRoute = routeOverride ? routeOverride.map(p => ({ x: p.x, y: p.y })) : [scannerCenter(scanner)];
chick.scannerId = scanner.id;
if (!scanner.queue.includes(chick.id)) scanner.queue.push(chick.id);
markChicksChanged();
positionScannerQueue(scanner);
}
@ -381,6 +512,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const end = route[route.length - 1];
chick.x = end.x;
chick.y = end.y;
markPositionsChanged();
return;
}
let remain = distanceBack;
@ -391,6 +523,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const t = 1 - remain / len;
chick.x = a.x + (b.x - a.x) * t;
chick.y = a.y + (b.y - a.y) * t;
markPositionsChanged();
return;
}
remain -= len;
@ -398,13 +531,16 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const start = route[0];
chick.x = start.x;
chick.y = start.y;
markPositionsChanged();
}
function updateScannerQueues(dt) {
for (const scanner of game.scanners) {
if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt);
const powered = scanner.kind !== 'auto' || isEquipmentPowered(game, scanner);
if (scanner.keyPressTime > 0) scanner.keyPressTime = Math.max(0, scanner.keyPressTime - dt);
positionScannerQueue(scanner);
if (!powered) continue;
if (scanner.cooldown > 0) scanner.cooldown = Math.max(0, scanner.cooldown - dt);
if (scanner.kind !== 'auto' || scanner.cooldown > 0 || !scanner.queue.length) continue;
const id = scanner.queue[0];
const index = game.chicks.findIndex(c => c.id === id);
@ -494,7 +630,13 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const graph = ensureFactoryGraph(game);
const keys = (graph.scannerPorts.get(scanner.id)?.[side]?.keys || []).filter(Boolean);
if (!keys.length) return null;
const parsed = keys.map(parseKey);
const parsed = keys.map(parseKey).sort((a, b) => {
const ac = cellCenter(a.col, a.row);
const bc = cellCenter(b.col, b.row);
const ab = targetBlockedByChick(null, ac) ? 1 : 0;
const bb = targetBlockedByChick(null, bc) ? 1 : 0;
return ab - bb;
});
const exact = parsed.find(p => p.col === connector.col && p.row === connector.row);
return exact || parsed[0] || null;
}
@ -513,6 +655,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (!chick || chick.stage !== 'queued') return false;
const scanner = scannerById(game, chick.scannerId);
if (!scanner) return false;
if (scanner.kind === 'auto' && !isEquipmentPowered(game, scanner)) return false;
if (['coal', 'zundaMochi'].includes(chick.sex)) {
floating(game, chick.x, chick.y - 16, 'NOT SCANNABLE', THEME.warn);
return false;
}
const startCell = scannerOutputStartCell(scanner, side);
if (!startCell) {
if (!auto && scanner.kind === 'manual') resetManualCombo(scanner);
@ -532,6 +679,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
chick.pendingScannerId = null;
chick.queueRoute = null;
chick.queueIndex = -1;
markChicksChanged();
const pulseColor = auto ? THEME.green : (side === 'left' ? THEME.mixerBlue : THEME.truckPink);
scannerPulse(game, scannerCenter(scanner).x, scannerCenter(scanner).y, pulseColor);
floating(game, chick.x, chick.y - 20, auto ? 'AUTO' : side.toUpperCase(), pulseColor);
@ -542,21 +690,33 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const chick = game.chicks[index];
if (!chick) return null;
game.chicks.splice(index, 1);
markChicksChanged();
return chick;
}
function resolveMixer(index, mixer = game.facilities.mixer) {
if (!isEquipmentPowered(game, mixer)) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
return;
}
if (mixer?.processingCooldown > 0) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
return;
}
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'CHICKS ONLY');
const chick = takeChick(index);
if (!chick) return;
const { x, y } = chick;
countMixer(game);
recordFacilityProcess(game, mixer);
setFacilityCooldownAfterProcess(game, mixer);
if (chick.sex === 'meat' || chick.sex === 'sausage') {
countMistake(game);
floating(game, x, y - 18, 'CHICKS ONLY', THEME.warn);
return;
}
if (chick.sex === 'poop') {
const penalty = applyMixerPoopFine(game);
countPoopDestination(game, 'mixer');
@ -565,28 +725,60 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
return;
}
const amount = applyMixerIncome(game);
if (chick.sex === 'male') countCorrect(game);
else countMistake(game);
const out = facilityOutputCell(game, mixer);
if (!out || !facilityOutputHasConveyor(game, mixer) || !outputCellFree(out)) {
const amount = applyMixerIncome(game);
floating(game, x, y - 18, `DIRECT +${yen(amount)}`, THEME.green);
return;
}
mixer.meatBuffer = Math.max(0, mixer.meatBuffer || 0) + 1;
mixer.meatQualityQueue = mixer.meatQualityQueue || [];
mixer.meatQualityQueue.push(chick.sourceQuality || 1);
game.stats.meatProduced = (game.stats.meatProduced || 0) + 1;
game.totals.meatProduced = (game.totals.meatProduced || 0) + 1;
meatEffect(game, x, y);
floating(game, x, y - 18, `+${yen(amount)}`, THEME.green);
floating(game, x, y - 18, 'MEAT +1', THEME.meatRed);
flushFacilityOutputs();
}
function resolveTruck(index, truck = game.facilities.truck) {
if (!isEquipmentPowered(game, truck)) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
return;
}
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'NO IMPORTS');
const chick = takeChick(index);
if (!chick) return;
const { x, y } = chick;
addTruckCargo(chick.sex, truck);
addTruckCargo(chick.sex, truck, chick.sourceQuality || 1);
truckLoadEffect(game, x, y, chick.sex);
countTruckCargo(game, chick.sex);
const target = truckTarget(game);
const isTarget = isTargetTruckCargo(game, chick.sex);
if (chick.sex === 'sausage') {
game.stats.sausageShipped = (game.stats.sausageShipped || 0) + 1;
game.totals.sausageShipped = (game.totals.sausageShipped || 0) + 1;
floating(game, x, y - 18, `SAUSAGE +${yen(upgradedSausagePrice(game))}`, THEME.sausageOrange);
return;
}
if (chick.sex === 'meat') {
countMistake(game);
floating(game, x, y - 18, 'RAW MEAT REJECT', THEME.warn);
return;
}
if (chick.sex === 'poop') {
const penalty = applyTruckPoopFine(game);
countPoopDestination(game, 'truck');
if (isTarget) countCorrect(game);
else countMistake(game);
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
if (isTarget) {
countCorrect(game);
floating(game, x, y - 18, `+${yen(upgradedTruckPrice(game))}`, THEME.green);
} else {
const penalty = applyTruckPoopFine(game);
countMistake(game);
floating(game, x, y - 18, `-${yen(penalty)}`, THEME.danger);
}
return;
}
if (chick.sex === 'female') {
@ -614,12 +806,100 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
}
function createProduct(product, cell) {
const c = cellCenter(cell.col, cell.row);
return {
id: game.nextId++, sex: product, x: c.x, y: c.y,
route: [{ x: c.x, y: c.y }], targetIndex: 1, stage: 'belt',
scannerId: null, nextScannerId: null, pendingScannerId: null,
radius: product === 'sausage' ? 11 : 10, bob: Math.random() * Math.PI * 2,
queueIndex: -1, queueRoute: null, stoppedTimer: 0
};
}
function outputCellFree(cell) {
if (!cell || !game.conveyorTiles.has(key(cell.col, cell.row))) return false;
const c = cellCenter(cell.col, cell.row);
return !targetBlockedByChick(null, c);
}
function emitFromFacility(facility, product, bufferKey) {
if (!isEquipmentPowered(game, facility)) return false;
const out = facilityOutputCell(game, facility);
if (!out || !facilityOutputHasConveyor(game, facility) || !outputCellFree(out)) return false;
if (Math.max(0, facility[bufferKey] || 0) <= 0) return false;
facility[bufferKey] -= 1;
const created = createProduct(product, out);
if (product === 'meat') created.sourceQuality = (facility.meatQualityQueue || []).shift() || 1;
if (product === 'sausage') created.sourceQuality = (facility.sausageQualityQueue || []).shift() || 1;
game.chicks.push(created);
markChicksChanged();
const c = cellCenter(out.col, out.row);
floating(game, c.x, c.y - 16, product === 'meat' ? 'MEAT OUT' : 'SAUSAGE OUT', product === 'meat' ? THEME.meatRed : THEME.sausageOrange);
return true;
}
function flushFacilityOutputs() {
for (const facility of Object.values(game.facilities || {})) {
const kind = facilityKind(facility);
if (kind === 'mixer') emitFromFacility(facility, 'meat', 'meatBuffer');
if (kind === 'sausageMaker') emitFromFacility(facility, 'sausage', 'sausageBuffer');
}
}
function resolveSausageMaker(index, machine) {
if (!isEquipmentPowered(game, machine)) {
const item = game.chicks[index];
if (item) item.stoppedTimer = 0.25;
return;
}
if (machine?.processingCooldown > 0) {
const item = game.chicks[index];
if (item) item.stoppedTimer = 0.25;
return;
}
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'MEAT ONLY');
const item = takeChick(index);
if (!item) return;
const { x, y } = item;
if (item.sex !== 'meat') {
countMistake(game);
floating(game, x, y - 18, 'MEAT ONLY', THEME.warn);
return;
}
recordFacilityProcess(game, machine);
setFacilityCooldownAfterProcess(game, machine);
machine.meatBuffer = Math.max(0, machine.meatBuffer || 0) + 1;
machine.meatQualityQueue = machine.meatQualityQueue || [];
machine.meatQualityQueue.push(item.sourceQuality || 1);
if (machine.meatBuffer >= 2) {
machine.meatBuffer -= 2;
machine.sausageBuffer = Math.max(0, machine.sausageBuffer || 0) + 1;
const q1 = machine.meatQualityQueue.shift() || 1;
const q2 = machine.meatQualityQueue.shift() || 1;
machine.sausageQualityQueue = machine.sausageQualityQueue || [];
machine.sausageQualityQueue.push((q1 + q2) / 2);
game.stats.sausageProduced = (game.stats.sausageProduced || 0) + 1;
game.totals.sausageProduced = (game.totals.sausageProduced || 0) + 1;
floating(game, x, y - 18, 'SAUSAGE +1', THEME.sausageOrange);
} else {
floating(game, x, y - 18, 'MEAT 1/2', THEME.meatRed);
}
flushFacilityOutputs();
}
function resolveTrash(index, trash = game.facilities.trash) {
if (!isEquipmentPowered(game, trash)) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
return;
}
if (trash?.processingCooldown > 0) {
const chick = game.chicks[index];
if (chick) chick.stoppedTimer = 0.25;
return;
}
if (isImportItem(game.chicks[index])) return rejectImportItem(index, 'NO IMPORTS');
const chick = takeChick(index);
if (!chick) return;
const { x, y } = chick;
@ -627,12 +907,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
countTrash(game);
recordFacilityProcess(game, trash);
setFacilityCooldownAfterProcess(game, trash);
const bonus = applyShredderBonus(game);
const bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
let bonus = { amount: 0 };
let bonusText = '';
if (chick.sex === 'poop') {
bonus = applyShredderBonus(game);
bonusText = bonus.amount > 0 ? ` ${bonus.tier === 'jackpot' ? 'JACKPOT ' : '+'}${yen(bonus.amount)}` : ' MISS';
countPoopDestination(game, 'trash');
countCorrect(game);
const compost = Math.max(0, Number(game.cardEffects?.composter) || 0) * 3;
const compost = Math.ceil(Math.max(0, Number(game.cardEffects?.composter) || 0) * 3 * shredderPayoutMultiplier(game));
if (compost > 0) {
const compostAmount = applyRevenue(game, compost);
game.stats.composterIncome = (game.stats.composterIncome || 0) + compostAmount;
@ -646,16 +928,51 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
}
function resolveGenerator(index, generator) {
const item = game.chicks[index];
if (!item) return;
if (!addFuelPulse(generator, item.sex)) {
item.stoppedTimer = 0.25;
return;
}
const used = takeChick(index);
if (!used) return;
if ((generator.baseId || generator.id) === 'biomassGenerator') {
game.stats.biomassFuelConsumed = (game.stats.biomassFuelConsumed || 0) + 1;
game.totals.biomassFuelConsumed = (game.totals.biomassFuelConsumed || 0) + 1;
}
if ((generator.baseId || generator.id) === 'coalGenerator') {
game.stats.coalConsumed = (game.stats.coalConsumed || 0) + 1;
game.totals.coalConsumed = (game.totals.coalConsumed || 0) + 1;
}
recordFacilityProcess(game, generator);
markPowerDirty(game);
floating(game, used.x, used.y - 18, 'FUEL +V', THEME.green);
}
function removeChick(index, label) {
const chick = takeChick(index);
if (!chick) return;
floating(game, chick.x, chick.y - 12, label, THEME.muted);
}
function addTruckCargo(sex, truck = game.facilities.truck) {
function isImportItem(item) {
return item?.sex === 'coal' || item?.sex === 'zundaMochi';
}
function rejectImportItem(index, label) {
const item = game.chicks[index];
if (!item) return;
item.stoppedTimer = 0.25;
floating(game, item.x, item.y - 18, label, THEME.warn);
}
function addTruckCargo(sex, truck = game.facilities.truck, sourceQuality = 1) {
const t = truck || game.facilities.truck;
if (!t) return;
game.truckCargo.push({ sex, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) });
game.shippedCargo = game.shippedCargo || [];
game.shippedCargo.push({ sex, sourceQuality });
game.truckCargo.push({ sex, sourceQuality, x: randomBetween(22, t.w - 22), y: randomBetween(66, t.h - 24) });
if (game.truckCargo.length > 45) game.truckCargo.shift();
}
@ -665,15 +982,29 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const k = key(cell.col, cell.row);
if (game.conveyorTiles.has(k)) return k;
}
const nearest = nearestConveyorKey(game, chick.x, chick.y);
if (!nearest) return null;
const p = parseKey(nearest);
const c = cellCenter(p.col, p.row);
return Math.hypot(chick.x - c.x, chick.y - c.y) <= GRID.cell * 0.55 ? nearest : null;
if (!cell) return null;
let nearest = null;
let nearestDistance = Infinity;
for (const p of [cell, ...DIRS.map(d => ({ col: cell.col + d.dc, row: cell.row + d.dr }))]) {
if (!inGrid(p.col, p.row)) continue;
const k = key(p.col, p.row);
if (!game.conveyorTiles.has(k)) continue;
const c = cellCenter(p.col, p.row);
const distance = Math.hypot(chick.x - c.x, chick.y - c.y);
if (distance < nearestDistance) {
nearest = k;
nearestDistance = distance;
}
}
return nearestDistance <= GRID.cell * 0.55 ? nearest : null;
}
function updateCongestion() {
function updateCongestion(frameId = game.frameId || 0) {
const cache = frameCache();
const routingVersion = game.routingVersion || 0;
if (cache.congestionFrame === frameId && cache.routingVersion === routingVersion) return game.congestion;
const { components, cellToComponent } = buildConveyorComponents(game);
const componentChicks = new Map();
for (const chick of game.chicks) {
if (chick.stage === 'flying') continue;
const k = conveyorKeyAtChick(chick);
@ -681,20 +1012,30 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const id = cellToComponent.get(k);
const comp = components.get(id);
if (comp) comp.count += 1;
if (id != null) {
if (!componentChicks.has(id)) componentChicks.set(id, []);
componentChicks.get(id).push(chick);
}
}
for (const comp of components.values()) comp.ratio = comp.count / comp.capacity;
game.congestion = components;
game.componentLookup = cellToComponent;
cache.routingVersion = routingVersion;
cache.congestionFrame = frameId;
cache.componentChicks = componentChicks;
return game.congestion;
}
function checkCongestionExplosions() {
for (const comp of game.congestion.values()) {
if (comp.ratio > 0.8 && comp.ratio < 1) {
const explodeAt = congestionExplosionThreshold(game);
const warnAt = congestionWarningThreshold(game);
if (comp.ratio > warnAt && comp.ratio < explodeAt) {
for (const chick of chicksInComponent(comp.id).slice(0, 3)) {
if (Math.random() < 0.06) rageEffect(game, chick.x, chick.y - chick.radius - 8);
}
}
if (comp.ratio >= 1) {
if (comp.ratio >= explodeAt) {
const victims = chicksInComponent(comp.id);
if (!victims.length || victims.some(chick => chick.stage !== 'queued' && chick.stoppedTimer <= 0.2)) continue;
const last = game.lastExplodedComponent.get(comp.id) || 0;
@ -708,10 +1049,8 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
function chicksInComponent(id) {
return game.chicks.filter(ch => {
const k = conveyorKeyAtChick(ch);
return k && game.componentLookup?.get(k) === id;
});
updateCongestion(game.frameId || 0);
return game.frameCache?.componentChicks?.get(id) || [];
}
function explodeComponent(comp) {
@ -720,6 +1059,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
for (const v of victims) flyingDebris(game, v.sex, v.x, v.y);
game.chicks = game.chicks.filter(ch => !victims.some(v => v.id === ch.id));
for (const scanner of game.scanners) scanner.queue = scanner.queue.filter(id => game.chicks.some(c => c.id === id));
markChicksChanged();
for (const k of comp.cells) {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
@ -730,11 +1070,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
shake(game, 18, .75);
const first = parseKey(comp.cells[0]);
const fc = cellCenter(first.col, first.row);
floating(game, fc.x, fc.y - 22, '100% JAM EXPLOSION', THEME.danger);
game.stats.explosions = (game.stats.explosions || 0) + 1;
game.totals.explosions = (game.totals.explosions || 0) + 1;
floating(game, fc.x, fc.y - 22, `${Math.round(congestionExplosionThreshold(game) * 100)}% JAM EXPLOSION`, THEME.danger);
}
return {
activeQueuedChick,
beginFrame,
currentSpawnDelay,
sortSlot,
updateCongestion,

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

View file

@ -1,5 +1,5 @@
import { ECONOMY, FACILITY_DEFS, INCOME_FACILITY_IDS } from '../core/config.js';
import { factoryValue, targetTruckCount, truckTarget } from './contracts.js';
import { factoryValue, targetTruckCount, truckTarget, poopFineMultiplier, poopValueMultiplier, shredderPayoutMultiplier } from './events.js';
function effectCount(game, id) {
return Math.max(0, Number(game?.cardEffects?.[id]) || 0);
@ -25,6 +25,7 @@ export function equipmentBasePrice(objOrHit) {
if (type === 'eggFarm') return obj.price || FACILITY_DEFS.eggFarm.price;
if (type === 'scanner') return obj.price || (obj.kind === 'auto' ? FACILITY_DEFS.autoScanner.price : FACILITY_DEFS.manualScanner.price);
if (type === 'facility') return obj.price || FACILITY_DEFS[obj.id]?.price || 0;
if (type === 'maintenanceRoom') return obj.price || FACILITY_DEFS.maintenanceRoom.price;
return FACILITY_DEFS[obj.id]?.price || 0;
}
@ -52,12 +53,16 @@ export function upgradedTruckPrice(game) {
return Math.ceil(ECONOMY.income.truck * incomeMultiplier(game, 'truck'));
}
export function upgradedSausagePrice(game) {
return Math.ceil((ECONOMY.income.sausage || 26) * incomeMultiplier(game, 'mixer') * incomeMultiplier(game, 'sausageMaker'));
}
export function mixerPoopPenalty(game) {
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'mixer'));
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'mixer') * poopFineMultiplier(game));
}
export function truckPoopPenalty(game) {
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'truck'));
return Math.ceil(ECONOMY.poopFine * incomeMultiplier(game, 'truck') * poopFineMultiplier(game));
}
export function maleTruckPenalty(game) {
@ -65,31 +70,69 @@ export function maleTruckPenalty(game) {
return Math.ceil(baseFine * incomeMultiplier(game, 'truck'));
}
export function shredderUpgradeCount(game) {
const raw = Math.max(0, (game.facilities?.trash?.level || 1) - 1);
return Math.min(ECONOMY.shredderBonus.maxCards, raw);
export function rawShredderUpgradeCount(game) {
if (!game?.facilities) return 0;
return Object.values(game.facilities)
.filter(f => (f?.baseId || f?.id) === 'trash')
.reduce((sum, f) => sum + Math.max(0, (f.level || 1) - 1), 0);
}
export function rawShredderUpgradeCount(game) {
return Math.max(0, (game.facilities?.trash?.level || 1) - 1);
export function shredderUpgradeCount(game) {
return Math.min(ECONOMY.shredderBonus.maxCards, rawShredderUpgradeCount(game));
}
export function shredderBonusMaxCards() {
return ECONOMY.shredderBonus.maxCards;
}
function shredderRules() {
return ECONOMY.shredderBonus || {};
}
export function shredderBasePayout(gameOrCount) {
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
const rules = shredderRules();
const capped = Math.min(rules.maxCards || 30, Math.max(0, count));
return capped > 0 ? Math.ceil((rules.payoutCoefficient || 1.6) * Math.pow(capped, rules.payoutExponent || 1.25)) : 0;
}
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);
if (count <= 0) return 0;
const rules = shredderRules();
return Math.min(rules.maxHitChance || 0.82, (rules.baseHitChance || 0.28) + count * (rules.hitChancePerUpgrade || 0.018));
}
function shredderExpectedMultiplier(count) {
const rules = shredderRules();
const jackpot = Math.min(rules.jackpotMaxChance || 0.08, (rules.jackpotBaseChance || 0.01) + count * (rules.jackpotChancePerUpgrade || 0.0025));
const big = Math.min(rules.bigWinMaxChance || 0.12, (rules.bigWinBaseChance || 0.07) + count * (rules.bigWinChancePerUpgrade || 0.0015));
const double = Math.min(1 - jackpot - big, rules.doubleChance || 0.20);
const normal = Math.max(0, 1 - jackpot - big - double);
return jackpot * (rules.jackpotMultiplier || 10) + big * (rules.bigWinMultiplier || 4) + double * (rules.doubleMultiplier || 2) + normal;
}
export function shredderBonusIncome(gameOrCount) {
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
return Math.round(shredderBasePayout(count) * shredderBonusChance(count) * shredderExpectedMultiplier(count));
}
export function rollShredderBonus(game) {
const cards = shredderUpgradeCount(game);
const chance = shredderBonusChance(cards);
const amount = cards > 0 && Math.random() < chance ? 1 : 0;
return { cards, chance, amount, expected: chance };
const baseAmount = shredderBasePayout(cards);
if (cards <= 0 || Math.random() >= chance) return { cards, chance, amount: 0, expected: shredderBonusIncome(cards), baseAmount, multiplier: 0, tier: 'miss' };
const rules = shredderRules();
const jackpot = Math.min(rules.jackpotMaxChance || 0.08, (rules.jackpotBaseChance || 0.01) + cards * (rules.jackpotChancePerUpgrade || 0.0025));
const big = Math.min(rules.bigWinMaxChance || 0.12, (rules.bigWinBaseChance || 0.07) + cards * (rules.bigWinChancePerUpgrade || 0.0015));
const double = Math.min(1 - jackpot - big, rules.doubleChance || 0.20);
const roll = Math.random();
let multiplier = 1, tier = 'normal';
if (roll < jackpot) { multiplier = rules.jackpotMultiplier || 10; tier = 'jackpot'; }
else if (roll < jackpot + big) { multiplier = rules.bigWinMultiplier || 4; tier = 'big'; }
else if (roll < jackpot + big + double) { multiplier = rules.doubleMultiplier || 2; tier = 'double'; }
const amount = Math.ceil(baseAmount * multiplier * shredderPayoutMultiplier(game));
return { cards, chance, amount, expected: shredderBonusIncome(cards), baseAmount, multiplier, tier };
}
export function applyShredderBonus(game) {
@ -102,28 +145,23 @@ export function applyShredderBonus(game) {
return result;
}
export function zundaTaxInfo(cash, game = null) {
const rules = ECONOMY.zundaTax;
const legalWork = Math.max(0, game?.cardEffects?.legalWork || 0);
const exemption = rules.exemption + legalWork * rules.legalWorkExemptionBonus;
const maxRateCash = rules.maxRateCash + legalWork * rules.legalWorkExemptionBonus;
const profit = Math.max(0, Math.floor(Number(cash) || 0));
const taxable = Math.max(0, profit - exemption);
if (taxable <= 0) return { profit, exemption, maxRateCash, taxable: 0, rate: 0, ratePercent: 0, tax: 0, step: 0, legalWork };
const step = Math.max(1, Math.ceil(taxable / rules.stepAmount));
const rate = Math.min(rules.maxRate, step * rules.stepRate);
const tax = Math.ceil(taxable * rate);
return { profit, exemption, maxRateCash, taxable, rate, ratePercent: Math.round(rate * 100), tax, step, legalWork };
}
export function fairiesTributeInfo(game, turn = game.turn) {
export function fairiesTributeInfo(game, turn = game.turn, basisProfit = null) {
const day = Math.max(1, Math.floor(Number(turn) || 1));
const base = Math.ceil(day * ECONOMY.fairiesTribute.perDay);
const rules = ECONOMY.fairiesTribute || { perDay: 10, profitExemption: 300, legalWorkExemptionBonus: 250, profitRate: 0.12 };
const base = Math.ceil(day * (rules.perDay || 10));
const profit = Math.max(0, Math.floor(Number(basisProfit ?? game?.lastResult?.profit ?? game?.stats?.profit ?? 0) || 0));
const legalWork = Math.max(0, game?.cardEffects?.legalWork || 0);
const profitExemption = Math.max(0, (rules.profitExemption || 0) + legalWork * (rules.legalWorkExemptionBonus || 0));
const taxableProfit = Math.max(0, profit - profitExemption);
const profitTribute = Math.ceil(taxableProfit * Math.max(0, Number(rules.profitRate) || 0));
const gross = base + profitTribute;
const flatteryCount = Math.max(0, game?.cardEffects?.fairiesFlatteryNext || game?.cardEffects?.flattery || 0);
const multiplier = flatteryCount > 0 ? Math.pow(0.5, flatteryCount) : 1;
const amount = Math.max(0, Math.ceil(base * multiplier));
const reduction = Math.max(0, base - amount);
return { day, base, flatteryCount, perCardReduction: Math.ceil(base * 0.5), reduction, amount };
const flatteryMultiplier = flatteryCount > 0 ? Math.pow(0.5, flatteryCount) : 1;
const eventMultiplier = Math.max(0.01, Number(game?.pendingFairiesTributeMultiplier) || 1);
const multiplier = flatteryMultiplier * eventMultiplier;
const amount = Math.max(0, Math.ceil(gross * multiplier));
const reduction = Math.max(0, gross - amount);
return { day, base, profit, legalWork, profitExemption, taxableProfit, profitTribute, gross, flatteryCount, eventMultiplier, perCardReduction: Math.ceil(gross * 0.5), reduction, amount };
}
export function resaleValueFor(hit, game) {
@ -146,7 +184,6 @@ function positivePayout(_game, amount) {
}
export function applyCashDelta(game, delta) {
const before = game.cash;
game.cash += delta;
game.stats.profit += delta;
game.totals.profit += delta;
@ -173,7 +210,6 @@ export function spendCash(game, amount) {
export function refundCash(game, amount) {
if (amount <= 0) return;
const before = game.cash;
game.cash += amount;
game.stats.profit += amount;
game.totals.profit += amount;
@ -181,7 +217,7 @@ export function refundCash(game, amount) {
}
export function applyMixerIncome(game) {
const amount = upgradedMixerPrice(game);
const amount = ECONOMY.income.mixer;
game.stats.mixerRevenue += amount;
applyRevenue(game, amount);
return amount;
@ -221,48 +257,60 @@ export function applyExplosionDamage(game, price) {
export function settleTruckRevenue(game) {
const target = truckTarget(game);
const targetCount = targetTruckCount(game.stats, target);
const unitPrice = upgradedTruckPrice(game);
const base = targetCount * unitPrice;
const adjusted = positivePayout(game, base);
const unitPrice = target === 'poop' ? Math.ceil(upgradedTruckPrice(game) * poopValueMultiplier(game)) : upgradedTruckPrice(game);
const cargo = Array.isArray(game.shippedCargo) ? game.shippedCargo : (Array.isArray(game.truckCargo) ? game.truckCargo : []);
const targetCargo = cargo.filter(item => item.sex === target);
const targetCount = targetCargo.length || targetTruckCount(game.stats, target);
const base = targetCargo.length
? targetCargo.reduce((sum, item) => sum + Math.ceil(unitPrice * (target === 'female' ? Math.max(1, item.sourceQuality || 1) : 1)), 0)
: targetCount * unitPrice;
const sausageCargo = cargo.filter(item => item.sex === 'sausage');
const sausageCount = sausageCargo.length || Math.max(0, game?.stats?.sausageShipped || 0);
const sausageUnitPrice = upgradedSausagePrice(game);
const sausageRevenue = sausageCargo.length
? sausageCargo.reduce((sum, item) => sum + Math.ceil(sausageUnitPrice * Math.max(1, item.sourceQuality || 1)), 0)
: sausageCount * sausageUnitPrice;
const qualityBaseline = targetCount * unitPrice + sausageCount * sausageUnitPrice;
const qualityRevenue = Math.max(0, base + sausageRevenue - qualityBaseline);
game.stats.qualityRevenue = (game.stats.qualityRevenue || 0) + qualityRevenue;
game.totals.qualityRevenue = (game.totals.qualityRevenue || 0) + qualityRevenue;
const adjusted = positivePayout(game, base + sausageRevenue);
game.stats.pendingTruckRevenue = adjusted;
game.stats.truckRevenue = adjusted;
if (target === 'poop') game.stats.poopShipmentIncome = adjusted;
else game.stats.chickShipmentIncome = adjusted;
game.stats.sausageRevenue = sausageRevenue;
if (target === 'poop') game.stats.poopShipmentIncome = base;
else game.stats.chickShipmentIncome = base;
if (adjusted > 0) applyRevenue(game, adjusted);
return { base, adjusted, target, targetCount, unitPrice };
return { base, adjusted, target, targetCount, unitPrice, sausageCount, sausageUnitPrice, sausageRevenue };
}
export function collectZundaTax(game, basisProfit = 0) {
const info = zundaTaxInfo(basisProfit, game);
if (info.tax <= 0) return info;
// ZUNDA TAX is settled between days. It reduces cash and lifetime totals,
// but it is intentionally excluded from the next day's NET display.
game.totals.zundaTax = (game.totals.zundaTax || 0) + info.tax;
game.totals.penalty = (game.totals.penalty || 0) + info.tax;
game.totals.profit = (game.totals.profit || 0) - info.tax;
game.cash -= info.tax;
return info;
}
export function collectFairiesTribute(game, turn = game.turn) {
const info = fairiesTributeInfo(game, turn);
export function collectFairiesTribute(game, turn = game.turn, basisProfit = null, options = {}) {
const info = fairiesTributeInfo(game, turn, basisProfit);
if (game.cardEffects) {
game.cardEffects.fairiesFlatteryNext = 0;
if (game.cardEffects.flattery != null) delete game.cardEffects.flattery;
}
if (game.pendingFairiesTributeMultiplier != null) game.pendingFairiesTributeMultiplier = 1;
if (info.amount <= 0) return info;
game.stats.fairiesTribute = (game.stats.fairiesTribute || 0) + info.amount;
game.totals.fairiesTribute = (game.totals.fairiesTribute || 0) + info.amount;
applyPenalty(game, info.amount);
if (options.recordStats) {
game.stats.fairiesTribute = (game.stats.fairiesTribute || 0) + info.amount;
game.totals.fairiesTribute = (game.totals.fairiesTribute || 0) + info.amount;
applyPenalty(game, info.amount);
} else {
game.totals.fairiesTribute = (game.totals.fairiesTribute || 0) + info.amount;
game.totals.penalty = (game.totals.penalty || 0) + info.amount;
game.totals.profit = (game.totals.profit || 0) - info.amount;
game.cash -= info.amount;
}
return info;
}
export function collectChemicalWeaponSubsidy(game) {
const cards = effectCount(game, 'chemicalWeaponSubsidy');
const poopTruck = Math.max(0, game?.stats?.poopTruck || 0);
const blocks = Math.floor(poopTruck / 100);
const amount = cards > 0 && blocks > 0 ? blocks * 2000 * cards : 0;
const rules = ECONOMY.chemicalWeaponSubsidy || { poopPerBlock: 70, yenPerBlock: 1200 };
const blocks = Math.floor(poopTruck / Math.max(1, rules.poopPerBlock || 70));
const amount = cards > 0 && blocks > 0 ? blocks * Math.max(0, rules.yenPerBlock || 1200) * cards : 0;
if (amount > 0) {
game.stats.chemicalWeaponSubsidy = (game.stats.chemicalWeaponSubsidy || 0) + amount;
game.totals.chemicalWeaponSubsidy = (game.totals.chemicalWeaponSubsidy || 0) + amount;
@ -271,33 +319,10 @@ export function collectChemicalWeaponSubsidy(game) {
return { cards, poopTruck, blocks, amount };
}
export function collectLoanRepayments(game) {
const effects = game?.cardEffects;
if (!effects || !Array.isArray(effects.loans) || !effects.loans.length) return { amount: 0, paidLoans: 0, remainingLoans: 0 };
let amount = 0;
let paidLoans = 0;
const nextLoans = [];
for (const loan of effects.loans) {
if ((loan.daysRemaining || 0) <= 0) continue;
const payment = Math.max(0, Math.ceil(loan.dailyPayment || 400));
amount += payment;
paidLoans += 1;
const daysRemaining = Math.max(0, (loan.daysRemaining || 0) - 1);
if (daysRemaining > 0) nextLoans.push({ ...loan, daysRemaining });
}
effects.loans = nextLoans;
if (amount > 0) {
game.stats.loanRepayment = (game.stats.loanRepayment || 0) + amount;
game.totals.loanRepayment = (game.totals.loanRepayment || 0) + amount;
applyPenalty(game, amount);
}
return { amount, paidLoans, remainingLoans: nextLoans.length };
}
export function finalScore(game) {
const value = factoryValue(game);
const days = Math.max(1, game.totals.turnsCompleted || game.turn || 1);
const base = Math.max(0, game.cash + game.totals.revenue + value * 0.5 + game.totals.contractBonus * 0.5);
const base = Math.max(0, game.cash + game.totals.revenue + value * 0.5 + game.totals.eventBonus * 0.5);
const dailyEarned = Math.ceil(base / days);
const dayPenalty = Math.floor(days * 35 + Math.max(0, days - 5) * 15);
const correctBonus = game.totals.correct * 5;

View file

@ -1,4 +1,5 @@
import { EFFECT_PRIORITY, THEME } from '../core/config.js';
import { BALANCE } from '../core/balance.js';
import { randomBetween, yen } from '../core/utils.js';
import { applyExplosionDamage } from './economy.js';
@ -46,7 +47,8 @@ export function smokeBurst(game, x, y, count = 10) {
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), noDamage });
const horizontalMultiplier = Math.max(0, Number(BALANCE.effects?.explosionHorizontalVelocityMultiplier) || 1);
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 * horizontalMultiplier, 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 });

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

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

View file

@ -0,0 +1,42 @@
import { BALANCE } from '../core/balance.js';
import { conveyorSpeedForGame } from './cards.js';
export function boostOutputCapacityPerSecond(game) {
return conveyorSpeedForGame(game) * (BALANCE.facilities.boostConveyor.speedMultiplier || 2) / Math.max(1, BALANCE.conveyor.minimumItemSpacingPx || 25);
}
export function farmTheoreticalRatePerSecond(game, farm, currentSpawnDelay) {
const delay = Math.max(BALANCE.farmQuality.safeMinIntervalSeconds, Number(currentSpawnDelay) || 1);
const zunda = (farm.zundaBoostTime || 0) > 0 ? BALANCE.farmQuality.zundaIntervalMultiplier : 1;
return 1 / Math.max(BALANCE.farmQuality.safeMinIntervalSeconds, delay * zunda);
}
export function qualityForRate(rate, capacity) {
const rules = BALANCE.farmQuality;
const c = Math.max(0.0001, capacity);
const overflowRatio = Math.max(0, rate / c - 1);
return Math.max(rules.minMultiplier, Math.min(rules.maxMultiplier, 1 + rules.overflowCoefficient * Math.pow(overflowRatio, rules.overflowExponent)));
}
export function actualSpawnIntervalForRate(rate, capacity) {
return 1 / Math.max(0.0001, Math.min(rate, capacity));
}
export function qualityGrade(multiplier) {
if (multiplier >= 1.75) return 'Luxury';
if (multiplier >= 1.35) return 'Premium';
if (multiplier >= 1.15) return 'Select';
return 'Standard';
}
export function addZundaBoost(game, farm) {
const before = farm.zundaBoostTime || 0;
farm.zundaBoostTime = Math.min(BALANCE.farmQuality.zundaMaxSeconds, before + BALANCE.farmQuality.zundaSeconds);
const added = Math.max(0, farm.zundaBoostTime - before);
game.stats.zundaConsumed = (game.stats.zundaConsumed || 0) + 1;
game.stats.zundaBoostSeconds = (game.stats.zundaBoostSeconds || 0) + added;
game.totals.zundaConsumed = (game.totals.zundaConsumed || 0) + 1;
game.totals.zundaBoostSeconds = (game.totals.zundaBoostSeconds || 0) + added;
game.stats.zundaBoostSecondsByFarm = game.stats.zundaBoostSecondsByFarm || {};
game.stats.zundaBoostSecondsByFarm[farm.id] = (game.stats.zundaBoostSecondsByFarm[farm.id] || 0) + added;
}

View file

@ -14,7 +14,8 @@ export function snapshot(game) {
gridCols: game.gridCols || GRID.cols,
gridRows: game.gridRows || GRID.rows,
gridExpansionPurchases: game.gridExpansionPurchases || 0,
gridExpansionPurchasesByDirection: game.gridExpansionPurchasesByDirection || { right: 0, up: 0 },
gridExpansionPurchasesByDirection: game.gridExpansionPurchasesByDirection || { left: 0, right: 0, up: 0, down: 0 },
gridChunkOriginX: game.gridChunkOriginX || 0,
gridChunkOriginY: game.gridChunkOriginY || 0,
ownedCells: [...(game.ownedCells || [])],
ownedChunks: [...(game.ownedChunks || [])],
@ -28,6 +29,13 @@ export function snapshot(game) {
scanners: game.scanners.map(cleanScanner),
conveyorTiles: [...game.conveyorTiles],
conveyorMeta: [...game.conveyorMeta.entries()],
powerWires: [...(game.powerWires || new Map()).entries()],
footbridges: [...(game.footbridges || new Map()).entries()],
footbridgeOrientation: game.footbridgeOrientation || 'vertical',
powerOverlay: !!game.powerOverlay,
imports: {
schedules: [...(game.imports?.schedules || new Map()).entries()]
},
branchCounters: [...game.branchCounters.entries()],
cardEffects: game.cardEffects,
cardDraft: game.cardDraft,
@ -47,7 +55,10 @@ export function restore(game, text) {
game.gridCols = GRID.cols;
game.gridRows = GRID.rows;
game.gridExpansionPurchases = data.gridExpansionPurchases || 0;
game.gridExpansionPurchasesByDirection = data.gridExpansionPurchasesByDirection || { right: 0, up: 0 };
game.gridExpansionPurchasesByDirection = data.gridExpansionPurchasesByDirection || { left: 0, right: 0, up: 0, down: 0 };
if (game.gridExpansionPurchasesByDirection.left == null) game.gridExpansionPurchasesByDirection.left = 0;
if (game.gridExpansionPurchasesByDirection.down == null) game.gridExpansionPurchasesByDirection.down = 0;
game.gridChunkOriginX = data.gridChunkOriginX || 0;
game.gridChunkOriginY = data.gridChunkOriginY || 0;
game.ownedCells = new Set(data.ownedCells || []);
game.ownedChunks = new Set(data.ownedChunks || []);
@ -63,6 +74,11 @@ export function restore(game, text) {
game.scanners = (data.scanners || []).map(s => ({ ...s, queue: [], cooldown: s.cooldown || 0 }));
game.conveyorTiles = new Set(data.conveyorTiles || []);
game.conveyorMeta = new Map(data.conveyorMeta || []);
game.powerWires = new Map(data.powerWires || []);
game.footbridges = new Map(data.footbridges || []);
game.footbridgeOrientation = data.footbridgeOrientation || 'vertical';
game.powerOverlay = !!data.powerOverlay;
game.imports = { schedules: new Map(data.imports?.schedules || []) };
game.branchCounters = new Map(data.branchCounters || []);
game.cardEffects = data.cardEffects || { bearing: 0, legalWork: 0, fairiesFlatteryNext: 0, scannerQueueSpacing: 0 };
if (game.cardEffects.drawBonus != null) delete game.cardEffects.drawBonus;
@ -74,6 +90,8 @@ export function restore(game, text) {
game.selectionBox = null;
game.pan = null;
game.routingVersion = (game.routingVersion || 0) + 1;
game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
game.power = { ...(game.power || {}), dirty: true };
game.factoryGraph = null;
game.factoryGraphCommittedVersion = -1;
}

149
src/systems/imports.js Normal file
View file

@ -0,0 +1,149 @@
import { BALANCE } from '../core/balance.js';
import { TURN_SECONDS } from '../core/config.js';
import { cellCenter, key } from '../core/utils.js';
import { applyPenalty } from './economy.js';
import { facilityOutputCell, facilityOutputHasConveyor } from './routing.js';
import { isEquipmentPowered } from './power.js';
import { recordFacilityProcess } from './maintenance.js';
function hash01(seed) {
let h = 2166136261;
for (let i = 0; i < String(seed).length; i += 1) {
h ^= String(seed).charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 100000) / 100000;
}
export function clampInboundOrder(order = {}) {
const limit = BALANCE.imports.combinedLimitPerTruck;
let coal = Math.max(0, Math.floor(Number(order.coalPerDay) || 0));
let zunda = Math.max(0, Math.floor(Number(order.zundaMochiPerDay) || 0));
if (coal + zunda > limit) {
const scale = limit / Math.max(1, coal + zunda);
coal = Math.floor(coal * scale);
zunda = Math.max(0, limit - coal);
}
return { coalPerDay: coal, zundaMochiPerDay: zunda };
}
export function inboundOrderCost(order = {}) {
const clean = clampInboundOrder(order);
return clean.coalPerDay * BALANCE.imports.prices.coal + clean.zundaMochiPerDay * BALANCE.imports.prices.zundaMochi;
}
export function createImportSchedule(order, seed = 'imports') {
const clean = clampInboundOrder(order);
const items = [
...Array.from({ length: clean.coalPerDay }, () => ({ type: 'coal' })),
...Array.from({ length: clean.zundaMochiPerDay }, () => ({ type: 'zundaMochi' }))
];
const total = Math.max(1, items.length);
return items.map((item, i) => {
const interval = TURN_SECONDS / total;
const baseTime = ((i + 0.5) / total) * TURN_SECONDS;
const jitter = (hash01(`${seed}:${item.type}:${i}`) * 2 - 1) * BALANCE.imports.jitterRatio * interval;
return { type: item.type, due: Math.max(0, Math.min(TURN_SECONDS, baseTime + jitter)), released: false };
}).sort((a, b) => a.due - b.due || a.type.localeCompare(b.type));
}
export function startInboundOrders(game) {
game.imports = { schedules: new Map() };
for (const facility of Object.values(game.facilities || {})) {
if ((facility.baseId || facility.id) !== 'inboundTruck') continue;
facility.inboundOrder = clampInboundOrder(facility.inboundOrder);
facility.inboundQueue = [];
facility.importClock = 0;
const cost = inboundOrderCost(facility.inboundOrder);
if (cost > 0) {
applyPenalty(game, cost);
game.stats.importPurchaseCost = (game.stats.importPurchaseCost || 0) + cost;
game.stats.coalOrdered = (game.stats.coalOrdered || 0) + facility.inboundOrder.coalPerDay;
game.stats.zundaOrdered = (game.stats.zundaOrdered || 0) + facility.inboundOrder.zundaMochiPerDay;
game.totals.importPurchaseCost = (game.totals.importPurchaseCost || 0) + cost;
game.totals.coalOrdered = (game.totals.coalOrdered || 0) + facility.inboundOrder.coalPerDay;
game.totals.zundaOrdered = (game.totals.zundaOrdered || 0) + facility.inboundOrder.zundaMochiPerDay;
}
game.imports.schedules.set(facility.id, createImportSchedule(facility.inboundOrder, `${game.runSeed}:${game.turn}:${facility.id}`));
}
}
function outputCellFree(game, cell) {
if (!cell || !game.conveyorTiles.has(key(cell.col, cell.row))) return false;
const c = cellCenter(cell.col, cell.row);
return !game.chicks.some(item => item.stage !== 'flying' && Math.hypot(item.x - c.x, item.y - c.y) < 25);
}
function createImportItem(game, type, cell) {
const c = cellCenter(cell.col, cell.row);
return {
id: game.nextId++,
sex: type,
x: c.x,
y: c.y,
route: [{ x: c.x, y: c.y }],
targetIndex: 1,
stage: 'belt',
scannerId: null,
nextScannerId: null,
pendingScannerId: null,
radius: type === 'coal' ? 10 : 11,
bob: 0,
queueIndex: -1,
queueRoute: null,
stoppedTimer: 0
};
}
export function updateInboundTrucks(game, dt, markChicksChanged = () => {}) {
if (game.phase !== 'running') return;
for (const facility of Object.values(game.facilities || {})) {
if ((facility.baseId || facility.id) !== 'inboundTruck') continue;
if (!isEquipmentPowered(game, facility)) continue;
facility.importClock = (facility.importClock || 0) + dt;
const schedule = game.imports?.schedules?.get(facility.id) || [];
for (const item of schedule) {
if (!item.released && item.due <= facility.importClock) {
facility.inboundQueue = facility.inboundQueue || [];
facility.inboundQueue.push(item);
item.released = true;
}
}
const out = facilityOutputCell(game, facility);
if (!facility.inboundQueue?.length || !facilityOutputHasConveyor(game, facility) || !outputCellFree(game, out)) continue;
const queued = facility.inboundQueue.shift();
const type = queued.type;
queued.delivered = true;
game.chicks.push(createImportItem(game, type, out));
if (type === 'coal') {
game.stats.coalReleased = (game.stats.coalReleased || 0) + 1;
game.totals.coalReleased = (game.totals.coalReleased || 0) + 1;
}
if (type === 'zundaMochi') {
game.stats.zundaReleased = (game.stats.zundaReleased || 0) + 1;
game.totals.zundaReleased = (game.totals.zundaReleased || 0) + 1;
}
recordFacilityProcess(game, facility);
markChicksChanged();
}
}
export function settleUndeliveredImports(game) {
for (const facility of Object.values(game.facilities || {})) {
if ((facility.baseId || facility.id) !== 'inboundTruck') continue;
const schedule = game.imports?.schedules?.get(facility.id) || [];
for (const item of schedule) {
const undelivered = !item.delivered;
if (!undelivered) continue;
if (item.type === 'coal') {
game.stats.coalUndelivered = (game.stats.coalUndelivered || 0) + 1;
game.totals.coalUndelivered = (game.totals.coalUndelivered || 0) + 1;
}
if (item.type === 'zundaMochi') {
game.stats.zundaUndelivered = (game.stats.zundaUndelivered || 0) + 1;
game.totals.zundaUndelivered = (game.totals.zundaUndelivered || 0) + 1;
}
}
facility.inboundQueue = [];
}
}

View file

@ -1,8 +1,8 @@
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 { DIRS, GRID, THEME } from '../core/config.js';
import { cellCenter, parseKey, key, inGrid } from '../core/utils.js';
import { floating } from './effects.js';
import { isEquipmentPowered } from './power.js';
const RULES = BALANCE.maintenance;
@ -25,19 +25,25 @@ export function durabilityCapFor(game, type, qualityMultiplier = 1) {
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);
}
function maintenanceRoomCenter(room, index = 0) {
if (!room) return { x: GRID.x + GRID.cell * (0.5 + index * 0.55), y: GRID.y + GRID.rows * GRID.cell + 84 };
const x = GRID.x + room.col * GRID.cell + GRID.cell;
const y = GRID.y + room.row * GRID.cell + GRID.cell;
return { x, y };
}
function repairmanHome(game, index = 0) {
const room = (game.maintenanceRooms || [])[index];
return maintenanceRoomCenter(room, index);
}
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,
@ -45,6 +51,8 @@ export function ensureMaintenanceState(game) {
repairedToday: 0
};
}
if (!Array.isArray(game.maintenanceRooms)) game.maintenanceRooms = [];
if (!Array.isArray(game.repairmen)) game.repairmen = [];
for (const [k, meta] of game.conveyorMeta || []) {
if (meta.uses == null) meta.uses = 0;
if (meta.maintenanceType == null) meta.maintenanceType = 'conveyor';
@ -59,8 +67,11 @@ export function ensureMaintenanceState(game) {
if (scanner.kind === 'auto') attachMaintenance(scanner, 'autoScanner', game);
}
for (const f of Object.values(game.facilities || {})) {
if (f.id === 'mixer') attachMaintenance(f, 'mixer', game);
if (f.id === 'trash') attachMaintenance(f, 'trash', game);
const kind = facilityKind(f);
if (kind === 'mixer') attachMaintenance(f, 'mixer', game);
if (kind === 'trash') attachMaintenance(f, 'trash', game);
if (kind === 'sausageMaker') attachMaintenance(f, 'sausageMaker', game);
if (['starterGenerator', 'biomassGenerator', 'coalGenerator', 'inboundTruck'].includes(kind)) attachMaintenance(f, kind, game);
}
}
@ -155,7 +166,7 @@ export function facilityProcessingDelay(game, target) {
ensureMaintenanceState(game);
const f = facilityFromTarget(game, target);
const id = facilityKind(f || target);
if (!f || !['mixer', 'trash'].includes(id)) return 0;
if (!f || !['mixer', 'trash', 'sausageMaker'].includes(id)) return 0;
attachMaintenance(f, id);
const t = Math.max(0, delayMultiplier(f) - 1);
return t * (RULES.processingDelayMaxSeconds?.[id] || 1);
@ -187,7 +198,7 @@ export function recordFacilityProcess(game, target) {
ensureMaintenanceState(game);
const f = facilityFromTarget(game, target);
const id = facilityKind(f || target);
if (!f || !['mixer', 'trash'].includes(id)) return;
if (!f || !['mixer', 'trash', 'sausageMaker', 'starterGenerator', 'biomassGenerator', 'coalGenerator', 'inboundTruck'].includes(id)) return;
attachMaintenance(f, id);
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game));
}
@ -202,7 +213,8 @@ export function setFacilityCooldownAfterProcess(game, target) {
export function updateProcessingCooldowns(game, dt) {
for (const f of Object.values(game.facilities || {})) {
if (!['mixer', 'trash'].includes(f.baseId || f.id)) continue;
if (!['mixer', 'trash', 'sausageMaker'].includes(f.baseId || f.id)) continue;
if (!isEquipmentPowered(game, f)) continue;
if (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
}
}
@ -213,9 +225,10 @@ export function equipmentMaintenanceTargets(game) {
for (const [k, meta] of game.conveyorMeta || []) targets.push({ type: 'conveyor', key: k, meta, label: `${meta.kind === 'boostConveyor' ? 'Boost Belt' : 'Belt'} ${k}`, center: cellCenter(parseKey(k).col, parseKey(k).row) });
for (const farm of game.eggFarms || []) targets.push({ type: 'eggFarm', ref: farm, label: `EGG #${farm.id}`, center: cellCenter(farm.col, farm.row) });
for (const scanner of (game.scanners || []).filter(s => s.kind === 'auto')) targets.push({ type: 'autoScanner', ref: scanner, label: `AUTO #${scanner.id}`, center: cellCenter(scanner.col, scanner.row) });
for (const id of ['mixer', 'trash']) {
const f = game.facilities?.[id];
if (f) targets.push({ type: id, ref: f, label: f.shortName || f.name || id, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
for (const f of Object.values(game.facilities || {})) {
const kind = facilityKind(f);
if (!['mixer', 'trash', 'sausageMaker', 'starterGenerator', 'biomassGenerator', 'coalGenerator', 'inboundTruck'].includes(kind)) continue;
targets.push({ type: kind, ref: f, label: `${f.shortName || f.name || kind} #${f.id}`, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
}
return targets;
}
@ -238,28 +251,33 @@ export function repairAllEquipment(game, percent = 0.02) {
return repaired;
}
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.pendingRepairWorkerWages = (game.pendingRepairWorkerWages || 0) + cost;
game.totals.repairWorkerWages = (game.totals.repairWorkerWages || 0) + 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;
const rooms = game.maintenanceRooms || [];
game.repairmen = rooms.map((room, index) => {
const exitCell = nearestWalkableFromRoom(game, room);
const home = exitCell ? cellCenter(exitCell.col, exitCell.row) : repairmanHome(game, index);
return {
id: `room-${room.id}`,
roomId: room.id,
active: true,
x: home.x,
y: home.y,
target: null,
repairedToday: 0
};
});
const first = game.repairmen[0];
game.repairman.active = !!first;
game.repairman.target = first?.target || null;
game.repairman.repairedToday = 0;
if (first) { game.repairman.x = first.x; game.repairman.y = first.y; }
}
export function deactivateRepairman(game) {
ensureMaintenanceState(game);
for (const r of game.repairmen || []) { r.active = false; r.target = null; }
game.repairmen = [];
game.repairman.active = false;
game.repairman.target = null;
}
@ -273,6 +291,7 @@ function resolveStoredTarget(game, stored) {
function chooseRepairTarget(game) {
let best = null;
for (const t of equipmentMaintenanceTargets(game)) {
if (!repairWorkCell(game, t)) continue;
const w = targetWear(t);
if (w <= 0.001) continue;
if (!best || w > targetWear(best)) best = t;
@ -282,17 +301,63 @@ function chooseRepairTarget(game) {
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);
const repairmen = game.repairmen?.length ? game.repairmen : (game.repairman?.active ? [game.repairman] : []);
if (!repairmen.length || game.phase !== 'running') return;
const assigned = new Set(repairmen.map(r => r.target?.key).filter(Boolean));
for (const r of repairmen) updateSingleRepairman(game, r, dt, assigned);
const first = repairmen[0];
if (first && game.repairman !== first) {
game.repairman.active = true;
game.repairman.x = first.x;
game.repairman.y = first.y;
game.repairman.target = first.target;
game.repairman.repairedToday = first.repairedToday || 0;
}
}
function updateSingleRepairman(game, r, dt, assigned) {
if (!r.active) return;
const room = (game.maintenanceRooms || []).find(item => item.id === r.roomId);
if (room && !isEquipmentPowered(game, room)) return;
let target = resolveStoredTarget(game, r.target);
if (!target || targetWear(target) <= 0.001 || !repairWorkCell(game, target)) {
if (r.target?.key) assigned.delete(r.target.key);
target = chooseRepairTargetAvoiding(game, assigned);
}
if (!target) {
r.target = null;
return;
}
r.target = { key: targetKey(target), label: target.label };
const tKey = targetKey(target);
assigned.add(tKey);
const workCell = repairWorkCell(game, target, r);
if (!workCell) {
game.stats.unreachableRepairTargets = (game.stats.unreachableRepairTargets || 0) + 1;
game.totals.unreachableRepairTargets = (game.totals.unreachableRepairTargets || 0) + 1;
assigned.delete(tKey);
r.target = null;
return;
}
r.target = { key: tKey, label: target.label, workKey: key(workCell.col, workCell.row) };
ensureRepairPath(game, r, workCell);
if (r.path?.length && r.pathIndex < r.path.length) {
const next = cellCenter(r.path[r.pathIndex].col, r.path[r.pathIndex].row);
const dx = next.x - r.x;
const dy = next.y - r.y;
const dist = Math.hypot(dx, dy);
if (dist > 4) {
const step = Math.min(dist, RULES.repairman.walkSpeed * dt);
r.x += dx / dist * step;
r.y += dy / dist * step;
return;
}
r.pathIndex += 1;
return;
}
const speed = RULES.repairman.walkSpeed;
const dx = target.center.x - r.x;
const dy = target.center.y - r.y;
const workCenter = cellCenter(workCell.col, workCell.row);
const dx = workCenter.x - r.x;
const dy = workCenter.y - r.y;
const dist = Math.hypot(dx, dy);
if (dist > 8) {
const step = Math.min(dist, speed * dt);
@ -300,16 +365,162 @@ export function updateRepairman(game, dt) {
r.y += dy / dist * step;
return;
}
const repairWear = dt / (RULES.repairman.secondsPerOnePercent * 100);
const workTimeMultiplier = game?.cardEffects?.laborExploitation ? 0.7 : 1;
const repairWear = dt / (RULES.repairman.secondsPerOnePercent * workTimeMultiplier * 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;
if (targetWear(target) <= 0.001) {
assigned.delete(tKey);
r.target = null;
}
}
function chooseRepairTargetAvoiding(game, assigned = new Set()) {
let best = null;
for (const t of equipmentMaintenanceTargets(game)) {
if (assigned.has(targetKey(t))) continue;
if (!repairWorkCell(game, t)) continue;
const w = targetWear(t);
if (w <= 0.001) continue;
if (!best || w > targetWear(best)) best = t;
}
return best || chooseRepairTarget(game);
}
function cellsForTarget(target) {
if (target.type === 'conveyor') return [parseKey(target.key)];
if (target.type === 'eggFarm') return [{ col: target.ref.col, row: target.ref.row }];
if (target.type === 'autoScanner') {
const s = target.ref;
return [{ col: s.col, row: s.row - 1 }, { col: s.col + 1, row: s.row - 1 }, { col: s.col, row: s.row }, { col: s.col + 1, row: s.row }];
}
const f = target.ref;
if (f?.gridPlaced) {
const cells = [];
for (let dr = 0; dr < (f.hCells || 1); dr += 1) for (let dc = 0; dc < (f.wCells || 1); dc += 1) cells.push({ col: f.col + dc, row: f.row + dr });
return cells;
}
return f?.entry ? [f.entry] : [];
}
function equipmentCellKeys(game) {
const blocked = new Set();
for (const farm of game.eggFarms || []) blocked.add(key(farm.col, farm.row));
for (const s of game.scanners || []) for (const cell of [{ col: s.col, row: s.row - 1 }, { col: s.col + 1, row: s.row - 1 }, { col: s.col, row: s.row }, { col: s.col + 1, row: s.row }]) blocked.add(key(cell.col, cell.row));
for (const room of game.maintenanceRooms || []) for (let dr = 0; dr < 2; dr += 1) for (let dc = 0; dc < 2; dc += 1) blocked.add(key(room.col + dc, room.row + dr));
for (const f of Object.values(game.facilities || {})) {
if (!f.gridPlaced) continue;
for (let dr = 0; dr < (f.hCells || 1); dr += 1) for (let dc = 0; dc < (f.wCells || 1); dc += 1) blocked.add(key(f.col + dc, f.row + dr));
}
return blocked;
}
function walkable(game, col, row) {
if (!inGrid(col, row) || !game.ownedCells?.has?.(key(col, row)) || game.blockedCells?.has?.(key(col, row))) return false;
const k = key(col, row);
if (equipmentCellKeys(game).has(k)) return false;
if (game.conveyorTiles?.has(k) && !game.footbridges?.has(k)) return false;
return true;
}
function nearestWalkableFromRoom(game, room) {
if (!room) return { col: 0, row: Math.max(0, GRID.rows - 1) };
const candidates = [];
for (let dc = 0; dc < 2; dc += 1) {
candidates.push({ col: room.col + dc, row: room.row - 1 }, { col: room.col + dc, row: room.row + 2 });
}
for (let dr = 0; dr < 2; dr += 1) {
candidates.push({ col: room.col - 1, row: room.row + dr }, { col: room.col + 2, row: room.row + dr });
}
return candidates.find(cell => walkable(game, cell.col, cell.row)) || null;
}
function bfsPath(game, start, goals) {
if (!start) return null;
const goalKeys = new Set(goals.map(cell => key(cell.col, cell.row)));
const startKey = key(start.col, start.row);
const queue = [start];
const cameFrom = new Map([[startKey, null]]);
while (queue.length) {
const current = queue.shift();
const currentKey = key(current.col, current.row);
if (goalKeys.has(currentKey)) {
const path = [];
let cursor = currentKey;
while (cursor) {
const cell = parseKey(cursor);
path.push(cell);
cursor = cameFrom.get(cursor);
}
return path.reverse();
}
for (const d of DIRS) {
const next = { col: current.col + d.dc, row: current.row + d.dr };
const nk = key(next.col, next.row);
if (cameFrom.has(nk) || !walkable(game, next.col, next.row)) continue;
cameFrom.set(nk, currentKey);
queue.push(next);
}
}
return null;
}
function repairCandidateCells(game, target) {
const candidates = [];
for (const cell of cellsForTarget(target)) {
if (target.type === 'conveyor' && game.footbridges?.has(key(cell.col, cell.row)) && walkable(game, cell.col, cell.row)) candidates.push(cell);
for (const d of DIRS) {
const candidate = { col: cell.col + d.dc, row: cell.row + d.dr };
if (walkable(game, candidate.col, candidate.row)) candidates.push(candidate);
}
}
const seen = new Set();
return candidates.filter(cell => {
const k = key(cell.col, cell.row);
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
function repairWorkCell(game, target, repairman = null) {
const goals = repairCandidateCells(game, target);
if (!goals.length) return null;
const room = (game.maintenanceRooms || []).find(item => item.id === repairman?.roomId) || (game.maintenanceRooms || [])[0];
const start = repairman ? repairmanPathStart(game, repairman, room) : nearestWalkableFromRoom(game, room);
const path = bfsPath(game, start, goals);
return path?.[path.length - 1] || null;
}
function pointCell(x, y) {
const col = Math.floor((x - GRID.x) / GRID.cell);
const row = Math.floor((y - GRID.y) / GRID.cell);
return inGrid(col, row) ? { col, row } : null;
}
function repairmanPathStart(game, repairman, room) {
const current = pointCell(repairman.x, repairman.y);
if (current && walkable(game, current.col, current.row)) return current;
return nearestWalkableFromRoom(game, room);
}
function ensureRepairPath(game, r, workCell) {
const workKey = key(workCell.col, workCell.row);
if (r.path && r.pathVersion === game.walkabilityVersion && r.target?.workKey === workKey && r.pathIndex < r.path.length) return;
const room = (game.maintenanceRooms || []).find(item => item.id === r.roomId);
const start = repairmanPathStart(game, r, room);
r.path = bfsPath(game, start, [workCell]) || [];
const current = pointCell(r.x, r.y);
const startsOnWalkablePath = current && start && current.col === start.col && current.row === start.row && walkable(game, current.col, current.row);
r.pathIndex = startsOnWalkablePath && r.path.length > 1 ? 1 : 0;
r.pathVersion = game.walkabilityVersion || 0;
}
function worstDegradation(game) {
let worst = 0;
let label = 'none';
@ -323,5 +534,5 @@ function worstDegradation(game) {
export function maintenanceSummary(game) {
const worst = worstDegradation(game);
const avgBeltPerformance = averageConveyorPerformance(game);
return { worst, avgBeltPerformance, repairman: game.repairman || null };
return { worst, avgBeltPerformance, repairman: game.repairman || null, repairmen: game.repairmen || [], rooms: game.maintenanceRooms || [] };
}

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

@ -0,0 +1,302 @@
import { BALANCE } from '../core/balance.js';
import { DIRS } from '../core/config.js';
import { key, parseKey } from '../core/utils.js';
import { gridFacilityFootprintCells, maintenanceRoomFootprint } from '../core/entities.js';
function facilityKind(item) {
return item?.baseId || item?.id || '';
}
export function powerSpecFor(item) {
if (!item) return null;
const kind = item.type === 'scanner' ? `${item.kind}Scanner` : facilityKind(item) || item.type;
if (BALANCE.power.exempt.includes(kind)) return null;
return BALANCE.power.machines[kind] || null;
}
export function generatorSpecFor(item) {
return BALANCE.power.generators[facilityKind(item)] || null;
}
export function currentGeneratorOutputV(generator) {
const spec = generatorSpecFor(generator);
if (!spec) return 0;
const base = spec.outputV || spec.baseOutputV || 0;
const pulses = (generator.fuelPulses || []).reduce((sum, pulse) => sum + Math.max(0, pulse.voltage || 0), 0);
return base + pulses;
}
function sourceCells(generator) {
if (!generator?.gridPlaced) return [];
return gridFacilityFootprintCells(generator);
}
function contactCellsFor(item) {
if (!item) return [];
if (item.type === 'scanner') {
return [
{ col: item.col, row: item.row - 1 },
{ col: item.col + 1, row: item.row - 1 },
{ col: item.col, row: item.row },
{ col: item.col + 1, row: item.row }
];
}
if (item.type === 'maintenanceRoom') return maintenanceRoomFootprint(item);
if (item.gridPlaced) {
const kind = facilityKind(item);
const midRow = item.row + Math.floor((item.hCells || 1) / 2);
if (kind === 'sausageMaker') return [
{ col: item.col - 1, row: midRow },
{ col: item.col + (item.wCells || 3), row: midRow }
];
return [
{ col: item.col - 1, row: midRow },
{ col: item.col + (item.wCells || 1), row: midRow },
{ col: item.col + Math.floor((item.wCells || 1) / 2), row: item.row - 1 },
{ col: item.col + Math.floor((item.wCells || 1) / 2), row: item.row + (item.hCells || 1) }
];
}
if (item.entry) return [item.entry];
return [];
}
function adjacentWireLosses(game, cells, distances) {
let best = Infinity;
for (const cell of cells) {
for (const d of DIRS) {
const nk = key(cell.col + d.dc, cell.row + d.dr);
const distance = distances.get(nk);
if (distance != null) best = Math.min(best, distance);
}
const same = distances.get(key(cell.col, cell.row));
if (same != null) best = Math.min(best, same);
}
return Number.isFinite(best) ? best : null;
}
function propagateFromGenerator(game, generator) {
const distances = new Map();
const queue = [];
const push = (k, loss) => {
if (loss < 0 || loss >= currentGeneratorOutputV(generator)) return;
if (distances.has(k) && distances.get(k) <= loss) return;
distances.set(k, loss);
queue.push({ k, loss });
};
for (const cell of sourceCells(generator)) {
for (const d of DIRS) {
const nk = key(cell.col + d.dc, cell.row + d.dr);
if (game.powerWires?.has(nk)) push(nk, BALANCE.power.wires[game.powerWires.get(nk).kind]?.lossV || 0);
}
}
while (queue.length) {
queue.sort((a, b) => a.loss - b.loss);
const current = queue.shift();
if (distances.get(current.k) !== current.loss) continue;
const cell = parseKey(current.k);
for (const d of DIRS) {
const nk = key(cell.col + d.dc, cell.row + d.dr);
const wire = game.powerWires?.get(nk);
if (!wire) continue;
const step = BALANCE.power.wires[wire.kind]?.lossV ?? 0.5;
push(nk, current.loss + step);
}
}
return distances;
}
function poweredItems(game) {
const items = [];
for (const facility of Object.values(game.facilities || {})) if (powerSpecFor(facility)) items.push(facility);
for (const scanner of game.scanners || []) if (powerSpecFor(scanner)) items.push(scanner);
for (const room of game.maintenanceRooms || []) if (powerSpecFor(room)) items.push(room);
return items;
}
function generatorItems(game) {
return Object.values(game.facilities || {}).filter(generatorSpecFor);
}
function stableId(item) {
return String(item.id ?? `${item.type}:${item.col},${item.row}`);
}
function buildOrder(item) {
return item.builtSession == null ? -1 : item.builtSession;
}
export function recalculatePowerNetwork(game) {
if (!game) return { dirty: false, routes: new Map(), assignments: new Map(), sources: new Map(), underVoltage: new Set() };
const routesBySource = new Map();
const sources = generatorItems(game).map(generator => {
const distances = propagateFromGenerator(game, generator);
routesBySource.set(generator.id, distances);
return { generator, outputV: currentGeneratorOutputV(generator), admitted: [], busVoltage: currentGeneratorOutputV(generator) };
});
const candidates = [];
for (const item of poweredItems(game)) {
const spec = powerSpecFor(item);
const routesForItem = [];
for (const source of sources) {
const loss = adjacentWireLosses(game, contactCellsFor(item), routesBySource.get(source.generator.id));
if (loss == null) continue;
routesForItem.push({ source, loss, openCircuit: source.outputV - loss });
}
routesForItem.sort((a, b) => b.openCircuit - a.openCircuit || a.loss - b.loss || stableId(a.source.generator).localeCompare(stableId(b.source.generator)));
if (routesForItem[0]) candidates.push({ item, spec, routes: routesForItem });
}
candidates.sort((a, b) =>
(b.spec.powerPriority || 50) - (a.spec.powerPriority || 50)
|| a.routes[0].loss - b.routes[0].loss
|| buildOrder(a.item) - buildOrder(b.item)
|| stableId(a.item).localeCompare(stableId(b.item))
);
let assignments = new Map();
let previousSignature = '';
let stablePasses = 0;
const maxPasses = Math.max(1, BALANCE.power.maxAssignmentPasses || 8);
for (let pass = 0; pass < maxPasses; pass += 1) {
stablePasses = pass + 1;
for (const source of sources) {
source.admitted = [];
source.busVoltage = source.outputV;
}
const nextAssignments = new Map();
for (const candidate of candidates) {
for (const route of candidate.routes) {
const source = route.source;
const nextLoad = source.admitted.reduce((sum, entry) => sum + entry.spec.drawV, 0) + candidate.spec.drawV;
const nextBus = source.outputV - nextLoad;
const admittedRemainPowered = source.admitted.every(entry => nextBus - entry.loss >= entry.spec.minVoltage);
const candidatePowered = nextBus - route.loss >= candidate.spec.minVoltage;
if (!admittedRemainPowered || !candidatePowered) continue;
source.admitted.push({ item: candidate.item, spec: candidate.spec, loss: route.loss });
source.busVoltage = nextBus;
nextAssignments.set(stableId(candidate.item), {
sourceId: source.generator.id,
sourceName: source.generator.name || source.generator.shortName || source.generator.id,
sourceBusV: nextBus,
routeLossV: route.loss,
receivedV: nextBus - route.loss,
minVoltage: candidate.spec.minVoltage,
drawV: candidate.spec.drawV
});
break;
}
}
const signature = [...nextAssignments.entries()].map(([id, assignment]) => `${id}:${assignment.sourceId}:${assignment.receivedV.toFixed(3)}`).join('|');
assignments = nextAssignments;
if (signature === previousSignature) break;
previousSignature = signature;
}
const routes = new Map();
const sourceSummary = new Map();
for (const source of sources) {
sourceSummary.set(source.generator.id, {
outputV: source.outputV,
busVoltage: source.busVoltage,
admitted: source.admitted.length,
stablePasses
});
}
for (const [id, data] of assignments) routes.set(id, data);
const previousUnder = game.power?.underVoltage || new Set();
const underVoltage = new Set();
for (const item of poweredItems(game)) if (!assignments.has(stableId(item))) underVoltage.add(stableId(item));
const previousAssignments = game.power?.assignments || new Map();
let assignmentChanges = 0;
for (const [id, assignment] of assignments) {
if (previousAssignments.get(id)?.sourceId !== assignment.sourceId) assignmentChanges += 1;
}
if (game.phase === 'running' && game.stats) {
game.stats.generatorAssignmentChanges = (game.stats.generatorAssignmentChanges || 0) + assignmentChanges;
if (game.totals) game.totals.generatorAssignmentChanges = (game.totals.generatorAssignmentChanges || 0) + assignmentChanges;
}
game.power = { ...(game.power || {}), dirty: false, routes, assignments, sources: sourceSummary, underVoltage, previousUnder };
return game.power;
}
export function ensurePowerState(game) {
if (!game.power || game.power.dirty) recalculatePowerNetwork(game);
return game.power;
}
export function markPowerDirty(game) {
if (!game.power) game.power = {};
game.power.dirty = true;
}
export function updatePowerSystem(game, dt) {
let pulseChanged = false;
for (const generator of generatorItems(game)) {
if (game.stats) {
game.stats.maxGeneratorOutput = game.stats.maxGeneratorOutput || {};
const id = generator.id || facilityKind(generator);
game.stats.maxGeneratorOutput[id] = Math.max(game.stats.maxGeneratorOutput[id] || 0, currentGeneratorOutputV(generator));
}
if (!generator.fuelPulses?.length) continue;
for (const pulse of generator.fuelPulses) pulse.timeLeft -= dt;
const before = generator.fuelPulses.length;
generator.fuelPulses = generator.fuelPulses.filter(pulse => pulse.timeLeft > 0);
if (generator.fuelPulses.length !== before) pulseChanged = true;
}
if (pulseChanged) markPowerDirty(game);
if (!game.power) game.power = { dirty: true, elapsed: 0 };
game.power.elapsed = (game.power.elapsed || 0) + dt;
const interval = 1 / Math.max(1, BALANCE.power.recalcHz || 10);
if (game.power.dirty || game.power.elapsed >= interval) {
game.power.elapsed = 0;
recalculatePowerNetwork(game);
}
const powered = ensurePowerState(game);
if (game.phase === 'running') {
const count = powered.underVoltage?.size || 0;
if (count) {
game.stats.underVoltageSeconds = (game.stats.underVoltageSeconds || 0) + count * dt;
game.stats.underVoltageSecondsByMachine = game.stats.underVoltageSecondsByMachine || {};
for (const id of powered.underVoltage) {
game.stats.underVoltageSecondsByMachine[id] = (game.stats.underVoltageSecondsByMachine[id] || 0) + dt;
}
game.totals.underVoltageSeconds = (game.totals.underVoltageSeconds || 0) + count * dt;
}
}
}
export function receivedVoltageFor(game, equipment) {
return ensurePowerState(game).assignments.get(stableId(equipment))?.receivedV || 0;
}
export function isEquipmentPowered(game, equipment) {
if (!powerSpecFor(equipment)) return true;
return ensurePowerState(game).assignments.has(stableId(equipment));
}
export function powerDetailsFor(game, equipment) {
const spec = powerSpecFor(equipment);
if (!spec) return null;
return ensurePowerState(game).assignments.get(stableId(equipment)) || {
sourceName: 'None',
sourceBusV: 0,
routeLossV: 0,
receivedV: 0,
minVoltage: spec.minVoltage,
drawV: spec.drawV
};
}
export function powerSummary(game) {
const state = ensurePowerState(game);
const sources = [...(state.sources || new Map()).entries()].map(([id, data]) => `${id} ${data.outputV.toFixed(0)}V / ${data.admitted} powered`);
return `POWER: ${sources.join(' | ') || 'none'} / ${state.underVoltage?.size || 0} under-voltage`;
}
export function addFuelPulse(generator, itemType) {
const kind = facilityKind(generator);
const spec = BALANCE.power.generators[kind];
if (!spec) return false;
if (kind === 'biomassGenerator' && !['male', 'female', 'poop'].includes(itemType)) return false;
if (kind === 'coalGenerator' && itemType !== 'coal') return false;
generator.fuelPulses = generator.fuelPulses || [];
generator.fuelPulses.push({ voltage: spec.pulseVoltage, timeLeft: spec.pulseSeconds });
return true;
}

View file

@ -1,4 +1,4 @@
import { DIRS, GRID, MACHINE_FACILITY_IDS } from '../core/config.js';
import { DIRS, GRID, MACHINE_FACILITY_IDS, REQUIRED_RECEIVER_FACILITY_IDS } from '../core/config.js';
import { routeLabel } from '../core/text.js';
import { key, parseKey, inGrid, cellCenter, distance, sameCell } from '../core/utils.js';
@ -11,14 +11,12 @@ export function scannerFootprintCells(scanner) {
{ col: scanner.col, row: scanner.row - 1 },
{ col: scanner.col + 1, row: scanner.row - 1 },
{ col: scanner.col, row: scanner.row },
{ col: scanner.col + 1, row: scanner.row },
{ col: scanner.col, row: scanner.row + 1 },
{ col: scanner.col + 1, row: scanner.row + 1 }
{ col: scanner.col + 1, row: scanner.row }
];
}
export function scannerCenter(scanner) {
const c = cellCenter(scanner.col, scanner.row);
return { x: c.x + GRID.cell / 2, y: c.y };
return { x: c.x + GRID.cell / 2, y: c.y - GRID.cell / 2 };
}
export function farmAt(game, col, row) { return game.eggFarms.find(f => f.col === col && f.row === row) || null; }
export function scannerAt(game, col, row) { return game.scanners.find(s => scannerFootprintCells(s).some(p => p.col === col && p.row === row)) || null; }
@ -29,6 +27,52 @@ function facilityEntriesForKind(game, kind) {
return Object.entries(game.facilities || {}).filter(([, f]) => facilityKind(f) === kind);
}
function usableOwnedCell(game, p) {
return !!(p && inGrid(p.col, p.row) && (!game?.ownedCells?.size || game.ownedCells.has(key(p.col, p.row))));
}
export function gridFacilityAt(game, col, row) {
return Object.values(game?.facilities || {}).find(f => {
if (!f?.gridPlaced) return false;
return col >= f.col && col < f.col + (f.wCells || 3) && row >= f.row && row < f.row + (f.hCells || 3);
}) || null;
}
export function facilityInputCell(game, facility) {
const f = typeof facility === 'string' ? game?.facilities?.[facility] : facility;
if (!f) return null;
if (facilityKind(f) === 'sausageMaker' && f.gridPlaced) {
const p = { col: f.col - 1, row: f.row + Math.floor((f.hCells || 3) / 2) };
return usableOwnedCell(game, p) ? p : null;
}
return usableOwnedCell(game, f.entry) ? { ...f.entry } : null;
}
export function facilityOutputCell(game, facility) {
const f = typeof facility === 'string' ? game?.facilities?.[facility] : facility;
const kind = facilityKind(f);
if (!f || !['mixer', 'sausageMaker', 'inboundTruck'].includes(kind)) return null;
if (kind === 'sausageMaker' && f.gridPlaced) {
const p = { col: f.col + (f.wCells || 3), row: f.row + Math.floor((f.hCells || 3) / 2) };
return usableOwnedCell(game, p) ? p : null;
}
const input = facilityInputCell(game, f);
if (!input) return null;
const offsets = [2, -2, 3, -3, 1, -1];
for (const offset of offsets) {
const p = (f.side === 'left' || f.side === 'right')
? { col: input.col, row: input.row + offset }
: { col: input.col + offset, row: input.row };
if (usableOwnedCell(game, p)) return p;
}
return null;
}
export function facilityOutputHasConveyor(game, facility) {
const p = facilityOutputCell(game, facility);
return !!(p && game?.conveyorTiles?.has?.(key(p.col, p.row)));
}
// -----------------------------------------------------------------------------
// Port definitions. Ports prefer the exact connector cell, but also accept
// visually touching adjacent conveyor cells so drawn connections and routing match.
@ -73,7 +117,7 @@ function graphPortConveyorCells(game, point, blocked = [], preferred = []) {
export function scannerInputCells(game, scanner) {
const connector = scannerConnector(scanner, 'inputA');
if (!connector || !inGrid(connector.col, connector.row)) return [];
return game.conveyorTiles.has(key(connector.col, connector.row)) ? [{ ...connector, viaTolerance: false }] : [];
return graphPortConveyorCells(game, connector, scannerFootprintCells(scanner));
}
function outputStartCells(game, scanner, side) {
@ -84,7 +128,8 @@ function outputStartCells(game, scanner, side) {
export function facilityEntryPoint(game, dest) {
const f = game.facilities[dest] || facilityEntriesForKind(game, dest)[0]?.[1];
if (!f) return null;
if (f.entry) return cellCenter(f.entry.col, f.entry.row);
const input = facilityInputCell(game, f);
if (input) return cellCenter(input.col, input.row);
const kind = facilityKind(f) || dest;
if (kind === 'mixer') return { x: f.x + f.w, y: f.y + f.h * 0.52 };
if (kind === 'truck') return { x: f.x, y: f.y + f.h * 0.52 };
@ -103,6 +148,11 @@ export function refreshRoutingAfterEdit(game) {
game.routingVersion = (game.routingVersion || 0) + 1;
game.factoryGraph = null;
game.factoryGraphCommittedVersion = -1;
if (game.frameCache) {
game.frameCache.routingVersion = -1;
game.frameCache.congestionFrame = -1;
game.frameCache.draw = {};
}
}
// -----------------------------------------------------------------------------
@ -138,19 +188,15 @@ function inferredConveyorOutDirNames(game, cellKey) {
.filter(d => game.conveyorTiles.has(graphNodeKey(p.col + d.dc, p.row + d.dr)))
.map(d => d.name);
const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir));
const inferred = neighbors;
const dirs = explicit.length ? explicit : inferred;
const dirs = explicit.length ? explicit : neighbors;
if (neighbors.length === 1 && (explicit.length ? explicit.includes(neighbors[0]) : true) && neighborPointsIntoCell(game, p, neighbors[0])) return [];
if (neighbors.length < 3) return dirs;
const entrances = new Set(neighbors.filter(dir => neighborPointsIntoCell(game, p, dir)));
if (meta.branchMode && meta.branchMode !== 'random' && neighbors.includes(meta.branchMode)) {
const selectedEntrances = new Set(entrances);
selectedEntrances.add(meta.branchMode);
if (neighbors.filter(dir => !selectedEntrances.has(dir)).length >= 2) entrances.add(meta.branchMode);
}
const exits = neighbors.filter(dir => !entrances.has(dir));
if (exits.length) return exits;
const fallback = dirs.filter(dir => !entrances.has(dir));
return fallback.length ? fallback : dirs;
const physicalExits = neighbors.filter(dir => !entrances.has(dir));
let candidates = entrances.size ? physicalExits : [...new Set(dirs)];
if (!candidates.length && !entrances.size) candidates = neighbors;
if (meta.branchMode && meta.branchMode !== 'random' && candidates.length >= 2 && candidates.includes(meta.branchMode)) return [meta.branchMode];
return candidates;
}
function emptyFactoryGraph(game) {
@ -242,10 +288,8 @@ function indexGraphPorts(game, graph) {
const ports = {};
for (const type of ['inputA', 'left', 'right']) {
const cell = scannerConnector(scanner, type);
const cells = type === 'inputA'
? (cell && graph.cells.has(graphNodeKey(cell.col, cell.row)) ? [{ ...cell }] : [])
: graphPortConveyorCells(game, cell, scannerFootprintCells(scanner))
.filter(p => graph.cells.has(graphNodeKey(p.col, p.row)));
const cells = graphPortConveyorCells(game, cell, scannerFootprintCells(scanner))
.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 };
@ -257,11 +301,12 @@ function indexGraphPorts(game, graph) {
}
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 input = facilityInputCell(game, f);
if (!input) continue;
const cells = graphPortConveyorCells(game, input).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.facilityPorts.set(id, { id, cell: { ...input }, cells, keys: cellKeys, key: cellKeys[0] || null, connected });
graph.metrics.facilityPorts += 1;
if (connected) graph.metrics.connectedFacilityPorts += 1;
}
@ -463,7 +508,12 @@ function candidateCongestionScore(game, candidate) {
const cells = candidate.cells || [];
let score = 0;
const limit = Math.min(cells.length, 8);
for (let i = 0; i < limit; i += 1) score += chickCellOccupancyScore(game, cells[i], i);
for (let i = 0; i < limit; i += 1) {
const cell = cells[i];
const compId = game.componentLookup?.get?.(graphNodeKey(cell.col, cell.row));
const ratio = compId ? (game.congestion?.get?.(compId)?.ratio || 0) : 0;
score += chickCellOccupancyScore(game, cell, i) + ratio * 25;
}
return score;
}
@ -483,8 +533,9 @@ export function chooseRoundRobin(game, id, candidates, advance = false) {
if (!advance || sorted.length === 1) return sorted[0];
const bestBias = sorted[0].roleBias ?? 0;
let pool = sorted.filter(item => (item.roleBias ?? 0) === bestBias);
const bestScore = Math.min(...pool.map(item => candidateCongestionScore(game, item)));
pool = pool.filter(item => candidateCongestionScore(game, item) === bestScore);
const scored = pool.map(item => ({ item, score: candidateCongestionScore(game, item) }));
const bestScore = Math.min(...scored.map(entry => entry.score));
pool = scored.filter(entry => entry.score === bestScore).map(entry => entry.item);
const n = game.branchCounters.get(id) || 0;
game.branchCounters.set(id, n + 1);
return pool[Math.floor(Math.random() * pool.length)];
@ -508,7 +559,7 @@ export function autoSideFor(scanner, chick) {
export function destinationLabel(dest) { return routeLabel(dest); }
export function destinationColor(dest) {
return { mixer: '#2477ff', truck: '#ff6aa8', trash: '#22b94f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
return { mixer: '#2477ff', truck: '#ff6aa8', inboundTruck: '#8bd0ff', trash: '#22b94f', sausageMaker: '#d78b3f', biomassGenerator: '#7bd84f', coalGenerator: '#d0d5dc', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
}
export function routeFromFarmToScanner(game, farm, advance = false) {
@ -597,7 +648,7 @@ function outputRouteReachesFacility(game, scanner, visited = new Set()) {
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 (['mixer', 'trash', 'truck', 'sausageMaker'].includes(plan.destination)) return true;
if (plan.destination === 'scanner') {
const next = scannerById(game, plan.nextScannerId);
if (outputRouteReachesFacility(game, next, visited)) return true;
@ -659,7 +710,7 @@ export function minimumStartConnectionIssues(game) {
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.'];
return ['No route from an EGG scanner to a receiver.'];
}
function scannerName(scanner) {
@ -670,12 +721,12 @@ function scannerName(scanner) {
export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), options = {}) {
const includeRoutes = options.includeRoutes !== false;
const issues = [];
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck' };
const labels = { mixer: 'Mixer', trash: 'Shredder', truck: 'Truck', sausageMaker: 'Sausage Machine' };
for (const id of MACHINE_FACILITY_IDS) {
const facilities = facilityEntriesForKind(game, id);
if (!facilities.length) {
issues.push(`${labels[id] || id} is missing`);
if (REQUIRED_RECEIVER_FACILITY_IDS.includes(id)) issues.push(`${labels[id] || id} is missing`);
continue;
}
for (const [facilityId] of facilities) {
@ -711,7 +762,3 @@ export function validateFactoryGraph(game, graph = ensureFactoryGraph(game), opt
export function facilityConnectionIssues(game) {
return minimumStartConnectionIssues(game);
}
export function factoryReady(game) {
return facilityConnectionIssues(game).length === 0;
}

View file

@ -1,15 +1,17 @@
import { GRID } from '../core/config.js';
import { DIRS, GRID } from '../core/config.js';
import { key, parseKey, cellCenter } from '../core/utils.js';
import { nearestGridEdge, layoutFacilityOnEdge } from '../core/entities.js';
import { farmAt, scannerAt, scannerCenter, scannerFootprintCells, refreshRoutingAfterEdit } from './routing.js';
import { nearestGridEdge, layoutFacilityOnEdge, maintenanceRoomFootprint, gridFacilityFootprintCells, syncGridFacilityGeometry } from '../core/entities.js';
import { farmAt, scannerAt, scannerCenter, scannerConnector, scannerFootprintCells, gridFacilityAt, facilityInputCell, facilityOutputCell, refreshRoutingAfterEdit } from './routing.js';
import { snapshot } from './history.js';
import { buildPrice } from './economy.js';
import { markPowerDirty } from './power.js';
export function createSelectionSystem({ game, canvasPoint, updatePanels, equipmentAtPoint, fail, pointInGrid, rectOfFacility, rectsOverlap }) {
function selectionToken(item) {
if (!item) return '';
if (item.type === 'conveyor') return `conveyor:${item.id || item.oldKey}`;
if (item.type === 'facility') return `facility:${item.id || item.ref?.id}`;
if (item.type === 'maintenanceRoom') return `maintenanceRoom:${item.id || item.ref?.id}`;
return `${item.type}:${item.id || item.ref?.id}`;
}
@ -29,7 +31,10 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (sel.type === 'eggFarm') return game.eggFarms.find(f => f.id === sel.id) || null;
if (sel.type === 'scanner') return game.scanners.find(sc => sc.id === sel.id) || null;
if (sel.type === 'facility') return game.facilities[sel.id] || null;
if (sel.type === 'maintenanceRoom') return (game.maintenanceRooms || []).find(r => r.id === sel.id) || null;
if (sel.type === 'conveyor') return game.conveyorTiles.has(sel.id) ? { type: 'conveyor', id: sel.id } : null;
if (sel.type === 'powerWire') return game.powerWires?.has(sel.id) ? { type: 'powerWire', id: sel.id } : null;
if (sel.type === 'footbridge') return game.footbridges?.has(sel.id) ? { type: 'footbridge', id: sel.id } : null;
return null;
}
@ -40,8 +45,22 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
const c = cellCenter(p.col, p.row);
items.push({ type: 'conveyor', id: k, center: c, cell: p });
}
if (game.powerOverlay) {
for (const [k] of game.powerWires || new Map()) {
const p = parseKey(k);
items.push({ type: 'powerWire', id: k, center: cellCenter(p.col, p.row), cell: p });
}
}
for (const [k] of game.footbridges || new Map()) {
const p = parseKey(k);
items.push({ type: 'footbridge', id: k, center: cellCenter(p.col, p.row), cell: p });
}
for (const farm of game.eggFarms) items.push({ type: 'eggFarm', id: farm.id, ref: farm, center: cellCenter(farm.col, farm.row), cell: { col: farm.col, row: farm.row } });
for (const scanner of game.scanners) items.push({ type: 'scanner', id: scanner.id, ref: scanner, center: scannerCenter(scanner), cell: { col: scanner.col, row: scanner.row } });
for (const room of game.maintenanceRooms || []) {
const c = cellCenter(room.col, room.row);
items.push({ type: 'maintenanceRoom', id: room.id, ref: room, center: { x: c.x + GRID.cell / 2, y: c.y + GRID.cell / 2 }, cell: { col: room.col, row: room.row } });
}
for (const f of Object.values(game.facilities)) {
const bodyCenter = { x: f.x + f.w / 2, y: f.y + f.h / 2 };
const portCenter = f.entry ? cellCenter(f.entry.col, f.entry.row) : bodyCenter;
@ -117,8 +136,26 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
const cell = parseKey(sel.id);
return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: game.conveyorMeta.get(sel.id) || { price: buildPrice('conveyor'), builtSession: null }, col: cell.col, row: cell.row };
}
if (sel.type === 'eggFarm' || sel.type === 'scanner') return { ...sel, obj, col: obj.col, row: obj.row };
if (sel.type === 'facility') return { ...sel, obj, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } };
if (sel.type === 'powerWire') {
const cell = parseKey(sel.id);
return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: { ...(game.powerWires?.get(sel.id) || {}) }, col: cell.col, row: cell.row };
}
if (sel.type === 'footbridge') {
const cell = parseKey(sel.id);
const meta = game.footbridges?.get(sel.id) || {};
const cells = [];
for (const [k, bridge] of game.footbridges || new Map()) {
if ((meta.bridgeId && bridge.bridgeId === meta.bridgeId) || k === sel.id) {
const part = parseKey(k);
cells.push({ key: k, dc: part.col - cell.col, dr: part.row - cell.row, meta: { ...bridge } });
}
}
return { ...sel, obj, oldKey: sel.id, currentKey: sel.id, meta: { ...meta }, cells, dragToken: `footbridge:${meta.bridgeId || sel.id}`, col: cell.col, row: cell.row };
}
if (sel.type === 'eggFarm' || sel.type === 'scanner' || sel.type === 'maintenanceRoom') return { ...sel, obj, col: obj.col, row: obj.row };
if (sel.type === 'facility') return obj.gridPlaced
? { ...sel, obj, gridPlaced: true, col: obj.col, row: obj.row, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } }
: { ...sel, obj, gridPlaced: false, x: obj.x, y: obj.y, w: obj.w, h: obj.h, entry: obj.entry ? { ...obj.entry } : null, side: obj.side, center: { x: obj.x + obj.w / 2, y: obj.y + obj.h / 2 } };
return null;
}
@ -127,7 +164,14 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
const hit = equipmentAtPoint(p);
if (!hit) return false;
const selections = selectedSetForDrag(hit);
const origins = selections.map(selectionOrigin).filter(Boolean);
const origins = [];
const seenOrigins = new Set();
for (const origin of selections.map(selectionOrigin).filter(Boolean)) {
const token = origin.dragToken || selectionToken(origin);
if (seenOrigins.has(token)) continue;
seenOrigins.add(token);
origins.push(origin);
}
if (!origins.length) return false;
game.groupDrag = { start: p, selections, origins, historySnapshot: snapshot(game), committed: false, lastDCol: 0, lastDRow: 0 };
game.multiSelected = selections;
@ -149,16 +193,63 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
if (farm && !selectedTokens.has(`eggFarm:${farm.id}`)) return true;
const scanner = scannerAt(game, col, row);
if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true;
const room = (game.maintenanceRooms || []).find(item => maintenanceRoomFootprint(item).some(cell => cell.col === col && cell.row === row));
if (room && !selectedTokens.has(`maintenanceRoom:${room.id}`)) return true;
const gridFacility = gridFacilityAt(game, col, row);
if (gridFacility && !selectedTokens.has(`facility:${gridFacility.id}`)) return true;
if (game.conveyorTiles.has(k) && !selectedTokens.has(`conveyor:${k}`)) return true;
return false;
}
function facilityPortOccupiedByNonSelected(col, row, selectedTokens) {
for (const facility of Object.values(game.facilities || {})) {
if (selectedTokens.has(`facility:${facility.id}`)) continue;
const input = facilityInputCell(game, facility);
const output = facilityOutputCell(game, facility);
if ((input && input.col === col && input.row === row) || (output && output.col === col && output.row === row)) return true;
}
return false;
}
function gridFacilityPortOccupiedByNonSelected(facility, col, row, selectedTokens) {
const farm = farmAt(game, col, row);
if (farm && !selectedTokens.has(`eggFarm:${farm.id}`)) return true;
const scanner = scannerAt(game, col, row);
if (scanner && !selectedTokens.has(`scanner:${scanner.id}`)) return true;
const room = (game.maintenanceRooms || []).find(item => maintenanceRoomFootprint(item).some(cell => cell.col === col && cell.row === row));
if (room && !selectedTokens.has(`maintenanceRoom:${room.id}`)) return true;
const gridFacility = gridFacilityAt(game, col, row);
if (gridFacility && gridFacility.id !== facility.id && !selectedTokens.has(`facility:${gridFacility.id}`)) return true;
for (const other of Object.values(game.facilities || {})) {
if (other.id === facility.id || selectedTokens.has(`facility:${other.id}`)) continue;
const input = facilityInputCell(game, other);
const output = facilityOutputCell(game, other);
if ((input && input.col === col && input.row === row) || (output && output.col === col && output.row === row)) return true;
}
return false;
}
function overlayTargetBlocked(origin, col, row, selectedTokens) {
const k = key(col, row);
if (!pointInGrid(col, row) || game.blockedCells?.has?.(k)) return 'Selection hits blocked ground';
if (farmAt(game, col, row) || scannerAt(game, col, row)) return 'Cell occupied';
const room = (game.maintenanceRooms || []).find(item => maintenanceRoomFootprint(item).some(cell => cell.col === col && cell.row === row));
if (room && !selectedTokens.has(`maintenanceRoom:${room.id}`)) return 'Cell occupied';
const gridFacility = gridFacilityAt(game, col, row);
if (gridFacility && !selectedTokens.has(`facility:${gridFacility.id}`)) return 'Cell occupied';
const originOwnsCell = origin.cells?.some(part => part.key === k);
if (origin.type === 'powerWire' && game.powerWires?.has(k) && !selectedTokens.has(`powerWire:${k}`)) return 'Power wire already covers that cell';
if (origin.type === 'footbridge' && game.footbridges?.has(k) && !selectedTokens.has(`footbridge:${k}`) && !originOwnsCell) return 'Footbridge already covers that cell';
return '';
}
function facilityDragWouldOverlap(dx, dy, selectedTokens) {
const candidateRects = [];
for (const origin of game.groupDrag.origins) {
if (origin.type !== 'facility') continue;
if (origin.type !== 'facility' || origin.gridPlaced) continue;
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
const { entry, side } = nearestGridEdge(nextCenter);
const { entry, side } = nearestGridEdge(nextCenter, game);
if (!pointInGrid(entry.col, entry.row)) return true;
if (game.blockedCells?.has?.(key(entry.col, entry.row))) return true;
const draft = { ...origin.obj, entry: { ...entry }, side };
@ -179,6 +270,27 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
return false;
}
function repairScannerOutputDirections(scanner) {
const body = new Set(scannerFootprintCells(scanner).map(cell => key(cell.col, cell.row)));
for (const side of ['left', 'right']) {
const port = scannerConnector(scanner, side);
const portKey = key(port.col, port.row);
if (!game.conveyorTiles.has(portKey)) continue;
const meta = game.conveyorMeta.get(portKey);
if (!meta) continue;
const preferred = DIRS.find(d => d.name === side);
const candidates = DIRS.filter(d => {
const nk = key(port.col + d.dc, port.row + d.dr);
return game.conveyorTiles.has(nk) && !body.has(nk);
});
const chosen = candidates.find(d => d.name === preferred?.name) || candidates[0] || preferred;
if (!chosen) continue;
meta.dir = chosen.name;
meta.outDirs = [chosen.name];
meta.branchMode = 'random';
}
}
function updateGroupDrag(event) {
if (!game.groupDrag) return;
const p = canvasPoint(event);
@ -190,17 +302,44 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
const selectedTokens = new Set(game.groupDrag.selections.map(selectionToken));
const targetCells = new Set();
for (const origin of game.groupDrag.origins) {
if (origin.type === 'facility') continue;
if (origin.type === 'facility' && !origin.gridPlaced) continue;
const col = origin.col + dcol, row = origin.row + drow;
const cells = origin.type === 'scanner' ? scannerFootprintCells({ col, row }) : [{ col, row }];
if (origin.type === 'powerWire' || origin.type === 'footbridge') {
const parts = origin.type === 'footbridge' && origin.cells?.length
? origin.cells.map(part => ({ col: col + part.dc, row: row + part.dr }))
: [{ col, row }];
for (const part of parts) {
const reason = overlayTargetBlocked(origin, part.col, part.row, selectedTokens);
if (reason) return fail(reason);
const tk = `${origin.type}:${key(part.col, part.row)}`;
if (targetCells.has(tk)) return fail('Selection overlap');
targetCells.add(tk);
}
continue;
}
const cells = origin.type === 'scanner'
? scannerFootprintCells({ col, row })
: (origin.type === 'maintenanceRoom'
? maintenanceRoomFootprint({ col, row })
: (origin.type === 'facility' ? gridFacilityFootprintCells({ ...origin.obj, col, row }) : [{ col, row }]));
for (const cell of cells) {
if (!pointInGrid(cell.col, cell.row)) return fail('Selection outside grid');
if (game.blockedCells?.has?.(key(cell.col, cell.row))) return fail('Selection hits blocked ground');
const tk = key(cell.col, cell.row);
if (targetCells.has(tk)) return fail('Selection overlap');
if (cellOccupiedByNonSelected(cell.col, cell.row, selectedTokens)) return fail('Cell occupied');
if (origin.type !== 'conveyor' && facilityPortOccupiedByNonSelected(cell.col, cell.row, selectedTokens)) return fail('Machine port must remain open');
targetCells.add(tk);
}
if (origin.type === 'facility' && origin.gridPlaced) {
const draft = { ...origin.obj, col, row };
const input = { col: col - 1, row: row + Math.floor((draft.hCells || 3) / 2) };
const output = { col: col + (draft.wCells || 3), row: row + Math.floor((draft.hCells || 3) / 2) };
for (const port of [input, output]) {
if (!pointInGrid(port.col, port.row) || game.blockedCells?.has?.(key(port.col, port.row))) return fail('Machine port outside usable grid');
if (gridFacilityPortOccupiedByNonSelected(origin.obj, port.col, port.row, selectedTokens)) return fail('Machine port is occupied');
}
}
}
if (facilityDragWouldOverlap(dx, dy, selectedTokens)) return fail('Facility overlap');
commitGroupDragHistory();
@ -217,6 +356,25 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
game.conveyorMeta.set(newK, o.meta || { price: buildPrice('conveyor'), builtSession: null, uses: 0 });
o.currentKey = newK;
}
const wireOrigins = game.groupDrag.origins.filter(o => o.type === 'powerWire');
for (const o of wireOrigins) game.powerWires.delete(o.currentKey || o.oldKey);
for (const o of wireOrigins) {
const newK = key(o.col + dcol, o.row + drow);
game.powerWires.set(newK, o.meta || {});
o.currentKey = newK;
}
const footbridgeOrigins = game.groupDrag.origins.filter(o => o.type === 'footbridge');
for (const o of footbridgeOrigins) {
for (const part of o.cells || [{ key: o.currentKey || o.oldKey }]) game.footbridges.delete(part.key);
}
for (const o of footbridgeOrigins) {
const newK = key(o.col + dcol, o.row + drow);
for (const part of o.cells || [{ dc: 0, dr: 0, meta: o.meta || {} }]) {
const partKey = key(o.col + dcol + (part.dc || 0), o.row + drow + (part.dr || 0));
game.footbridges.set(partKey, { ...(part.meta || o.meta || {}) });
}
o.currentKey = newK;
}
for (const origin of game.groupDrag.origins) {
if (origin.type === 'eggFarm') {
origin.obj.col = origin.col + dcol;
@ -224,16 +382,26 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
} else if (origin.type === 'scanner') {
origin.obj.col = origin.col + dcol;
origin.obj.row = origin.row + drow;
repairScannerOutputDirections(origin.obj);
} else if (origin.type === 'maintenanceRoom') {
origin.obj.col = origin.col + dcol;
origin.obj.row = origin.row + drow;
} else if (origin.type === 'facility' && origin.gridPlaced) {
origin.obj.col = origin.col + dcol;
origin.obj.row = origin.row + drow;
syncGridFacilityGeometry(origin.obj);
} else if (origin.type === 'facility') {
const nextCenter = { x: origin.center.x + dx, y: origin.center.y + dy };
const { entry, side } = nearestGridEdge(nextCenter);
const { entry, side } = nearestGridEdge(nextCenter, game);
layoutFacilityOnEdge(origin.obj, entry, side);
}
}
game.groupDrag.selections = game.groupDrag.origins.map(o => o.type === 'conveyor' ? { type: 'conveyor', id: o.currentKey || o.oldKey } : { type: o.type, id: o.id });
game.groupDrag.selections = game.groupDrag.origins.map(o => ['conveyor', 'powerWire', 'footbridge'].includes(o.type) ? { type: o.type, id: o.currentKey || o.oldKey } : { type: o.type, id: o.id });
game.multiSelected = [...game.groupDrag.selections];
game.selected = game.multiSelected[0] || null;
refreshRoutingAfterEdit(game);
if (game.groupDrag.origins.some(origin => origin.type !== 'powerWire')) game.walkabilityVersion = (game.walkabilityVersion || 0) + 1;
if (game.groupDrag.origins.some(origin => origin.type === 'powerWire' || (origin.type !== 'conveyor' && origin.type !== 'footbridge'))) markPowerDirty(game);
updatePanels();
}

View file

@ -1,11 +1,11 @@
import { BUILD_TOOL_IDS, CONVEYOR_SPEED_MAX, CONTRACT_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js';
import { BUILD_TOOL_IDS, CONVEYOR_SPEED_MAX, DAILY_EVENT_FIRST_TURN, FACILITY_DEFS, MACHINE_FACILITY_IDS, STARTING_CASH } from '../core/config.js';
import { yen } from '../core/utils.js';
import { TEXT } from '../core/text.js';
import { routeFromFarmToScanner, factoryReady, facilityConnectionIssues } from './routing.js';
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty, zundaTaxInfo } from './economy.js';
import { truckTarget } from './contracts.js';
import { routeFromFarmToScanner, facilityConnectionIssues } from './routing.js';
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js';
import { conveyorSpeedForGame } from './cards.js';
import { maintenanceSummary, repairmanDailyCost } from './maintenance.js';
import { maintenanceSummary } from './maintenance.js';
import { powerSummary } from './power.js';
export function createUISystem({ game, ui, build, startGame, beginCardDraft, activeQueuedChick }) {
function escapeHtml(value) {
@ -27,7 +27,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function updatePanels() {
updateToolButtons();
updateBuildStatus();
updateContractPanel();
updateTurnSummary();
updateHistoryButtons();
}
@ -37,6 +36,8 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
if (!btn?.classList?.contains('tool-button')) continue;
btn.classList.toggle('active', game.buildTool === name);
}
ui.buttons.powerOverlay?.classList.toggle('active', !!game.powerOverlay);
if (ui.buttons.powerOverlay) ui.buttons.powerOverlay.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
for (const id of BUILD_TOOL_IDS) {
const btn = ui.buttons[id];
if (!btn) continue;
@ -56,15 +57,6 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
: '';
}
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 2% durability per second.`;
}
const cashout = noAffordableBuildTools();
ui.buttons.nextTurn?.classList.toggle('cashout-emphasis', cashout);
ui.buttons.erase?.classList.toggle('cashout-emphasis', cashout);
@ -89,53 +81,15 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function updateTurnSummary() {
const connected = game.eggFarms.filter(f => routeFromFarmToScanner(game, f)?.scannerId).length;
const issues = facilityConnectionIssues(game);
const zundaBasisProfit = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const tax = zundaTaxInfo(zundaBasisProfit, game);
const tributeDay = game.phase === 'running' ? Math.max(1, (game.totals.turnsCompleted || 0) + 1) : Math.max(1, Number(game.turn) || 1);
const tribute = fairiesTributeInfo(game, tributeDay);
const lastTribute = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.fairiesTribute || 0 : null;
const lastTributeReduction = game.lastBuildFees?.turn === tributeDay ? game.lastBuildFees.reduction || 0 : 0;
ui.turnSummary.innerHTML = [
`Seed: <b>${escapeHtml(game.runSeed || 'none')}</b> | Grid box: <b>${game.gridRows || 10}x${game.gridCols || 20}</b> | Owned: <b>${game.gridOwnedCells || game.ownedCells?.size || ((game.gridRows || 10) * (game.gridCols || 20))}</b> cells | Blocked: <b>${game.blockedCells?.size || 0}</b> cells`,
`Truck target: <b>${truckTarget(game).toUpperCase()}</b> | Male fine: <b>-${yen(maleTruckPenalty(game))}</b>`,
`Poop fine: <b>Mixer -${yen(mixerPoopPenalty(game))}</b> / <b>Shipment -${yen(truckPoopPenalty(game))}</b>`,
`ZUNDA TAX on Next Day: <b>-${yen(tax.tax)}</b> | basis net ${yen(zundaBasisProfit)} | taxable ${yen(tax.taxable)} x${tax.ratePercent}% | exemption ${yen(tax.exemption)} | 95% at ${yen(tax.maxRateCash)}`,
lastTribute != null
? `Fairies tribute paid before Build: <b>-${yen(lastTribute)}</b>${lastTributeReduction ? ` | Flattery -${yen(lastTributeReduction)}` : ''}`
: `Fairies tribute before Build: <b>-${yen(tribute.amount)}</b>${tribute.reduction ? ` | Flattery -${yen(tribute.reduction)}` : ''}`,
`Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Repairman: <b>${game.repairman?.hiredForNextDay ? 'hired next day' : game.repairman?.active ? 'working' : 'none'}</b>`,
`Manual combo: <b>${game.manualCombo?.count || 0}</b> | Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</b> | Belt: <b>${displaySpeed()}px/s</b> | Farms with scanner route: <b>${connected}/${game.eggFarms.length}</b>`,
`Penalties: <b>wrong shipment -${yen(maleTruckPenalty(game))}</b> | <b>Mixer poop -${yen(mixerPoopPenalty(game))}</b> | <b>Shipment poop -${yen(truckPoopPenalty(game))}</b>`,
powerSummary(game),
`Maintenance: <b>worst ${maintenanceSummary(game).worst.percent}% dirty (${maintenanceSummary(game).worst.label})</b> | Rooms: <b>${(game.maintenanceRooms || []).length}</b> | Repairmen: <b>${(game.repairmen || []).length}</b>`,
`Card draft: <b>${game.cardDraft?.pending ? Math.max(1, game.cardDraft.picksRemaining || 1) + ' pick(s) left' : 'ready after each day'}</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;
if (game.phase !== 'build') {
ui.contractPanel.innerHTML = TEXT.status.eventPanelRunning;
return;
}
if (!offer) {
ui.contractPanel.className = 'contract-card empty';
const nextTurn = game.turn + 1;
if (nextTurn < CONTRACT_EVENT_FIRST_TURN) {
ui.contractPanel.innerHTML = `<strong>Event lock active.</strong><span>No irregular events during Days 1-7. First possible forced event: Day ${CONTRACT_EVENT_FIRST_TURN}.</span>`;
} else {
ui.contractPanel.innerHTML = '<strong>No event.</strong><span>Random forced events can appear later.</span>';
}
return;
}
ui.contractPanel.className = 'contract-card accepted';
ui.contractPanel.innerHTML = `
<div class="contract-status">FORCED NEXT DAY</div>
<h3>${offer.title}</h3>
<div class="contract-metrics">
<span>Route: ${offer.route}</span>
<span>Target: ${offer.targetAmount} truck exports</span>
<span>Reward: ${yen(offer.reward)}</span>
<span>Output x${offer.productionMultiplier} / Poop x${offer.poopMultiplier}</span>
</div>`;
].filter(Boolean).join('<br>');
}
function updateUI() {
updateToolButtons();
@ -176,27 +130,35 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.buttons.s2Left.textContent = `S2 ${s2.keys?.left?.label || 'Left'} -> Waste`;
ui.buttons.s2Right.textContent = `S2 ${s2.keys?.right?.label || 'Right'} -> Truck`;
}
const nextTax = zundaTaxInfo(Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0)), game).tax;
ui.buttons.nextTurn.textContent = game.phase === 'build' && nextTax > 0 ? `Next Day - ZUNDA ${yen(nextTax)}` : TEXT.actions.nextDay;
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
const nextTributeBasis = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const nextTribute = fairiesTributeInfo(game, Math.max(1, Number(game.turn) || 1), nextTributeBasis).amount;
const nextTurnHtml = game.phase === 'build' && nextTribute > 0
? `<span class="next-day-main">NEXT DAY</span><small class="next-day-fee">FAIRIES -${yen(nextTribute)}</small>`
: `<span class="next-day-main">${TEXT.actions.nextDay}</span>`;
// Replacing a pressed button's child nodes every animation frame can cancel
// the browser's pointerdown -> pointerup -> click sequence. Render only when
// the label actually changes, so one deliberate press always reaches the handler.
if (ui.buttons.nextTurn.dataset.renderedHtml !== nextTurnHtml) {
ui.buttons.nextTurn.innerHTML = nextTurnHtml;
ui.buttons.nextTurn.dataset.renderedHtml = nextTurnHtml;
}
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending;
}
function updatePriorityStrip() {
if (!ui.eventBrief || !ui.targetBrief || !ui.speedBrief) return;
const target = truckTarget(game).toUpperCase();
ui.targetBrief.textContent = `TRUCK TARGET: ${target}`;
ui.speedBrief.textContent = `BELT: ${displaySpeed()} px/s | COMBO: ${game.manualCombo?.count || 0}`;
if (!ui.eventBrief || !ui.speedBrief) return;
ui.speedBrief.textContent = `BELT SPEED: ${displaySpeed()} 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;
const active = game.eventActive;
const offer = game.eventOffer;
if (active) {
ui.eventBrief.textContent = `ACTIVE EVENT: ${active.route} | Output x${active.productionMultiplier} | Poop x${active.poopMultiplier}`;
ui.eventBrief.textContent = `ACTIVE EVENT: ${active.title} | ${active.route}`;
ui.eventBrief.className = 'event-brief active';
} else if (game.phase === 'build' && offer) {
ui.eventBrief.textContent = `NEXT DAY EVENT: ${offer.route} | Target ${offer.targetAmount} | Reward ${yen(offer.reward)}`;
ui.eventBrief.textContent = `NEXT DAY EVENT: ${offer.title} | ${offer.route}${offer.targetAmount ? ` | Target ${offer.targetAmount}` : ''}${offer.reward ? ` | Reward ${yen(offer.reward)}` : ''}`;
ui.eventBrief.className = 'event-brief next';
} else if (game.phase === 'build' && game.turn + 1 < CONTRACT_EVENT_FIRST_TURN) {
ui.eventBrief.textContent = `NO EVENTS UNTIL DAY ${CONTRACT_EVENT_FIRST_TURN}`;
} else if (game.phase === 'build' && game.turn + 1 < DAILY_EVENT_FIRST_TURN) {
ui.eventBrief.textContent = `NO EVENTS UNTIL DAY ${DAILY_EVENT_FIRST_TURN}`;
ui.eventBrief.className = 'event-brief locked';
} else {
ui.eventBrief.textContent = 'NO ACTIVE EVENT';
@ -214,7 +176,7 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
function showTitle() {
ui.modalTitle.textContent = TEXT.versionTitle;
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.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 -> Meat</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', 'gameover-modal');
@ -226,22 +188,33 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
const negative = value => `<span class="${value > 0 ? 'cash-negative' : ''}">${value > 0 ? '-' : ''}${yen(value)}</span>`;
const netClass = r.profit >= 0 ? 'cash-positive' : 'cash-negative';
const penalties = (r.mixerPoopFine || 0) + (r.truckPoopFine || 0) + (r.maleTruckFine || 0) + (r.explosionDamage || 0) + (r.cardRerollCost || 0);
const avgQuality = r.qualityMultiplierCount ? (r.qualityMultiplierSum || 0) / r.qualityMultiplierCount : 1;
const qualityRevenue = r.qualityRevenue || 0;
const qualityClass = qualityRevenue > 0 ? 'cash-positive' : '';
const maxGeneratorOutput = Object.values(r.maxGeneratorOutput || {}).reduce((max, value) => Math.max(max, Number(value) || 0), 0);
ui.modalTitle.textContent = `Day ${r.turn} Settlement`;
ui.modalBody.innerHTML = `
<div class="settlement-net ${netClass}"><span>Net result</span><strong>${yen(r.profit)}</strong></div>
<div class="result-grid settlement-grid">
<div><strong>Chick shipment income</strong>${positive(r.chickShipmentIncome || 0)}</div>
<div><strong>Poop shipment income</strong>${positive(r.poopShipmentIncome || 0)}</div>
<div><strong>Mixer income</strong>${positive(r.mixerRevenue || 0)}</div>
<div><strong>Sausage shipment income</strong>${positive(r.sausageRevenue || 0)}</div>
<div><strong>Meat / sausages</strong><span>${r.meatProduced || 0} meat -> ${r.sausageProduced || 0} made / ${r.sausageShipped || 0} shipped</span></div>
<div><strong>Imports</strong><span>coal ${r.coalReleased || 0}/${r.coalOrdered || 0}, zunda ${r.zundaReleased || 0}/${r.zundaOrdered || 0}</span></div>
<div><strong>Import use</strong><span>coal fuel ${r.coalConsumed || 0}, zunda used ${r.zundaConsumed || 0}, undelivered ${r.coalUndelivered || 0}/${r.zundaUndelivered || 0}</span></div>
<div><strong>Import cost</strong>${negative(r.importPurchaseCost || 0)}</div>
<div><strong>Generator fuel</strong><span>biomass ${r.biomassFuelConsumed || 0}, max output ${maxGeneratorOutput.toFixed(0)}V</span></div>
<div><strong>Quality revenue</strong><span><b class="${qualityClass}">${qualityRevenue > 0 ? '+' : ''}${yen(qualityRevenue)}</b> avg x${avgQuality.toFixed(2)} / max x${(r.maxQualityMultiplier || 1).toFixed(2)}</span></div>
<div><strong>Zunda boost</strong><span>${(r.zundaBoostSeconds || 0).toFixed(1)} farm-seconds</span></div>
<div><strong>Power trouble</strong><span>${(r.underVoltageSeconds || 0).toFixed(1)} machine-seconds / ${r.generatorAssignmentChanges || 0} source changes</span></div>
<div><strong>Repair access</strong><span>${r.unreachableRepairTargets || 0} unreachable target checks</span></div>
<div><strong>Composter income</strong>${positive(r.composterIncome || 0)}</div>
<div><strong>DUD refund income</strong>${positive(r.dudRefundIncome || 0)}</div>
<div><strong>Manual combo bonus</strong>${positive(r.manualComboBonus || 0)}</div>
<div><strong>Chemical subsidy</strong>${positive(r.chemicalWeaponSubsidy || 0)}</div>
<div><strong>Contract bonus</strong>${positive(r.contractBonus || 0)}</div>
<div><strong>Event bonus</strong>${positive(r.eventBonus || 0)}</div>
<div><strong>Penalties</strong>${negative(penalties)}</div>
<div><strong>Fairies fee</strong>${negative(r.fairiesTribute?.amount || r.fairiesTribute || 0)}</div>
<div><strong>Repair worker wages</strong>${negative(r.repairWorkerWages || 0)}</div>
<div><strong>Loan repayments</strong>${negative(r.loanRepayment || 0)}</div>
</div>
<p class="formula-box compact">Income ${yen(r.revenue)} - losses ${yen(r.penalty)} = <b class="${netClass}">${yen(r.profit)}</b></p>`;
ui.modalActions.innerHTML = '';
@ -277,8 +250,8 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
ui.modalBody.innerHTML = `<p class="gameover-reason">Your cash went negative.</p>
<div class="result-grid gameover-summary"><div><strong>Final Score</strong><span>${fs.score}</span></div><div><strong>Survival</strong><span>${game.totals.turnsCompleted} days</span></div><div><strong>Final Cash</strong><span>${yen(game.cash)}</span></div><div><strong>Accuracy</strong><span>${accuracy}%</span></div></div>
<details class="gameover-details"><summary>Score breakdown</summary>
<div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value 繝サ繝サ繝サ0.5 + Contract bonus 繝サ繝サ繝サ0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base 繝サ繝サ・ス・キ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct繝サ繝サ繝サ - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid gameover-breakdown"><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>ZUNDA TAX</strong><span>${yen(game.totals.zundaTax)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Contract Bonus</strong><span>${yen(game.totals.contractBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Contracts</strong><span>${game.totals.contractSuccess} success / ${game.totals.contractFailed} failed</span></div></div>
<div class="formula-box compact"><strong>Formula</strong><br>Base = Cash + Total revenue + Factory value ×0.5 + Event bonus ×0.5 = ${yen(fs.base)}<br>Daily earned = ceil(Base ÷ Days) = ${yen(fs.dailyEarned)}<br>Score = max(0, floor(Base + Daily earned + Correct bonus - Total outflow - Day penalty)) = ${fs.score}</div>
<div class="result-grid gameover-breakdown"><div><strong>Factory Value</strong><span>${yen(fs.factoryValue)}</span></div><div><strong>Total Revenue</strong><span>${yen(game.totals.revenue)}</span></div><div><strong>Daily Earned</strong><span>${yen(fs.dailyEarned)} / day</span></div><div><strong>Total Outflow</strong><span>${yen(game.totals.penalty)}</span></div><div><strong>Fairies Tribute</strong><span>${yen(game.totals.fairiesTribute || 0)}</span></div><div><strong>Correct Sorts</strong><span>${game.totals.correct}</span></div><div><strong>Auto Sorted</strong><span>${game.totals.autoSorted}</span></div><div><strong>Poop Control</strong><span>${game.totals.poopTrash} trashed / ${game.totals.poopTruck} shipped / ${game.totals.poopMixer} mixer</span></div><div><strong>Explosion Penalty</strong><span>${yen(game.totals.explosionDamage)}</span></div><div><strong>Event Bonus</strong><span>${yen(game.totals.eventBonus)}</span></div><div><strong>Day Penalty</strong><span>-${fs.dayPenalty}</span></div><div><strong>Events</strong><span>${game.totals.eventSuccess} success / ${game.totals.eventFailed} failed</span></div></div>
</details>`;
ui.modalActions.innerHTML = '';
ui.modalActions.appendChild(button(TEXT.actions.restart || 'Restart', startGame, 'primary-button restart-button'));

View file

@ -37,7 +37,6 @@ h1, h2, p { margin: 0; }
.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); }
.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; }
@ -52,6 +51,7 @@ h1, h2, p { margin: 0; }
.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 .tool-icon { width: 24px; height: 24px; image-rendering: pixelated; float: left; margin: 0 8px 4px 0; object-fit: contain; }
.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); }
@ -66,31 +66,32 @@ h1, h2, p { margin: 0; }
.facility-action.danger { background: #ffdada; }
.control-dock { position: absolute; z-index: 7; left: 50%; bottom: 12px; transform: translateX(-50%); display: grid; grid-template-columns: repeat(4, 150px); gap: 8px; padding: 7px; border: 3px solid var(--line); background: rgba(255,255,255,.90); }
.sort-button { min-height: 46px; font-size: 12px; }
/* Debug UI is intentionally kept for active balancing builds. Do not remove during cleanup/audit passes unless explicitly requested. */
.debug-panel {
position: absolute;
z-index: 9;
left: 10px;
bottom: 10px;
width: min(292px, calc(100% - 20px));
width: min(410px, calc(100% - 20px));
border: 3px solid var(--line);
background: rgba(255,255,255,.94);
background: rgba(255,255,255,.96);
box-shadow: 4px 4px 0 rgba(16,32,21,.16);
font-weight: 900;
}
.debug-panel summary {
cursor: pointer;
padding: 4px 8px;
padding: 5px 8px;
color: var(--danger);
font-size: 14px;
line-height: 1.1;
}
.debug-body {
display: grid;
grid-template-columns: 1fr 74px;
gap: 5px;
padding: 6px;
grid-template-columns: 1fr 92px;
gap: 6px;
padding: 7px;
border-top: 3px solid var(--line);
font-size: 13px;
font-size: 12px;
line-height: 1.15;
}
.debug-body label,
@ -101,12 +102,21 @@ h1, h2, p { margin: 0; }
font: inherit;
}
.debug-check,
.debug-card {
.debug-card,
.debug-target,
.debug-event,
.debug-readout {
grid-column: 1 / -1;
}
.debug-row {
display: grid;
grid-template-columns: 1fr 92px;
gap: 6px;
grid-column: 1 / -1;
}
.debug-day {
display: grid;
grid-template-columns: 34px 1fr;
grid-template-columns: 38px 1fr;
align-items: center;
gap: 4px;
}
@ -115,7 +125,7 @@ h1, h2, p { margin: 0; }
background: var(--white);
cursor: pointer;
font-weight: 900;
padding: 4px 5px;
padding: 5px 6px;
line-height: 1.05;
}
.debug-body input[type="number"],
@ -123,18 +133,20 @@ h1, h2, p { margin: 0; }
width: 100%;
border: 2px solid var(--line);
background: var(--white);
padding: 3px 4px;
min-height: 28px;
padding: 4px 5px;
min-height: 29px;
}
.debug-target select:disabled {
opacity: .55;
}
.debug-readout {
grid-column: 1 / -1;
border: 2px solid var(--line);
background: #f7fff5;
color: var(--muted);
padding: 5px;
line-height: 1.2;
max-height: 48px;
overflow: hidden;
line-height: 1.25;
max-height: 64px;
overflow: auto;
}
.modal { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 22px; background: rgba(16,32,21,.45); }
.modal.visible { display: flex; }
@ -168,17 +180,17 @@ h1, h2, p { margin: 0; }
.route-cards, .result-grid { grid-template-columns: 1fr; }
}
.contract-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
.contract-card.empty { color: var(--muted); background: rgba(255,255,255,.72); }
.contract-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; }
.contract-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; }
.contract-card p { margin: 5px 0; }
.contract-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
.contract-card.declined { background: #f1f1f1; color: var(--muted); }
.contract-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; }
.contract-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; }
.contract-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); }
.contract-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.event-card { border: 3px solid var(--line); background: #f7fff5; padding: 12px; line-height: 1.35; color: var(--ink); font-size: 11px; box-shadow: 4px 4px 0 rgba(16,32,21,.14); }
.event-card.empty { color: var(--muted); background: rgba(255,255,255,.72); }
.event-card.empty strong { display: block; color: var(--ink); margin-bottom: 4px; }
.event-card h3 { margin: 2px 0 7px; font-size: 14px; text-transform: uppercase; letter-spacing: .04em; }
.event-card p { margin: 5px 0; }
.event-card.accepted { background: var(--green-soft); box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(16,32,21,.14); }
.event-card.declined { background: #f1f1f1; color: var(--muted); }
.event-status { display: inline-block; padding: 2px 7px; border: 2px solid var(--line); background: var(--white); font-size: 9px; font-weight: 900; letter-spacing: .1em; margin-bottom: 4px; }
.event-metrics { display: grid; grid-template-columns: 1fr; gap: 4px; margin: 8px 0; }
.event-metrics span { display: block; border: 2px solid rgba(16,32,21,.55); background: rgba(255,255,255,.82); padding: 5px; font-weight: 900; color: var(--ink); }
.event-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.result-section-title { margin: 16px 0 8px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: .12em; color: var(--green); }
.result-section-title:first-child { margin-top: 0; }
.muted-title { color: var(--muted); border: 3px solid var(--line); padding: 10px; background: #f7fff5; }
@ -252,7 +264,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;
@ -293,7 +305,7 @@ body { font-size: 16px; }
.build-panel h2 { font-size: 13px; }
.tool-button strong { font-size: 14px; }
.tool-button span { font-size: 11px; }
.mini-box, .contract-card { font-size: 12px; }
.mini-box, .event-card { font-size: 12px; }
.sort-button { font-size: 14px; }
.modal-card h2 { font-size: 29px; }
.card-choice strong { font-size: 16px; }
@ -462,7 +474,7 @@ body { font-size: 18px; }
.priority-strip > div { font-size: 15px; }
.hud-card span { font-size: 12px; }
.hud-card strong { font-size: clamp(20px, 1.7vw, 28px); }
.mini-box, .contract-card { font-size: 14px; line-height: 1.45; }
.mini-box, .event-card { font-size: 14px; line-height: 1.45; }
.compact-rules .mini-box { font-size: 13px; }
.tool-button {
position: relative;
@ -521,7 +533,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; }
@ -559,7 +571,7 @@ body { font-size: 20px; }
.priority-strip > div { font-size: 16px; }
.hud-card span { font-size: 13px; }
.hud-card strong { font-size: clamp(22px, 1.8vw, 30px); }
.mini-box, .contract-card { font-size: 15px; line-height: 1.5; }
.mini-box, .event-card { font-size: 15px; line-height: 1.5; }
.compact-rules .mini-box { font-size: 14px; }
.tool-button strong { font-size: 16px; line-height: 1.15; }
.tool-button span { font-size: 13px; line-height: 1.2; }
@ -589,7 +601,7 @@ body { font-size: 20px; }
.equipment-menu-lines.compact, .formula-box.compact { font-size: 11px; }
.hover-tooltip strong { font-size: 18px; }
/* Keep construction flavor text readable as a hover bubble, not button content. */
/* Keep construction flavor text readable as a hover bubble. */
.large-tools .tool-button {
min-height: 62px;
position: relative;
@ -602,9 +614,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;
@ -614,7 +627,7 @@ body { font-size: 20px; }
background: rgba(255,255,255,.98);
color: var(--ink);
box-shadow: 5px 5px 0 rgba(16,32,21,.18);
font-size: 14px;
font-size: 13px;
line-height: 1.35;
pointer-events: none;
overflow-wrap: anywhere;
@ -625,11 +638,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);
@ -748,3 +761,563 @@ body { font-size: 20px; }
@media (max-width: 1180px) {
.scanner-monitor { bottom: 132px; width: min(520px, calc(100% - 16px)); left: 8px; }
}
/* ------------------------------------------------------------------
DARK FACTORY VISUAL PASS
- Facility sprites are now loaded from assets/dark_factory/.
- Debug UI is intentionally retained for balancing builds. Do not remove.
------------------------------------------------------------------ */
:root {
--bg: #0b0c0f;
--screen: #111318;
--panel: rgba(24, 27, 34, 0.96);
--panel-2: #20242b;
--panel-3: #2a303a;
--ink: #e6edf2;
--muted: #8f9bab;
--line: #050609;
--green: #7bd84f;
--green-soft: #273c24;
--white: #e6edf2;
--danger: #e34a36;
--warn: #ffb827;
--cyan: #4aa3ff;
--orange: #c86a2b;
--shadow: 6px 6px 0 rgba(0, 0, 0, 0.62);
}
html, body { background: #050609; color: var(--ink); }
body {
background:
linear-gradient(90deg, rgba(255,184,39,.035) 1px, transparent 1px) 0 0 / 32px 32px,
linear-gradient(rgba(255,184,39,.028) 1px, transparent 1px) 0 0 / 32px 32px,
#050609;
}
#app { width: min(100vw, 1720px); padding: 0; }
.game-shell {
min-height: 100vh;
border: 0;
background: var(--screen);
box-shadow: inset 0 0 0 4px #050609, inset 0 0 0 8px #303744;
}
#gameCanvas {
height: 100vh;
min-height: 720px;
background: var(--screen);
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.hud { gap: 4px; }
.hud-top-left {
top: 8px;
left: 8px;
right: 420px;
grid-template-columns: 1.25fr repeat(5, minmax(92px, .65fr));
}
.hud-top-right {
top: 8px;
right: 8px;
grid-template-columns: repeat(4, minmax(74px, auto));
}
.hud-card {
min-width: 74px;
padding: 7px 9px;
border: 3px solid var(--line);
background: linear-gradient(#242a33, #171a20);
box-shadow: 4px 4px 0 rgba(0,0,0,.7), inset 0 0 0 2px rgba(255,184,39,.10);
color: var(--ink);
}
.hud-card span { color: var(--warn); font-size: 9px; letter-spacing: .12em; }
.hud-card strong { color: var(--ink); text-shadow: 2px 2px 0 #050609; }
.hud-card.cash strong, .hud-card.profit strong, .hud-card.combo strong, .cash-positive { color: var(--green) !important; }
.hud-card.time-critical { background: #3a1f1f; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(0,0,0,.7); }
.priority-strip {
top: 74px;
left: 10px;
width: clamp(250px, 21vw, 330px);
max-width: none;
grid-template-columns: 1fr;
gap: 6px;
}
.priority-strip > div {
border: 3px solid var(--line);
background: linear-gradient(#222832, #15181e);
color: var(--ink);
box-shadow: 4px 4px 0 rgba(0,0,0,.66), inset 0 0 0 2px rgba(255,184,39,.12);
}
.event-brief.active { background: #38251a; box-shadow: inset 0 0 0 3px var(--orange), 4px 4px 0 rgba(0,0,0,.66); }
.event-brief.next { background: #1c2c20; box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(0,0,0,.66); }
.event-brief.locked { background: #181b20; color: var(--muted); }
.speed-brief { color: var(--cyan); }
.build-panel {
top: 82px;
right: 8px;
bottom: 8px;
width: clamp(320px, 24vw, 410px);
padding: 9px;
border: 4px solid var(--line);
background: linear-gradient(#222832, #14171d);
box-shadow: 6px 6px 0 rgba(0,0,0,.72), inset 0 0 0 3px rgba(255,184,39,.10);
scrollbar-color: var(--warn) #111318;
}
.panel-head {
display: grid;
grid-template-columns: 1fr 126px;
align-items: stretch;
gap: 8px;
margin-bottom: 8px;
}
.panel-head h1 { color: var(--warn); text-shadow: 2px 2px 0 #050609; font-size: 17px; }
.panel-head h1 span { color: #050609; background: var(--warn); border-color: var(--line); }
.panel-head p { color: var(--muted); }
.panel-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.build-panel h2, .result-section-title { color: var(--warn); }
.large-tools {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.tool-button, .primary-button, .sort-button, .facility-action {
border: 3px solid var(--line);
background: linear-gradient(#2a303a, #171a20);
color: var(--ink);
box-shadow: 4px 4px 0 rgba(0,0,0,.70), inset 0 0 0 2px rgba(255,184,39,.08);
text-shadow: 1px 1px 0 #050609;
}
.tool-button { min-height: 48px; padding: 8px; }
.tool-button strong { color: var(--ink); }
.tool-button span { color: var(--muted); }
.tool-button.active {
background: linear-gradient(#33311d, #1e2117);
box-shadow: inset 0 0 0 3px var(--warn), 4px 4px 0 rgba(0,0,0,.70);
}
.tool-button.danger.active { background: linear-gradient(#4b1f1c, #241014); box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(0,0,0,.70); }
.primary-button { background: linear-gradient(#8b2d23, #4b1714); color: #fff3d2; }
.primary-button:not(:disabled) { box-shadow: inset 0 0 0 3px #ffb827, 5px 5px 0 rgba(0,0,0,.72); }
.primary-button:disabled, .tool-button:disabled, .sort-button:disabled, .facility-action:disabled { opacity: .42; filter: grayscale(1); }
.mini-box, .event-card, .formula-box, .equipment-menu-lines, .route-card, .result-grid div, .muted-title {
border: 3px solid var(--line);
background: #15181e;
color: var(--muted);
box-shadow: inset 0 0 0 2px rgba(255,184,39,.08);
}
.event-card h3, .event-card strong, .event-metrics span { color: var(--ink); }
.event-card.accepted { background: #1c2c20; box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(0,0,0,.66); }
.event-card.empty { background: #15181e; color: var(--muted); }
.event-metrics span { border-color: #3d4654; background: #20242b; }
.event-status { background: #2a303a; color: var(--warn); border-color: var(--line); }
.control-dock {
left: 50%;
right: auto;
bottom: 8px;
transform: translateX(-50%);
grid-template-columns: repeat(4, minmax(135px, 160px));
gap: 6px;
padding: 6px;
border: 4px solid var(--line);
background: rgba(13,15,19,.96);
box-shadow: 5px 5px 0 rgba(0,0,0,.72), inset 0 0 0 2px rgba(255,184,39,.10);
}
.sort-button { min-height: 44px; font-size: 12px; }
.scanner-monitor {
position: absolute;
z-index: 5;
left: 10px;
top: 198px;
width: clamp(250px, 21vw, 330px);
}
.scanner-monitor-card {
border: 3px solid var(--line) !important;
background: linear-gradient(#222832, #15181e) !important;
color: var(--ink) !important;
box-shadow: 4px 4px 0 rgba(0,0,0,.66), inset 0 0 0 2px rgba(255,184,39,.10) !important;
}
.scanner-monitor-card strong { color: var(--warn); }
.debug-panel {
left: auto;
right: 12px;
bottom: 10px;
width: min(420px, calc(100% - 24px));
border-color: var(--line);
background: rgba(16,18,23,.97);
box-shadow: 5px 5px 0 rgba(0,0,0,.72), inset 0 0 0 2px rgba(227,74,54,.18);
color: var(--ink);
}
.debug-panel summary { color: var(--danger); }
.debug-body { border-top-color: var(--line); }
.debug-body button, .debug-body input[type="number"], .debug-body select, .debug-readout {
border-color: var(--line);
background: #20242b;
color: var(--ink);
}
.debug-readout { color: var(--muted); }
.modal { background: rgba(3,4,6,.72); }
.modal-card {
border-color: var(--line);
background: linear-gradient(#242a33, #15181e);
color: var(--ink);
box-shadow: 8px 8px 0 rgba(0,0,0,.72), inset 0 0 0 3px rgba(255,184,39,.12);
}
.modal-card h2 { color: var(--warn); text-shadow: 2px 2px 0 #050609; }
.modal-card ul, .modal-card p { color: var(--muted); }
.hover-tooltip {
border-color: var(--line) !important;
background: rgba(20,23,29,.98) !important;
color: var(--ink) !important;
box-shadow: 5px 5px 0 rgba(0,0,0,.72) !important;
}
::-webkit-scrollbar { width: 14px; height: 14px; }
::-webkit-scrollbar-track { background: #111318; }
::-webkit-scrollbar-thumb { background: #333b48; border: 3px solid #111318; }
::-webkit-scrollbar-thumb:hover { background: #ffb827; }
@media (max-width: 1180px) {
.hud-top-left { right: 8px; grid-template-columns: repeat(3, minmax(80px, 1fr)); }
.hud-top-right { top: 132px; left: 8px; right: auto; grid-template-columns: repeat(2, minmax(82px, auto)); }
.priority-strip { top: 226px; }
.scanner-monitor { top: 348px; }
.build-panel { left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 46vh; }
.control-dock { grid-template-columns: repeat(2, 150px); }
}
/* v27.16: compact collapsed debug panel + icon card layout */
.debug-panel:not([open]) {
width: 104px !important;
min-width: 0;
overflow: hidden;
}
.debug-panel:not([open]) summary {
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.debug-panel[open] {
width: min(420px, calc(100% - 24px));
}
.card-choice {
position: relative;
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 10px;
align-items: start;
min-height: 150px;
overflow: hidden;
background:
linear-gradient(135deg, rgba(255,255,255,.08), rgba(0,0,0,.20)),
#20242b;
border-color: var(--line);
}
.card-choice::before {
content: "";
position: absolute;
inset: 5px;
border: 1px solid rgba(255,184,39,.18);
pointer-events: none;
}
.card-icon {
position: relative;
z-index: 1;
width: 46px;
height: 46px;
display: grid;
place-items: center;
border: 3px solid var(--line);
background: #15181e;
box-shadow: inset 0 0 0 2px rgba(255,184,39,.10), 3px 3px 0 rgba(0,0,0,.35);
image-rendering: pixelated;
}
.card-icon img {
width: 36px;
height: 36px;
object-fit: contain;
image-rendering: pixelated;
}
.card-icon.glyph {
color: var(--warn, #ffb827);
font-size: 24px;
font-weight: 900;
text-align: center;
line-height: 1;
}
.card-copy {
position: relative;
z-index: 1;
min-width: 0;
}
.card-choice strong {
color: var(--warn, #ffb827);
text-shadow: 1px 1px 0 #050609;
}
.card-choice small { color: var(--muted); }
.card-tags span {
background: #15181e;
color: var(--white);
border-color: var(--line);
}
.card-choice.common { background: linear-gradient(145deg, #262c35, #171a20); }
.card-choice.rare {
background: linear-gradient(145deg, #3b3020, #191b20);
box-shadow: inset 0 0 0 3px #f0a020, 5px 5px 0 rgba(0,0,0,.50);
}
.card-choice.ultra-rare {
background: linear-gradient(145deg, #401d2a, #171a20);
box-shadow: inset 0 0 0 3px #c42424, 5px 5px 0 rgba(0,0,0,.55);
}
.card-choice.dud {
background: linear-gradient(145deg, #2b2b2b, #191919);
color: #878787;
}
.card-choice.dud .card-tags span { background: #222; color: #aaa; }
@media (max-width: 1180px) {
.debug-panel:not([open]) {
width: 92px !important;
right: 8px;
bottom: 8px;
}
}
/* v27.18 inherited cache-proof identity and final UI overrides */
.build-stamp {
position: absolute;
z-index: 12;
right: 14px;
top: 76px;
padding: 5px 9px;
border: 2px solid #ffb827;
background: rgba(14, 17, 22, .94);
color: #80e067;
box-shadow: 3px 3px 0 rgba(0,0,0,.7);
font: 900 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .04em;
pointer-events: none;
}
.debug-panel:not([open]) {
width: 58px !important;
max-width: 58px !important;
height: 30px;
min-height: 0;
}
.debug-panel:not([open]) summary {
box-sizing: border-box;
width: 58px;
height: 27px;
padding: 5px 7px;
text-align: center;
overflow: hidden;
text-overflow: clip;
}
.debug-panel[open] {
width: min(390px, calc(100% - 24px)) !important;
height: auto;
}
.card-choice {
border-width: 3px;
box-shadow: 4px 4px 0 rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,184,39,.14);
}
.card-choice:hover {
transform: translateY(-2px);
box-shadow: 4px 6px 0 rgba(0,0,0,.65), inset 0 0 0 2px rgba(128,224,103,.24);
}
.card-icon img {
image-rendering: pixelated;
filter: drop-shadow(2px 2px 0 rgba(0,0,0,.7));
}
#gameCanvas { image-rendering: pixelated; }
/* v27.18: status relocation, consistent industrial controls, card watermarks */
.hud-top-left { grid-template-columns: repeat(5, minmax(82px, auto)); }
.left-bottom-status {
position: absolute;
z-index: 6;
left: 10px;
bottom: 10px;
width: min(310px, calc(100% - 20px));
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
pointer-events: none;
}
.left-bottom-status > div {
min-width: 0;
border: 3px solid var(--line);
background: rgba(22, 26, 32, .95);
color: var(--ink);
padding: 8px 10px;
box-shadow: 4px 4px 0 rgba(0,0,0,.68), inset 0 0 0 1px rgba(255,184,39,.10);
font-size: 12px;
line-height: 1.2;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .025em;
overflow: hidden;
text-overflow: ellipsis;
}
.left-bottom-status .event-brief { grid-column: 1 / -1; white-space: normal; min-height: 39px; }
.left-bottom-status .event-brief.active { background: #35221c; box-shadow: inset 0 0 0 3px var(--danger), 4px 4px 0 rgba(0,0,0,.68); }
.left-bottom-status .event-brief.next { background: #22301f; box-shadow: inset 0 0 0 3px var(--green), 4px 4px 0 rgba(0,0,0,.68); }
.left-bottom-status .event-brief.locked { background: #1c2027; color: var(--muted); }
.left-bottom-status .speed-brief { color: #d9e2e8; }
.left-bottom-status .combo-brief { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.left-bottom-status .combo-brief span { color: var(--muted); font-size: 10px; }
.left-bottom-status .combo-brief strong { color: var(--green); font-size: 22px; line-height: 1; }
/* Remove the remaining bright green/white control styling from modal actions. */
.modal-actions button,
.facility-action,
.card-target-button {
border: 3px solid #07080a;
background: linear-gradient(#353d48, #1b1f25);
color: #e6edf2;
box-shadow: inset 0 0 0 1px rgba(255,184,39,.14), 4px 4px 0 rgba(0,0,0,.68);
}
.modal-actions button:hover:not(:disabled),
.facility-action:hover:not(:disabled),
.card-target-button:hover:not(:disabled) {
background: linear-gradient(#424b58, #242930);
color: #fff0c9;
box-shadow: inset 0 0 0 2px rgba(255,184,39,.42), 4px 4px 0 rgba(0,0,0,.72);
}
.facility-action.warn,
.modal-actions .reroll-button {
background: linear-gradient(#554221, #2b2419);
color: #ffd36a;
box-shadow: inset 0 0 0 3px #b97810, 5px 5px 0 rgba(0,0,0,.72);
}
.modal-actions .reroll-button:hover:not(:disabled) {
background: linear-gradient(#6b5227, #342919);
color: #fff0bd;
box-shadow: inset 0 0 0 3px #ffb827, 5px 5px 0 rgba(0,0,0,.76);
}
.facility-action.good { background: linear-gradient(#29422a, #19271a); color: #a9ed91; }
.facility-action.danger { background: linear-gradient(#57251f, #2d1715); color: #ffb1a3; }
.modal-actions button:disabled,
.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;
min-height: 170px;
padding: 16px;
isolation: isolate;
}
.card-choice::before { z-index: 1; }
.card-copy { position: relative; z-index: 3; padding-right: 6px; padding-left: 54px; }
.card-icon {
position: absolute;
z-index: 0;
right: -9px;
bottom: -12px;
width: 124px;
height: 124px;
border: 0;
background: transparent;
box-shadow: none;
opacity: .14;
transform: rotate(-7deg);
pointer-events: none;
overflow: visible;
}
.card-icon img {
width: 100%;
height: 100%;
object-fit: contain;
filter: grayscale(.12) contrast(1.2) drop-shadow(3px 3px 0 rgba(0,0,0,.55));
}
.card-icon.glyph {
display: grid;
place-items: center;
color: #f0b437;
font-size: 88px;
line-height: 1;
text-shadow: 4px 4px 0 rgba(0,0,0,.55);
}
.card-choice.rare .card-icon { opacity: .16; }
.card-choice.ultra-rare .card-icon { opacity: .17; }
.card-choice.dud .card-icon { opacity: .08; filter: grayscale(1); }
/* Construction tool asset icons. */
.large-tools .tool-button .tool-icon {
width: 28px;
height: 28px;
image-rendering: pixelated;
object-fit: contain;
float: left;
margin: 0 8px 4px 0;
filter: drop-shadow(1px 1px 0 rgba(0,0,0,.55));
}
.large-tools .tool-button strong,
.large-tools .tool-button .tool-price {
overflow: hidden;
}
@media (max-width: 1180px) {
.hud-top-left { grid-template-columns: repeat(3, minmax(80px, 1fr)); }
.left-bottom-status { width: 290px; left: 8px; bottom: 8px; }
}
@media (max-width: 760px) {
.left-bottom-status { width: min(270px, calc(100% - 16px)); grid-template-columns: 1fr; }
.left-bottom-status .event-brief { grid-column: auto; }
}
/* v27.19: compact Next Day fee label and deduplicated side panel */
.panel-head { grid-template-columns: minmax(0, 1fr) minmax(112px, 126px); }
.primary-button#nextTurnButton {
min-width: 0;
max-width: 126px;
padding: 8px 7px;
white-space: normal;
overflow: hidden;
line-height: 1.08;
display: grid;
place-content: center;
gap: 4px;
text-align: center;
}
#nextTurnButton .next-day-main { display: block; font-size: 15px; letter-spacing: .035em; }
#nextTurnButton .next-day-fee {
display: block;
max-width: 100%;
color: #ffd36a;
font-size: 9px;
line-height: 1.15;
letter-spacing: 0;
overflow-wrap: anywhere;
}

177
test_v29_00.mjs Normal file
View file

@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import { BALANCE } from './src/core/balance.js';
import { createGame, resetLayout } from './src/core/state.js';
import { key } from './src/core/utils.js';
import { recalculatePowerNetwork, isEquipmentPowered, currentGeneratorOutputV, addFuelPulse, receivedVoltageFor, updatePowerSystem } from './src/systems/power.js';
import { clampInboundOrder, createImportSchedule, inboundOrderCost } from './src/systems/imports.js';
import { boostOutputCapacityPerSecond, qualityForRate } from './src/systems/farmQuality.js';
function approx(actual, expected, epsilon = 1e-6) {
assert.ok(Math.abs(actual - expected) <= epsilon, `expected ${actual} to be within ${epsilon} of ${expected}`);
}
function emptyPowerGame() {
const game = createGame();
resetLayout(game);
game.facilities = {};
game.scanners = [];
game.maintenanceRooms = [];
game.powerWires = new Map();
game.power = { dirty: true, elapsed: 0, routes: new Map(), assignments: new Map(), sources: new Map(), underVoltage: new Set() };
return game;
}
function starter(id, col, row, outputV = 25) {
const generator = {
type: 'facility',
id,
baseId: 'starterGenerator',
name: id,
col,
row,
wCells: 2,
hCells: 2,
gridPlaced: true,
fuelPulses: []
};
if (outputV !== 25) generator.fuelPulses = [{ voltage: outputV - 25, timeLeft: 10 }];
return generator;
}
function biomass(id, col, row) {
return { type: 'facility', id, baseId: 'biomassGenerator', name: id, col, row, wCells: 3, hCells: 3, gridPlaced: true, fuelPulses: [] };
}
function edgeMachine(id, baseId, col, row, builtSession = 1) {
return { type: 'facility', id, baseId, name: id, entry: { col, row }, side: 'left', builtSession };
}
function wireLine(game, row, fromCol, toCol, kind = 'powerWire') {
const step = fromCol <= toCol ? 1 : -1;
for (let col = fromCol; col !== toCol + step; col += step) game.powerWires.set(key(col, row), { kind, price: 0, builtSession: null });
}
{
assert.equal(BALANCE.version, 'v29.00');
assert.equal(BALANCE.power.wires.powerWire.lossV, 0.5);
assert.equal(BALANCE.power.wires.insulatedPowerWire.lossV, 0.15);
assert.equal(BALANCE.imports.prices.coal, 2);
assert.equal(BALANCE.imports.prices.zundaMochi, 4);
}
{
const game = createGame();
resetLayout(game);
recalculatePowerNetwork(game);
assert.ok(game.facilities.starterGenerator, 'new games include starter generator');
assert.ok(game.powerWires.size > 0, 'new games include starter wires');
assert.ok(isEquipmentPowered(game, game.facilities.mixer), 'initial mixer is powered');
assert.ok(isEquipmentPowered(game, game.facilities.trash), 'initial shredder is powered');
assert.ok(isEquipmentPowered(game, game.facilities.truck), 'initial shipping truck is powered');
const loads = ['mixer', 'trash', 'truck'].reduce((sum, id) => sum + BALANCE.power.machines[id].drawV, 0);
assert.equal(loads, 9);
for (const [wireKey, wire] of game.powerWires) {
assert.equal(wire.price, 0, `starter wire ${wireKey} is free`);
assert.equal(game.blockedCells.has(wireKey), false, `starter wire ${wireKey} avoids obstacles`);
}
assert.equal(game.facilities.starterGenerator.price, 0);
assert.equal(game.facilities.starterGenerator.unsellable, true);
}
{
const game = emptyPowerGame();
game.facilities.gen = starter('gen', 5, 5);
game.facilities.mixer = edgeMachine('mixer', 'mixer', 3, 5);
wireLine(game, 5, 4, 4, 'powerWire');
game.phase = 'build';
recalculatePowerNetwork(game);
approx(receivedVoltageFor(game, game.facilities.mixer), 20.5);
assert.equal(game.stats.generatorAssignmentChanges, 0, 'build-mode power summaries do not affect daily assignment stats');
game.phase = 'running';
game.powerWires.set(key(4, 4), { kind: 'powerWire', price: 0, builtSession: null });
game.power.dirty = true;
recalculatePowerNetwork(game);
assert.equal(game.stats.generatorAssignmentChanges, 0, 'unchanged running assignment does not increment source changes');
}
{
const game = emptyPowerGame();
game.facilities.gen = starter('gen', 12, 5);
game.facilities.mixer = edgeMachine('mixer', 'mixer', 1, 5);
wireLine(game, 5, 11, 2, 'powerWire');
recalculatePowerNetwork(game);
approx(receivedVoltageFor(game, game.facilities.mixer), 16);
}
{
const game = emptyPowerGame();
game.facilities.gen = starter('gen', 12, 5);
game.facilities.mixer = edgeMachine('mixer', 'mixer', 1, 5);
wireLine(game, 5, 11, 2, 'insulatedPowerWire');
recalculatePowerNetwork(game);
approx(receivedVoltageFor(game, game.facilities.mixer), 19.5);
}
{
const game = emptyPowerGame();
game.facilities.gen = starter('gen', 12, 5);
game.facilities.mixer = edgeMachine('mixer', 'mixer', 1, 5);
for (let col = 11; col >= 7; col -= 1) game.powerWires.set(key(col, 5), { kind: 'powerWire', price: 0, builtSession: null });
for (let col = 6; col >= 2; col -= 1) game.powerWires.set(key(col, 5), { kind: 'insulatedPowerWire', price: 0, builtSession: null });
recalculatePowerNetwork(game);
approx(receivedVoltageFor(game, game.facilities.mixer), 17.75);
}
{
const game = emptyPowerGame();
game.facilities.genA = biomass('genA', 2, 2);
game.facilities.genB = biomass('genB', 7, 3);
addFuelPulse(game.facilities.genA, 'male');
addFuelPulse(game.facilities.genB, 'female');
game.facilities.truck = edgeMachine('truck', 'truck', 5, 5);
game.powerWires.set(key(5, 4), { kind: 'powerWire', price: 0, builtSession: null });
game.powerWires.set(key(6, 5), { kind: 'powerWire', price: 0, builtSession: null });
recalculatePowerNetwork(game);
assert.equal(isEquipmentPowered(game, game.facilities.truck), false, 'separate generators do not sum voltage');
addFuelPulse(game.facilities.genA, 'poop');
recalculatePowerNetwork(game);
assert.equal(isEquipmentPowered(game, game.facilities.truck), true, 'one source with enough stacked pulse can power the truck');
}
{
const generator = { baseId: 'biomassGenerator', fuelPulses: [] };
addFuelPulse(generator, 'male');
addFuelPulse(generator, 'female');
addFuelPulse(generator, 'poop');
assert.equal(currentGeneratorOutputV(generator), 15);
const coal = { baseId: 'coalGenerator', fuelPulses: [] };
addFuelPulse(coal, 'coal');
assert.equal(currentGeneratorOutputV(coal), 15);
updatePowerSystem({ facilities: { coal }, stats: {}, totals: {}, power: { dirty: false, elapsed: 0 } }, 3.1);
assert.equal(currentGeneratorOutputV(coal), 0);
assert.equal(addFuelPulse(coal, 'poop'), false);
}
{
assert.deepEqual(clampInboundOrder({ coalPerDay: 100, zundaMochiPerDay: 100 }), { coalPerDay: 40, zundaMochiPerDay: 40 });
assert.equal(inboundOrderCost({ coalPerDay: 10, zundaMochiPerDay: 5 }), 40);
const sixty = createImportSchedule({ coalPerDay: 60 }, 'test');
assert.equal(sixty.length, 60);
approx(sixty[1].due - sixty[0].due, 1, 0.35);
const six = createImportSchedule({ coalPerDay: 6 }, 'test');
approx(six[1].due - six[0].due, 10, 3.1);
const mixed = createImportSchedule({ coalPerDay: 40, zundaMochiPerDay: 40 }, 'test');
assert.equal(mixed.length, BALANCE.imports.combinedLimitPerTruck);
approx(mixed[1].due - mixed[0].due, 0.75, 0.3);
}
{
const game = createGame();
const cap = boostOutputCapacityPerSecond(game);
assert.ok(cap > 0);
assert.equal(qualityForRate(cap, cap), 1);
assert.ok(qualityForRate(cap * 2, cap) > qualityForRate(cap * 1.5, cap));
assert.equal(qualityForRate(cap * 100, cap), BALANCE.farmQuality.maxMultiplier);
}
console.log('v29.00 tests passed');