This commit is contained in:
33333-33333 2026-06-11 22:49:46 +09:00
commit 22e6366dc2
35 changed files with 2595 additions and 379 deletions

View file

@ -2,22 +2,93 @@
Serve this folder with a static HTTP server and open `index.html`.
The launch page uses the unversioned app assets:
The launch page uses:
- `styles.css`
- `dist/game.js`
## Build
Run `npm install` once, then `npm run build` after editing JavaScript or `styles.css`. Without a local Node/npm toolchain, `dist/game.js` remains a source-module wrapper for local static serving.
Run `npm install` once, then `npm run build` after editing JavaScript.
## Current changes
## Current gameplay specification (v28.21)
- Initial no-build tiles are 3% of the starting 20x10 land.
- Each purchased 10x10 lot increases its own no-build ratio by 0.5 percentage points, capped at 10%.
- Floor slime is sparse, deterministic splatter instead of frequent puddles.
- No-build tiles use subdued black/yellow hazard striping.
- The duplicate right-side event panel was removed.
- The Status panel no longer repeats event, belt speed, combo, or truck target data.
- NEXT DAY displays the Fairies fee on a contained second line.
- Dynamite lets the player select any highlighted no-build tiles, up to three. Empty clicks do not cancel selection.
### Boost conveyor rendering hotfix (v28.21)
- 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.

View file

@ -1,16 +0,0 @@
# Assets organization (prepared, not yet wired into the game)
This patch adds a **dark factory** art set without changing runtime references yet.
## Folder guide
- `assets/dark_factory/facilities/` : new factory equipment sprites
- `assets/dark_factory/previews/` : visual overview sheet
- `assets/images/` : user-supplied chick and poop sprites used by the renderer
- `assets/images/` : left untouched for compatibility with the current build
## Notes
- Grid base unit: **16x16**
- Non-grid UI object base unit: **32x32**
- Color count intentionally limited for a coarse pixel-art look
- Sprites are supplied as individual PNG files with transparency
- This patch **does not yet hook the new art into the live game**

View file

@ -1 +0,0 @@
Dark factory facility sprites. See ../manifest.json for file roles and dimensions.\n

Binary file not shown.

Before

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

View file

@ -59,28 +59,25 @@
{
"id": "mixer",
"files": [
"mixer_32.png",
"mixer_compact_16.png"
"mixer_32.png"
],
"size": "32x32 + 16x16 compact",
"size": "32x32",
"folder": "facilities/machines"
},
{
"id": "shredder",
"files": [
"shredder_32.png",
"shredder_compact_16.png"
"shredder_32.png"
],
"size": "32x32 + 16x16 compact",
"size": "32x32",
"folder": "facilities/machines"
},
{
"id": "truck",
"files": [
"truck_32.png",
"truck_compact_16.png"
"truck_32.png"
],
"size": "32x32 + 16x16 compact",
"size": "32x32",
"folder": "facilities/vehicles"
},
{
@ -100,4 +97,4 @@
"folder": "facilities/utility"
}
]
}
}

View file

@ -1 +0,0 @@
Preview sheet(s) for quick inspection. These are reference only, not runtime assets.\n

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

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`.

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: 060;
- zunda mochi: 030;
- 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.001.14x: Standard
- 1.151.34x: Select
- 1.351.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.

53
dist/game.js vendored

File diff suppressed because one or more lines are too long

View file

@ -6,13 +6,13 @@
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Chick Sorter v27.20 — Card and Expansion Patch</title>
<title>Chick Sorter v28.21 — Boost Draw Hotfix</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app">
<section id="gameShell" class="game-shell" data-build="v27.20" aria-label="Game screen">
<div class="build-stamp" aria-label="loaded build">v27.20 VERIFIED</div>
<section id="gameShell" class="game-shell" data-build="v28.21" aria-label="Game screen">
<div class="build-stamp" aria-label="loaded build">v28.21 VERIFIED</div>
<canvas id="gameCanvas" width="1440" height="900" aria-label="Chick Sorter game canvas"></canvas>
<div class="hud hud-top-left" aria-label="Run status">
@ -34,14 +34,12 @@
<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 id="targetBrief" class="target-brief status-chip">TRUCK TARGET: FEMALE</div>
</div>
<div id="buildPanel" class="build-panel" aria-label="Build and facility upgrades">
<div class="panel-head">
<div>
<h1>CHICK SORTER <span>v27.20</span></h1>
<p>Build, expand, connect at least one valid EGG route, then start the next day.</p>
<h1>CHICK SORTER <span>v28.21</span></h1>
</div>
<button id="nextTurnButton" class="primary-button" type="button">Next Day</button>
</div>
@ -53,11 +51,12 @@
<button id="buildConveyorButton" class="tool-button" type="button"><strong>Conveyor</strong><span>¥30 / tile</span></button>
<button id="buildBoostConveyorButton" class="tool-button" type="button"><strong>Boost Conveyor</strong><span>¥75 / tile</span></button>
<button id="buildEggFarmButton" class="tool-button" type="button"><strong>Egg Farm</strong><span>¥250</span></button>
<button id="buildAutoScannerButton" class="tool-button" type="button"><strong>Auto Scanner</strong><span>¥180 / 3.5s cooldown</span></button>
<button id="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>Truck</strong><span>¥450</span></button>
<button id="buildSausageMakerButton" class="tool-button" type="button"><strong>Sausage Machine</strong><span>¥380 / 3×3</span></button>
<button id="eraseButton" class="tool-button danger" type="button"><strong>Sell</strong><span>Sell or refund equipment</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>
@ -94,6 +93,7 @@
<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>
@ -110,6 +110,6 @@
</div>
</div>
<script type="module" src="./dist/game.js"></script>
<script type="module" src="./dist/game.js?v=28.21"></script>
</body>
</html>

4
package-lock.json generated
View file

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

View file

@ -1,11 +1,12 @@
{
"name": "zunda-shiwake",
"version": "27.20.0",
"version": "28.2.1",
"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 && esbuild src/game.js --bundle --format=esm --target=es2020 --minify --outfile=dist/game.js",
"test": "node test_v28_21.mjs"
},
"devDependencies": {
"esbuild": "^0.25.0"

View file

@ -1,7 +1,7 @@
// Central balance sheet for tuning gameplay.
// Edit this file first when adjusting prices, income, penalties, timing, card rates, or caps.
export const BALANCE = {
version: 'v27.20 CARD + EXPANSION PATCH',
version: 'v28.21 BOOST DRAW HOTFIX',
time: {
daySeconds: 60,
farmShutdownGraceSeconds: 10
@ -18,9 +18,9 @@ export const BALANCE = {
expansion: {
colsPerPurchase: 10,
rowsPerPurchase: 10,
costBase: 1000,
costMultiplier: 4 / 3,
truncateUnit: 100
costBase: 850,
costGrowthRate: 0.75,
truncateUnit: 50
}
},
map: {
@ -31,7 +31,8 @@ export const BALANCE = {
economy: {
income: {
mixer: 5,
truck: 10
truck: 10,
sausage: 26
},
poopFine: 10,
fairiesTribute: {
@ -45,7 +46,21 @@ export const BALANCE = {
maleTruckFinePerHalfDay: 30,
shredderBonus: {
maxCards: 30,
incomePerUpgrade: 1.25
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,
@ -54,7 +69,7 @@ export const BALANCE = {
},
production: {
poopRate: 0.08,
autoScannerCooldown: 3.5,
autoScannerCooldown: 1.5,
autoScannerUpgradeRate: 0.95,
autoScannerCardUpgradeRate: 0.95,
autoScannerMinCooldown: 0.5,
@ -71,6 +86,9 @@ export const BALANCE = {
maxSpeed: 300,
bearingSpeedMultiplier: 1.075
},
effects: {
explosionHorizontalVelocityMultiplier: 0.82
},
cards: {
definitions: [
{
@ -97,6 +115,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'All AUTO SCANNER cooldowns use default cooldown x0.95 per copy.',
requiresFacility: 'autoScanner',
tags: ['SCANNER', 'PASSIVE']
},
{
@ -105,7 +124,7 @@ export const BALANCE = {
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']
},
{
@ -117,13 +136,22 @@ 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']
},
{
@ -132,6 +160,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.',
requiresFacility: 'eggFarm',
tags: ['EGG', 'RISK', 'PASSIVE']
},
{
@ -140,6 +169,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'EGG production interval -4%. Poop rate +3%. No card limit.',
requiresFacility: 'eggFarm',
tags: ['EGG', 'POOP', 'PASSIVE']
},
{
@ -164,6 +194,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']
},
{
@ -180,6 +211,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']
},
{
@ -188,6 +220,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'Conveyor speed +7.5%.',
requiresFacility: 'conveyor',
tags: ['CONVEYOR', 'PASSIVE']
},
{
@ -196,6 +229,7 @@ export const BALANCE = {
rarity: 'common',
type: 'instant',
description: 'Restores 2% durability to every machine. A box of almost-compatible parts.',
requiresAnyFacility: ['conveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'sausageMaker'],
tags: ['MAINTENANCE', 'ACTIVE']
},
{
@ -220,6 +254,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']
},
{
@ -228,6 +263,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']
},
{
@ -253,7 +289,7 @@ export const BALANCE = {
rarity: 'rare',
type: 'cellAction',
target: 'blockedCell',
description: 'Choose and remove up to 10 unbuildable cells.',
description: 'Choose and remove up to 3 unbuildable cells.',
tags: ['RISK', 'RARE', 'ONE-SHOT']
},
{
@ -369,11 +405,13 @@ export const BALANCE = {
eggFarm: 475,
autoScanner: 850,
mixer: 775,
trash: 950
trash: 950,
sausageMaker: 825
},
processingDelayMaxSeconds: {
mixer: 1.20,
trash: 1.00
trash: 1.00,
sausageMaker: 0.90
},
repairman: {
secondsPerOnePercent: 0.5,
@ -413,6 +451,10 @@ export const BALANCE = {
id: 'truck', type: 'facility', name: 'Truck', shortName: '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'
},
maintenanceRoom: {
id: 'maintenanceRoom', type: 'maintenanceRoom', name: 'Maintenance Room', shortName: 'REPAIR ROOM', price: 220,
buildable: true, upgradeable: false, footprint: { cols: 2, rows: 2 }

View file

@ -26,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', 'maintenanceRoom'];
export const MACHINE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
export const INCOME_FACILITY_IDS = ['mixer', 'truck'];
export const BUILD_TOOL_IDS = ['conveyor', 'boostConveyor', 'eggFarm', 'autoScanner', 'manualScanner', 'mixer', 'trash', 'truck', 'sausageMaker', 'maintenanceRoom'];
export const EDGE_FACILITY_IDS = ['mixer', 'trash', 'truck'];
export const GRID_FACILITY_IDS = ['sausageMaker'];
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]));
@ -48,6 +51,8 @@ export const THEME = {
mixerBlue: '#4aa3ff',
truckPink: '#ffb33e',
wasteGreen: '#88d35a',
meatRed: '#d96b72',
sausageOrange: '#d78b3f',
danger: '#e34a36',
warn: '#ffb827',
white: '#e6edf2',

View file

@ -1,4 +1,5 @@
import { BUILD_COSTS, FACILITY_DEFS, GRID } from './config.js';
import { isOwnedCell } from './gridExpansion.js';
import { nextSpawnDelay } from './state.js';
import { currentPoopRate } from '../systems/events.js';
@ -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,83 @@ 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;
facility.wCells = Math.max(1, facility.wCells || 3);
facility.hCells = Math.max(1, facility.hCells || 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';
}
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,7 +188,7 @@ 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
@ -95,6 +197,24 @@ export function createFacility(game, id, p, price) {
}
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: id === 'sausageMaker' ? 3 : 1,
hCells: id === 'sausageMaker' ? 3 : 1,
gridPlaced: true,
level: 1,
price: price || def?.price || 0,
builtSession: game.buildSession
};
return syncGridFacilityGeometry(f);
}
export function maintenanceRoomFootprint(room) {
if (!room) return [];
return [

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, down: 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,17 @@ 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' };
const down = { chunkX, chunkY: chunkY + 1, direction: 'down' };
for (const candidate of [right, up, down]) {
if (candidate.chunkX < 0) continue;
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 = { right: 0, up: 1, down: 2 };
const directionOrder = { left: 0, right: 1, up: 2, down: 3 };
return [...candidates.values()].sort((a, b) => {
const dir = (directionOrder[a.direction] ?? 9) - (directionOrder[b.direction] ?? 9);
if (dir) return dir;
@ -157,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

@ -21,6 +21,10 @@ export function newTurnStats() {
pendingTruckRevenue: 0,
mixerRevenue: 0,
truckRevenue: 0,
sausageRevenue: 0,
meatProduced: 0,
sausageProduced: 0,
sausageShipped: 0,
chickShipmentIncome: 0,
poopShipmentIncome: 0,
manualComboBonus: 0,
@ -62,6 +66,9 @@ export function newTotalStats() {
poopTruck: 0,
poopTrash: 0,
poopMixer: 0,
meatProduced: 0,
sausageProduced: 0,
sausageShipped: 0,
explosionDamage: 0,
shredderBonus: 0,
eventBonus: 0,
@ -102,7 +109,8 @@ 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(),
@ -136,6 +144,19 @@ 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(),
eventOffer: null,
eventActive: null,
@ -171,7 +192,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() {
@ -263,9 +291,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));
}

View file

@ -33,6 +33,7 @@ export const TEXT = {
mixer: 'MIXER',
truck: 'TRUCK',
trash: 'WASTE',
sausageMaker: 'SAUSAGE',
scanner: 'NEXT',
'scanner-role-1': 'NEXT',
input: 'IN'
@ -66,19 +67,37 @@ 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: 'Ships target chicks and all sausages. Wrong live cargo still leaves a paper trail.',
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,9 +1,9 @@
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 { 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';
@ -12,7 +12,7 @@ import { createUISystem } from './systems/uiSystem.js';
import { createChickSystem } from './systems/chickSystem.js';
import { CARD_DEFS, cardById, conveyorSpeedForGame, createCardSystem, debugCardTargets, debugGrantCard } from './systems/cards.js';
import { floating, shockwave, sparkBurst, updateEffects } from './systems/effects.js';
import { rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
import { EVENT_TARGETS, createEventOffer, rollEventOffer, activateEventOffer, clearActiveEvent, resolveEvent, eventDaySeconds, fairiesTributeMultiplierFromEvent } from './systems/events.js';
import { ensureMaintenanceState, activateRepairmanForDay, deactivateRepairman, conveyorSpeedFactorForKey } from './systems/maintenance.js';
const canvas = document.getElementById('gameCanvas');
@ -21,7 +21,7 @@ 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'),
modal: document.getElementById('modal'), modalTitle: document.getElementById('modalTitle'), modalBody: document.getElementById('modalBody'), modalActions: document.getElementById('modalActions'),
@ -35,13 +35,14 @@ const ui = {
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'), maintenanceRoom: document.getElementById('buildMaintenanceRoomButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
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'), sausageMaker: document.getElementById('buildSausageMakerButton'), maintenanceRoom: document.getElementById('buildMaintenanceRoomButton'), erase: document.getElementById('eraseButton'), undo: document.getElementById('undoButton'), redo: document.getElementById('redoButton')
}
};
@ -56,6 +57,7 @@ function initializeStaticText() {
mixer: 'mixer',
trash: 'trash',
truck: 'truck',
sausageMaker: 'sausageMaker',
maintenanceRoom: 'maintenanceRoom'
};
for (const [buttonKey, toolId] of Object.entries(buttonToolMap)) {
@ -74,7 +76,7 @@ function debugCategoryForCard(card) {
if (['safetyCover', 'preventiveMaintenance', 'durabilityCoating', 'sparePartsBin', 'laborExploitation'].includes(card.id) || tags.has('MAINTENANCE')) return 'Maintenance / Risk';
if (['dudFilter', 'dudRefund', 'freeReroll', 'extraCards'].includes(card.id) || tags.has('CARD')) return 'Card Engine';
if (tags.has('ULTRA RARE')) return 'Ultra Rare';
if (tags.has('ECONOMY') || ['upgradeMixer', 'upgradeTruck', 'recyclingSubsidy', 'usedMachine', 'newMachine', 'flattery', 'legalWork', 'rescueLoan'].includes(card.id)) return 'Economy / Shipment';
if (tags.has('ECONOMY') || ['upgradeMixer', 'upgradeTruck', 'upgradeSausageMaker', 'recyclingSubsidy', 'usedMachine', 'newMachine', 'flattery', 'legalWork', 'rescueLoan'].includes(card.id)) return 'Economy / Shipment';
return 'Other';
}
@ -103,9 +105,38 @@ function initializeDebugPanel() {
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;
@ -144,9 +175,23 @@ function debugSetDay() {
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.phase === 'running') closeFarmShutters();
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();
@ -161,7 +206,7 @@ function debugGrantSelectedCard() {
ui.debug.grantCard.textContent = result.ok ? 'Granted' : 'Failed';
window.setTimeout(() => { if (ui.debug.grantCard) ui.debug.grantCard.textContent = 'Grant Selected'; }, 900);
}
chicks?.updateCongestion?.();
chicks?.updateCongestion?.(game.frameId || 0);
uiSystem.updatePanels();
uiSystem.updateUI();
refreshDebugTargetSelect();
@ -171,7 +216,7 @@ function debugGrantSelectedCard() {
function debugGrantAllCards() {
const results = CARD_DEFS.map(card => debugGrantCard(game, card, { targetKey: '__auto__' }));
const ok = results.filter(result => result.ok).length;
chicks?.updateCongestion?.();
chicks?.updateCongestion?.(game.frameId || 0);
uiSystem.updatePanels();
uiSystem.updateUI();
refreshDebugTargetSelect();
@ -193,6 +238,7 @@ function updateDebugReadout(message = '') {
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(' | ');
@ -217,7 +263,7 @@ function startGame() {
resetCameraToFactoryStart();
ensureMaintenanceState(game);
uiSystem.hideModal();
chicks.updateCongestion();
chicks.updateCongestion(game.frameId || 0);
uiSystem.updatePanels();
uiSystem.updateUI();
updateDebugReadout();
@ -232,6 +278,7 @@ function startNextTurn() {
}
const issues = facilityConnectionIssues(game);
if (issues.length) {
focusFirstStartIssue();
build.fail(`Cannot start: ${issues[0]}`);
uiSystem.updatePanels();
return;
@ -270,7 +317,7 @@ function startNextTurn() {
game.groupDrag = null;
game.selectionBox = null;
game.pan = null;
chicks.updateCongestion();
chicks.updateCongestion(game.frameId || 0);
uiSystem.updatePanels();
uiSystem.updateUI();
}
@ -320,7 +367,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.eventOffer = rollEventOffer(game);
game.eventOffer = debugNextEventOffer();
if (cardSystem) cardSystem.prepareDraft();
game.cardTargetPick = null;
game.buildTool = null;
@ -366,9 +413,11 @@ function update(timestamp) {
if (!game.lastTimestamp) game.lastTimestamp = timestamp;
const dt = Math.min((timestamp - game.lastTimestamp) / 1000, 0.05);
game.lastTimestamp = timestamp;
game.frameId = (game.frameId || 0) + 1;
chicks.beginFrame?.(game.frameId);
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();
@ -434,6 +483,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;
@ -530,7 +616,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>`;
}
@ -541,7 +627,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) {
@ -566,10 +652,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>`);
}
@ -585,6 +671,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 = '';
@ -611,8 +717,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('');
@ -631,11 +737,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);
@ -669,9 +779,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));
@ -683,7 +793,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 */ }
@ -697,7 +807,7 @@ 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.maintenanceRoom.addEventListener('click', () => build.setBuildTool('maintenanceRoom')); ui.buttons.erase.addEventListener('click', () => build.setBuildTool('erase'));
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.sausageMaker.addEventListener('click', () => build.setBuildTool('sausageMaker')); 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);
@ -733,4 +843,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);

View file

@ -1,10 +1,11 @@
import { DIRS, GRID, THEME, EFFECT_PRIORITY, VERSION } from '../core/config.js';
import { expansionCost, expansionLots, lotBounds, expansionButtonBounds, isOwnedCell } from '../core/gridExpansion.js';
import { key, parseKey, cellCenter, mixHex, randomBetween, yen } from '../core/utils.js';
import { scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
import { upgradedMixerPrice, upgradedTruckPrice } from '../systems/economy.js';
import { cardTargetBounds } from '../systems/cards.js';
import { wearRatio } from '../systems/maintenance.js';
import { scannerCenter, scannerConnector, scannerOutputs, destinationLabel, destinationColor, scannerPortHasConveyor, facilityInputCell, facilityOutputCell, facilityOutputHasConveyor, disconnectedBuildWarnings } from '../systems/routing.js';
import { upgradedTruckPrice, upgradedSausagePrice } from '../systems/economy.js';
import { cardTargetBounds, conveyorSpeedForGame } from '../systems/cards.js';
import { wearRatio, conveyorSpeedFactorForKey } from '../systems/maintenance.js';
import { facilityBodyPortPoint } from '../core/entities.js';
// Optional art overlay hook.
// Keep external art probing disabled by default so a clean checkout does not emit 404s for missing PNGs.
@ -33,9 +34,6 @@ const ASSET_PATHS = {
mixer: './assets/dark_factory/facilities/machines/mixer_32.png',
shredder: './assets/dark_factory/facilities/machines/shredder_32.png',
truck: './assets/dark_factory/facilities/vehicles/truck_32.png',
mixerCompact: './assets/dark_factory/facilities/machines/mixer_compact_16.png',
shredderCompact: './assets/dark_factory/facilities/machines/shredder_compact_16.png',
truckCompact: './assets/dark_factory/facilities/vehicles/truck_compact_16.png',
maintenanceRoom: './assets/dark_factory/facilities/utility/maintenance_room_32.png',
repairman: './assets/dark_factory/facilities/utility/repairman_16.png',
obstacleCrate: './assets/dark_factory/facilities/utility/obstacle_crate_16.png'
@ -55,10 +53,23 @@ for (const [name, src] of Object.entries(ASSET_PATHS)) {
// Each draw* function first checks whether an image loaded; if not, it falls back to simple canvas shapes.
function drawCache(game) {
if (!game.frameCache) game.frameCache = {};
if (!game.frameCache.draw) game.frameCache.draw = {};
return game.frameCache.draw;
}
function makeCanvas(width, height) {
const c = document.createElement('canvas');
c.width = Math.max(1, Math.ceil(width));
c.height = Math.max(1, Math.ceil(height));
return c;
}
export function drawAll(ctx, canvas, game, helpers) {
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground(ctx, canvas);
drawBackground(ctx, canvas, game);
ctx.save();
const sx = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
const sy = game.shake.time > 0 ? randomBetween(-game.shake.strength, game.shake.strength) : 0;
@ -190,7 +201,7 @@ function drawFloorSlime(ctx, x, y, col, row) {
ctx.fill();
ctx.restore();
}
function drawBackground(ctx, canvas) {
function renderBackground(ctx, canvas) {
ctx.fillStyle = THEME.bg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
@ -208,6 +219,18 @@ function drawBackground(ctx, canvas) {
for (let y = 0; y < canvas.height; y += 64) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
ctx.restore();
}
function drawBackground(ctx, canvas, game = null) {
const cache = game ? drawCache(game) : null;
const signature = `${canvas.width}x${canvas.height}`;
if (!cache) return renderBackground(ctx, canvas);
if (!cache.background || cache.background.signature !== signature) {
const layer = makeCanvas(canvas.width, canvas.height);
renderBackground(layer.getContext('2d'), layer);
cache.background = { signature, layer };
}
ctx.drawImage(cache.background.layer, 0, 0);
}
function ownedCellKeys(game) {
if (game.ownedCells?.size) return [...game.ownedCells];
const cells = [];
@ -215,7 +238,7 @@ function ownedCellKeys(game) {
return cells;
}
function drawGrid(ctx, game) {
function renderGrid(ctx, game) {
ctx.save();
const cells = ownedCellKeys(game);
for (const k of cells) {
@ -253,17 +276,17 @@ function drawGrid(ctx, game) {
ctx.beginPath();
ctx.rect(Math.round(x + 1), Math.round(y + 1), GRID.cell - 2, GRID.cell - 2);
ctx.clip();
ctx.fillStyle = 'rgba(70,72,76,.16)';
ctx.fillStyle = 'rgba(124,82,36,.36)';
ctx.fillRect(Math.round(x), Math.round(y), GRID.cell, GRID.cell);
ctx.lineWidth = 6;
ctx.strokeStyle = 'rgba(165,145,73,.24)';
ctx.strokeStyle = 'rgba(255,199,73,.72)';
for (let stripe = -GRID.cell; stripe < GRID.cell * 2; stripe += 14) {
ctx.beginPath();
ctx.moveTo(x + stripe, y + GRID.cell);
ctx.lineTo(x + stripe + GRID.cell, y);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(27,29,33,.22)';
ctx.strokeStyle = 'rgba(18,20,24,.58)';
for (let stripe = -GRID.cell + 7; stripe < GRID.cell * 2; stripe += 14) {
ctx.beginPath();
ctx.moveTo(x + stripe, y + GRID.cell);
@ -271,21 +294,70 @@ function drawGrid(ctx, game) {
ctx.stroke();
}
ctx.restore();
ctx.strokeStyle = 'rgba(20,23,26,.42)';
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,213,91,.86)';
ctx.lineWidth = 2;
ctx.strokeRect(Math.round(x) + 1.5, Math.round(y) + 1.5, GRID.cell - 3, GRID.cell - 3);
ctx.strokeStyle = 'rgba(20,23,26,.82)';
ctx.lineWidth = 1;
ctx.strokeRect(Math.round(x) + 5.5, Math.round(y) + 5.5, GRID.cell - 11, GRID.cell - 11);
}
ctx.restore();
}
function gridCacheSignature(game) {
const owned = game.ownedCells?.size ? [...game.ownedCells].sort().join('|') : `default:${GRID.cols}x${GRID.rows}`;
const blocked = [...(game.blockedCells || [])].sort().join('|');
return `${GRID.x},${GRID.y},${GRID.cols},${GRID.rows};${owned};${blocked}`;
}
function gridLayerBounds(game) {
const cells = ownedCellKeys(game).map(parseKey);
for (const k of game.blockedCells || []) cells.push(parseKey(k));
if (!cells.length) return { x: 0, y: 0, w: 1, h: 1 };
const minCol = Math.min(...cells.map(p => p.col));
const maxCol = Math.max(...cells.map(p => p.col));
const minRow = Math.min(...cells.map(p => p.row));
const maxRow = Math.max(...cells.map(p => p.row));
return {
x: GRID.x + minCol * GRID.cell - 6,
y: GRID.y + minRow * GRID.cell - 6,
w: (maxCol - minCol + 1) * GRID.cell + 12,
h: (maxRow - minRow + 1) * GRID.cell + 12
};
}
function drawGrid(ctx, game) {
const cache = drawCache(game);
const signature = gridCacheSignature(game);
if (!cache.grid || cache.grid.signature !== signature) {
const bounds = gridLayerBounds(game);
const layer = makeCanvas(bounds.w, bounds.h);
const layerCtx = layer.getContext('2d');
layerCtx.translate(-bounds.x, -bounds.y);
renderGrid(layerCtx, game);
cache.grid = { signature, bounds, layer };
}
const { bounds, layer } = cache.grid;
ctx.drawImage(layer, bounds.x, bounds.y);
}
function drawExpansionOffers(ctx, game) {
if (game.phase !== 'build' || game.cardDraft?.pending || game.cardTargetPick?.pending) return;
const cost = expansionCost(game);
const affordable = game.cash >= cost;
const lotBlockedByFacility = lot => {
const b = lotBounds(lot);
return Object.values(game.facilities || {}).some(f => {
const kind = f?.baseId || f?.id;
if (!['mixer', 'trash', 'truck'].includes(kind)) return false;
return !(b.x + b.w <= f.x || f.x + f.w <= b.x || b.y + b.h <= f.y || f.y + f.h <= b.y);
});
};
for (const lot of expansionLots(game)) {
const disabled = lotBlockedByFacility(lot);
const b = lotBounds(lot);
ctx.save();
ctx.fillStyle = affordable ? 'rgba(183,245,198,.34)' : 'rgba(255,255,255,.22)';
ctx.strokeStyle = affordable ? THEME.green : THEME.muted;
ctx.fillStyle = disabled ? 'rgba(112,130,118,.34)' : (affordable ? 'rgba(118,226,142,.36)' : 'rgba(255,255,255,.18)');
ctx.strokeStyle = disabled ? '#708278' : (affordable ? '#6bd579' : THEME.muted);
ctx.lineWidth = 5;
ctx.setLineDash([16, 10]);
rect(ctx, b.x, b.y, b.w, b.h, true, true);
@ -297,17 +369,16 @@ function drawExpansionOffers(ctx, game) {
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 4;
rect(ctx, button.x, button.y, button.w, button.h, true, true);
ctx.fillStyle = affordable ? THEME.green : THEME.danger;
ctx.fillStyle = disabled ? '#7c9285' : (affordable ? THEME.green : THEME.danger);
ctx.font = '900 16px ui-monospace, monospace';
ctx.textAlign = 'center';
const lotLabel = lot.direction === 'right' ? 'BUY RIGHT LOT' : (lot.direction === 'down' ? 'BUY LOWER LOT' : 'BUY UPPER LOT');
const lotLabel = lot.direction === 'left'
? 'BUY LEFT LOT'
: (lot.direction === 'right' ? 'BUY RIGHT LOT' : (lot.direction === 'down' ? 'BUY LOWER LOT' : 'BUY UPPER LOT'));
ctx.fillText(lotLabel, cx, cy - 12);
ctx.fillStyle = THEME.ink;
ctx.font = '900 16px ui-monospace, monospace';
ctx.fillText(`${yen(cost)} / 10×10`, cx, cy + 15);
ctx.fillStyle = THEME.muted;
ctx.font = '900 11px ui-monospace, monospace';
ctx.fillText('blocked tiles included', cx, cy + 30);
ctx.restore();
}
}
@ -352,7 +423,35 @@ function conveyorAssetForTile(game, k, meta = {}) {
if (dirs.length === 1) return has('up') || has('down') ? assets[`${prefix}StraightV`] : assets[`${prefix}StraightH`];
return has('up') || has('down') ? assets[`${prefix}StraightV`] : assets[`${prefix}StraightH`];
}
function drawConveyors(ctx, game) {
function conveyorCacheSignature(game) {
const assetsReady = Object.entries(assets)
.filter(([name]) => name.includes('conveyor') || name.includes('Conveyor'))
.map(([name, img]) => `${name}:${img.loaded ? 1 : 0}`)
.join('|');
const tiles = [...game.conveyorTiles].sort().map(k => {
const meta = game.conveyorMeta.get(k) || {};
return `${k}:${meta.kind || ''}:${meta.dir || ''}:${(meta.outDirs || []).join(',')}:${meta.branchMode || ''}`;
}).join('|');
return `${game.routingVersion || 0};${assetsReady};${tiles}`;
}
function conveyorLayerBounds(game) {
if (!game.conveyorTiles?.size) return { x: 0, y: 0, w: 1, h: 1 };
const cells = [...game.conveyorTiles].map(parseKey);
const minCol = Math.min(...cells.map(p => p.col));
const maxCol = Math.max(...cells.map(p => p.col));
const minRow = Math.min(...cells.map(p => p.row));
const maxRow = Math.max(...cells.map(p => p.row));
return {
x: GRID.x + minCol * GRID.cell - 36,
y: GRID.y + minRow * GRID.cell - 36,
w: (maxCol - minCol + 1) * GRID.cell + 72,
h: (maxRow - minRow + 1) * GRID.cell + 72
};
}
function conveyorRenderData(game) {
const edges = [];
const seen = new Set();
for (const k of game.conveyorTiles) {
@ -364,64 +463,122 @@ function drawConveyors(ctx, game) {
const e = [k, nk].sort().join('|');
if (seen.has(e)) continue;
seen.add(e);
const ratio = Math.max(componentRatio(game, k), componentRatio(game, nk));
edges.push({ a: cellCenter(col, row), b: cellCenter(n.col, n.row), ratio });
edges.push({ fromKey: k, toKey: nk, a: cellCenter(col, row), b: cellCenter(n.col, n.row) });
}
}
const directionMarkers = collectConveyorDirectionMarkers(game);
const tiles = [...game.conveyorTiles].map(k => {
const p = parseKey(k);
const c = cellCenter(p.col, p.row);
const meta = game.conveyorMeta.get(k) || {};
const connectedDirs = connectedConveyorDirs(game, k);
return {
k,
c,
meta,
connectedDirs,
branchDirs: branchSelectableExitDirs(game, k, connectedDirs),
markers: directionMarkers.get(k) || [],
sprite: conveyorAssetForTile(game, k, meta)
};
});
return { edges, tiles };
}
function renderConveyorBase(ctx, data) {
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (const layer of [
{ width: 31, color: '#0b0c0f', alpha: 1 },
{ width: 23, color: '#444b56', alpha: 1 },
{ width: 15, color: '#171a1f', alpha: 1 }
]) {
ctx.globalAlpha = layer.alpha; ctx.strokeStyle = layer.color; ctx.lineWidth = layer.width;
for (const e of edges) { ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke(); }
ctx.globalAlpha = layer.alpha;
ctx.strokeStyle = layer.color;
ctx.lineWidth = layer.width;
for (const e of data.edges) {
ctx.beginPath();
ctx.moveTo(e.a.x, e.a.y);
ctx.lineTo(e.b.x, e.b.y);
ctx.stroke();
}
}
for (const e of edges) {
const t = Math.max(0, (e.ratio - 0.5) / 0.5);
ctx.globalAlpha = 1;
for (const tile of data.tiles) {
const { c, meta, sprite } = tile;
ctx.fillStyle = 'rgba(0,0,0,.35)';
ctx.strokeStyle = 'rgba(255,184,39,.34)';
ctx.lineWidth = 1;
rect(ctx, c.x - 20, c.y - 20, 40, 40, true, true);
if (!drawImageIfLoaded(ctx, sprite, c.x - 19, c.y - 19, 38, 38)) {
ctx.fillStyle = meta.kind === 'boostConveyor' ? '#315e92' : '#454b55';
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 2;
rect(ctx, c.x - 18, c.y - 18, 36, 36, true, true);
}
}
ctx.restore();
}
function drawConveyors(ctx, game) {
const cache = drawCache(game);
const signature = conveyorCacheSignature(game);
if (!cache.conveyors || cache.conveyors.signature !== signature) {
const data = conveyorRenderData(game);
const bounds = conveyorLayerBounds(game);
const layer = makeCanvas(bounds.w, bounds.h);
const layerCtx = layer.getContext('2d');
layerCtx.translate(-bounds.x, -bounds.y);
renderConveyorBase(layerCtx, data);
cache.conveyors = { signature, bounds, layer, data };
}
const { bounds, layer, data } = cache.conveyors;
ctx.drawImage(layer, bounds.x, bounds.y);
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (const e of data.edges) {
const ratio = Math.max(componentRatio(game, e.fromKey), componentRatio(game, e.toKey));
const t = Math.max(0, (ratio - 0.5) / 0.5);
ctx.strokeStyle = mixHex(THEME.green, THEME.danger, t);
ctx.globalAlpha = e.ratio > 0.5 ? 0.35 + t * 0.55 : 0.8;
ctx.globalAlpha = ratio > 0.5 ? 0.35 + t * 0.55 : 0.8;
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke();
}
const directionMarkers = collectConveyorDirectionMarkers(game);
for (const k of game.conveyorTiles) {
const c = cellCenter(...Object.values(parseKey(k)));
const meta = game.conveyorMeta.get(k) || {};
ctx.globalAlpha = 1;
for (const tile of data.tiles) {
const { k, c, meta } = tile;
const ratio = componentRatio(game, k);
const sprite = conveyorAssetForTile(game, k, meta);
const congested = ratio > 0.5;
ctx.fillStyle = congested ? 'rgba(196,36,36,.28)' : 'rgba(0,0,0,.35)';
ctx.strokeStyle = congested ? THEME.danger : 'rgba(255,184,39,.34)';
ctx.lineWidth = congested ? 3 : 1;
rect(ctx, c.x - 20, c.y - 20, 40, 40, true, true);
if (!drawImageIfLoaded(ctx, sprite, c.x - 19, c.y - 19, 38, 38)) {
const baseFill = meta.kind === 'boostConveyor' ? '#315e92' : '#454b55';
ctx.fillStyle = ratio > 0.5 ? mixHex(baseFill, '#a12c2c', Math.max(0, (ratio - .5) / .5)) : baseFill;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 2;
rect(ctx, c.x - 18, c.y - 18, 36, 36, true, true);
if (ratio > 0.5) {
ctx.fillStyle = 'rgba(196,36,36,.28)';
ctx.strokeStyle = THEME.danger;
ctx.lineWidth = 3;
rect(ctx, c.x - 20, c.y - 20, 40, 40, true, true);
}
drawAnimatedConveyorStripes(ctx, c, k, meta, ratio);
drawAnimatedConveyorStripes(ctx, game, c, k, meta, ratio);
drawDirtOverlay(ctx, c.x - 18, c.y - 18, 36, 36, { meta });
drawSelection(ctx, game, 'conveyor', k, c.x, c.y, 38, 38);
drawConveyorDirectionMarkers(ctx, c, directionMarkers.get(k) || []);
const connectedDirs = connectedConveyorDirs(game, k);
drawBranchModeIcon(ctx, c, meta, branchSelectableExitDirs(game, k, connectedDirs), connectedDirs);
drawConveyorDirectionMarkers(ctx, c, tile.markers);
drawBranchModeIcon(ctx, c, meta, tile.branchDirs, tile.connectedDirs);
if (ratio > 0.5) label(ctx, c.x, c.y - 15, `${Math.floor(ratio * 100)}%`, THEME.danger);
}
ctx.restore();
}
function drawAnimatedConveyorStripes(ctx, center, tileKey, meta = {}, ratio = 0) {
export function drawAnimatedConveyorStripes(ctx, game, center, tileKey, meta = {}, ratio = 0) {
const boost = meta.kind === 'boostConveyor';
const names = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))];
const primary = names[0] || 'right';
const verticalTravel = primary === 'up' || primary === 'down';
const reverse = primary === 'left' || primary === 'up';
const boost = meta.kind === 'boostConveyor';
const t = typeof performance !== 'undefined' ? performance.now() / (boost ? 70 : 105) : 0;
const phase = ((reverse ? -t : t) % 10 + 10) % 10;
// Stripe travel uses the same effective px/s as chicks on this exact tile,
// including Boost x2, card upgrades, events, and wear. This keeps the visual
// belt movement synchronized with item movement instead of using a fixed timer.
const effectiveSpeed = conveyorSpeedForGame(game, tileKey) * conveyorSpeedFactorForKey(game, tileKey);
const elapsedSeconds = typeof performance !== 'undefined' ? performance.now() / 1000 : 0;
const travel = elapsedSeconds * effectiveSpeed;
const phase = ((reverse ? -travel : travel) % 10 + 10) % 10;
ctx.save();
ctx.beginPath();
ctx.rect(center.x - 18, center.y - 18, 36, 36);
@ -471,6 +628,7 @@ function reachableConveyorDirs(game, k, meta) {
.map(d => d.name);
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(name => neighborNames.includes(name));
if (neighborNames.length === 1 && (explicit.length ? explicit.includes(neighborNames[0]) : true) && neighborPointsIntoCell(game, p, neighborNames[0])) return [];
const inferred = neighborNames;
return explicit.length ? explicit : inferred;
}
@ -496,7 +654,7 @@ function branchExitCandidates(game, k, dirs, connectedDirs = connectedConveyorDi
const p = parseKey(k);
const physicalEntrances = new Set(connectedDirs.filter(dir => neighborPointsIntoCell(game, p, dir)));
const physicalExits = connectedDirs.filter(dir => !physicalEntrances.has(dir));
if (physicalEntrances.size && physicalExits.length >= 2) return physicalExits;
if (physicalEntrances.size) return physicalExits;
const explicit = dirs.filter(dir => connectedDirs.includes(dir));
if (explicit.length >= 2) return [...new Set(explicit)];
return connectedDirs;
@ -521,6 +679,7 @@ function effectiveConveyorDirs(game, k, meta) {
.map(d => d.name);
const explicit = [...new Set([...(Array.isArray(meta.outDirs) ? meta.outDirs : []), meta.dir].filter(Boolean))]
.filter(name => neighborNames.includes(name));
if (neighborNames.length === 1 && (explicit.length ? explicit.includes(neighborNames[0]) : true) && neighborPointsIntoCell(game, p, neighborNames[0])) return [];
const inferred = neighborNames;
return explicit.length ? explicit : inferred;
}
@ -542,7 +701,7 @@ function drawBranchModeIcon(ctx, center, meta, dirs, connectedDirs = dirs) {
ctx.fillStyle = '#020304';
if (mode !== 'random') {
const d = DIRS.find(item => item.name === mode);
drawDirectionTriangle(ctx, center.x, center.y, d?.angle || 0, 9, 6);
drawDirectionTriangle(ctx, center.x, center.y - 4, d?.angle || 0, 8, 5);
} else {
ctx.beginPath();
ctx.moveTo(center.x - 10, center.y);
@ -554,6 +713,16 @@ function drawBranchModeIcon(ctx, center, meta, dirs, connectedDirs = dirs) {
drawDirectionTriangle(ctx, center.x + 9, center.y - 9, -Math.PI / 4, 4, 3);
drawDirectionTriangle(ctx, center.x + 9, center.y + 9, Math.PI / 4, 4, 3);
}
if (mode === 'random') {
ctx.font = '900 9px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = 3;
ctx.strokeStyle = '#020304';
ctx.fillStyle = THEME.warn;
ctx.strokeText('RND', center.x, center.y + 11);
ctx.fillText('RND', center.x, center.y + 11);
}
ctx.restore();
}
@ -604,9 +773,9 @@ function drawDirectionTriangle(ctx, x, y, angle, length = 11, halfWidth = 8) {
function drawScannerPortDuct(ctx, scanner, type, p, color) {
const c = scannerCenter(scanner);
let target = null;
if (type === 'inputA') target = { x: c.x, y: c.y - 67 };
if (type === 'left') target = { x: c.x - 49, y: c.y + 14 };
if (type === 'right') target = { x: c.x + 49, y: c.y + 14 };
if (type === 'inputA') target = { x: c.x, y: c.y - 40 };
if (type === 'left') target = { x: c.x - 42, y: c.y + 10 };
if (type === 'right') target = { x: c.x + 42, y: c.y + 10 };
if (!target) return;
const elbow = type === 'inputA'
? { x: p.x, y: target.y }
@ -716,42 +885,39 @@ function drawScanner(ctx, scanner, game) {
ctx.save();
ctx.globalAlpha = Math.min(0.55, 0.16 + combo / 120);
ctx.strokeStyle = combo >= 30 ? THEME.warn : THEME.green;
ctx.lineWidth = Math.min(14, 5 + Math.floor(combo / 10));
ctx.lineWidth = Math.min(12, 4 + Math.floor(combo / 10));
ctx.shadowColor = ctx.strokeStyle;
ctx.shadowBlur = Math.min(28, 8 + combo);
rect(ctx, c.x - 54, c.y - 76, 108, 152, false, true);
ctx.shadowBlur = Math.min(24, 8 + combo);
rect(ctx, c.x - 48, c.y - 48, 96, 96, false, true);
ctx.restore();
}
const img = scanner.kind === 'auto' ? assets.scannerAuto : assets.scannerManual;
if (drawImageIfLoaded(ctx, img, c.x - 50, c.y - 73, 100, 146)) {
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 50, c.y - 73, 100, 146, scanner);
if (drawImageIfLoaded(ctx, img, c.x - 44, c.y - 44, 88, 88)) {
if (scanner.kind === 'auto') drawDirtOverlay(ctx, c.x - 44, c.y - 44, 88, 88, scanner);
else drawManualKeyboardIcon(ctx, scanner, c);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 108, 154);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 96, 96);
ctx.restore(); return;
}
ctx.fillStyle = scanner.kind === 'auto' ? '#d8ffe2' : THEME.white;
ctx.strokeStyle = THEME.ink; ctx.lineWidth = 4;
rect(ctx, c.x - 50, c.y - 73, 100, 146, true, true);
rect(ctx, c.x - 44, c.y - 44, 88, 88, true, true);
ctx.fillStyle = scanner.kind === 'auto' ? THEME.green : THEME.ink;
ctx.font = '900 19px ui-monospace, monospace'; ctx.textAlign = 'center';
ctx.fillText(`${scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`}`, c.x, c.y - 48);
ctx.font = '900 11px ui-monospace, monospace';
ctx.font = '900 16px ui-monospace, monospace'; ctx.textAlign = 'center';
ctx.fillText(scanner.kind === 'auto' ? 'AUTO' : `S${(scanner.slot ?? 0) + 1}`, c.x, c.y - 24);
ctx.font = '900 9px ui-monospace, monospace';
const leftKey = scanner.keys?.left?.label || (scanner.slot === 0 ? 'A' : 'Left');
const rightKey = scanner.keys?.right?.label || (scanner.slot === 0 ? 'D' : 'Right');
ctx.fillText(scanner.role === 0 ? `${leftKey}:M ${rightKey}:NEXT` : `${leftKey}:WASTE ${rightKey}:TRUCK`, c.x, c.y - 28);
ctx.fillText(scanner.kind === 'auto' ? '2.1 FARMS' : `${leftKey}/${rightKey}`, c.x, c.y - 8);
const q = scanner.queue.length;
if (scanner.kind === 'manual') {
drawManualKeyboardIcon(ctx, scanner, c);
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`Q:${q}`, c.x, c.y + 54);
} else {
ctx.fillStyle = q > 0 ? THEME.green : THEME.muted;
ctx.font = '900 12px ui-monospace, monospace';
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 54);
drawDirtOverlay(ctx, c.x - 50, c.y - 73, 100, 146, scanner);
ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`Q:${q} CD:${scanner.cooldown.toFixed(1)}`, c.x, c.y + 35);
drawDirtOverlay(ctx, c.x - 44, c.y - 44, 88, 88, scanner);
}
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 108, 154);
drawSelection(ctx, game, 'scanner', scanner.id, c.x, c.y, 96, 96);
ctx.restore();
}
@ -765,7 +931,7 @@ function drawManualKeyboardIcon(ctx, scanner, c) {
labels[1].text = scanner.keys?.right?.label || labels[1].text;
const pressedSide = scanner.keyPressTime > 0 ? scanner.keyPressSide : null;
const baseX = c.x - 30;
const baseY = c.y + 18;
const baseY = c.y + 8;
ctx.save();
ctx.lineWidth = 3;
ctx.fillStyle = '#f7fff5';
@ -800,6 +966,8 @@ function targetForTruck(game) {
}
function drawItemIcon(ctx, x, y, type) {
if (type === 'poop') drawTinyPoop(ctx, x, y);
else if (type === 'meat') drawMeatItem(ctx, x, y, 9);
else if (type === 'sausage') drawSausageItem(ctx, x, y, 10);
else drawTinyChick(ctx, x, y, type, false);
}
function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
@ -823,6 +991,7 @@ function drawTargetBadge(ctx, x, y, w, title, type, subtitle = '') {
function receiverTitle(id, game) {
if (id === 'mixer') return { title: 'IN: MALE', type: 'male', color: THEME.mixerBlue };
if (id === 'trash') return { title: 'IN: POOP', type: 'poop', color: THEME.wasteGreen };
if (id === 'sausageMaker') return { title: 'IN: MEAT', type: 'meat', color: THEME.sausageOrange };
if (id === 'truck') return { title: `IN: ${targetForTruck(game).toUpperCase()}`, type: targetForTruck(game), color: THEME.truckPink };
return { title: 'IN', type: 'female', color: THEME.green };
}
@ -831,10 +1000,22 @@ function facilityKind(fOrId) {
}
function drawFacilityReceiver(ctx, game, facility) {
const f = typeof facility === 'string' ? game.facilities[facility] : facility;
if (!f?.entry) return;
const input = facilityInputCell(game, f);
if (!f || !input) return;
const id = facilityKind(f);
const c = cellCenter(f.entry.col, f.entry.row);
const c = cellCenter(input.col, input.row);
const info = receiverTitle(id, game);
if (f.gridPlaced) {
const bodyPort = facilityBodyPortPoint(f, 'input');
if (bodyPort) {
ctx.save();
ctx.strokeStyle = '#07080a'; ctx.lineWidth = 11; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bodyPort.x, bodyPort.y); ctx.stroke();
ctx.strokeStyle = '#6f4a43'; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(bodyPort.x, bodyPort.y); ctx.stroke();
ctx.restore();
}
}
ctx.save();
ctx.fillStyle = '#191c22';
ctx.strokeStyle = info.color;
@ -940,17 +1121,56 @@ function drawFacilities(ctx, game) {
if (kind === 'mixer') drawMixer(ctx, game, f);
else if (kind === 'trash') drawTrash(ctx, game, f);
else if (kind === 'truck') drawTruck(ctx, game, f);
else if (kind === 'sausageMaker') drawSausageMaker(ctx, game, f);
}
for (const f of Object.values(game.facilities || {})) {
drawFacilityReceiver(ctx, game, f);
drawFacilityOutput(ctx, game, f);
}
for (const f of Object.values(game.facilities || {})) drawFacilityReceiver(ctx, game, f);
}
function drawFacilityOutput(ctx, game, f) {
const p = facilityOutputCell(game, f);
if (!p) return;
const c = cellCenter(p.col, p.row);
const bodyPort = facilityBodyPortPoint(f, 'output');
if (bodyPort) {
ctx.save();
ctx.strokeStyle = '#07080a';
ctx.lineWidth = 11;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(bodyPort.x, bodyPort.y); ctx.lineTo(c.x, c.y); ctx.stroke();
ctx.strokeStyle = '#77524a'; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(bodyPort.x, bodyPort.y); ctx.lineTo(c.x, c.y); ctx.stroke();
ctx.restore();
}
const kind = facilityKind(f);
const product = kind === 'mixer' ? 'meat' : 'sausage';
const color = kind === 'mixer' ? THEME.meatRed : THEME.sausageOrange;
const connected = facilityOutputHasConveyor(game, f);
ctx.save();
ctx.fillStyle = connected ? '#1d2423' : '#3a1e22';
ctx.strokeStyle = connected ? color : THEME.danger;
ctx.lineWidth = 5;
rect(ctx, c.x - 22, c.y - 22, 44, 44, true, true);
drawItemIcon(ctx, c.x, c.y - 3, product);
ctx.fillStyle = THEME.white;
ctx.font = '900 10px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText('OUT', c.x, c.y + 20);
ctx.restore();
}
function drawExternalDuct(ctx, f) {
if (!f?.entry) return;
if (!f?.entry || f.gridPlaced) return;
const c = cellCenter(f.entry.col, f.entry.row);
let bx = f.x + f.w / 2, by = f.y + f.h / 2;
if (f.side === 'left') { bx = f.x + f.w; by = c.y; }
else if (f.side === 'right') { bx = f.x; by = c.y; }
else if (f.side === 'top') { bx = c.x; by = f.y + f.h; }
else if (f.side === 'bottom') { bx = c.x; by = f.y; }
const bodyPort = facilityBodyPortPoint(f, 'input');
let bx = bodyPort?.x ?? (f.x + f.w / 2), by = bodyPort?.y ?? (f.y + f.h / 2);
if (facilityKind(f) !== 'mixer') {
if (f.side === 'left') { bx = f.x + f.w; by = c.y; }
else if (f.side === 'right') { bx = f.x; by = c.y; }
else if (f.side === 'top') { bx = c.x; by = f.y + f.h; }
else if (f.side === 'bottom') { bx = c.x; by = f.y; }
}
ctx.save();
ctx.strokeStyle = '#07080a';
ctx.lineWidth = 11;
@ -968,7 +1188,7 @@ function drawMixer(ctx, game, m = game.facilities.mixer) {
ctx.fillStyle = THEME.white; ctx.font = '900 14px ui-monospace, monospace'; ctx.textAlign = 'left';
ctx.fillText(`MIXER L${m.level}`, m.x + 14, m.y + 18);
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`PAY ${yen(upgradedMixerPrice(game))}`, m.x + 14, m.y + 34);
ctx.fillText(`MEAT ${m.meatBuffer || 0} | SAUSAGE ${yen(upgradedSausagePrice(game))}`, m.x + 14, m.y + 34);
ctx.fillStyle = '#1a2028'; ctx.strokeStyle = '#87bfff'; ctx.lineWidth = 4;
rect(ctx, m.x + 44, m.y + 34, 76, 50, true, true);
ctx.strokeStyle = '#4aa3ff'; ctx.lineWidth = 3;
@ -979,7 +1199,7 @@ function drawMixer(ctx, game, m = game.facilities.mixer) {
rect(ctx, m.x + 56, m.y + 24, 12, 10, true, true);
rect(ctx, m.x + 96, m.y + 24, 12, 10, true, true);
ctx.fillStyle = '#7ea9dc'; ctx.fillRect(m.x + 48, m.y + 42, 68, 5);
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'SEND MALE', 'male', 'safe meat route');
drawTargetBadge(ctx, m.x + 14, m.y + m.h - 40, m.w - 28, 'CHICK -> MEAT', 'meat', 'connect OUT to sausage machine');
drawDirtOverlay(ctx, m.x, m.y, m.w, m.h, m);
drawSelection(ctx, game, 'facility', m.id, m.x + m.w / 2, m.y + m.h / 2, m.w + 12, m.h + 12);
ctx.restore();
@ -1013,6 +1233,30 @@ function drawTrash(ctx, game, t = game.facilities.trash) {
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore();
}
function drawSausageMaker(ctx, game, s) {
ctx.save();
drawMetalPanel(ctx, s.x, s.y, s.w, s.h, '#3b3029', THEME.ink, THEME.sausageOrange);
drawFacilityDetails(ctx, s, THEME.sausageOrange);
ctx.fillStyle = THEME.white;
ctx.font = '900 14px ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(`SAUSAGE L${s.level}`, s.x + 14, s.y + 18);
ctx.fillStyle = THEME.muted;
ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`2 MEAT -> 1 | ${yen(upgradedSausagePrice(game))}`, s.x + 14, s.y + 34);
ctx.fillStyle = '#211915';
ctx.strokeStyle = THEME.sausageOrange;
ctx.lineWidth = 4;
rect(ctx, s.x + 20, s.y + 43, s.w - 40, 38, true, true);
for (let i = 0; i < 3; i += 1) drawSausageItem(ctx, s.x + s.w / 2 - 28 + i * 28, s.y + 62, 9);
ctx.fillStyle = THEME.meatRed; ctx.fillRect(s.x, s.y + s.h / 2 - 8, 8, 16);
ctx.fillStyle = THEME.sausageOrange; ctx.fillRect(s.x + s.w - 8, s.y + s.h / 2 - 8, 8, 16);
drawTargetBadge(ctx, s.x + 10, s.y + s.h - 42, s.w - 20, '2 MEAT -> SAUSAGE', 'sausage', `buffer ${s.meatBuffer || 0}/2`);
drawDirtOverlay(ctx, s.x, s.y, s.w, s.h, s);
drawSelection(ctx, game, 'facility', s.id, s.x + s.w / 2, s.y + s.h / 2, s.w + 12, s.h + 12);
ctx.restore();
}
function drawTruck(ctx, game, t = game.facilities.truck) {
ctx.save();
drawExternalDuct(ctx, t);
@ -1022,11 +1266,15 @@ function drawTruck(ctx, game, t = game.facilities.truck) {
ctx.fillText(`TRUCK L${t.level}`, t.x + 14, t.y + 18);
ctx.fillStyle = THEME.muted; ctx.font = '900 10px ui-monospace, monospace';
ctx.fillText(`UNIT ${yen(upgradedTruckPrice(game))}`, t.x + 14, t.y + 34);
drawFacilityTexture(ctx, assets.truckCompact.loaded ? assets.truckCompact : assets.truck, t, 2.0);
drawFacilityTexture(ctx, assets.truck, t, 2.0);
const truckTargetType = targetForTruck(game);
drawTargetBadge(ctx, t.x + 15, t.y + 50, t.w - 30, `SEND ${truckTargetType.toUpperCase()}`, truckTargetType, game.eventOffer && !game.eventActive ? 'next event' : 'truck cargo');
ctx.fillStyle = 'rgba(39,60,36,.9)'; rect(ctx, t.x + 13, t.y + 88, t.w - 26, Math.max(18, t.h - 103), true, false);
for (const cargo of game.truckCargo) cargo.sex === 'poop' ? drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y) : drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
for (const cargo of game.truckCargo) {
if (cargo.sex === 'poop') drawTinyPoop(ctx, t.x + cargo.x, t.y + cargo.y);
else if (cargo.sex === 'sausage') drawSausageItem(ctx, t.x + cargo.x, t.y + cargo.y, 7);
else drawTinyChick(ctx, t.x + cargo.x, t.y + cargo.y, cargo.sex, cargo.sex === 'male');
}
drawDirtOverlay(ctx, t.x, t.y, t.w, t.h, t);
drawSelection(ctx, game, 'facility', t.id, t.x + t.w / 2, t.y + t.h / 2, t.w + 12, t.h + 12);
ctx.restore();
@ -1046,6 +1294,8 @@ function drawChick(ctx, chick, active, game) {
if (active) { ctx.strokeStyle = THEME.green; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 8, 0, Math.PI * 2); ctx.stroke(); }
if (rage) { ctx.strokeStyle = THEME.danger; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(chick.x, y, chick.radius + 12 + Math.sin(chick.bob * 2) * 4, 0, Math.PI * 2); ctx.stroke(); }
if (chick.sex === 'poop') drawPoop(ctx, chick.x, y, chick.radius);
else if (chick.sex === 'meat') drawMeatItem(ctx, chick.x, y, chick.radius);
else if (chick.sex === 'sausage') drawSausageItem(ctx, chick.x, y, chick.radius);
else {
const img = chick.sex === 'male' ? assets.chickMale : assets.chickFemale;
const spriteDrawn = drawImageIfLoaded(ctx, img, chick.x - chick.radius, y - chick.radius, chick.radius * 2, chick.radius * 2);
@ -1092,6 +1342,31 @@ function drawPoop(ctx, x, y, radius) {
ctx.fillStyle = THEME.white; ctx.beginPath(); ctx.arc(x - 5, y + 1, 2, 0, Math.PI * 2); ctx.arc(x + 5, y + 1, 2, 0, Math.PI * 2); ctx.fill(); ctx.restore();
}
function drawTinyPoop(ctx, x, y) { drawPoop(ctx, x, y, 7); }
function drawMeatItem(ctx, x, y, radius = 10) {
ctx.save();
ctx.fillStyle = THEME.meatRed;
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.ellipse(x, y, radius * 1.15, radius * 0.78, -0.25, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = '#f1b8b8';
ctx.beginPath(); ctx.arc(x - radius * 0.25, y - radius * 0.12, radius * 0.22, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawSausageItem(ctx, x, y, radius = 10) {
ctx.save();
ctx.translate(x, y); ctx.rotate(-0.2);
ctx.fillStyle = THEME.sausageOrange;
ctx.strokeStyle = THEME.ink;
ctx.lineWidth = 3;
rect(ctx, -radius, -radius * 0.45, radius * 2, radius * 0.9, true, true);
ctx.strokeStyle = '#8a4823'; ctx.lineWidth = 2;
for (const dx of [-radius * 0.45, radius * 0.45]) {
ctx.beginPath(); ctx.moveTo(dx, -radius * 0.36); ctx.lineTo(dx, radius * 0.36); ctx.stroke();
}
ctx.restore();
}
function drawTinyChick(ctx, x, y, sex, warning = false) {
ctx.save();
const bodyColor = sex === 'male' ? THEME.male : THEME.female;

View file

@ -1,13 +1,13 @@
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, createMaintenanceRoom, maintenanceRoomFootprint } from '../core/entities.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, shiftRowIndexedSet, shiftRowIndexedMap, ownChunk } from '../core/gridExpansion.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, shredderBonusIncome, 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';
@ -22,7 +22,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); }
@ -45,7 +45,26 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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);
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 +75,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', 'down'].includes(request) ? request : 'right';
const direction = ['left', 'right', 'up', 'down'].includes(request) ? request : 'right';
return expansionLot(game, direction);
}
@ -69,6 +112,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 +126,13 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
}
record(game);
spendCash(game, cost);
if (!game.gridExpansionPurchasesByDirection) game.gridExpansionPurchasesByDirection = { right: 0, up: 0, down: 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));
@ -136,10 +189,21 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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;
}
@ -155,6 +219,9 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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;
@ -169,11 +236,34 @@ 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 draft = { baseId: id, id, gridPlaced: true, col, row, wCells: id === 'sausageMaker' ? 3 : 1, hCells: 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') {
const input = { col: col - 1, row: row + 1 };
const output = { col: col + 3, row: row + 1 };
if (!pointInGrid(input.col, input.row) || !pointInGrid(output.col, output.row)) return true;
if (isBlockedCell(input.col, input.row) || isBlockedCell(output.col, output.row)) return true;
for (const port of [input, output]) {
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;
@ -269,6 +359,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);
@ -278,7 +375,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;
@ -291,7 +392,7 @@ 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;
}
@ -302,11 +403,10 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
const meta = game.conveyorMeta.get(k) || {};
const physicalEntrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
const physicalExits = connected.filter(dir => !physicalEntrances.has(dir));
if (physicalEntrances.size && physicalExits.length >= 2) return physicalExits;
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));
if (explicit.length >= 2) return explicit;
return connected;
return explicit.length >= 2 ? explicit : connected;
}
function markConveyorDirection(from, to) {
@ -326,6 +426,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);
@ -340,7 +443,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);
@ -413,6 +517,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() {
@ -425,7 +545,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);
@ -533,10 +653,15 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
return;
}
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');
@ -545,8 +670,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', 'maintenanceRoom'].includes(game.buildTool)) return buildAtCell(cell);
if (MACHINE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
if (['conveyor', 'boostConveyor', 'eggFarm', 'manualScanner', 'autoScanner', 'maintenanceRoom', ...GRID_FACILITY_IDS].includes(game.buildTool)) return buildAtCell(cell);
if (EDGE_FACILITY_IDS.includes(game.buildTool)) return buildFacility(p, game.buildTool);
}
function buildFarm(col, row) {
@ -597,6 +722,22 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
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);
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);
@ -605,6 +746,9 @@ 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);
@ -863,7 +1007,7 @@ 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)) {
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);
@ -930,7 +1074,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;
@ -993,8 +1137,13 @@ 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('Click the Branch button or the large on-belt badge to cycle RANDOM / fixed exit.');
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.');
}
@ -1007,11 +1156,15 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
} 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') {
@ -1021,10 +1174,20 @@ 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(`Poop income bonus: ${yen(income)} per processed poop.`);
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})`);
}
return lines;
@ -1043,11 +1206,12 @@ 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.';
}
@ -1065,7 +1229,7 @@ export function createBuildSystem({ game, ui, canvas, canvasPoint, onUpdatePanel
gridExpansionCost, buyGridExpansion, expansionOfferAtPoint,
beginConveyorDrag, continueConveyorDrag, endConveyorDrag,
cycleSingleConveyorDirection,
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey,
branchSwitcherAtPoint, cycleBranchModeAtPoint, cycleBranchModeForKey, branchSelectableExitDirsForKey,
removeSelected, switchScannerRole, showManualScannerMenu,
showAutoScannerMenu, showSelectedMenu, setBuildTool, fail,
isEquipmentCell, equipmentHitBoxes,

View file

@ -1,7 +1,7 @@
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, shredderBonusIncome, 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';
@ -146,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;
@ -162,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 beforeIncome = shredderBonusIncome(before);
const afterIncome = shredderBonusIncome(after);
return `SHREDDER L${target.level || 1} -> L${(target.level || 1) + 1} | poop income ${yen(beforeIncome)} -> ${yen(afterIncome)} per item`;
return `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}`;
}
@ -191,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')
: [];
@ -216,9 +219,24 @@ function repairmanCardVisible(game) {
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;
@ -313,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 };
@ -424,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 };
}
@ -449,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);
@ -461,7 +484,9 @@ function cardDescription(game, card) {
const after = Math.min(shredderBonusMaxCards(), before + 1);
const beforeIncome = shredderBonusIncome(before);
const afterIncome = shredderBonusIncome(after);
return `SHREDDER level +1. Poop income ${yen(beforeIncome)} -> ${yen(afterIncome)} per item.`;
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)));
@ -507,7 +532,7 @@ function cardDescription(game, card) {
if (card.id === 'newMachine') return 'Cancel Used Machines. Future equipment returns to normal price, refund, and durability.';
if (card.id === 'laborExploitation') return 'Repairman dispatch paperwork is streamlined. Repair rooms keep operating; one time only.';
if (card.id === 'rescueLoan') return `At day-end bankruptcy check, gain ${yen(500)} if cash is below ${yen(0)}. Held: ${e.rescueLoanCharges || 0}.`;
if (card.id === 'dynamite') return 'Choose any blocked tiles on the map and destroy up to 10. Empty-space clicks are ignored; press Esc to finish after at least one blast.';
if (card.id === 'dynamite') return 'Choose any blocked tiles on the map and destroy up to 3. Empty-space clicks are ignored; press Esc to finish after at least one blast.';
return card.description;
}
@ -699,24 +724,25 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
function cardIconFor(card) {
const tags = new Set(card.tags || []);
const byTarget = {
eggFarm: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png', alt: 'EGG' },
eggOutlet: { type: 'image', src: './assets/dark_factory/facilities/machines/egg_farm_16.png', alt: 'EGG' },
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_compact_16.png', alt: 'MIX' },
truck: { type: 'image', src: './assets/dark_factory/facilities/vehicles/truck_compact_16.png', alt: 'TRUCK' },
trash: { type: 'image', src: './assets/dark_factory/facilities/machines/shredder_compact_16.png', alt: 'SHR' },
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', alt: 'EGG' };
if (tags.has('POOP')) return { type: 'glyph', text: '💩' };
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: 'glyph', text: '▣' };
if (tags.has('ECONOMY')) return { type: 'glyph', text: '¥' };
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: '' };
return { type: 'glyph', text: '*' };
}
function cardIconHtml(card) {
@ -724,7 +750,7 @@ export function createCardSystem({ game, ui, onUpdatePanels }) {
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>`;
return `<div class="card-badge-icon glyph" aria-hidden="true">${icon.text || '*'}</div><div class="card-icon glyph" aria-hidden="true">${icon.text || '*'}</div>`;
}
function cardButton(card, index) {

View file

@ -2,8 +2,8 @@ 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, shredderPayoutMultiplier, congestionExplosionThreshold, congestionWarningThreshold } from './events.js';
import { floating, shake, spawnPulse, scannerPulse, meatEffect, sludgeEffect, shredEffect, truckLoadEffect, shockwave, sparkBurst, smokeBurst, flyingDebris, rageEffect } from './effects.js';
@ -11,6 +11,74 @@ import { countAutoSorted, countCorrect, countMistake, countMixer, countPoopDesti
import { eggSpawnDelayMultiplier, recordFarmProduction, recordConveyorPass, recordAutoScan, recordFacilityProcess, updateProcessingCooldowns, setFacilityCooldownAfterProcess, updateRepairman } from './maintenance.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);
}
@ -36,6 +104,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const chick = createChick(game, { stage: 'belt', scannerId: null, route: [start] });
if (chick.sex === 'poop') countPoopSpawned(game);
game.chicks.push(chick);
markChicksChanged();
recordFarmProduction(game, farm);
spawned += 1;
}
@ -65,12 +134,13 @@ 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);
flushFacilityOutputs();
if (game.cleanupTimer !== null) {
game.cleanupTimer = Math.max(0, game.cleanupTimer - dt);
if (game.cleanupTimer <= 0) completeTurn();
@ -103,7 +173,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
updateRoutedChick(chick, i, dt);
}
updateScannerQueues(dt);
updateCongestion();
updateCongestion(game.frameId);
checkCongestionExplosions();
if (game.timeLeft <= 0) {
if (game.chicks.length === 0) completeTurn();
@ -119,7 +189,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 +224,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 +245,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 +263,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) {
@ -208,13 +284,14 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
}
function dirExitsToFacility(facility, dirName) {
if (facility?.gridPlaced && facilityKind(facility) === 'sausageMaker') 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 +299,7 @@ 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'].includes(item?.sex)) return { type: 'scanner', dirName, target: scannerCenter(scanner), scanner };
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) };
@ -250,11 +327,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
if (connected.length < 3) return dirs;
const entrances = new Set(connected.filter(dir => neighborPointsIntoCell(cell, dir)));
const physicalExits = connected.filter(dir => !entrances.has(dir));
let candidates = entrances.size && physicalExits.length >= 2
let candidates = entrances.size
? physicalExits
: [...new Set(dirs.filter(dir => connected.includes(dir)))];
if (candidates.length < 2) candidates = connected;
if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && candidates.includes(branchMode)) return [branchMode];
if (!candidates.length && !entrances.size) candidates = connected;
if (branchMode && branchMode !== 'random' && branchMode !== 'pass' && candidates.length >= 2 && candidates.includes(branchMode)) return [branchMode];
return candidates;
}
@ -263,7 +340,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));
@ -301,6 +378,7 @@ 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 removeChick(index, 'DONE');
return;
}
@ -345,10 +423,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();
}
}
}
@ -359,6 +439,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);
}
@ -379,6 +460,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;
@ -389,6 +471,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;
@ -396,6 +479,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const start = route[0];
chick.x = start.x;
chick.y = start.y;
markPositionsChanged();
}
function updateScannerQueues(dt) {
@ -492,7 +576,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;
}
@ -530,6 +620,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);
@ -540,6 +631,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
const chick = game.chicks[index];
if (!chick) return null;
game.chicks.splice(index, 1);
markChicksChanged();
return chick;
}
@ -555,6 +647,11 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
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');
@ -563,11 +660,20 @@ 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;
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) {
@ -579,6 +685,17 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
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') {
countPoopDestination(game, 'truck');
if (isTarget) {
@ -616,6 +733,72 @@ 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) {
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;
game.chicks.push(createProduct(product, out));
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 (machine?.processingCooldown > 0) {
const item = game.chicks[index];
if (item) item.stoppedTimer = 0.25;
return;
}
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;
if (machine.meatBuffer >= 2) {
machine.meatBuffer -= 2;
machine.sausageBuffer = Math.max(0, machine.sausageBuffer || 0) + 1;
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 (trash?.processingCooldown > 0) {
const chick = game.chicks[index];
@ -633,7 +816,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
let bonusText = '';
if (chick.sex === 'poop') {
bonus = applyShredderBonus(game);
bonusText = bonus.amount > 0 ? ` +${yen(bonus.amount)}` : '';
bonusText = bonus.amount > 0 ? ` ${bonus.tier === 'jackpot' ? 'JACKPOT ' : '+'}${yen(bonus.amount)}` : ' MISS';
countPoopDestination(game, 'trash');
countCorrect(game);
const compost = Math.ceil(Math.max(0, Number(game.cardEffects?.composter) || 0) * 3 * shredderPayoutMultiplier(game));
@ -669,15 +852,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);
@ -685,10 +882,18 @@ 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() {
@ -714,10 +919,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) {
@ -726,6 +929,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);
@ -743,6 +947,7 @@ export function createChickSystem({ game, currentConveyorSpeed, onGameOverCheck
return {
activeQueuedChick,
beginFrame,
currentSpawnDelay,
sortSlot,
updateCongestion,

View file

@ -53,6 +53,10 @@ 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') * poopFineMultiplier(game));
}
@ -81,21 +85,54 @@ export function shredderBonusMaxCards() {
return ECONOMY.shredderBonus.maxCards;
}
export function shredderBonusIncome(gameOrCount) {
function shredderRules() {
return ECONOMY.shredderBonus || {};
}
export function shredderBasePayout(gameOrCount) {
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
const capped = Math.min(ECONOMY.shredderBonus.maxCards, Math.max(0, count));
return capped > 0 ? Math.ceil(capped * (ECONOMY.shredderBonus.incomePerUpgrade || 1)) : 0;
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) {
return shredderBonusIncome(gameOrCount) > 0 ? 1 : 0;
const count = typeof gameOrCount === 'number' ? gameOrCount : shredderUpgradeCount(gameOrCount);
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 baseAmount = shredderBonusIncome(cards);
const amount = Math.ceil(baseAmount * shredderPayoutMultiplier(game));
return { cards, chance: amount > 0 ? 1 : 0, amount, expected: amount, baseAmount };
const chance = shredderBonusChance(cards);
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) {
@ -180,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;
@ -223,13 +260,17 @@ export function settleTruckRevenue(game) {
const targetCount = targetTruckCount(game.stats, target);
const unitPrice = target === 'poop' ? Math.ceil(upgradedTruckPrice(game) * poopValueMultiplier(game)) : upgradedTruckPrice(game);
const base = targetCount * unitPrice;
const adjusted = positivePayout(game, base);
const sausageCount = Math.max(0, game?.stats?.sausageShipped || 0);
const sausageUnitPrice = upgradedSausagePrice(game);
const sausageRevenue = sausageCount * sausageUnitPrice;
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 collectFairiesTribute(game, turn = game.turn, basisProfit = null, options = {}) {

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

View file

@ -2,7 +2,7 @@ import { TURN_SECONDS, POOP_RATE, POOP_RATE_CARD_BONUS, DAILY_EVENT_CHANCE, DAIL
import { getSpawnRange } from '../core/state.js';
import { applyRevenue } from './economy.js';
const EVENT_TARGETS = [
export const EVENT_TARGETS = [
{
key: 'premiumFemaleShipment',
target: 'female',
@ -138,11 +138,11 @@ function estimateForTemplate(game, template) {
return { estimate, poopRate };
}
export function rollEventOffer(game) {
const nextTurn = game.turn + 1;
if (nextTurn < DAILY_EVENT_FIRST_TURN) return null;
if (Math.random() > DAILY_EVENT_CHANCE) return null;
const template = EVENT_TARGETS[Math.floor(Math.random() * EVENT_TARGETS.length)];
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));
@ -169,6 +169,14 @@ export function rollEventOffer(game) {
};
}
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;
@ -249,7 +257,7 @@ function countLowDurabilityMachines(game) {
for (const scanner of game.scanners || []) if (scanner.kind === 'auto') add(scanner.maintenance?.uses, scanner.maintenance?.durability);
for (const f of Object.values(game.facilities || {})) {
const kind = f?.baseId || f?.id;
if (kind === 'mixer' || kind === 'trash') add(f.maintenance?.uses, f.maintenance?.durability);
if (kind === 'mixer' || kind === 'trash' || kind === 'sausageMaker') add(f.maintenance?.uses, f.maintenance?.durability);
}
return count;
}

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 || [])],
@ -47,7 +48,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 || []);

View file

@ -163,7 +163,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);
@ -195,7 +195,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'].includes(id)) return;
attachMaintenance(f, id);
f.maintenance.uses = Math.min(f.maintenance.durability, (f.maintenance.uses || 0) + degradationUseMultiplier(game));
}
@ -210,7 +210,7 @@ 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 (f?.processingCooldown > 0) f.processingCooldown = Math.max(0, f.processingCooldown - dt);
}
}
@ -223,7 +223,7 @@ export function equipmentMaintenanceTargets(game) {
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 f of Object.values(game.facilities || {})) {
const kind = facilityKind(f);
if (!['mixer', 'trash'].includes(kind)) continue;
if (!['mixer', 'trash', 'sausageMaker'].includes(kind)) continue;
targets.push({ type: kind, ref: f, label: `${f.shortName || f.name || kind} #${f.id}`, center: { x: f.x + f.w / 2, y: f.y + f.h / 2 } });
}
return targets;

View file

@ -1,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'].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 = {};
}
}
// -----------------------------------------------------------------------------
@ -139,12 +189,13 @@ function inferredConveyorOutDirNames(game, cellKey) {
.map(d => d.name);
const explicit = conveyorOutDirNames(game, cellKey).filter(dir => neighbors.includes(dir));
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)));
const physicalExits = neighbors.filter(dir => !entrances.has(dir));
let candidates = entrances.size && physicalExits.length >= 2 ? physicalExits : [...new Set(dirs)];
if (candidates.length < 2) candidates = neighbors;
if (meta.branchMode && meta.branchMode !== 'random' && candidates.includes(meta.branchMode)) return [meta.branchMode];
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;
}
@ -237,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 };
@ -252,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;
}
@ -458,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;
}
@ -478,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)];
@ -503,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', trash: '#22b94f', sausageMaker: '#d78b3f', 'scanner-role-1': '#526456', scanner: '#526456', input: '#526456' }[dest] || '#102015';
}
export function routeFromFarmToScanner(game, farm, advance = false) {
@ -592,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;
@ -654,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) {
@ -665,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) {
@ -706,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,7 +1,7 @@
import { GRID } from '../core/config.js';
import { DIRS, GRID } from '../core/config.js';
import { key, parseKey, cellCenter } from '../core/utils.js';
import { nearestGridEdge, layoutFacilityOnEdge, maintenanceRoomFootprint } 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';
@ -124,7 +124,9 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
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' || sel.type === 'maintenanceRoom') 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 === '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;
}
@ -157,16 +159,47 @@ export function createSelectionSystem({ game, canvasPoint, updatePanels, equipme
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 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 };
@ -187,6 +220,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);
@ -198,17 +252,31 @@ 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 }) : (origin.type === 'maintenanceRoom' ? maintenanceRoomFootprint({ col, row }) : [{ col, row }]);
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();
@ -232,12 +300,17 @@ 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);
}
}

View file

@ -1,9 +1,8 @@
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 { routeFromFarmToScanner, facilityConnectionIssues } from './routing.js';
import { buildPrice, fairiesTributeInfo, finalScore, maleTruckPenalty, mixerPoopPenalty, truckPoopPenalty } from './economy.js';
import { truckTarget } from './events.js';
import { conveyorSpeedForGame } from './cards.js';
import { maintenanceSummary } from './maintenance.js';
@ -129,16 +128,21 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
}
const nextTributeBasis = Math.max(0, Math.floor(game.lastResult?.profit ?? game.stats?.profit ?? 0));
const nextTribute = fairiesTributeInfo(game, Math.max(1, Number(game.turn) || 1), nextTributeBasis).amount;
ui.buttons.nextTurn.innerHTML = game.phase === 'build' && nextTribute > 0
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>`;
ui.buttons.nextTurn.disabled = game.phase !== 'build' || !!game.cardDraft?.pending || !!game.cardTargetPick?.pending || !factoryReady(game);
// 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}`;
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.eventActive;
@ -168,7 +172,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');
@ -186,7 +190,8 @@ export function createUISystem({ game, ui, build, startGame, beginCardDraft, act
<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>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>

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); }
@ -104,6 +104,7 @@ h1, h2, p { margin: 0; }
.debug-check,
.debug-card,
.debug-target,
.debug-event,
.debug-readout {
grid-column: 1 / -1;
}
@ -600,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;
@ -626,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;
@ -846,7 +847,6 @@ body {
.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); }
.target-brief { color: var(--warn); }
.speed-brief { color: var(--cyan); }
.build-panel {
top: 82px;
@ -1164,7 +1164,6 @@ body {
.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 .target-brief { color: #ffcf68; }
.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; }
@ -1273,6 +1272,21 @@ body {
.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; }