diff --git a/README.md b/README.md index a761a09..19d991e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/README_ASSETS_DARK_FACTORY.md b/assets/README_ASSETS_DARK_FACTORY.md deleted file mode 100644 index 5057cf3..0000000 --- a/assets/README_ASSETS_DARK_FACTORY.md +++ /dev/null @@ -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** diff --git a/assets/dark_factory/facilities/README.md b/assets/dark_factory/facilities/README.md deleted file mode 100644 index 81deae0..0000000 --- a/assets/dark_factory/facilities/README.md +++ /dev/null @@ -1 +0,0 @@ -Dark factory facility sprites. See ../manifest.json for file roles and dimensions.\n \ No newline at end of file diff --git a/assets/dark_factory/facilities/machines/mixer_compact_16.png b/assets/dark_factory/facilities/machines/mixer_compact_16.png deleted file mode 100644 index 980ab2e..0000000 Binary files a/assets/dark_factory/facilities/machines/mixer_compact_16.png and /dev/null differ diff --git a/assets/dark_factory/facilities/machines/shredder_compact_16.png b/assets/dark_factory/facilities/machines/shredder_compact_16.png deleted file mode 100644 index 573e8d7..0000000 Binary files a/assets/dark_factory/facilities/machines/shredder_compact_16.png and /dev/null differ diff --git a/assets/dark_factory/facilities/vehicles/truck_compact_16.png b/assets/dark_factory/facilities/vehicles/truck_compact_16.png deleted file mode 100644 index 72275bd..0000000 Binary files a/assets/dark_factory/facilities/vehicles/truck_compact_16.png and /dev/null differ diff --git a/assets/dark_factory/manifest.json b/assets/dark_factory/manifest.json index 98ccc56..555fac0 100644 --- a/assets/dark_factory/manifest.json +++ b/assets/dark_factory/manifest.json @@ -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" } ] -} \ No newline at end of file +} diff --git a/assets/dark_factory/previews/README.md b/assets/dark_factory/previews/README.md deleted file mode 100644 index b1790a8..0000000 --- a/assets/dark_factory/previews/README.md +++ /dev/null @@ -1 +0,0 @@ -Preview sheet(s) for quick inspection. These are reference only, not runtime assets.\n \ No newline at end of file diff --git a/assets/dark_factory/previews/facilities_overview_dark_factory.png b/assets/dark_factory/previews/facilities_overview_dark_factory.png deleted file mode 100644 index b387a8d..0000000 Binary files a/assets/dark_factory/previews/facilities_overview_dark_factory.png and /dev/null differ diff --git a/assets/images/README.md b/assets/images/README.md deleted file mode 100644 index 0e29b23..0000000 --- a/assets/images/README.md +++ /dev/null @@ -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`. diff --git a/codex_all_new_ideas_prompt_en.md b/codex_all_new_ideas_prompt_en.md new file mode 100644 index 0000000..999dc8f --- /dev/null +++ b/codex_all_new_ideas_prompt_en.md @@ -0,0 +1,903 @@ +# Codex Implementation Prompt — Chick Sorter v29.00 + +Implement the next major version of the attached **Chick Sorter v28.21 source project**. + +The objective is to add a deterministic power-network system, fuel-based generation, inbound logistics, egg-farm quality overflow, and realistic repair-worker navigation while preserving every feature and regression fix already present in v28.21. + +Treat this prompt as the authoritative implementation brief. Do not expand the mandatory scope with optional backlog items unless they are explicitly marked as required. + +--- + +## 1. Non-negotiable engineering rules + +1. Do not edit the minified production bundle directly. +2. Modify source files under `src/` and the appropriate source HTML/CSS/README files, then rebuild the production bundle. +3. Preserve all v28.21 behavior and regression fixes. +4. Keep all tunable values in the existing centralized balance/configuration module. +5. Prefer isolated modules for new systems, including: + - `src/systems/power.js` + - `src/systems/imports.js` + - `src/systems/farmQuality.js` if farm-quality logic cannot remain cleanly isolated in the existing farm module. +6. Do not add texture assets. Draw new equipment with Canvas geometry, symbols, labels, and simple temporary colors. +7. Update the version to `29.0.0`, the visible version to `v29.00`, and the bundle cache key to `game.js?v=29.00` or the equivalent used by the project. +8. Implement the work in stages. After every stage, run relevant syntax checks and tests. +9. Do not silently discard transport items because of congestion, power loss, or invalid machine state. +10. Update README so every documented value matches the actual implementation constants. + +After each implementation stage, report: + +- files changed; +- behavior implemented; +- tests run and results; +- unresolved assumptions or defects. + +--- + +## 2. Preserve all existing v28.21 features + +Do not regress any of the following: + +- land expansion starts at 850 yen and uses the current reduced linear increase model; +- the level-1 automatic scanner occupies 2x2 cells and processes approximately 2.1 level-1 farms; +- shredder upgrades scale nonlinearly and retain high random variance; +- the mixer emits meat through its configured output and pays a fixed fallback revenue of exactly +5 yen when meat cannot be emitted; +- the 3x3 sausage machine consumes two meat items and emits one sausage; +- sausage revenue is collected by the shipping truck; +- equipment-specific cards are excluded from the card pool when the relevant equipment does not exist; +- README matches implemented obstacle and balance behavior; +- mixer input/output ports, sausage-machine placement, conveyor branching rules, triangle direction indicators, and running-phase camera panning remain intact; +- clicking an edge conveyor reverses the connected conveyor line correctly; +- Boost Conveyor animation is twice the regular conveyor animation speed; +- conveyor animation speed stays synchronized with actual transported-item movement, including modifiers and wear; +- NEXT DAY works on a single click; +- explosion horizontal impulse remains reduced to the v28.20 value; +- normal and Boost Conveyor drawing never throws `boost is not defined` or another undefined-variable error. + +Keep the existing internal truck ID for compatibility. Rename only the player-facing label of the current truck to **Shipping Truck**. + +--- + +# Mandatory v29.00 systems + +## 3. Power model + +### 3.1 Equipment exempt from electricity + +The following operate without voltage: + +- manual scanner; +- egg farm; +- regular conveyor; +- Boost Conveyor; +- regular power wire; +- insulated power wire; +- footbridge; +- starter generator; +- biomass generator; +- coal generator. + +Every other active machine requires sufficient received voltage. + +When a machine is under-voltage: + +- stop accepting or processing new items; +- do not remove an item from an upstream conveyor until acceptance is confirmed; +- pause processing timers rather than resetting them; +- retain internal queues and pending outputs; +- resume normally after voltage is restored. + +### 3.2 Voltage terminology + +This is an intentionally simplified game system, not a physical electrical simulation. + +Each powered machine has: + +- `minVoltage`: minimum received voltage required to operate; +- `drawV`: its contribution to generator load sag; +- `relayDropV`: optional extra route loss only if the implementation allows a power route to pass through a declared machine relay node. + +Use the player-facing term **Voltage Load** for `drawV`. + +### 3.3 Generator load sag + +A pure path-loss system would allow one 25V generator to power unlimited parallel machines. Prevent that with source load sag. + +For each source: + +```text +busVoltage = currentGeneratorOutputV - sum(drawV of admitted machines) +receivedVoltage(machine) = busVoltage - routeLossV(machine) +``` + +Admit a machine only when adding its load keeps every already admitted machine at or above its own `minVoltage`. + +Different generators do not add their voltages together in v29.00. + +A machine is assigned to at most one generator. + +Choose and admit sources deterministically: + +1. calculate the best route from every available generator; +2. prefer the source with the highest open-circuit received voltage; +3. admit higher `powerPriority` first; +4. then lower route loss; +5. then earlier build order; +6. then stable equipment ID. + +Use default `powerPriority = 50`. + +Use priority 100 for the initial mixer, shredder, and shipping truck. + +Repeat assignment until stable, with a hard maximum of eight passes. + +### 3.4 Power routing + +Power wires form a four-directional graph. + +- Regular Power Wire loses **0.5V per traversed tile**. +- Insulated Power Wire loses **0.15V per traversed tile**. +- Wire types connect directly to each other. +- Branches do not divide voltage. +- Loops must never increase voltage. +- Separate generators are not summed. +- Active fuel pulses in the same fuel generator are summed. +- Stop exploring a path at 0V or below. +- Use a maximum-priority Dijkstra-style propagation or an equivalent monotonic minimum-loss algorithm. + +Do not run a complete graph search every render frame. Recalculate after structural changes and at no more than 10Hz while timed generator pulses change. + +Recommended public functions: + +```js +ensurePowerState(game) +markPowerDirty(game) +updatePowerSystem(game, dt) +recalculatePowerNetwork(game) +receivedVoltageFor(game, equipment) +isEquipmentPowered(game, equipment) +powerSummary(game) +``` + +### 3.5 Power layer + +Power wires use an overlay layer separate from equipment and conveyor occupancy. + +Wires may overlap: + +- regular conveyors; +- Boost Conveyors; +- footbridges. + +Wires may not occupy: + +- unowned cells; +- obstacle cells; +- equipment body cells, unless a future explicit relay contact supports it. + +Equipment receives power through an adjacent wire touching a declared power contact. + +When Power Overlay is off, wires should not interfere with normal conveyor clicking. + +### 3.6 Power-wire equipment + +#### Regular Power Wire + +- ID: `powerWire` +- Price: 10 yen per tile +- Loss: 0.5V per tile +- Temporary art: thin dark line, brighter in Power Overlay mode + +#### Insulated Power Wire + +- ID: `insulatedPowerWire` +- Price: 22 yen per tile +- Loss: 0.15V per tile +- Temporary art: thicker line with a light outer casing +- Replacing one wire type with the other should be a direct operation that charges or refunds the price difference. + +### 3.7 Powered-machine values + +Place these values in the centralized balance table. + +| Equipment | Minimum voltage | Voltage load | Relay drop | +|---|---:|---:|---:| +| Mixer | 10V | 4V | 4V | +| Shredder | 8V | 3V | 3V | +| Shipping Truck | 6V | 2V | 2V | +| Automatic Scanner | 8V | 2V | 2V | +| Sausage Machine | 12V | 5V | 5V | +| Maintenance Room | 6V | 1V | 1V | +| Inbound Truck | 5V | 1V | 1V | + +### 3.8 Starter generator and initial wiring + +Add one starter generator to every new game. + +- ID: `starterGenerator` +- Size: 2x2 +- Continuous output: 25V +- Exists from game start +- Cannot be sold +- Cannot be built again +- May be moved during build phase +- Requires no electricity +- Temporary art: dark body, lightning symbol, and `25V` label + +A new game must also contain free regular power wires connecting the starter generator to: + +- the initial mixer; +- the initial shredder; +- the initial shipping truck. + +All three machines must be powered on day one without player intervention. + +Implementation requirements: + +1. reserve the generator footprint and required route cells before obstacle generation; +2. create default equipment first; +3. run a deterministic helper such as `createStarterPowerLayout(game)`; +4. route through owned, non-obstacle cells; +5. allow wires to cross conveyor cells because wires are on a separate layer; +6. never route through equipment bodies; +7. charge zero money for the starter generator and initial wires; +8. validate the completed layout; +9. fail loudly in development if valid initial routing cannot be created. + +The initial mixer, shredder, and shipping truck create 9V total load, leaving a 16V bus before path loss. + +### 3.9 Biomass generator + +- ID: `biomassGenerator` +- Size: 3x3 +- Price: 520 yen +- Inputs: three ports at left-center, top-center, and bottom-center +- Accepts male chicks, female chicks, and poop +- Rejects meat, sausage, coal, and zunda mochi +- Each accepted item adds +5V for 2 seconds +- Multiple active pulses in the same generator stack additively +- Three overlapping inputs therefore provide +15V during the overlap +- Clear pulses at day end +- Requires no electricity +- Temporary art: leaf/flame symbol and current output label + +### 3.10 Coal generator + +- ID: `coalGenerator` +- Size: 2x3 +- Price: 680 yen +- One coal input at left-center +- Each coal adds +15V for 3 seconds +- Multiple active pulses in the same generator stack additively +- Reject all non-coal items +- Clear pulses at day end +- Requires no electricity + +### 3.11 Power UI + +Add a build-phase **POWER OVERLAY** toggle. + +Display: + +- wire type; +- generator output; +- assigned source; +- source bus voltage after admitted load; +- route loss; +- received voltage; +- minimum required voltage; +- Voltage Load; +- under-voltage state. + +Example equipment tooltip: + +```text +Power: 9.5V / 10V +Source: Starter Generator +Source bus: 15.5V +Route loss: 6.0V +Voltage Load: 4V +Status: UNDER VOLTAGE +``` + +Show a red crossed-lightning symbol on an under-voltage machine during operation. + +Warn before NEXT DAY when machines are unpowered, but do not block starting the day. + +--- + +## 4. Inbound logistics + +### 4.1 Inbound Truck + +Add a new edge machine. + +- ID: `inboundTruck` +- Price: 350 yen +- Multiple inbound trucks may be built +- One output port faces the grid +- Requires 5V and contributes 1V Voltage Load +- Imports coal and zunda mochi +- Clicking it opens a per-truck configuration panel + +Store: + +```js +inboundOrder: { + coalPerDay: 0, + zundaMochiPerDay: 0 +} +``` + +Daily limits per truck: + +- coal: 0–60; +- zunda mochi: 0–30; +- combined total: 80 items. + +Purchase prices: + +- coal: **2 yen each**; +- zunda mochi: **4 yen each**. + +Charge the complete order cost at the beginning of the day. + +### 4.2 Delivery timing + +Distribute imports across the 60-second operating day. + +For `n` items: + +```js +baseTime = ((i + 0.5) / n) * TURN_SECONDS +``` + +Apply deterministic or seeded jitter within ±15% of the base interval. + +Merge coal and zunda schedules and dispatch in timestamp order. + +If the output is blocked: + +- retain the item in the truck queue; +- do not delete it; +- resume release when space becomes available. + +If the truck is under-voltage, pause release timing and resume after power restoration. + +Items still unreleased at day end are undelivered, do not carry over, and are not refunded. + +### 4.3 New transport types + +Add transport-item types compatible with the current entity structure: + +```js +sex: 'coal' +sex: 'zundaMochi' +``` + +Temporary art: + +- coal: small black rounded square; +- zunda mochi: small green circle or diamond. + +Manual and automatic scanners must reject both items without deleting them. + +--- + +## 5. Egg-farm output cap and product quality + +### 5.1 Design goal + +Farm upgrades currently reach a point where farms produce faster than even a Boost Conveyor can remove items. + +From v29.00 onward: + +- production speed up to the theoretical capacity of one Boost Conveyor increases item count; +- theoretical production beyond that capacity no longer increases item count; +- the excess becomes a diminishing product-quality multiplier. + +Never derive quality from real congestion, a blocked output, queue size, or deliberate obstruction. + +### 5.2 Derive Boost Conveyor capacity + +Do not introduce a duplicate hard-coded throughput constant. + +Derive capacity from the live movement model, for example: + +```js +boostOutputCapacityPerSecond = + BALANCE.conveyor.boostSpeedPxPerSecond / + BALANCE.transport.minimumItemSpacingPx; +``` + +Use the actual variable names from the project. + +Changing Boost Conveyor speed or minimum spacing must automatically change the farm cap. + +### 5.3 Farm formula + +For each farm: + +```text +R = theoretical production rate after permanent upgrades, cards, events, and zunda boost +C = theoretical maximum output rate of one Boost Conveyor +actualSpawnRate = min(R, C) +overflowRatio = max(0, R / C - 1) +qualityMultiplier = clamp(1 + 0.45 * overflowRatio^0.85, 1.0, 2.5) +``` + +Store the formula constants in centralized balance data. + +Expected behavior: + +| Theoretical rate | Actual item rate | Approximate quality | +|---:|---:|---:| +| 0.8C | 0.8C | 1.00x | +| 1.0C | 1.0C | 1.00x | +| 1.5C | 1.0C | about 1.25x | +| 2.0C | 1.0C | 1.45x | +| 3.0C | 1.0C | about 1.81x | +| 5.0C | 1.0C | about 2.46x | + +Snapshot quality when an item is spawned: + +```js +sourceQuality: 1.0 +``` + +### 5.4 Quality inheritance + +- A female chick shipped directly multiplies shipping revenue by its `sourceQuality`. +- A male chick processed into meat transfers its quality to that meat item. +- A sausage made from two meat items uses the arithmetic mean of the two meat-quality values. +- Shipping sausage multiplies its base value by sausage quality. +- Poop has no quality multiplier. +- Biomass power output ignores quality. +- Coal and zunda have no quality. +- The mixer blocked-output fallback remains exactly +5 yen and ignores quality. + +### 5.5 Farm UI + +Show: + +- theoretical production rate; +- actual capped output rate; +- Boost Conveyor cap; +- current quality multiplier; +- quality grade. + +Display-only grades: + +- 1.00–1.14x: Standard +- 1.15–1.34x: Select +- 1.35–1.74x: Premium +- 1.75x and above: Luxury + +Revenue must use the continuous multiplier, not grade buckets. + +If the connected belt is slower than the farm's actual capped output, show: + +```text +OUTPUT BELT BELOW FARM CAPACITY +``` + +--- + +## 6. Zunda mochi farm boost + +A zunda mochi item reaching a conveyor cell orthogonally adjacent to an egg farm is consumed by that farm. + +- Accept from any of the four adjacent directions. +- Each item adds 8 seconds to the boost timer. +- Maximum stored boost time: 30 seconds. +- While active, production interval multiplier: 0.55. +- Multiple items extend duration but do not multiply the speed factor. +- Clear remaining boost time at day end. +- Keep the existing safe minimum production interval of 0.25 seconds. +- Zunda changes theoretical rate `R`. +- If zunda raises `R` above the Boost Conveyor cap, the excess becomes quality rather than extra item count. + +Record realized zunda value in daily statistics. + +Do not add all of the following balancing restrictions at once. They are tuning controls only if playtests show zunda is dominant: + +- reduce duration from 8 seconds to 6 seconds; +- add a per-farm consumption cooldown; +- reduce only the zunda-derived quality-conversion efficiency; +- add a per-farm daily appetite limit. + +--- + +## 7. Repair-worker navigation and footbridges + +### 7.1 Collision rules + +Replace direct straight-line repair-worker movement with four-directional A* or BFS grid pathfinding. + +A worker may enter a cell only when it is: + +- owned; +- not an obstacle; +- not occupied by equipment, a farm, scanner, or maintenance room; +- not occupied by a conveyor unless a footbridge covers that conveyor cell. + +Power wires do not block walking. + +### 7.2 Footbridge + +- ID: `footbridge` +- Price: 90 yen +- Size: 1x3 or 3x1 +- Rotatable +- May overlap conveyor and power-wire layers +- May not overlap obstacles, unowned cells, or equipment bodies +- Affects repair-worker movement only +- Must not alter conveyor transport, direction editing, or branch selection +- Temporary art: translucent deck with side rails + +### 7.3 Path caching + +Each worker should store: + +```js +path +pathIndex +pathVersion +``` + +Maintain `game.walkabilityVersion` and increment it whenever construction changes passability. + +Recalculate only when: + +- target changes; +- walkability version changes; +- current path becomes invalid. + +Do not run pathfinding every frame. + +### 7.4 Repair position + +Workers repair from a reachable cell orthogonally adjacent to the target footprint. + +They must not stand inside the target machine. + +- Generate adjacent candidate work cells. +- Choose the nearest reachable candidate. +- Repair conveyors from an adjacent walkable cell or a valid footbridge cell. +- Repair edge equipment from the nearest reachable grid-side work cell. +- Skip unreachable targets. +- Show `NO REPAIR ACCESS` in details for unreachable equipment. +- Workers must exit the maintenance room from a valid perimeter cell rather than its center. + +--- + +## 8. Layer selection and editing + +Because conveyors, power wires, and footbridges may overlap: + +- equipment selection has highest priority; +- with Power Overlay enabled, wires participate in hit testing; +- with Power Overlay disabled, conveyors take priority and wires are not directly clickable; +- footbridges must remain selectable without breaking conveyor editing; +- erase one layer at a time in this order: + 1. footbridge; + 2. power wire; + 3. conveyor; +- optionally support Shift+Erase to remove all overlay layers on one cell. + +Do not regress edge-conveyor reversal or conveyor branch selection rules. + +--- + +## 9. Power gates for existing machines + +Add power checks at these points: + +- automatic scanner before queue processing; +- mixer before acceptance and processing; +- shredder before acceptance and processing; +- shipping truck before acceptance; +- sausage machine before acceptance, processing, and output; +- inbound truck before item release; +- maintenance room before worker dispatch and while repair work is progressing. + +Do not remove an item from the upstream conveyor before the destination confirms that it is powered and has capacity. + +--- + +## 10. Card behavior and durability + +Preserve equipment-dependent card filtering. + +Do not add generator, wire, inbound-truck, or footbridge cards in v29.00. + +Repairable new equipment: + +- starter generator; +- biomass generator; +- coal generator; +- inbound truck. + +No wear in v29.00: + +- regular wire; +- insulated wire; +- footbridge. + +Generator wear must not reduce output voltage in this version. Use the existing normal delay/maintenance model only if required. + +--- + +## 11. Statistics and UI + +Add daily statistics for: + +- coal ordered, released, consumed, and undelivered; +- zunda ordered, released, consumed, and undelivered; +- import purchase cost; +- fuel consumed by generator type; +- maximum output per generator; +- under-voltage seconds per machine; +- generator-source assignment changes; +- zunda boost seconds per farm; +- farm theoretical rate; +- farm actual output rate; +- average and maximum quality multiplier; +- revenue added by quality; +- unreachable repair-target count. + +Build controls: + +- POWER WIRE +- INSULATED WIRE +- BIOMASS GEN +- COAL GEN +- INBOUND TRUCK +- FOOTBRIDGE +- POWER OVERLAY + +Do not add a build button for the starter generator. + +Inbound Truck panel: + +- coal per day; +- zunda mochi per day; +- combined total; +- daily purchase cost; +- average release interval; +- current output queue. + +Editable only in build phase. + +Top-level power summary example: + +```text +POWER: Starter 25V / 3 powered / 1 under-voltage +``` + +Do not display a summed `TOTAL V`, because independent generators are not combined. + +--- + +## 12. README requirements + +Document the implemented values and behavior for: + +- machines that do and do not require electricity; +- generator load sag; +- regular-wire loss of 0.5V per tile; +- insulated-wire loss of 0.15V per tile; +- non-splitting branches; +- non-additive separate generators; +- machine minimum voltage and Voltage Load; +- starter, biomass, and coal generators; +- free initial generator and wiring; +- inbound orders and item prices; +- zunda farm boost; +- Boost Conveyor production cap; +- quality overflow and inheritance; +- repair-worker collision rules; +- footbridges; +- temporary geometric art for new machines. + +Every README number must come from or match the centralized balance values. + +--- + +## 13. Required tests + +Retain all v28.21 tests and add a dedicated v29.00 test suite. + +### Power + +1. One regular wire tile loses 0.5V. +2. Ten regular wire tiles lose 5V. +3. One insulated wire tile loses 0.15V. +4. Ten insulated wire tiles lose 1.5V. +5. Mixed paths calculate exact combined loss. +6. Branches do not divide voltage. +7. Loops never increase voltage. +8. Separate generators are not summed. +9. Three overlapping biomass pulses produce +15V. +10. One coal pulse produces +15V for 3 seconds. +11. Starter mixer, shredder, and shipping truck create 9V total load. +12. A machine that would invalidate the admitted source set remains unpowered. +13. Under-voltage machines do not consume items. +14. Paused processing resumes after power restoration. +15. New games include the starter generator and initial wires. +16. Initial mixer, shredder, and shipping truck are powered on day one. +17. Starter power equipment costs zero. +18. Initial power cells do not overlap obstacles or equipment bodies. + +### Imports + +19. Coal costs 2 yen each. +20. Zunda mochi costs 4 yen each. +21. Sixty coal items schedule at approximately one-second intervals. +22. Six items schedule at approximately ten-second intervals. +23. A blocked inbound output retains its item. +24. Coal and zunda cannot enter scanners. +25. Order cost is charged correctly at day start. +26. Under-voltage pauses release without losing queued imports. + +### Farm quality + +27. Spawn count is unchanged below the Boost Conveyor capacity. +28. Spawn rate is capped at the derived Boost Conveyor capacity. +29. Quality is exactly 1.0x at or below the cap. +30. Quality increases monotonically above the cap. +31. Quality never exceeds 2.5x. +32. Blocking farm output does not raise quality. +33. Female shipping revenue uses source quality. +34. Meat inherits chick quality. +35. Sausage quality equals the arithmetic mean of its two meat inputs. +36. Mixer fallback remains exactly +5 yen regardless of quality. +37. Poop and generator output ignore farm quality. +38. Changing Boost Conveyor movement speed changes the derived cap without editing another constant. + +### Zunda + +39. A farm consumes adjacent zunda mochi. +40. One item adds 8 seconds up to 30 seconds. +41. Zunda changes theoretical farm rate. +42. Zunda cannot increase count beyond the Boost Conveyor cap. +43. Zunda overflow increases quality through the same formula. +44. Multiple zunda items extend duration but do not multiply the speed factor. + +### Repair workers + +45. Workers cannot pass through equipment. +46. Workers cannot walk on uncovered conveyor cells. +47. Workers can cross conveyor cells covered by a footbridge. +48. Workers repair from an adjacent cell. +49. Unreachable machines are skipped and flagged. +50. Construction edits invalidate and recalculate cached paths. + +### Regression and build + +51. Every v28.21 test passes. +52. `npm run build` succeeds. +53. Generated output contains no `boost is not defined` regression. +54. NEXT DAY enters the running phase with one click. +55. Regular and Boost Conveyor drawing does not throw. +56. Edge-conveyor reversal still works. +57. Conveyor item movement remains synchronized with conveyor animation. +58. A new factory runs without manual power edits. +59. The final ZIP contains source, updated README, and rebuilt production files. + +--- + +## 14. Implementation order + +Implement in this order, with a working checkpoint after each stage: + +1. balance constants, IDs, equipment definitions, temporary drawing; +2. starter-generator placement reservation and free initial wiring; +3. wire overlay and route-loss calculation; +4. generator load admission and machine power gates; +5. biomass and coal generators; +6. inbound truck and imported transport items; +7. egg-farm throughput cap and quality inheritance; +8. zunda integration; +9. footbridge and repair-worker pathfinding; +10. UI, statistics, and README; +11. full regression tests, production build, and release ZIP. + +Do not implement all systems as one large patch. + +--- + +## 15. Completion criteria + +The implementation is complete only when: + +- a new game visibly includes a starter generator and connected wires; +- the initial mixer, shredder, and shipping truck work on day one; +- long wire runs reduce received voltage; +- insulated wire loses exactly 0.15V per tile; +- adding powered machines creates source load and can cause under-voltage; +- under-voltage never deletes or silently consumes items; +- fuel inputs create timed additive output pulses inside each generator; +- coal and zunda imports are distributed across the day; +- farm output count cannot exceed one Boost Conveyor's theoretical transport capacity; +- production above that cap increases product quality rather than count; +- deliberate congestion never creates quality; +- quality survives the female shipping and meat-to-sausage chains; +- repair workers avoid machines and uncovered conveyors; +- footbridges permit repair-worker conveyor crossings; +- README values match implementation values; +- all old and new tests pass; +- the production bundle builds successfully. + +--- + +# Optional future-design backlog — do not implement in v29.00 + +Record these ideas in a short `FUTURE_IDEAS.md` or an equivalent non-executable design note. Do not implement them unless explicitly requested. + +## A. Capacitor / battery + +Store short biomass and coal voltage pulses and release them gradually to reduce intermittent under-voltage. + +Potential parameters: + +- storage capacity; +- maximum charge rate; +- maximum discharge rate; +- round-trip efficiency; +- player-selectable reserve threshold. + +## B. Transformer or power-combiner station + +Allow multiple generators to feed one managed bus only through a dedicated late-game machine. This would intentionally replace the v29.00 rule that independent generator voltages are never summed. + +## C. Editable power priorities + +Allow Low, Normal, and High priority per machine so nonessential production shuts down before critical logistics during shortages. + +## D. Advanced power statistics + +Possible additions: + +- average received voltage; +- brownout count; +- energy/fuel cost per shipped item; +- source utilization percentage; +- unused generator output; +- voltage-loss heat map. + +## E. Additional wire tiers + +Possible late-game wire types with lower loss, higher purchase price, or limited placement requirements. Do not reduce Insulated Power Wire below its required 0.15V-per-tile value in v29.00. + +## F. Zunda balancing controls + +Only if telemetry shows zunda is dominant: + +- shorter duration; +- farm consumption cooldown; +- reduced zunda-only contribution to quality overflow; +- daily appetite limit. + +Change one control at a time and measure the result. + +## G. Repair-access visualization + +A build-mode overlay showing: + +- reachable worker cells; +- planned repair paths; +- machines with no adjacent work cell; +- recommended footbridge locations. + +## H. Power-aware card ideas + +Possible future cards after the base system is stable: + +- reduced machine Voltage Load; +- lower regular-wire loss; +- longer fuel pulses; +- improved biomass voltage per item; +- temporary emergency generator output; +- capacitor efficiency. + +Do not add these cards in v29.00 because the base power economy must be measured first. diff --git a/dist/game.js b/dist/game.js index 06d7c9c..a4d13f9 100644 --- a/dist/game.js +++ b/dist/game.js @@ -1 +1,52 @@ -import '../src/game.js'; +var V={version:"v28.21 BOOST DRAW HOTFIX",time:{daySeconds:60,farmShutdownGraceSeconds:10},cash:{starting:250},grid:{x:170,y:78,cols:20,rows:10,cell:46,expansion:{colsPerPurchase:10,rowsPerPurchase:10,costBase:850,costGrowthRate:.75,truncateUnit:50}},map:{blockedRatio:0,blockedExpansionIncrement:.0075,blockedRatioMax:.12},economy:{income:{mixer:5,truck:10,sausage:26},poopFine:10,fairiesTribute:{perDay:10,profitExemption:300,legalWorkExemptionBonus:250,profitRate:.12},incomeUpgradeRate:1.1,explosionDamageDivisor:30,maleTruckFinePerHalfDay:30,shredderBonus:{maxCards:30,payoutCoefficient:1.6,payoutExponent:1.25,baseHitChance:.28,hitChancePerUpgrade:.018,maxHitChance:.82,jackpotBaseChance:.01,jackpotChancePerUpgrade:.0025,jackpotMaxChance:.08,bigWinBaseChance:.07,bigWinChancePerUpgrade:.0015,bigWinMaxChance:.12,doubleChance:.2,jackpotMultiplier:10,bigWinMultiplier:4,doubleMultiplier:2},chemicalWeaponSubsidy:{poopPerBlock:70,yenPerBlock:1200}},production:{poopRate:.08,autoScannerCooldown:1.5,autoScannerUpgradeRate:.95,autoScannerCardUpgradeRate:.95,autoScannerMinCooldown:.5,poopRateCardBonus:.04,eggSpawnRanges:[[1.95,4.35],[1.55,3.45],[1.25,2.8],[1.05,2.35]]},conveyor:{baseSpeed:52,maxSpeed:300,bearingSpeedMultiplier:1.075},effects:{explosionHorizontalVelocityMultiplier:.82},cards:{definitions:[{id:"upgradeEgg",title:"EGG Improvement",rarity:"common",type:"equipmentUpgrade",target:"eggFarm",description:"Choose one EGG FARM on the map and raise it by 1 level.",tags:["EGG","ACTIVE"]},{id:"upgradeAutoScanner",title:"AUTO SCANNER Improvement",rarity:"common",type:"equipmentUpgrade",target:"autoScanner",description:"Choose one AUTO SCANNER on the map and raise it by 1 level.",tags:["SCANNER","ACTIVE"]},{id:"autoScannerTuning",title:"AUTO SCANNER Tuning",rarity:"common",type:"instant",description:"All AUTO SCANNER cooldowns use default cooldown x0.95 per copy.",requiresFacility:"autoScanner",tags:["SCANNER","PASSIVE"]},{id:"upgradeMixer",title:"MIXER Improvement",rarity:"common",type:"equipmentUpgrade",target:"mixer",description:"Choose one MIXER. Each level improves meat quality and raises sausage sale value by 10%.",tags:["ECONOMY","ACTIVE"]},{id:"upgradeTruck",title:"TRUCK Improvement",rarity:"common",type:"equipmentUpgrade",target:"truck",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:"Adds a nonlinear scrap-lottery upgrade. More copies raise both payout and win chance; max 30.",tags:["POOP","ACTIVE"]},{id:"crowdedFarming",title:"Crowded Farming",rarity:"common",type:"instant",description:"EGG production interval -5%. Equipment deterioration +4%. Max 10 cards.",requiresFacility:"eggFarm",tags:["EGG","RISK","PASSIVE"]},{id:"hatchingFeed",title:"Hatching Feed",rarity:"common",type:"instant",description:"EGG production interval -4%. Poop rate +3%. No card limit.",requiresFacility:"eggFarm",tags:["EGG","POOP","PASSIVE"]},{id:"poopRateBooster",title:"Poop Culture",rarity:"common",type:"instant",description:"Base poop rate +4 percentage points. No card limit.",tags:["POOP","PASSIVE"]},{id:"safetyCover",title:"Safety Cover",rarity:"common",type:"instant",description:"Explosion damage -5%. No card limit.",tags:["RISK","PASSIVE"]},{id:"preventiveMaintenance",title:"Preventive Maintenance Manual",rarity:"common",type:"instant",description:"All equipment deterioration speed -5%. No card limit.",requiresAnyFacility:["conveyor","eggFarm","autoScanner","manualScanner","mixer","trash","sausageMaker"],tags:["MAINTENANCE","PASSIVE"]},{id:"dudFilter",title:"DUD Filter",rarity:"common",type:"instant",description:"DUD chance -5 percentage points. Stacks until DUD chance reaches 0%.",tags:["CARD","PASSIVE"]},{id:"durabilityCoating",title:"Durability Coating",rarity:"common",type:"instant",description:"All equipment maximum durability +3%. No card limit.",requiresAnyFacility:["conveyor","eggFarm","autoScanner","manualScanner","mixer","trash","sausageMaker"],tags:["MAINTENANCE","PASSIVE"]},{id:"bearing",title:"High-Quality Bearing",rarity:"common",type:"instant",description:"Conveyor speed +7.5%.",requiresFacility:"conveyor",tags:["CONVEYOR","PASSIVE"]},{id:"sparePartsBin",title:"SPARE PARTS BIN",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"]},{id:"dudRefund",title:"DUD REFUND",rarity:"common",type:"instant",description:"DUDs now come with paperwork. Gain JPY 50 per copy when you pick a DUD.",tags:["CARD","ECONOMY","PASSIVE"]},{id:"freeReroll",title:"FREE REROLL",rarity:"common",type:"instant",description:"The first bad ideas are free.",tags:["CARD","PASSIVE"]},{id:"composter",title:"COMPOSTER",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"]},{id:"scannerQueueSpacing",title:"Scanner Queue Spacing",rarity:"common",type:"instant",description:"Manual and Auto Scanner queue spacing -15%. No card limit.",requiresAnyFacility:["manualScanner","autoScanner"],tags:["SCANNER","PASSIVE"]},{id:"extraEggOutlet",title:"Extra Egg Outlet",rarity:"rare",type:"equipmentUpgrade",target:"eggOutlet",description:"Choose one EGG FARM and add +1 output port to that EGG. Max +3 per EGG.",tags:["EGG","RARE","ACTIVE"]},{id:"recyclingSubsidy",title:"Recycling Subsidy",rarity:"rare",type:"instant",description:"SELL refund rate +10 points. Max 3 cards; normal SELL can reach 80%.",tags:["ECONOMY","RARE","PASSIVE"]},{id:"dynamite",title:"Dynamite",rarity:"rare",type:"cellAction",target:"blockedCell",description:"Choose and remove up to 3 unbuildable cells.",tags:["RISK","RARE","ONE-SHOT"]},{id:"usedMachine",title:"Used Machines",rarity:"rare",type:"instant",description:"Future equipment costs half. New purchases have no refund and 60% durability. New Machines cancels it.",tags:["ECONOMY","RISK","RARE"]},{id:"newMachine",title:"New Machines",rarity:"common",type:"instant",description:"Cancel Used Machines and restore normal prices, refunds, and durability.",tags:["ECONOMY","ONE-SHOT"]},{id:"flattery",title:"Flattery",rarity:"rare",type:"instant",description:"Next Fairies tribute -50%.",tags:["ECONOMY","RARE","ONE-SHOT"]},{id:"extraCards",title:"Extra Cards",rarity:"rare",type:"instant",description:"Draw 2 cards now and choose both.",tags:["RARE","ACTIVE"]},{id:"legalWork",title:"Legal Work",rarity:"rare",type:"instant",description:"Fairies tribute profit exemption +JPY 250.",tags:["ECONOMY","RARE","PASSIVE"]},{id:"chemicalWeaponSubsidy",title:"Chemical Weapons Subsidy",rarity:"ultraRare",type:"instant",description:"Each day, gain JPY 1200 per 70 poop shipped by truck. No card limit.",tags:["POOP","ULTRA RARE","PASSIVE"]},{id:"laborExploitation",title:"Motivational Exploitation",rarity:"ultraRare",type:"instant",description:"Repair room work time -30%. The repairman smiles with bloodshot eyes. One time only.",tags:["MAINTENANCE","ULTRA RARE","PASSIVE"]},{id:"rescueLoan",title:"Emergency Loan",rarity:"ultraRare",type:"instant",description:"If cash is below JPY 0 at day-end settlement, gain JPY 500. Consumed on use.",tags:["ECONOMY","ULTRA RARE","ONE-SHOT"]}],defaultEffects:{bearing:0,legalWork:0,fairiesFlatteryNext:0,autoScannerTuning:0,poopRateBooster:0,crowdedFarming:0,hatchingFeed:0,safetyCover:0,recyclingSubsidy:0,chemicalWeaponSubsidy:0,preventiveMaintenance:0,dudFilter:0,durabilityCoating:0,sparePartsBin:0,dudRefund:0,freeReroll:0,composter:0,scannerQueueSpacing:0,laborExploitation:0,usedMachineActive:!1,rescueLoanCharges:0},commonWeight:8,rareWeight:2,ultraRareWeight:.45,baseDraftSize:3,maxDudsPerDraft:2,dudChancePerCard:.3,dudChanceReductionPerFilter:.05,extraCardsAddedPicks:2,rerollCost:{dayDivisor:2,multiplier:100}},events:{eventChance:.1,firstTurn:8},maintenance:{fullPerformanceUntilWear:.5,minimumPerformance:.5,durability:{conveyor:1300,eggFarm:475,autoScanner:850,mixer:775,trash:950,sausageMaker:825},processingDelayMaxSeconds:{mixer:1.2,trash:1,sausageMaker:.9},repairman:{secondsPerOnePercent:.5,walkSpeed:90}},facilities:{conveyor:{id:"conveyor",type:"conveyor",name:"Conveyor",shortName:"Belt",price:30,buildable:!0,upgradeable:!1},boostConveyor:{id:"boostConveyor",type:"conveyor",name:"Boost Conveyor",shortName:"BOOST BELT",price:75,buildable:!0,upgradeable:!1,speedMultiplier:2},eggFarm:{id:"eggFarm",type:"eggFarm",name:"Egg Farm",shortName:"EGG",price:250,buildable:!0,upgradeable:!0,maxLevel:4},manualScanner:{id:"manualScanner",type:"scanner",kind:"manual",name:"Manual Scanner",shortName:"Manual Scanner",price:260,buildable:!0,upgradeable:!1},autoScanner:{id:"autoScanner",type:"scanner",kind:"auto",name:"Auto Scanner",shortName:"Auto Scanner",price:180,buildable:!0,upgradeable:!1},mixer:{id:"mixer",type:"facility",name:"Mixer",shortName:"MIXER",price:450,buildable:!0,upgradeable:!0,incomeKey:"mixer",body:{w:164,h:126}},trash:{id:"trash",type:"facility",name:"Waste Shredder",shortName:"SHREDDER",price:240,buildable:!0,upgradeable:!0,body:{w:180,h:100}},truck:{id:"truck",type:"facility",name:"Truck",shortName:"TRUCK",price:450,buildable:!0,upgradeable:!0,incomeKey:"truck",body:{w:176,h:138}},sausageMaker:{id:"sausageMaker",type:"facility",name:"Sausage Machine",shortName:"SAUSAGE",price:380,buildable:!0,upgradeable:!0,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:!0,upgradeable:!1,footprint:{cols:2,rows:2}}}};var si=V.version,Gt=V.time.daySeconds,Rn=V.cash.starting,xe=V.economy,ai=V.time.farmShutdownGraceSeconds,Qn=V.production.autoScannerCooldown,li=V.production.autoScannerUpgradeRate,Yo=V.production.autoScannerCardUpgradeRate||V.production.autoScannerUpgradeRate,Vo=V.production.autoScannerMinCooldown,ci=V.events.eventChance,Pn=V.events.firstTurn,zo=V.production.poopRate,Xo=V.production.poopRateCardBonus||0,Jn=V.conveyor.baseSpeed,tn=V.conveyor.maxSpeed,Zn=V.conveyor.bearingSpeedMultiplier,Ue=V.cards,ui=V.cards.definitions,jo=V.cards.defaultEffects,Et=V.production.eggSpawnRanges,f=V.grid,Te=V.facilities,Qo=["conveyor","boostConveyor","eggFarm","autoScanner","manualScanner","mixer","trash","truck","sausageMaker","maintenanceRoom"],eo=["mixer","trash","truck"],to=["sausageMaker"],Jo=[...eo,...to],di=["mixer","trash","truck"],fi=["mixer","truck","sausageMaker"],Rt=Object.fromEntries(Object.entries(Te).map(([e,t])=>[e,t.price])),fe=[{name:"right",dc:1,dr:0,angle:0,opposite:"left"},{name:"left",dc:-1,dr:0,angle:Math.PI,opposite:"right"},{name:"down",dc:0,dr:1,angle:Math.PI/2,opposite:"up"},{name:"up",dc:0,dr:-1,angle:-Math.PI/2,opposite:"down"}],b={bg:"#111318",ink:"#090a0d",muted:"#8f9bab",green:"#7bd84f",greenSoft:"#273c24",mixerBlue:"#4aa3ff",truckPink:"#ffb33e",wasteGreen:"#88d35a",meatRed:"#d96b72",sausageOrange:"#d78b3f",danger:"#e34a36",warn:"#ffb827",white:"#e6edf2",poop:"#7b4a23",male:"#4aa3ff",female:"#ff6fae"},Ie={ambient:0,important:1,critical:2};function x(e){let t=Number(e)||0;return`${t<0?"-":""}\uFFE5${Math.ceil(Math.abs(t)).toLocaleString("ja-JP")}`}function I(e,t){return`${e},${t}`}function re(e){let[t,n]=String(e).split(",").map(Number);return{col:t,row:n}}function An(e,t,n){return Math.max(t,Math.min(n,e))}function z(e,t){return e+Math.random()*(t-e)}function $e(e,t){return e>=0&&e=0&&tMath.round(An(r,0,255)).toString(16).padStart(2,"0");return`#${o(e)}${o(t)}${o(n)}`}function In(e,t,n){let o=pi(e),r=pi(t);return Wa(Zo(o.r,r.r,n),Zo(o.g,r.g,n),Zo(o.b,r.b,n))}var ce={appTitle:"Chick Sorter",versionTitle:`Chick Sorter ${si}`,currency:"\uFFE5",phases:{title:"Title",build:"Build",running:"Sorting",clearing:"Clearing",loanDecision:"Emergency Loan",gameover:"Game Over"},actions:{startGame:"Start Game",nextDay:"Next Day",buildPhase:"Build Phase",restart:"Restart",close:"Close",remove:"Remove",refund:"Refund",autoMenu:"Auto Menu"},status:{build:e=>`BUILD: ${(e==="erase"?"SELL":e||"SELECT").toUpperCase()} | Right-drag pan | Hover equipment for info`,sorting:"Sorting: use scanner keys. Build after clearing the day.",allPortsConnected:"Start condition met: one EGG route reaches an exit.",equipmentPanelEmpty:"Hover equipment in Build phase."},routeLabels:{mixer:"MIXER",truck:"TRUCK",trash:"WASTE",sausageMaker:"SAUSAGE",scanner:"NEXT","scanner-role-1":"NEXT",input:"IN"},fail:{outOfGrid:"Out of grid",cellOccupied:"Cell occupied",notEnoughCash:"Not enough cash",facilityExists:"Facility already exists",facilityOverlap:"Facility overlap",nothingToErase:"Nothing to sell",noUpgrade:"No upgrade available"}};function Pt(e){return Te[e]?.name||String(e||"").replace(/^[a-z]/,t=>t.toUpperCase())}function Ha(e){let t=Te[e];return t?e==="conveyor"||e==="boostConveyor"?`${x(t.price)} / tile`:e==="autoScanner"?`${x(t.price)} / ${Qn.toFixed(1)}s cooldown`:e==="trash"?x(t.price):x(t.price):""}function Ka(e){return{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:"Processes about 40 items per day at Lv1, roughly 2.1 Lv1 farms.",manualScanner:"A manual checkpoint. The operator is the algorithm.",mixer:"Makes meat when OUT is clear; otherwise falls back to direct sale.",trash:"A polite shredder for poop and other regrets.",truck:"Ships target chicks and all sausages. Wrong live cargo still leaves a paper trail.",sausageMaker:"A 3\xD73 grid machine. Left inlet takes 2 meat; right outlet emits 1 sausage.",maintenanceRoom:"2\xD72 repair bay. Each room dispatches one repairman during the day."}[e]||""}function qa(e){return{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"}[e]||""}function hi(e){let t=Ka(e),n=qa(e);return`${n?``:""}${Pt(e)}${Ha(e)}${t?`${t}`:""}`}function mi(e){return ce.routeLabels[e]||String(e).toUpperCase()}function bi(e){let t=2166136261;for(let n=0;n>>0}function vi(e){let t=e>>>0;return()=>{t+=1831565813;let n=Math.imul(t^t>>>15,1|t);return n^=n+Math.imul(n^n>>>7,61|n),((n^n>>>14)>>>0)/4294967296}}function wi(e,{base:t=0,increment:n=.0075,max:o=.12}={}){let r=Math.max(0,Math.floor(Number(e)||0));return Number(Math.min(o,t+r*n).toFixed(6))}function oo(){return`RUN-${Date.now().toString(36)}-${Math.floor(Math.random()*1e6).toString(36)}`.toUpperCase()}function Si({seed:e,targetRatio:t=.2,reserved:n=[]}={}){let o=vi(bi(e||oo())),r=new Set(n.map(c=>typeof c=="string"?c:I(c.col,c.row))),i=[];for(let c=0;c0;c-=1){let h=Math.floor(o()*(c+1));[i[c],i[h]]=[i[h],i[c]]}let a=Math.round(f.cols*f.rows*t+1e-9);return new Set(i.slice(0,Math.min(a,i.length)).map(c=>c.k))}function ki({seed:e,colStart:t=0,colEnd:n=f.cols,rowStart:o=0,rowEnd:r=f.rows,targetRatio:i=.2,reserved:a=[]}={}){let c=vi(bi(e||oo())),h=new Set(a.map(Z=>typeof Z=="string"?Z:I(Z.col,Z.row))),g=[],C=Math.max(0,Math.floor(t)),P=Math.max(C,Math.floor(n)),L=Math.max(0,Math.floor(o)),K=Math.max(L,Math.floor(r));for(let Z=L;Z0;Z-=1){let Q=Math.floor(c()*(Z+1));[g[Z],g[Q]]=[g[Q],g[Z]]}let j=Math.round((P-C)*(K-L)*i+1e-9);return new Set(g.slice(0,Math.min(j,g.length)).map(Z=>Z.k))}var nn=10,gi=2,Ci=1;function on(e,t){return`${e},${t}`}function Ya(e){let[t,n]=String(e).split(",").map(Number);return{chunkX:t,chunkY:n}}function ro(e){let t=V.grid.expansion||{},n=Math.max(1,(e.gridExpansionPurchases||0)+1),o=Math.max(0,Number(t.costBase)||850),r=Math.max(0,Number(t.costGrowthRate)||.75),i=o*(1+r*(n-1)),a=Math.max(1,t.truncateUnit||50);return Math.floor(i/a)*a}function Mi(e){e.ownedCells=new Set,e.ownedChunks=new Set,e.gridChunkOriginX=0,e.gridChunkOriginY=0,e.gridExpansionPurchases=0,e.gridExpansionPurchasesByDirection={left:0,right:0,up:0,down:0};for(let t=0;t=0&&t=0&&n{let a=(o[r.direction]??9)-(o[i.direction]??9);return a||(r.chunkY!==i.chunkY?r.chunkY-i.chunkY:r.chunkX-i.chunkX)})}function Ei(e,t="right"){let n=Ut(e);return n.find(o=>o.direction===t)||n[0]||null}function dt(e){return{x:f.x+e.colStart*f.cell,y:f.y+e.rowStart*f.cell,w:e.cols*f.cell,h:e.rows*f.cell}}function tr(e){let t=dt(e);return{x:t.x+t.w/2-116,y:t.y+t.h/2-36,w:232,h:72,cx:t.x+t.w/2,cy:t.y+t.h/2}}function Ri(e,t){let n=tr(e);return t.x>=n.x&&t.x<=n.x+n.w&&t.y>=n.y&&t.y<=n.y+n.h}function Pi(e){let t=[];for(let n=e.rowStart;n$e(t.col,t.row))}function el(e){return[{col:e.col,row:e.row-1},{col:e.col+1,row:e.row-1},{col:e.col,row:e.row},{col:e.col+1,row:e.row}].filter(t=>$e(t.col,t.row))}function tl(e){return[{col:e.col,row:e.row+1},{col:e.col-1,row:e.row},{col:e.col+1,row:e.row},{col:e.col,row:e.row-1}].filter(t=>$e(t.col,t.row))}function Oi(e){let t=new Set([...e.conveyorTiles]),n=o=>{o&&$e(o.col,o.row)&&t.add(I(o.col,o.row))};for(let o of e.scanners){for(let r of el(o))n(r);for(let r of Za(o))n(r)}for(let o of e.eggFarms){n(o);for(let r of tl(o))n(r)}for(let o of Object.values(e.facilities))n(o.entry);return t}function Di(e){let t=Xa.map(([n,o])=>({col:n,row:o}));for(let{col:n,row:o}of t)Bi(e,n,o);for(let n=0;n({...n,keys:n.keys?{left:{...n.keys.left},right:{...n.keys.right}}:null,queue:[]})),e.eggFarms=Qa.map(n=>{let o=Wt(n);return{...n,nextSpawn:o,lastInterval:o}});let t=Oi(e);e.blockedCells=Si({seed:e.runSeed,targetRatio:V.map.blockedRatio,reserved:[...t]}),nl(e),Di(e),e.routingVersion=(e.routingVersion||0)+1,e.factoryGraph=null,e.factoryGraphCommittedVersion=-1}function ft(e){return e?[{col:e.col,row:e.row-1},{col:e.col+1,row:e.row-1},{col:e.col,row:e.row},{col:e.col+1,row:e.row}]:[]}function ge(e){let t=$(e.col,e.row);return{x:t.x+f.cell/2,y:t.y-f.cell/2}}function it(e,t,n){return e.eggFarms.find(o=>o.col===t&&o.row===n)||null}function st(e,t,n){return e.scanners.find(o=>ft(o).some(r=>r.col===t&&r.row===n))||null}function Ht(e,t){return e.scanners.find(n=>n.id===t)||null}function ar(e,t){return e.scanners.find(n=>n.kind==="manual"&&n.slot===t)||null}function sn(e){return e?.baseId||e?.id||""}function lr(e,t){return Object.entries(e.facilities||{}).filter(([,n])=>sn(n)===t)}function lo(e,t){return!!(t&&$e(t.col,t.row)&&(!e?.ownedCells?.size||e.ownedCells.has(I(t.col,t.row))))}function pt(e,t,n){return Object.values(e?.facilities||{}).find(o=>o?.gridPlaced?t>=o.col&&t=o.row&&n({col:e.col+t.dc,row:e.row+t.dr})).filter(t=>$e(t.col,t.row)):[]}function _i(e,t,n=[],o=[]){if(!t||!$e(t.col,t.row))return[];let r=[],i=a=>{!a||!$e(a.col,a.row)||!e.conveyorTiles.has(I(a.col,a.row))||n.some(c=>c&&no(a,c))||r.some(c=>no(c,a))||r.push({...a,viaTolerance:!no(a,t)})};i(t);for(let a of o)i(a);for(let a of Ni(t))i(a);return r}function sr(e,t,n=[],o=[]){let r=_i(e,t,n,o),i=r.filter(a=>!a.viaTolerance);return i.length?i:r}function ol(e,t){let n=Fe(t,"inputA");return!n||!$e(n.col,n.row)?[]:sr(e,n,ft(t))}function rl(e,t,n){let o=Fe(t,n);return _i(e,o,ft(t))}function il(e,t){let n=e.facilities[t]||lr(e,t)[0]?.[1];if(!n)return null;let o=nt(e,n);if(o)return $(o.col,o.row);let r=sn(n)||t;return r==="mixer"?{x:n.x+n.w,y:n.y+n.h*.52}:r==="truck"?{x:n.x,y:n.y+n.h*.52}:r==="trash"?{x:n.x+n.w/2,y:n.y+8}:{x:n.x+n.w/2,y:n.y+n.h/2}}function Gi(e,t,n){return n==="inputA"?ol(e,t).length>0:rl(e,t,n).length>0}function Me(e){e.branchCounters?.clear?.(),e.routeCache=null,e.routingVersion=(e.routingVersion||0)+1,e.factoryGraph=null,e.factoryGraphCommittedVersion=-1,e.frameCache&&(e.frameCache.routingVersion=-1,e.frameCache.congestionFrame=-1,e.frameCache.draw={})}function We(e,t){return I(e,t)}function rn(e){return re(e)}function sl(e,t){let n=e.conveyorMeta?.get?.(t)||{},o=[];return Array.isArray(n.outDirs)&&o.push(...n.outDirs),n.dir&&o.push(n.dir),[...new Set(o)].filter(r=>fe.some(i=>i.name===r))}function Li(e,t,n){let o=fe.find(a=>a.name===n);if(!o)return!1;let r=e.conveyorMeta?.get?.(We(t.col+o.dc,t.row+o.dr));return r?[...new Set([...Array.isArray(r.outDirs)?r.outDirs:[],r.dir].filter(Boolean))].includes(o.opposite):!1}function al(e,t){let n=rn(t),o=e.conveyorMeta?.get?.(t)||{},r=fe.filter(C=>e.conveyorTiles.has(We(n.col+C.dc,n.row+C.dr))).map(C=>C.name),i=sl(e,t).filter(C=>r.includes(C)),a=i.length?i:r;if(r.length===1&&(!i.length||i.includes(r[0]))&&Li(e,n,r[0]))return[];if(r.length<3)return a;let c=new Set(r.filter(C=>Li(e,n,C))),h=r.filter(C=>!c.has(C)),g=c.size?h:[...new Set(a)];return!g.length&&!c.size&&(g=r),o.branchMode&&o.branchMode!=="random"&&g.length>=2&&g.includes(o.branchMode)?[o.branchMode]:g}function ll(e){return{version:e.routingVersion||0,cells:new Set,adjacency:new Map,components:new Map,cellToComponent:new Map,scannerInputs:new Map,scannerPorts:new Map,facilityPorts:new Map,farmOutputs:new Map,pathCache:new Map,metrics:{conveyorCells:0,edges:0,components:0,deadEnds:0,crosses:0,scannerPorts:0,connectedScannerPorts:0,facilityPorts:0,connectedFacilityPorts:0,farmOutputs:0},issues:[]}}function cl(e){let t=ll(e);for(let o of e.conveyorTiles)t.cells.add(o),t.adjacency.set(o,[]);for(let o of t.cells){let r=rn(o),i=al(e,o),a=fe.filter(c=>i.includes(c.name));for(let c of a){let h=We(r.col+c.dc,r.row+c.dr);t.cells.has(h)&&t.adjacency.get(o).push({key:h,dir:c.name})}}let n=0;for(let[o,r]of t.adjacency.entries()){n+=r.length;let i=new Set(r.map(a=>a.dir));r.length===1&&(t.metrics.deadEnds+=1),r.length===4&&i.has("left")&&i.has("right")&&i.has("up")&&i.has("down")&&(t.metrics.crosses+=1)}return t.metrics.conveyorCells=t.cells.size,t.metrics.edges=Math.floor(n/2),ul(t),dl(e,t),t.issues=Cl(e,t,{includeRoutes:!1}),t}function ul(e){let t=1;for(let n of e.cells){if(e.cellToComponent.has(n))continue;let o=t++,r=[n],i=[];for(e.cellToComponent.set(n,o);r.length;){let a=r.shift();i.push(a);for(let c of e.adjacency.get(a)||[])e.cellToComponent.has(c.key)||(e.cellToComponent.set(c.key,o),r.push(c.key))}e.components.set(o,{id:o,cells:i,capacity:Math.max(1,i.length),count:0,ratio:0})}e.metrics.components=e.components.size}function dl(e,t){for(let n of e.scanners){let o={};for(let r of["inputA","left","right"]){let i=Fe(n,r),a=sr(e,i,ft(n)).filter(g=>t.cells.has(We(g.col,g.row))),c=a.map(g=>We(g.col,g.row)),h=c.length>0;if(o[r]={type:r,cell:i,cells:a,keys:c,key:c[0]||null,connected:h},t.metrics.scannerPorts+=1,h&&(t.metrics.connectedScannerPorts+=1),r==="inputA")for(let g of c)t.scannerInputs.set(g,n.id)}t.scannerPorts.set(n.id,o)}for(let[n,o]of Object.entries(e.facilities)){let r=nt(e,o);if(!r)continue;let i=sr(e,r).filter(h=>t.cells.has(We(h.col,h.row))),a=i.map(h=>We(h.col,h.row)),c=a.length>0;t.facilityPorts.set(n,{id:n,cell:{...r},cells:i,keys:a,key:a[0]||null,connected:c}),t.metrics.facilityPorts+=1,c&&(t.metrics.connectedFacilityPorts+=1)}for(let n of e.eggFarms){let o=Ni({col:n.col,row:n.row}).map(r=>We(r.col,r.row)).filter(r=>t.cells.has(r));t.farmOutputs.set(n.id,o),t.metrics.farmOutputs+=o.length}}function Je(e){let t=e.routingVersion||0;return(!e.factoryGraph||e.factoryGraph.version!==t)&&(e.factoryGraph=cl(e)),e.factoryGraph}function Ui(e){let t=Je(e);return e.factoryGraphCommittedVersion=t.version,e.factoryGraphSnapshot=fl(t),t}function fl(e){return e?{version:e.version,conveyorCells:e.metrics.conveyorCells,edges:e.metrics.edges,components:e.metrics.components,deadEnds:e.metrics.deadEnds,crosses:e.metrics.crosses,scannerPorts:`${e.metrics.connectedScannerPorts}/${e.metrics.scannerPorts}`,facilityPorts:`${e.metrics.connectedFacilityPorts}/${e.metrics.facilityPorts}`,farmOutputs:e.metrics.farmOutputs,issues:[...e.issues||[]]}:null}function pl(e,t){let n=t.col-e.col,o=t.row-e.row;return fe.find(r=>r.dc===n&&r.dr===o)?.name||null}function cr(e,t,n){let o=Je(e),r=We(t.col,t.row);if(!o.cells.has(r))return[];let i=`${r}|${n.cacheKey||"dynamic"}`;if(n.cacheKey&&o.pathCache.has(i))return o.pathCache.get(i).map(le=>le.map(ue=>({...ue})));let a=`${r}|none`,c=[{key:r,col:t.col,row:t.row,incoming:"none",depth:0}],h=new Map([[a,0]]),g=new Map,C=[],P=1/0,L=8,K=24,j=80;for(;c.length;){let le=c.shift();if(le.depth>P)break;let ue=`${le.key}|${le.incoming}`;if(n(le)){if(C.push(ue),P=le.depth,C.length>=K)break;continue}for(let D of o.adjacency.get(le.key)||[]){let U=rn(D.key),ee=pl(le,U),O=le.depth+1;if(O>P)continue;let G=`${D.key}|${ee}`,Y=h.get(G);if(Y==null)h.set(G,O),g.set(G,[ue]),c.push({key:D.key,col:U.col,row:U.row,incoming:ee,depth:O});else if(Y===O){let ie=g.get(G)||[];ie.length{if(Z.length>=j)return;let[D]=le.split("|"),U=[rn(D),...ue];if(le===a||D===r){Z.push(U);return}for(let ee of g.get(le)||[])Q(ee,U)};for(let le of C)Q(le,[]);let ke=[],pe=new Set;for(let le of Z){let ue=le.map(D=>We(D.col,D.row)).join(">");pe.has(ue)||(pe.add(ue),ke.push(le))}return n.cacheKey&&o.pathCache.set(i,ke.map(le=>le.map(ue=>({...ue})))),ke}function Wi(e){let t=Je(e),n=new Map;for(let[o,r]of t.components.entries())n.set(o,{id:o,cells:[...r.cells],capacity:r.capacity,count:0,ratio:0});return{components:n,cellToComponent:new Map(t.cellToComponent)}}function uo(e,t=null){let n=e.map(o=>$(o.col,o.row));return t&&n.push(t),n}function At(e,t){t&&(!e.length||yi(e[e.length-1],t)>1)&&e.push(t)}function yl(e,t,n,o=null){let r=[{x:e.x,y:e.y}];At(r,$(t.col,t.row));for(let i of uo(n,o))At(r,i);return r}function hl(e,t){let n=uo(e),o=Fe(t,"inputA");return o&&At(n,$(o.col,o.row)),At(n,ge(t)),n}function ml(e,t,n,o){let r=[{x:e.x,y:e.y}];At(r,$(t.col,t.row));for(let a of uo(n))At(r,a);let i=Fe(o,"inputA");return i&&At(r,$(i.col,i.row)),At(r,ge(o)),r}function bl(e,t,n=0){let o=$(t.col,t.row),r=0;for(let i of e.chicks||[]){if(i.stage==="flying"||i.stage==="queued")continue;Math.hypot(i.x-o.x,i.y-o.y){let L=C.roleBias??0,K=P.roleBias??0;if(L!==K)return L-K;let j=C.cells?.length??C.route?.length??Number.POSITIVE_INFINITY,Z=P.cells?.length??P.route?.length??Number.POSITIVE_INFINITY;if(j!==Z)return j-Z;let Q=C.key||JSON.stringify(C.route||C.cells||C),ke=P.key||JSON.stringify(P.route||P.cells||P);return Q.localeCompare(ke)});if(!o||r.length===1)return r[0];let i=r[0].roleBias??0,a=r.filter(C=>(C.roleBias??0)===i),c=a.map(C=>({item:C,score:vl(e,C)})),h=Math.min(...c.map(C=>C.score));a=c.filter(C=>C.score===h).map(C=>C.item);let g=e.branchCounters.get(t)||0;return e.branchCounters.set(t,g+1),a[Math.floor(Math.random()*a.length)]}function Hi(e){return e.role===0?{left:{match:"male",destination:"mixer"},right:{match:"default",destination:"scanner-role-1"}}:{left:{match:"poop",destination:"trash"},right:{match:"default",destination:"truck"}}}function ur(e){let t=Hi(e);return{left:t.left.destination,right:t.right.destination}}function dr(e,t){return Hi(e).left.match===t.sex?"left":"right"}function fr(e){return mi(e)}function pr(e){return{mixer:"#2477ff",truck:"#ff6aa8",trash:"#22b94f",sausageMaker:"#d78b3f","scanner-role-1":"#526456",scanner:"#526456",input:"#526456"}[e]||"#102015"}function Kt(e,t,n=!1){let o=Je(e),r=(o.farmOutputs.get(t.id)||[]).map(rn),i=[];for(let h of r){let g=P=>o.scannerInputs.has(We(P.col,P.row));g.cacheKey=`farmInput:${h.col},${h.row}:v${o.version}`;let C=cr(e,h,g);for(let P of C){let L=P[P.length-1],K=o.scannerInputs.get(We(L.col,L.row)),j=Ht(e,K);j&&i.push({key:`${j.id}:${L.col},${L.row}:${P.length}`,scanner:j,cells:P})}}let a=co(e,`farm:${t.id}`,i,n);if(a)return{scannerId:a.scanner.id,route:hl(a.cells,a.scanner)};let c=co(e,`farm:${t.id}:output`,r.map(h=>({key:I(h.col,h.row),cells:[h]})),n);return c?{scannerId:null,route:uo(c.cells)}:null}function Ki(e,t,n,o,r=!1){let i=Ht(e,o);if(!i)return null;let c=Je(e).scannerPorts.get(i.id)?.[t],h=Fe(i,t),g=c?.connected?(c.keys||[c.key]).filter(Boolean).map(rn):[];if(!h||!g.length)return null;let C=ur(i)[t];return C==="scanner-role-1"?wl(e,i,t,n,h,g,r):Sl(e,i,t,n,h,g,C,r)}function wl(e,t,n,o,r,i,a){let c=Je(e),h=e.scanners.filter(P=>P.id!==t.id&&c.scannerPorts.get(P.id)?.inputA?.connected),g=[];for(let P of i)for(let L of h){let K=c.scannerPorts.get(L.id)?.inputA?.key;if(!K)continue;let j=(c.scannerPorts.get(L.id)?.inputA?.keys||[K]).filter(Boolean),Z=pe=>j.includes(We(pe.col,pe.row));Z.cacheKey=`toScanner:${P.col},${P.row}:${L.id}:v${c.version}`;let Q=cr(e,P,Z),ke=L.role===1?0:1;for(let pe of Q)g.push({key:`${ke}:start${I(P.col,P.row)}:s${L.id}:${pe.length}:${I(pe[pe.length-1].col,pe[pe.length-1].row)}`,target:L,cells:pe,roleBias:ke})}let C=co(e,`scanner:${t.id}:${n}:toRole1`,g,a);return C?{destination:"scanner",nextScannerId:C.target.id,route:ml(o,r,C.cells,C.target)}:null}function Sl(e,t,n,o,r,i,a,c){let h=Je(e),g=[...h.facilityPorts.entries()].filter(([L,K])=>K?.connected&&sn(e.facilities?.[L])===a);if(!g.length)return null;let C=[];for(let L of i)for(let[K,j]of g){let Z=(j.keys||[j.key]).filter(Boolean);if(!Z.length)continue;let Q=pe=>Z.includes(We(pe.col,pe.row));Q.cacheKey=`toFacility:${L.col},${L.row}:${K}:v${h.version}`;let ke=cr(e,L,Q);for(let pe of ke)C.push({key:`start${I(L.col,L.row)}:${K}:${pe.length}:${I(pe[pe.length-1].col,pe[pe.length-1].row)}`,cells:pe,facilityId:K})}let P=co(e,`scanner:${t.id}:${n}:${a}`,C,c);return P?{destination:a,facilityId:P.facilityId,route:yl(o,r,P.cells,il(e,P.facilityId))}:null}function qi(e,t,n=new Set){if(!t||n.has(t.id))return!1;n.add(t.id);for(let o of["left","right"]){let r=Ki(e,o,ge(t),t.id,!1);if(r){if(["mixer","trash","truck","sausageMaker"].includes(r.destination))return!0;if(r.destination==="scanner"){let i=Ht(e,r.nextScannerId);if(qi(e,i,n))return!0}}}return!1}function kl(e,t){return!!Kt(e,t,!1)?.scannerId}function an(e){if(e.phase!=="build")return[];let t=[];for(let n of e.eggFarms||[])kl(e,n)||t.push({type:"eggFarm",id:n.id,ref:n,message:"This Egg will not produce chicks"});for(let n of Jo)for(let[o,r]of lr(e,n))Je(e).facilityPorts.get(o)?.connected||t.push({type:"facility",id:o,ref:r,message:"This facility cannot receive items"});return t}function gl(e){let t=Je(e);for(let r of e.eggFarms||[]){let i=Kt(e,r,!1);if(!i?.scannerId)continue;let a=Ht(e,i.scannerId);if(qi(e,a))return[]}let n=e.eggFarms?.length||0,o=[...t.facilityPorts.entries()].filter(([r,i])=>i?.connected&&["mixer","trash","truck"].includes(sn(e.facilities?.[r]))).length;return n?t.metrics.farmOutputs?e.scanners?.length?o?["No route from an EGG scanner to a receiver."]:["Connect at least one receiver exit: Mixer, Shredder, or Truck."]:["Build at least one scanner and connect it to an EGG route."]:["Connect at least one EGG to a conveyor."]:["Build at least one EGG before starting the next day."]}function ao(e){return e.kind==="manual"?`S${(e.slot??0)+1}`:`Auto Scanner #${e.id}`}function Cl(e,t=Je(e),n={}){let o=n.includeRoutes!==!1,r=[],i={mixer:"Mixer",trash:"Shredder",truck:"Truck",sausageMaker:"Sausage Machine"};for(let a of Jo){let c=lr(e,a);if(!c.length){di.includes(a)&&r.push(`${i[a]||a} is missing`);continue}for(let[h]of c)t.facilityPorts.get(h)?.connected||r.push(`${i[a]||a} receiver has no conveyor`)}for(let a of e.scanners){let c=t.scannerPorts.get(a.id)||{};if(c.inputA?.connected||r.push(`${ao(a)} input has no conveyor`),c.left?.connected||r.push(`${ao(a)} left output has no conveyor`),c.right?.connected||r.push(`${ao(a)} right output has no conveyor`),!!o)for(let h of["left","right"])c[h]?.connected&&!Ki(e,h,ge(a),a.id)&&r.push(`${ao(a)} ${h} route is incomplete`)}if(o)for(let a of e.eggFarms)Kt(e,a)?.scannerId||r.push(`Egg Farm #${a.id} has no scanner route`);else for(let a of e.eggFarms)(t.farmOutputs.get(a.id)||[]).length||r.push(`Egg Farm #${a.id} has no output conveyor`);return[...new Set(r)]}function fo(e){return gl(e)}var Fn=[{key:"premiumFemaleShipment",target:"female",truckTarget:"female",title:"Premium Female Shipment",route:"Female -> Truck",note:"Today only: ship female chicks to Truck. Meet the export target for a bonus.",objective:"truckTarget"},{key:"contaminantSampleExport",target:"poop",truckTarget:"poop",title:"Contaminant Sample Export",route:"Poop value x2",note:"Today only: poop is valid Truck cargo and all poop income is doubled.",objective:"truckTarget",poopValueMultiplier:2},{key:"healthInspection",title:"Health Inspection",route:"Poop -> Shredder",note:"Today only: poop mistakes are fined double. Shred enough poop for an inspection bonus.",objective:"poopTrash",poopFineMultiplier:2},{key:"conveyorJamWarning",title:"Conveyor Jam Warning",route:"No explosions",note:"Today only: jam explosions trigger at 85% congestion. Finish with zero explosions for a bonus.",objective:"noExplosion",congestionExplosionThreshold:.85,congestionWarningThreshold:.68},{key:"powerSavingOrder",title:"Power Saving Order",route:"Slow factory subsidy",note:"Today only: conveyors -20% speed and Auto Scanners +20% cooldown. A subsidy is paid at settlement.",objective:"flatBonus",conveyorSpeedMultiplier:.8,autoScannerCooldownMultiplier:1.2},{key:"overtimeDay",title:"Overtime Day",route:"80 second day",note:"Today only: the work day is longer. No extra reward or penalty.",objective:"none",daySeconds:80},{key:"shortShift",title:"Short Shift",route:"40 second day",note:"Today only: the work day is shorter. The next Fairies tribute is reduced by 10%.",objective:"none",daySeconds:40,fairiesTributeMultiplier:.9},{key:"chickBoom",title:"Chick Boom",route:"EGG interval x0.65",note:"Today only: eggs hatch faster and poop rate rises by 5 percentage points.",objective:"none",productionMultiplier:1/.65,poopRateAdd:.05},{key:"rottenFeed",title:"Rotten Feed",route:"Poop surge",note:"Today only: poop rate rises by 20 percentage points and Shredder poop income is multiplied by 1.5.",objective:"none",poopRateAdd:.2,shredderIncomeMultiplier:1.5},{key:"maintenanceAudit",title:"Maintenance Audit",route:"Durability check",note:"Today only: machines below 50% remaining durability fail the audit. Passing with none below 50% pays a bonus.",objective:"maintenanceAudit"}];function Ee(e,t=0){let n=Number(e);return Number.isFinite(n)?n:t}function Ml(e,t=0,n=1){let o=Math.max(1,Number(e?.turn||1)+1),r=Math.floor(yr(e)*(.08+Math.random()*.08)),i=Math.floor(Math.max(0,e.cash||0)*.25),a=Math.floor(Math.max(0,t)*Math.max(1,o)*.75);return Math.max(120,Math.ceil(Math.max(r,i,a)*n))}function Yi(e){return Math.max(0,Math.ceil(500+Math.max(1,e?.turn||1)*25))}function yr(e){return[...e.eggFarms.map(t=>t.price||0),...e.scanners.map(t=>t.price||0),...Object.values(e.facilities).map(t=>t.price||0),...[...e.conveyorMeta.values()].map(t=>t.price||0)].reduce((t,n)=>t+n,0)}function xl(e,t=1,n=Gt){return e.eggFarms.reduce((o,r)=>{let[i,a]=$n(r),c=Math.max(.1,(i+a)/2);return o+Math.max(1,n)/c*Math.max(.01,t||1)},0)}function Tl(e,t){let n=Ee(t.productionMultiplier,1),o=Ee(t.daySeconds,Gt),r=xl(e,n,o),i=Math.max(0,Number(e?.cardEffects?.hatchingFeed)||0),a=Math.max(0,Number(e?.cardEffects?.poopRateBooster)||0),c=Math.pow(1.03,i),h=zo+Xo*a,g=Math.min(.95,Math.max(0,(h+Ee(t.poopRateAdd,0))*Ee(t.poopMultiplier,1)*c));return{estimate:r,poopRate:g}}function hr(e,t){let n=typeof t=="string"?Fn.find(c=>c.key===t):t;if(!n)return null;let{estimate:o,poopRate:r}=Tl(e,n),i=0;n.objective==="truckTarget"?i=Math.max(1,Math.ceil(o*.3)):n.objective==="poopTrash"&&(i=Math.max(1,Math.ceil(o*r*.8)));let a=0;return["truckTarget","poopTrash","noExplosion"].includes(n.objective)&&(a=Ml(e,o)),n.objective==="flatBonus"&&(a=Yi(e)),n.objective==="maintenanceAudit"&&(a=Yi(e)),{id:`event-${e.turn}-${Date.now()}-${Math.floor(Math.random()*9999)}`,...n,target:n.target||null,truckTarget:n.truckTarget||null,forced:!0,productionMultiplier:Ee(n.productionMultiplier,1),poopMultiplier:Ee(n.poopMultiplier,1),poopRateAdd:Ee(n.poopRateAdd,0),daySeconds:Ee(n.daySeconds,Gt),targetAmount:i,estimatedOutput:Math.round(o),reward:a,createdTurn:e.turn,oneTurnOnly:!0}}function Vi(e){if(e.turn+1ci)return null;let n=Fn[Math.floor(Math.random()*Fn.length)];return hr(e,n)}function zi(e){e.eventActive=e.eventOffer?{...e.eventOffer}:null,e.eventOffer=null}function Xi(e){e.eventActive=null}function ji(e){return Math.max(1,Ee(e.eventActive?.daySeconds,Gt))}function Qi(e){return Ee(e.eventActive?.productionMultiplier,1)}function Ji(e){let t=Math.max(0,Number(e?.cardEffects?.hatchingFeed)||0),n=Math.max(0,Number(e?.cardEffects?.poopRateBooster)||0),o=Math.pow(1.03,t),r=zo+Xo*n,i=Ee(e.eventActive?.poopRateAdd,0);return Math.min(.95,Math.max(0,(r+i)*Ee(e.eventActive?.poopMultiplier,1)*o))}function ln(e){return e.eventActive?.truckTarget||e.eventActive?.target||"female"}function mr(e,t){return t==="male"?e.truckMale||0:t==="poop"?e.poopTruck||0:e.truckFemale||0}function Zi(e,t){return ln(e)===t}function es(e){return ln(e)!=="male"}function br(e){return Math.max(.01,Ee(e.eventActive?.poopValueMultiplier,1))}function po(e){return Math.max(.01,br(e)*Ee(e.eventActive?.shredderIncomeMultiplier,1))}function vr(e){return Math.max(.01,Ee(e.eventActive?.poopFineMultiplier,1))}function wr(e){return Math.max(.1,Math.min(1,Ee(e.eventActive?.congestionExplosionThreshold,1)))}function ts(e){return Math.max(.1,Math.min(1,Ee(e.eventActive?.congestionWarningThreshold,.8)))}function ns(e){return Math.max(.01,Ee(e?.fairiesTributeMultiplier,1))}function El(e){let t=0,n=(o,r)=>{let i=Math.max(1,Ee(r,1)),a=Math.max(0,Ee(o,0));1-Math.min(1,a/i)<.5&&(t+=1)};for(let o of e.conveyorMeta?.values?.()||[])n(o.uses,o.durability);for(let o of e.eggFarms||[])n(o.maintenance?.uses,o.maintenance?.durability);for(let o of e.scanners||[])o.kind==="auto"&&n(o.maintenance?.uses,o.maintenance?.durability);for(let o of Object.values(e.facilities||{})){let r=o?.baseId||o?.id;(r==="mixer"||r==="trash"||r==="sausageMaker")&&n(o.maintenance?.uses,o.maintenance?.durability)}return t}function os(e){let t=e.eventActive;if(!t)return null;let n=0,o=!0,r=0,i="";t.objective==="truckTarget"?(n=mr(e.stats,t.target),o=n>=t.targetAmount,r=o?t.reward:0):t.objective==="poopTrash"?(n=e.stats.poopTrash||0,o=n>=t.targetAmount,r=o?t.reward:0):t.objective==="noExplosion"?(n=e.stats.explosions||0,o=n===0,r=o?t.reward:0,i=`${n} explosion(s)`):t.objective==="flatBonus"?(o=!0,r=t.reward):t.objective==="maintenanceAudit"?(n=El(e),o=n===0,o&&(r=t.reward),i=`${n} machine(s) below 50%`):o=!0,r>0?(je(e,r),e.stats.eventBonus=(e.stats.eventBonus||0)+r,e.totals.eventBonus+=r,e.totals.eventSuccess+=1):t.objective&&t.objective!=="none"&&(e.stats.eventBonus=e.stats.eventBonus||0,e.totals.eventFailed+=o?0:1,o&&(e.totals.eventSuccess+=1));let a={title:t.title,key:t.key,target:t.target,route:t.route,objective:t.objective,targetAmount:t.targetAmount||0,count:n,success:o,bonus:r,reward:t.reward||0,productionMultiplier:t.productionMultiplier,poopMultiplier:t.poopMultiplier,daySeconds:t.daySeconds,detail:i};return e.stats.eventResult=a,a}function Sr(e,t){return Math.max(0,Number(e?.cardEffects?.[t])||0)}function Rl(e){return!!e?.cardEffects?.usedMachineActive}function Pl(e,t){return t?1:Math.min(.8,.5+.1*Math.min(3,Sr(e,"recyclingSubsidy")))}function Al(e){return Math.pow(.95,Sr(e,"safetyCover"))}function yo(e){let t=e?.ref||e||{},n=e?.type||t.type;return n==="conveyor"?t.price||Te[t.kind]?.price||Te.conveyor.price:n==="eggFarm"?t.price||Te.eggFarm.price:n==="scanner"?t.price||(t.kind==="auto"?Te.autoScanner.price:Te.manualScanner.price):n==="facility"?t.price||Te[t.id]?.price||0:n==="maintenanceRoom"?t.price||Te.maintenanceRoom.price:Te[t.id]?.price||0}function He(e,t=null){let n=Te[e]?.price||0;return Rl(t)?Math.ceil(n*.5):n}function Il(e,t){return e?.facilities?Object.values(e.facilities).filter(n=>(n?.baseId||n?.id)===t).reduce((n,o)=>n+Math.max(0,(o.level||1)-1),0):0}function yt(e,t){return fi.includes(t)?Math.pow(xe.incomeUpgradeRate,Il(e,t)):1}function Ve(e){return Math.ceil(xe.income.truck*yt(e,"truck"))}function Ze(e){return Math.ceil((xe.income.sausage||26)*yt(e,"mixer")*yt(e,"sausageMaker"))}function Dn(e){return Math.ceil(xe.poopFine*yt(e,"mixer")*vr(e))}function Bn(e){return Math.ceil(xe.poopFine*yt(e,"truck")*vr(e))}function kr(e){let t=xe.maleTruckFinePerHalfDay*(e.turn/2);return Math.ceil(t*yt(e,"truck"))}function gr(e){return e?.facilities?Object.values(e.facilities).filter(t=>(t?.baseId||t?.id)==="trash").reduce((t,n)=>t+Math.max(0,(n.level||1)-1),0):0}function mt(e){return Math.min(xe.shredderBonus.maxCards,gr(e))}function cn(){return xe.shredderBonus.maxCards}function ho(){return xe.shredderBonus||{}}function rs(e){let t=typeof e=="number"?e:mt(e),n=ho(),o=Math.min(n.maxCards||30,Math.max(0,t));return o>0?Math.ceil((n.payoutCoefficient||1.6)*Math.pow(o,n.payoutExponent||1.25)):0}function qt(e){let t=typeof e=="number"?e:mt(e);if(t<=0)return 0;let n=ho();return Math.min(n.maxHitChance||.82,(n.baseHitChance||.28)+t*(n.hitChancePerUpgrade||.018))}function $l(e){let t=ho(),n=Math.min(t.jackpotMaxChance||.08,(t.jackpotBaseChance||.01)+e*(t.jackpotChancePerUpgrade||.0025)),o=Math.min(t.bigWinMaxChance||.12,(t.bigWinBaseChance||.07)+e*(t.bigWinChancePerUpgrade||.0015)),r=Math.min(1-n-o,t.doubleChance||.2),i=Math.max(0,1-n-o-r);return n*(t.jackpotMultiplier||10)+o*(t.bigWinMultiplier||4)+r*(t.doubleMultiplier||2)+i}function ht(e){let t=typeof e=="number"?e:mt(e);return Math.round(rs(t)*qt(t)*$l(t))}function Fl(e){let t=mt(e),n=qt(t),o=rs(t);if(t<=0||Math.random()>=n)return{cards:t,chance:n,amount:0,expected:ht(t),baseAmount:o,multiplier:0,tier:"miss"};let r=ho(),i=Math.min(r.jackpotMaxChance||.08,(r.jackpotBaseChance||.01)+t*(r.jackpotChancePerUpgrade||.0025)),a=Math.min(r.bigWinMaxChance||.12,(r.bigWinBaseChance||.07)+t*(r.bigWinChancePerUpgrade||.0015)),c=Math.min(1-i-a,r.doubleChance||.2),h=Math.random(),g=1,C="normal";h0&&(e.stats.shredderBonus=(e.stats.shredderBonus||0)+t.amount,e.totals.shredderBonus=(e.totals.shredderBonus||0)+t.amount,je(e,t.amount)),t}function Cr(e,t=e.turn,n=null){let o=Math.max(1,Math.floor(Number(t)||1)),r=xe.fairiesTribute||{perDay:10,profitExemption:300,legalWorkExemptionBonus:250,profitRate:.12},i=Math.ceil(o*(r.perDay||10)),a=Math.max(0,Math.floor(Number(n??e?.lastResult?.profit??e?.stats?.profit??0)||0)),c=Math.max(0,e?.cardEffects?.legalWork||0),h=Math.max(0,(r.profitExemption||0)+c*(r.legalWorkExemptionBonus||0)),g=Math.max(0,a-h),C=Math.ceil(g*Math.max(0,Number(r.profitRate)||0)),P=i+C,L=Math.max(0,e?.cardEffects?.fairiesFlatteryNext||e?.cardEffects?.flattery||0),K=L>0?Math.pow(.5,L):1,j=Math.max(.01,Number(e?.pendingFairiesTributeMultiplier)||1),Z=K*j,Q=Math.max(0,Math.ceil(P*Z)),ke=Math.max(0,P-Q);return{day:o,base:i,profit:a,legalWork:c,profitExemption:h,taxableProfit:g,profitTribute:C,gross:P,flatteryCount:L,eventMultiplier:j,perCardReduction:Math.ceil(P*.5),reduction:ke,amount:Q}}function $t(e,t){let n=e?.ref||e||{},o=e?.type==="conveyor"?t?.conveyorMeta?.get(e.oldKey||n.id):n,r=o?.price||yo(e||n),i=o?.builtSession===t?.buildSession;if(o?.noRefund||n?.noRefund)return{amount:0,sameBuild:i,price:r,rate:0,noRefund:!0};let a=Pl(t,i);return{amount:Math.max(0,Math.ceil(r*a)),sameBuild:i,price:r,rate:a,noRefund:!1}}function Dl(e,t=null){let n=Math.max(1,Math.ceil((Number(e)||0)/xe.explosionDamageDivisor));return Math.max(1,Math.ceil(n*Al(t)))}function Bl(e,t){return Math.max(0,Math.ceil(t))}function ss(e,t){e.cash+=t,e.stats.profit+=t,e.totals.profit+=t,t>=0?(e.stats.revenue+=t,e.totals.revenue+=t):(e.stats.penalty+=Math.abs(t),e.totals.penalty+=Math.abs(t)),e.totals.maxCash=Math.max(e.totals.maxCash,e.cash)}function je(e,t){let n=Math.max(0,Math.ceil(t));return n>0&&ss(e,n),n}function Ft(e,t){let n=Math.max(0,Math.ceil(t));return n>0&&ss(e,-n),n}function Dt(e,t){Ft(e,t)}function as(e,t){t<=0||(e.cash+=t,e.stats.profit+=t,e.totals.profit+=t,e.totals.maxCash=Math.max(e.totals.maxCash,e.cash))}function ls(e){let t=xe.income.mixer;return e.stats.mixerRevenue+=t,je(e,t),t}function cs(e){let t=Dn(e);return e.stats.mixerPoopFine+=t,e.totals.mixerPoopFine+=t,Ft(e,t),t}function us(e){let t=Bn(e);return e.stats.truckPoopFine+=t,e.totals.truckPoopFine+=t,Ft(e,t),t}function ds(e){let t=kr(e);return e.stats.maleTruckFine+=t,e.totals.maleTruckFine+=t,Ft(e,t),t}function fs(e,t){let n=Dl(t,e);return e.stats.explosionDamage+=n,e.totals.explosionDamage+=n,Ft(e,n),n}function ps(e){let t=ln(e),n=mr(e.stats,t),o=t==="poop"?Math.ceil(Ve(e)*br(e)):Ve(e),r=n*o,i=Math.max(0,e?.stats?.sausageShipped||0),a=Ze(e),c=i*a,h=Bl(e,r+c);return e.stats.pendingTruckRevenue=h,e.stats.truckRevenue=h,e.stats.sausageRevenue=c,t==="poop"?e.stats.poopShipmentIncome=r:e.stats.chickShipmentIncome=r,h>0&&je(e,h),{base:r,adjusted:h,target:t,targetCount:n,unitPrice:o,sausageCount:i,sausageUnitPrice:a,sausageRevenue:c}}function ys(e,t=e.turn,n=null,o={}){let r=Cr(e,t,n);return e.cardEffects&&(e.cardEffects.fairiesFlatteryNext=0,e.cardEffects.flattery!=null&&delete e.cardEffects.flattery),e.pendingFairiesTributeMultiplier!=null&&(e.pendingFairiesTributeMultiplier=1),r.amount<=0||(o.recordStats?(e.stats.fairiesTribute=(e.stats.fairiesTribute||0)+r.amount,e.totals.fairiesTribute=(e.totals.fairiesTribute||0)+r.amount,Ft(e,r.amount)):(e.totals.fairiesTribute=(e.totals.fairiesTribute||0)+r.amount,e.totals.penalty=(e.totals.penalty||0)+r.amount,e.totals.profit=(e.totals.profit||0)-r.amount,e.cash-=r.amount)),r}function hs(e){let t=Sr(e,"chemicalWeaponSubsidy"),n=Math.max(0,e?.stats?.poopTruck||0),o=xe.chemicalWeaponSubsidy||{poopPerBlock:70,yenPerBlock:1200},r=Math.floor(n/Math.max(1,o.poopPerBlock||70)),i=t>0&&r>0?r*Math.max(0,o.yenPerBlock||1200)*t:0;return i>0&&(e.stats.chemicalWeaponSubsidy=(e.stats.chemicalWeaponSubsidy||0)+i,e.totals.chemicalWeaponSubsidy=(e.totals.chemicalWeaponSubsidy||0)+i,je(e,i)),{cards:t,poopTruck:n,blocks:r,amount:i}}function ms(e){let t=yr(e),n=Math.max(1,e.totals.turnsCompleted||e.turn||1),o=Math.max(0,e.cash+e.totals.revenue+t*.5+e.totals.eventBonus*.5),r=Math.ceil(o/n),i=Math.floor(n*35+Math.max(0,n-5)*15),a=e.totals.correct*5,c=e.totals.explosionDamage;return{score:Math.max(0,Math.floor(o+r+a-e.totals.penalty-i)),factoryValue:t,base:o,dailyEarned:r,dayPenalty:i,correctBonus:a,explosionPenalty:c,days:n}}function Ol(e){return{...e,keys:e.keys?{left:{...e.keys.left},right:{...e.keys.right}}:null,queue:[],cooldown:e.cooldown||0}}function On(e){return JSON.stringify({cash:e.cash,stats:e.stats,totals:e.totals,nextId:e.nextId,gridX:f.x,gridY:f.y,gridCols:e.gridCols||f.cols,gridRows:e.gridRows||f.rows,gridExpansionPurchases:e.gridExpansionPurchases||0,gridExpansionPurchasesByDirection:e.gridExpansionPurchasesByDirection||{left:0,right:0,up:0,down:0},gridChunkOriginX:e.gridChunkOriginX||0,gridChunkOriginY:e.gridChunkOriginY||0,ownedCells:[...e.ownedCells||[]],ownedChunks:[...e.ownedChunks||[]],blockedCells:[...e.blockedCells||[]],selected:e.selected,multiSelected:e.multiSelected||[],buildTool:e.buildTool,view:e.view,facilities:e.facilities,eggFarms:e.eggFarms,scanners:e.scanners.map(Ol),conveyorTiles:[...e.conveyorTiles],conveyorMeta:[...e.conveyorMeta.entries()],branchCounters:[...e.branchCounters.entries()],cardEffects:e.cardEffects,cardDraft:e.cardDraft,cardTargetPick:e.cardTargetPick})}function bs(e,t){let n=JSON.parse(t);e.cash=n.cash,e.stats=n.stats,e.totals=n.totals,e.nextId=n.nextId,f.x=n.gridX??V.grid.x,f.y=n.gridY??V.grid.y,f.cols=n.gridCols||V.grid.cols,f.rows=n.gridRows||V.grid.rows,e.gridCols=f.cols,e.gridRows=f.rows,e.gridExpansionPurchases=n.gridExpansionPurchases||0,e.gridExpansionPurchasesByDirection=n.gridExpansionPurchasesByDirection||{left:0,right:0,up:0,down:0},e.gridExpansionPurchasesByDirection.left==null&&(e.gridExpansionPurchasesByDirection.left=0),e.gridExpansionPurchasesByDirection.down==null&&(e.gridExpansionPurchasesByDirection.down=0),e.gridChunkOriginX=n.gridChunkOriginX||0,e.gridChunkOriginY=n.gridChunkOriginY||0,e.ownedCells=new Set(n.ownedCells||[]),e.ownedChunks=new Set(n.ownedChunks||[]),e.gridOwnedCells=e.ownedCells.size||f.cols*f.rows,e.blockedCells=new Set(n.blockedCells||[]),e.selected=n.selected,e.multiSelected=n.multiSelected||[],e.buildTool=n.buildTool,e.view=n.view||{x:0,y:0,scale:1},e.view.scale==null&&(e.view.scale=1),e.facilities=n.facilities||{},e.eggFarms=n.eggFarms||[],e.scanners=(n.scanners||[]).map(o=>({...o,queue:[],cooldown:o.cooldown||0})),e.conveyorTiles=new Set(n.conveyorTiles||[]),e.conveyorMeta=new Map(n.conveyorMeta||[]),e.branchCounters=new Map(n.branchCounters||[]),e.cardEffects=n.cardEffects||{bearing:0,legalWork:0,fairiesFlatteryNext:0,scannerQueueSpacing:0},e.cardEffects.drawBonus!=null&&delete e.cardEffects.drawBonus,e.cardDraft=n.cardDraft||{pending:!1,choices:[],rerolls:0,picksRemaining:0},e.cardDraft.picksRemaining==null&&(e.cardDraft.picksRemaining=e.cardDraft.pending?1:0),e.cardDraft.freeRerolls==null&&(e.cardDraft.freeRerolls=0),e.cardTargetPick=n.cardTargetPick||null,e.groupDrag=null,e.selectionBox=null,e.pan=null,e.routingVersion=(e.routingVersion||0)+1,e.factoryGraph=null,e.factoryGraphCommittedVersion=-1}function _e(e){e.phase==="build"&&(e.undoStack.push(On(e)),e.undoStack.length>80&&e.undoStack.shift(),e.redoStack=[])}function Mr(e){return e.phase!=="build"||!e.undoStack.length?!1:(e.redoStack.push(On(e)),bs(e,e.undoStack.pop()),!0)}function xr(e){return e.phase!=="build"||!e.redoStack.length?!1:(e.undoStack.push(On(e)),bs(e,e.redoStack.pop()),!0)}function H(e,t,n,o,r=b.ink){e.floatingTexts.push({x:t,y:n,text:o,color:r,life:1,maxLife:1})}function Yt(e,t,n){e.shake.strength=Math.max(e.shake.strength,t),e.shake.time=Math.max(e.shake.time,n)}function vs(e,t,n){e.effects.push({type:"spawn",priority:Ie.ambient,x:t,y:n,life:.42,maxLife:.42,size:12})}function ws(e,t,n,o=b.green){e.effects.push({type:"scannerPulse",priority:Ie.important,x:t,y:n,color:o,life:.42,maxLife:.42,size:24})}function Ss(e,t,n){for(let o=0;o<12;o+=1)e.effects.push({type:"meat",priority:Ie.important,x:t+z(-18,18),y:n+z(-12,16),vx:z(-60,60),vy:z(-90,-25),life:z(.45,.85),maxLife:.85,size:z(4,8)})}function ks(e,t,n){for(let o=0;o<16;o+=1)e.effects.push({type:"sludge",priority:Ie.important,x:t+z(-18,18),y:n+z(-12,16),vx:z(-46,46),vy:z(-70,-20),life:z(.45,.9),maxLife:.9,size:z(4,8)})}function gs(e,t,n,o){let r=o==="poop"?b.poop:o==="male"?b.male:b.female;for(let i=0;i<22;i+=1)e.effects.push({type:"shred",priority:Ie.important,x:t+z(-16,16),y:n+z(-8,8),vx:z(-70,70),vy:z(40,160),color:i%3===0?b.white:r,life:z(.45,.95),maxLife:.95,w:z(3,7),h:z(10,24),rot:z(-Math.PI,Math.PI)});at(e,t,n,b.green,34,Ie.important)}function Cs(e,t,n,o){let r=o==="poop"?b.poop:o==="male"?b.danger:b.green;for(let i=0;i<8;i+=1)e.effects.push({type:"load",priority:Ie.important,x:t+z(-18,18),y:n+z(-18,18),vx:z(-42,42),vy:z(-90,-25),color:r,life:z(.35,.65),maxLife:.65,size:8})}function at(e,t,n,o=b.danger,r=52,i=Ie.critical){e.effects.push({type:"shockwave",priority:i,x:t,y:n,color:o,life:.55,maxLife:.55,size:8,maxSize:r})}function bt(e,t,n,o=12,r=b.danger,i=Ie.critical){for(let a=0;a0&&(e.shake.time=Math.max(0,e.shake.time-t),e.shake.time<=0&&(e.shake.strength=0));for(let r of e.effects)if(r.life-=t,["meat","sludge","spark","smoke","shred","load","rage"].includes(r.type))r.x+=(r.vx||0)*t,r.y+=(r.vy||0)*t,r.vy=(r.vy||0)+150*t;else if(r.type==="flyingChick"){r.x+=r.vx*t,r.y+=r.vy*t,r.vy+=360*t,r.noDamage||Ll(e,r,o);let i=r.x+e.view.x,a=r.y+e.view.y;(i<-100||a<-100||i>n.width+100||a>n.height+100)&&(r.life=0)}e.effects=e.effects.filter(r=>r.life>0);for(let r of e.floatingTexts)r.life-=t,r.y-=34*t;e.floatingTexts=e.floatingTexts.filter(r=>r.life>0)}function Ll(e,t,n){t.hit||(t.hit=new Set);for(let o of n()){if(t.hit.has(o.id)||t.xo.x+o.w||t.yo.y+o.h)continue;t.hit.add(o.id);let r=fs(e,o.price);H(e,t.x,t.y-10,`-${x(r)}`,b.danger),at(e,t.x,t.y,b.danger,30),bt(e,t.x,t.y,5,b.danger),Yt(e,5,.18)}}var Qe=V.maintenance;function Er(e,t){return Math.max(0,Number(e?.cardEffects?.[t])||0)}function vo(e){let t=Math.min(1.4,1+.04*Math.min(10,Er(e,"crowdedFarming"))),n=Math.pow(.95,Er(e,"preventiveMaintenance"));return t*n}function Nl(e){return Math.pow(1.03,Er(e,"durabilityCoating"))}function wo(e,t,n=1){let o=Qe.durability[t]||1e3;return Math.max(1,Math.ceil(o*Math.max(.1,n||1)*Nl(e)))}function Ts(e){return Math.max(.1,Number(e?.durabilityBaseMultiplier)||1)}function _l(e,t=0){if(!e)return{x:f.x+f.cell*(.5+t*.55),y:f.y+f.rows*f.cell+84};let n=f.x+e.col*f.cell+f.cell,o=f.y+e.row*f.cell+f.cell;return{x:n,y:o}}function Gl(e,t=0){let n=(e.maintenanceRooms||[])[t];return _l(n,t)}function Re(e){e.repairman||(e.repairman={active:!1,x:f.x+f.cell*.5,y:f.y+f.rows*f.cell+84,target:null,repairedToday:0}),Array.isArray(e.maintenanceRooms)||(e.maintenanceRooms=[]),Array.isArray(e.repairmen)||(e.repairmen=[]);for(let[t,n]of e.conveyorMeta||[]){n.uses==null&&(n.uses=0),n.maintenanceType==null&&(n.maintenanceType="conveyor"),n.kind||(n.kind="conveyor"),n.speedMultiplier||(n.speedMultiplier=n.kind==="boostConveyor"?2:1),n.branchMode||(n.branchMode="random");let o=wo(e,"conveyor",Ts(n));(n.durability==null||n.durability0&&(n.processingCooldown=Math.max(0,n.processingCooldown-t))}function Gn(e){Re(e);let t=[];for(let[n,o]of e.conveyorMeta||[])t.push({type:"conveyor",key:n,meta:o,label:`${o.kind==="boostConveyor"?"Boost Belt":"Belt"} ${n}`,center:$(re(n).col,re(n).row)});for(let n of e.eggFarms||[])t.push({type:"eggFarm",ref:n,label:`EGG #${n.id}`,center:$(n.col,n.row)});for(let n of(e.scanners||[]).filter(o=>o.kind==="auto"))t.push({type:"autoScanner",ref:n,label:`AUTO #${n.id}`,center:$(n.col,n.row)});for(let n of Object.values(e.facilities||{})){let o=Co(n);["mixer","trash","sausageMaker"].includes(o)&&t.push({type:o,ref:n,label:`${n.shortName||n.name||o} #${n.id}`,center:{x:n.x+n.w/2,y:n.y+n.h/2}})}return t}function Vt(e){return e.meta?un(e):un(e.ref)}function Ln(e){return e.meta?Rr(e):Rr(e.ref)}function bo(e){return e.meta?Pr(e):Pr(e.ref)}function Is(e,t){Ul(e.meta?e:e.ref,Math.max(0,Ln(e)-t))}function Dr(e){return e.type==="conveyor"?`conveyor:${e.key}`:`${e.type}:${e.ref?.id||e.ref?.type||e.type}`}function $s(e,t=.02){Re(e);let n=Math.max(0,Number(t)||0),o=0;for(let r of Gn(e)){let i=Ln(r);Is(r,bo(r)*n),o+=Math.max(0,i-Ln(r))/bo(r)*100}return o}function Fs(e){Re(e);let t=e.maintenanceRooms||[];e.repairmen=t.map((o,r)=>{let i=Gl(e,r);return{id:`room-${o.id}`,roomId:o.id,active:!0,x:i.x,y:i.y,target:null,repairedToday:0}});let n=e.repairmen[0];e.repairman.active=!!n,e.repairman.target=n?.target||null,e.repairman.repairedToday=0,n&&(e.repairman.x=n.x,e.repairman.y=n.y)}function Ds(e){Re(e);for(let t of e.repairmen||[])t.active=!1,t.target=null;e.repairmen=[],e.repairman.active=!1,e.repairman.target=null}function Hl(e,t){return t&&Gn(e).find(o=>Dr(o)===t.key)||null}function Kl(e){let t=null;for(let n of Gn(e)){let o=Vt(n);o<=.001||(!t||o>Vt(t))&&(t=n)}return t}function Bs(e,t){Re(e);let n=e.repairmen?.length?e.repairmen:e.repairman?.active?[e.repairman]:[];if(!n.length||e.phase!=="running")return;let o=new Set(n.map(i=>i.target?.key).filter(Boolean));for(let i of n)ql(e,i,t,o);let r=n[0];r&&e.repairman!==r&&(e.repairman.active=!0,e.repairman.x=r.x,e.repairman.y=r.y,e.repairman.target=r.target,e.repairman.repairedToday=r.repairedToday||0)}function ql(e,t,n,o){if(!t.active)return;let r=Hl(e,t.target);if((!r||Vt(r)<=.001)&&(t.target?.key&&o.delete(t.target.key),r=Yl(e,o)),!r){t.target=null;return}let i=Dr(r);o.add(i),t.target={key:i,label:r.label};let a=Qe.repairman.walkSpeed,c=r.center.x-t.x,h=r.center.y-t.y,g=Math.hypot(c,h);if(g>8){let Z=Math.min(g,a*n);t.x+=c/g*Z,t.y+=h/g*Z;return}let C=e?.cardEffects?.laborExploitation?.7:1,P=n/(Qe.repairman.secondsPerOnePercent*C*100),L=Ln(r),K=P*bo(r);Is(r,K);let j=Math.max(0,(L-Ln(r))/bo(r)*100);t.repairedToday=(t.repairedToday||0)+j,j>0&&Math.random()<.02&&H(e,t.x,t.y-28,"REPAIR",b.green),Vt(r)<=.001&&(o.delete(i),t.target=null)}function Yl(e,t=new Set){let n=null;for(let o of Gn(e)){if(t.has(Dr(o)))continue;let r=Vt(o);r<=.001||(!n||r>Vt(n))&&(n=o)}return n||Kl(e)}function Vl(e){let t=0,n="none";for(let o of Gn(e)){let r=Vt(o);r>t&&(t=r,n=o.label)}return{ratio:t,percent:Math.round(t*100),label:n}}function Br(e){let t=Vl(e),n=Ar(e);return{worst:t,avgBeltPerformance:n,repairman:e.repairman||null,repairmen:e.repairmen||[],rooms:e.maintenanceRooms||[]}}var zl=Ue.commonWeight,Xl=Ue.rareWeight,jl=Ue.ultraRareWeight||.45,zt=Ue.baseDraftSize,fn=ui;function dn(e,t){let n=Ke(e);return Math.max(0,Number(n[t])||0)}function Ke(e){e.cardEffects||(e.cardEffects={...jo});for(let[t,n]of Object.entries(jo))e.cardEffects[t]==null&&(e.cardEffects[t]=Array.isArray(n)?[]:n);return e.cardEffects.flattery!=null&&(e.cardEffects.fairiesFlatteryNext=Math.max(0,e.cardEffects.fairiesFlatteryNext||0)+Math.max(0,e.cardEffects.flattery||0),delete e.cardEffects.flattery),e.cardEffects.drawBonus!=null&&delete e.cardEffects.drawBonus,e.cardDraft||(e.cardDraft={pending:!1,choices:[],rerolls:0,picksRemaining:0}),e.cardDraft.picksRemaining==null&&(e.cardDraft.picksRemaining=e.cardDraft.pending?1:0),e.cardDraft.freeRerolls==null&&(e.cardDraft.freeRerolls=0),e.cardEffects}function Hs(e){let t=Math.max(.5,1-.05*Math.min(10,dn(e,"crowdedFarming"))),n=Math.pow(.96,dn(e,"hatchingFeed"));return t*n}function Ks(e,t=null){return Math.min(3,Math.max(0,Number(t?.extraEggOutlet)||0))}function qs(e){return Math.pow(.85,dn(e,"scannerQueueSpacing"))}function pn(e,t=null){let n=Ke(e),o=Jn*Math.pow(Zn,Math.max(0,n.bearing||0)),r=t?Math.max(.1,Number(e?.conveyorMeta?.get?.(t)?.speedMultiplier)||1):1,i=t?1:Ar(e),a=Math.max(.01,Number(e?.eventActive?.conveyorSpeedMultiplier)||1);return Math.min(tn,o*i)*r*a}function St(e,t=null){let n=Math.max(0,(e?.level||1)-1),o=t?dn(t,"autoScannerTuning"):0,r=Qn*Math.pow(li,n)*Math.pow(Yo,o),i=Math.max(Vo,r),a=t?Math.max(.01,Number(t?.eventActive?.autoScannerCooldownMultiplier)||1):1;return t?Math.max(Vo,i*ko(e)*a):i}function Os(e){let t=e.cardDraft||{rerolls:0},n=Math.max(1,(t.rerolls||0)+1);return Math.ceil(Math.max(1,e.turn||1)/Ue.rerollCost.dayDivisor*n*Ue.rerollCost.multiplier)}function Xt(e){return fn.find(t=>t.id===e)||null}function Ys(e,t){let n=typeof t=="string"?Xt(t):t;return Ke(e),!n||!(n.type==="equipmentUpgrade"||n.type==="cellAction")?[]:et(e,n).map(o=>({key:wt(o),label:_r(e,o),type:o.type}))}function Nr(e,t,n={}){let o=typeof t=="string"?Xt(t):t;if(Ke(e),!o||o.type==="dud")return{ok:!1,reason:"Card not found."};if(o.id==="extraCards")return e.cardDraft=e.cardDraft||{pending:!1,choices:[],rerolls:0,picksRemaining:0},e.cardDraft.choices.push(...To(e,2)),{ok:!0,reason:`${o.title} added 2 draft cards.`};if(o.type==="instant")return zs(e,o),{ok:!0,reason:`${o.title} granted.`};if(o.type==="equipmentUpgrade"){let r=et(e,o);if(!r.length)return{ok:!1,reason:`No valid target for ${o.title}.`};let i=n.targetKey&&n.targetKey!=="__auto__"?String(n.targetKey):"",a=i?r.find(c=>wt(c)===i):r[0];return a?(Vs(e,a),{ok:!0,reason:`${o.title} applied to ${_r(e,a)}.`}):{ok:!1,reason:`Selected target is no longer valid for ${o.title}.`}}if(o.type==="cellAction"&&o.target==="blockedCell"){let r=et(e,o);if(!r.length)return{ok:!1,reason:"No blocked cells to remove."};let i=new Set((Array.isArray(n.targetKeys)?n.targetKeys:[n.targetKey]).filter(c=>c&&c!=="__auto__").map(String)),a=i.size?r.filter(c=>i.has(wt(c))):r.slice(0,10);if(!a.length)return{ok:!1,reason:`Selected target is no longer valid for ${o.title}.`};for(let c of a.slice(0,3)){e.blockedCells.delete(I(c.col,c.row));let h=$(c.col,c.row);vt(e,h.x,h.y),at(e,h.x,h.y,b.danger,42),bt(e,h.x,h.y,8,b.warn)}return Me(e),{ok:!0,reason:`${o.title} removed ${Math.min(10,a.length)} blocked cell(s).`}}return{ok:!1,reason:`${o.title} cannot be debug-granted.`}}function _r(e,t){if(t.type==="blockedCell")return`Blocked cell ${t.col},${t.row}`;if(t.type==="eggFarm")return`EGG #${t.id} L${t.level} -> L${t.level+1}`;if(t.type==="eggOutlet"){let n=Math.max(0,Number(t.farm?.extraEggOutlet)||0);return`EGG #${t.id} OUTLET ${1+n} -> ${2+n}`}if(t.type==="scanner")return`AUTO #${t.id} L${t.level} -> L${t.level+1} | CD ${St(t,e).toFixed(1)}s -> ${St({...t,level:t.level+1},e).toFixed(1)}s`;if(t.type==="facility"&&["mixer","sausageMaker"].includes(t.baseId||t.id)){let n=t.baseId||t.id,o=t.level||1,r=Ze(e);t.level=o+1;let i=Ze(e);return t.level=o,`${n==="mixer"?"MIXER":"SAUSAGE MACHINE"} L${t.level} -> L${t.level+1} | sausage ${x(r)} -> ${x(i)}`}if(t.type==="facility"&&(t.baseId||t.id)==="truck"){let n=t.level,o=Ve(e);t.level=n+1;let r=Ve(e);return t.level=n,`TRUCK L${t.level} -> L${t.level+1} | ${x(o)} -> ${x(r)}`}if(t.type==="facility"&&(t.baseId||t.id)==="trash"){let n=mt(e),o=Math.min(cn(),n+1),r=ht(n),i=ht(o);return`SHREDDER L${t.level||1} -> L${(t.level||1)+1} | expected ${x(r)} -> ${x(i)} per poop`}return`${t.name||t.id} L${t.level||1} -> L${(t.level||1)+1}`}function Ql(e){return[...e.blockedCells||[]].map(t=>{let[n,o]=String(t).split(",").map(Number);return{type:"blockedCell",id:t,col:n,row:o}}).filter(t=>Number.isFinite(t.col)&&Number.isFinite(t.row))}function et(e,t){let n=typeof t=="string"?Xt(t):t;return Ke(e),n?n.target==="blockedCell"?Ql(e):n.target==="eggFarm"?e.eggFarms.filter(o=>(o.level||1)<4):n.target==="eggOutlet"?e.eggFarms.filter(o=>Math.max(0,Number(o.extraEggOutlet)||0)<3).map(o=>({type:"eggOutlet",id:o.id,col:o.col,row:o.row,farm:o})):n.target==="autoScanner"?e.scanners.filter(o=>o.kind==="auto"):n.target==="mixer"?Object.values(e.facilities||{}).filter(o=>(o.baseId||o.id)==="mixer"):n.target==="truck"?Object.values(e.facilities||{}).filter(o=>(o.baseId||o.id)==="truck"):n.target==="sausageMaker"?Object.values(e.facilities||{}).filter(o=>(o.baseId||o.id)==="sausageMaker"):n.target==="trash"?gr(e)(o.baseId||o.id)==="trash"):[]:[]:[]}function Jl(e,t){let n=Ke(e);if(t.id==="crowdedFarming")return(n.crowdedFarming||0)>=10;if(t.id==="recyclingSubsidy")return(n.recyclingSubsidy||0)>=3;if(t.id==="dudFilter"){let o=Math.max(0,Math.min(1,Ue.dudChancePerCard||0)),r=Math.max(1e-4,Math.min(1,Ue.dudChanceReductionPerFilter||.05));return(n.dudFilter||0)>=Math.ceil(o/r)}return t.id==="laborExploitation"?!!n.laborExploitation:t.id==="usedMachine"?!!n.usedMachineActive:t.id==="newMachine"?!n.usedMachineActive:!1}function Zl(e){return!!((e.maintenanceRooms||[]).length||(e.repairmen||[]).length||e.repairman?.active)}function Ls(e,t){return t==="conveyor"?(e.conveyorTiles?.size||0)>0:t==="autoScanner"?(e.scanners||[]).some(n=>n.kind==="auto"):t==="manualScanner"?(e.scanners||[]).some(n=>n.kind==="manual"):t==="eggFarm"?(e.eggFarms||[]).length>0:Object.values(e.facilities||{}).some(n=>(n.baseId||n.id)===t)}function ec(e,t){return!(t.requiresFacility&&!Ls(e,t.requiresFacility)||Array.isArray(t.requiresAnyFacility)&&!t.requiresAnyFacility.some(n=>Ls(e,n)))}function tc(e){return fn.filter(t=>!(t.id==="laborExploitation"&&!Zl(e)||!ec(e,t)||Jl(e,t)||(t.type==="equipmentUpgrade"||t.type==="cellAction")&&et(e,t).length<=0))}function Ns(e,t){return t.rarity==="ultraRare"?jl:t.rarity==="rare"?Xl:zl}function nc(e,t){let n=t.reduce((r,i)=>r+Ns(e,i),0);if(n<=0)return t[Math.floor(Math.random()*t.length)];let o=Math.random()*n;for(let r of t)if(o-=Ns(e,r),o<=0)return r;return t[t.length-1]}var _s=0;function oc(){return _s+=1,{id:`dud:${Date.now()}:${_s}`,title:"Dud",rarity:"dud",type:"dud",description:"Click to flick this dud away. It does not consume a pick."}}function rc(e){let t=Ke(e),n=Math.max(0,Math.min(1,Ue.dudChancePerCard||0)),o=Math.max(0,Math.min(1,Ue.dudChanceReductionPerFilter||0));return Math.max(0,n-o*Math.max(0,t.dudFilter||0))}function ic(e,t){let n=[...t],o=0,r=Math.max(0,Ue.maxDudsPerDraft||0),i=rc(e);for(let a=0;ac.id===i.id);a>=0&&r.splice(a,1)}return ic(e,o)}function wt(e){return e?e.type==="blockedCell"?`blockedCell:${e.id}`:e.type==="eggFarm"?`eggFarm:${e.id}`:e.type==="eggOutlet"?`eggOutlet:${e.id}`:e.type==="scanner"?`scanner:${e.id}`:e.type==="facility"?`facility:${e.id}`:`${e.type}:${e.id}`:""}function Gs(e){if(!e)return null;if(e.type==="blockedCell"){let t=$(e.col,e.row);return{x:t.x-f.cell*.48,y:t.y-f.cell*.48,w:f.cell*.96,h:f.cell*.96,cx:t.x,cy:t.y}}if(e.type==="eggFarm"||e.type==="eggOutlet"){let t=$(e.col,e.row);return{x:t.x-34,y:t.y-34,w:68,h:68,cx:t.x,cy:t.y}}if(e.type==="scanner"){let t=ge(e);return{x:t.x-48,y:t.y-48,w:96,h:96,cx:t.x,cy:t.y}}return e.type==="facility"?{x:e.x,y:e.y,w:e.w,h:e.h,cx:e.x+e.w/2,cy:e.y+e.h/2}:null}function Gr(e){if(!e.cardTargetPick?.pending)return[];if(e.cardTargetPick.mode==="autoScannerMenu"){let n=new Set(e.cardTargetPick.targetKeys||[]);return e.scanners.filter(o=>o.kind==="auto"&&n.has(wt(o))).map(o=>({key:wt(o),label:"AUTO ROUTE SETTINGS",target:o,bounds:Gs(o)})).filter(o=>o.bounds)}let t=new Set(e.cardTargetPick.targetKeys||[]);return et(e,e.cardTargetPick.cardId).filter(n=>t.has(wt(n))).map(n=>({key:wt(n),label:_r(e,n),target:n,bounds:Gs(n)})).filter(n=>n.bounds)}function sc(e,t){let n=Gr(e);for(let o of n){let r=o.bounds;if(t.x>=r.x&&t.x<=r.x+r.w&&t.y>=r.y&&t.y<=r.y+r.h)return o.target}return null}function Vs(e,t){if(t.type==="eggOutlet"){let n=t.farm||e.eggFarms.find(r=>r.id===t.id);if(!n)return;n.extraEggOutlet=Math.min(3,Math.max(0,Number(n.extraEggOutlet)||0)+1);let o=$(n.col,n.row);H(e,o.x,o.y-30,`OUTLET ${1+n.extraEggOutlet}`,b.green),Me(e);return}if(t.level=(t.level||1)+1,t.type==="eggFarm"){t.nextSpawn=Wt(t),t.lastInterval=t.nextSpawn;let n=$(t.col,t.row);H(e,n.x,n.y-30,`LV ${t.level}`,b.green);return}if(t.type==="scanner"){let n=ge(t);H(e,n.x,n.y-34,`LV ${t.level}`,b.green);return}t.type==="facility"&&H(e,t.x+t.w/2,t.y+18,`LV ${t.level}`,b.green)}function zs(e,t){let n=Ke(e),o=r=>{n[r]=Math.max(0,n[r]||0)+1};if(t.id==="bearing"&&o("bearing"),t.id==="autoScannerTuning"&&o("autoScannerTuning"),t.id==="legalWork"&&o("legalWork"),t.id==="crowdedFarming"&&(n.crowdedFarming=Math.min(10,(n.crowdedFarming||0)+1)),t.id==="hatchingFeed"&&o("hatchingFeed"),t.id==="poopRateBooster"&&o("poopRateBooster"),t.id==="safetyCover"&&o("safetyCover"),t.id==="recyclingSubsidy"&&(n.recyclingSubsidy=Math.min(3,(n.recyclingSubsidy||0)+1)),t.id==="chemicalWeaponSubsidy"&&o("chemicalWeaponSubsidy"),t.id==="preventiveMaintenance"&&o("preventiveMaintenance"),t.id==="dudFilter"&&o("dudFilter"),t.id==="durabilityCoating"&&(o("durabilityCoating"),Re(e)),t.id==="sparePartsBin"){o("sparePartsBin");let r=$s(e,.02);e.stats.sparePartsRepair=(e.stats.sparePartsRepair||0)+r,e.totals.sparePartsRepair=(e.totals.sparePartsRepair||0)+r,H(e,f.x+f.cols*f.cell/2,f.y-30,"REPAIR +2%",b.green)}t.id==="dudRefund"&&o("dudRefund"),t.id==="freeReroll"&&o("freeReroll"),t.id==="composter"&&o("composter"),t.id==="scannerQueueSpacing"&&o("scannerQueueSpacing"),t.id==="flattery"&&o("fairiesFlatteryNext"),t.id==="usedMachine"&&(n.usedMachineActive=!0),t.id==="newMachine"&&(n.usedMachineActive=!1),t.id==="laborExploitation"&&(n.laborExploitation=1),t.id==="rescueLoan"&&(o("rescueLoanCharges"),H(e,f.x+f.cols*f.cell/2,f.y-30,"RESCUE HELD",b.green))}function ot(e){return`${Math.round(Number(e||0)*100)}%`}function Us(e){let t=Et[Math.max(0,Math.min(Et.length-1,e-1))]||Et[0]||[0,0];return`${t[0].toFixed(2)}-${t[1].toFixed(2)}s`}function Ws(e,t){return Math.ceil(e*Math.pow(xe.incomeUpgradeRate,Math.max(0,t-1)))}function Or(e,t,n){let r=et(e,t==="mixer"?"upgradeMixer":t==="sausageMaker"?"upgradeSausageMaker":"upgradeTruck")[0];if(!r)return{before:Ws(n,1),after:Ws(n,2)};let i=()=>t==="truck"?Ve(e):Ze(e),a=i(),c=r.level||1;r.level=c+1;let h=i();return r.level=c,{before:a,after:h}}function ac(e,t){let n=Ke(e);if(t.id==="upgradeEgg"){let o=et(e,t),r=Math.min(3,Math.max(1,o[0]?.level||1));return`Choose one EGG. Level +1. Production interval ${Us(r)} -> ${Us(r+1)}.`}if(t.id==="upgradeAutoScanner"){let o=et(e,t)[0]||{level:1},r=St(o,e),i=St({...o,level:(o.level||1)+1},e);return`Choose one AUTO SCANNER. Level +1. Cooldown ${r.toFixed(2)}s -> ${i.toFixed(2)}s.`}if(t.id==="upgradeMixer"){let o=Or(e,"mixer",xe.income.sausage);return`Choose one MIXER. Meat quality +10%. Sausage sale value ${x(o.before)} -> ${x(o.after)}.`}if(t.id==="upgradeSausageMaker"){let o=Or(e,"sausageMaker",xe.income.sausage);return`Choose one SAUSAGE MACHINE. Product value +10%. Sausage sale value ${x(o.before)} -> ${x(o.after)}.`}if(t.id==="upgradeTruck"){let o=Or(e,"truck",xe.income.truck);return`Choose one TRUCK. Earning +10%. Correct shipment income ${x(o.before)} -> ${x(o.after)}.`}if(t.id==="upgradeTrash"){let o=mt(e),r=Math.min(cn(),o+1),i=ht(o),a=ht(r),c=Math.round(qt(o)*100),h=Math.round(qt(r)*100);return`SHREDDER level +1. Expected value ${x(i)} -> ${x(a)} per poop; hit chance ${c}% -> ${h}%.`}if(t.id==="bearing"){let o=Math.min(tn,Jn*Math.pow(Zn,Math.max(0,n.bearing||0))),r=Math.min(tn,Jn*Math.pow(Zn,Math.max(0,(n.bearing||0)+1)));return`Conveyor speed +7.5%. After pick: ${o.toFixed(1)} -> ${r.toFixed(1)} px/s.`}if(t.id==="legalWork"){let o=Math.max(0,n.legalWork||0);return`Fairies tribute profit exemption +${x(250)}. After pick: exemption bonus ${x(250*(o+1))}.`}if(t.id==="crowdedFarming"){let o=Math.min(10,(n.crowdedFarming||0)+1);return`Held ${n.crowdedFarming||0}/10. After pick: EGG interval ${ot(Math.max(.5,1-.05*o))}, deterioration ${ot(Math.min(1.4,1+.04*o))}.`}if(t.id==="extraEggOutlet"){let o=et(e,t),r=Math.max(0,Number(o[0]?.farm?.extraEggOutlet)||0);return`Choose one EGG. Output ports ${1+r} -> ${2+r}. Max 4 ports per EGG.`}if(t.id==="hatchingFeed")return`Held ${n.hatchingFeed||0}. After pick: EGG interval ${ot(Math.pow(.96,(n.hatchingFeed||0)+1))}, poop rate multiplier ${ot(Math.pow(1.03,(n.hatchingFeed||0)+1))}.`;if(t.id==="poopRateBooster")return`Held ${n.poopRateBooster||0}. After pick: base poop rate +${Math.round((n.poopRateBooster||0)+1)*4} percentage points.`;if(t.id==="autoScannerTuning")return`Held ${n.autoScannerTuning||0}. After pick: all AUTO SCANNER default cooldown ${ot(Math.pow(Yo,(n.autoScannerTuning||0)+1))}.`;if(t.id==="safetyCover")return`Held ${n.safetyCover||0}. After pick: explosion damage ${ot(Math.pow(.95,(n.safetyCover||0)+1))}.`;if(t.id==="recyclingSubsidy")return`Held ${n.recyclingSubsidy||0}/3. After pick: normal SELL refund ${Math.round(Math.min(.8,.5+.1*((n.recyclingSubsidy||0)+1))*100)}%.`;if(t.id==="chemicalWeaponSubsidy")return`Held ${n.chemicalWeaponSubsidy||0}. After pick: ${x(1200*((n.chemicalWeaponSubsidy||0)+1))} per 70 truck-shipped poop each day.`;if(t.id==="preventiveMaintenance")return`Held ${n.preventiveMaintenance||0}. After pick: deterioration speed ${ot(Math.pow(.95,(n.preventiveMaintenance||0)+1))}.`;if(t.id==="dudFilter"){let o=Math.max(0,Math.min(1,Ue.dudChancePerCard||0)),r=Math.max(0,Math.min(1,Ue.dudChanceReductionPerFilter||.05)),i=Math.max(0,n.dudFilter||0),a=Math.max(0,o-r*i),c=Math.max(0,o-r*(i+1));return`Held ${i}. DUD chance per card ${ot(a)} -> ${ot(c)}.`}return t.id==="durabilityCoating"?`Held ${n.durabilityCoating||0}. After pick: maximum durability ${ot(Math.pow(1.03,(n.durabilityCoating||0)+1))}.`:t.id==="sparePartsBin"?`Held ${n.sparePartsBin||0}. Immediately restores 2% durability to every maintained machine. Common card weight.`:t.id==="dudRefund"?`Held ${n.dudRefund||0}. After pick: DUD clicks award ${x(50*((n.dudRefund||0)+1))}.`:t.id==="freeReroll"?`Held ${n.freeReroll||0}. After pick: each card screen starts with ${(n.freeReroll||0)+1} free reroll${(n.freeReroll||0)+1===1?"":"s"}.`:t.id==="composter"?`Held ${n.composter||0}. After pick: SHREDDER poop grants ${x(3*((n.composter||0)+1))}.`:t.id==="scannerQueueSpacing"?`Held ${n.scannerQueueSpacing||0}. After pick: Manual/Auto Scanner queue spacing ${ot(Math.pow(.85,(n.scannerQueueSpacing||0)+1))}.`:t.id==="flattery"?`Next Fairies tribute -50%. Held for next tribute: ${n.fairiesFlatteryNext||0}.`:t.id==="usedMachine"?"Enable Used Machines: future equipment costs 50%, has no SELL refund, and starts with 60% durability.":t.id==="newMachine"?"Cancel Used Machines. Future equipment returns to normal price, refund, and durability.":t.id==="laborExploitation"?"Repairman dispatch paperwork is streamlined. Repair rooms keep operating; one time only.":t.id==="rescueLoan"?`At day-end bankruptcy check, gain ${x(500)} if cash is below ${x(0)}. Held: ${n.rescueLoanCharges||0}.`:t.id==="dynamite"?"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.":t.description}function Lr(e,t){let n=e.cardDraft?.choices||[],o=n.findIndex(r=>r.id===t.id);o>=0&&n.splice(o,1)}function Xs({game:e,ui:t,onUpdatePanels:n}){function o(){n&&n()}function r(){Ke(e),e.cardTargetPick=null,e.cardDraft={pending:!0,choices:To(e,zt),rerolls:0,freeRerolls:dn(e,"freeReroll"),picksRemaining:1}}function i(){Ke(e),e.cardDraft.pending=!1,e.cardDraft.choices=[],e.cardDraft.picksRemaining=0,e.cardTargetPick=null,t.modal.classList.remove("visible","equipment-popover"),o()}function a(D=zt){Ke(e),e.cardDraft.choices=To(e,D)}function c(){Ke(e),!e.cardDraft.choices.length&&e.cardDraft.picksRemaining>0&&a(zt)}function h(D){Lr(e,D),e.cardDraft.picksRemaining=Math.max(0,(e.cardDraft.picksRemaining||1)-1),e.cardTargetPick=null,c(),e.cardDraft.picksRemaining<=0?i():ue()}function g(D){Lr(e,D),e.cardDraft.picksRemaining=Math.max(0,(e.cardDraft.picksRemaining||1)-1)+Ue.extraCardsAddedPicks,e.cardDraft.choices.push(...To(e,2)),ue()}function C(D,U){let ee=dn(e,"dudRefund")*50;if(ee>0){let O=je(e,ee);e.stats.dudRefundIncome=(e.stats.dudRefundIncome||0)+O,e.totals.dudRefundIncome=(e.totals.dudRefundIncome||0)+O,H(e,f.x+f.cols*f.cell/2,f.y-30,`DUD REFUND +${x(O)}`,b.green)}Yt(e,34,.62),document.body?.animate&&document.body.animate([{transform:"translate(0, 0)"},{transform:"translate(-10px, 7px)"},{transform:"translate(12px, -8px)"},{transform:"translate(-8px, -5px)"},{transform:"translate(9px, 6px)"},{transform:"translate(0, 0)"}],{duration:260,easing:"steps(5, end)"}),U&&(U.style.setProperty("--fling-x",`${260+Math.random()*260}px`),U.style.setProperty("--fling-y",`${-260-Math.random()*170}px`),U.style.setProperty("--fling-r",`${(Math.random()<.5?-1:1)*(52+Math.random()*38)}deg`),U.classList.add("flung")),window.setTimeout(()=>{Lr(e,D),c(),ue()},340)}function P(D,U=null){if(D.type==="dud")return C(D,U);if(D.id==="extraCards")return g(D);if(D.type==="equipmentUpgrade"||D.type==="cellAction")return L(D);zs(e,D),h(D)}function L(D){let U=et(e,D);U.length&&(e.cardTargetPick={pending:!0,cardId:D.id,mode:D.target==="blockedCell"?"blockedCell":"equipment",targetKeys:U.map(wt),remaining:D.id==="dynamite"?Math.min(10,U.length):1,used:0},t.modal.classList.remove("visible","equipment-popover"),o())}function K(D){if(!D||D.type!=="blockedCell")return!1;let U=I(D.col,D.row);if(!e.blockedCells?.has(U))return!1;e.blockedCells.delete(U);let ee=$(D.col,D.row);return vt(e,ee.x,ee.y),at(e,ee.x,ee.y,b.danger,62),bt(e,ee.x,ee.y,18,b.warn),mo(e,ee.x,ee.y,8),Yt(e,16,.35),H(e,ee.x,ee.y-18,"BOOM",b.danger),Me(e),e.cardTargetPick.remaining=Math.max(0,(e.cardTargetPick.remaining||1)-1),e.cardTargetPick.used=Math.max(0,e.cardTargetPick.used||0)+1,e.cardTargetPick.targetKeys=et(e,"dynamite").map(wt),o(),!0}function j(D){if(!e.cardTargetPick?.pending)return!1;let U=Xt(e.cardTargetPick.cardId),ee=sc(e,D);return U?U.id==="dynamite"?(!ee||!K(ee)||((e.cardTargetPick.remaining||0)<=0||!et(e,"dynamite").length)&&h(U),!0):ee?(Vs(e,ee),h(U),!0):(Z(),!0):(Z(),!0)}function Z(){if(!e.cardTargetPick?.pending)return;let D=e.cardTargetPick,U=Xt(D.cardId);if(U?.id==="dynamite"&&(D.used||0)>0){h(U);return}e.cardTargetPick=null,ue()}function Q(){let D=Math.max(0,e.cardDraft?.freeRerolls||0);if(D>0){e.cardDraft.freeRerolls=D-1,e.cardDraft.rerolls+=1,a(zt),ue();return}let U=Os(e);e.cash${U.alt||`:``}function le(D,U){let ee=document.createElement("button");ee.type="button";let O=D.type==="dud"?"dud":D.rarity==="ultraRare"?"ultra-rare":D.rarity==="rare"?"rare":"common";ee.className=`card-choice ${O}`;let G=D.type==="dud"?"MISS":D.rarity==="ultraRare"?"ULTRA RARE":D.rarity==="rare"?"RARE":"COMMON",Y=[G,...(D.tags||[]).filter(ie=>ie!==G)].slice(0,5);return ee.innerHTML=`${pe(D)}
${D.title}
${Y.map(ie=>`${ie}`).join("")}
${ac(e,D)}
`,ee.addEventListener("click",()=>P(D,ee)),ee.dataset.index=String(U),ee}function ue(){Ke(e),e.cardTargetPick=null,e.cardDraft?.pending||r(),e.cardDraft.choices?.length||a(zt);let D=e.cardDraft.choices||[],U=Os(e),ee=Math.max(0,e.cardDraft?.freeRerolls||0),O=Math.max(1,e.cardDraft.picksRemaining||1);t.modalTitle.textContent="Choose Upgrade Card",t.modalBody.innerHTML=`

Choose ${O} card${O===1?"":"s"} before Build phase. Dud cards can be flicked away without consuming a pick. Equipment cards target machines; Dynamite targets blocked cells.

`;let G=t.modalBody.querySelector("#cardChoices");D.forEach((De,me)=>G.appendChild(le(De,me))),t.modalActions.innerHTML="";let Y=document.createElement("button");Y.type="button",Y.className="facility-action warn reroll-button",Y.textContent=ee>0?`Reroll (FREE x${ee})`:`Reroll ${x(U)}`,Y.disabled=ee<=0&&e.cash0?`${ee} free reroll${ee===1?"":"s"} remaining.`:Y.disabled?"Not enough cash":"",Y.addEventListener("click",Q),t.modalActions.appendChild(Y);let ie=document.createElement("button");ie.type="button",ie.className="facility-action",ie.textContent="No Card",ie.title="Skip this upgrade pick and enter Build phase.",ie.addEventListener("click",i),t.modalActions.appendChild(ie),t.modal.classList.remove("equipment-popover"),t.modal.classList.add("visible")}return{prepareDraft:r,showDraft:ue,finishDraft:i,chooseTargetAtPoint:j,cancelTargetPick:Z}}var js=[[{key:"a",code:"KeyA",label:"A"},{key:"d",code:"KeyD",label:"D"}],[{key:"arrowleft",code:"ArrowLeft",label:"Left"},{key:"arrowright",code:"ArrowRight",label:"Right"}],[{key:"j",code:"KeyJ",label:"J"},{key:"l",code:"KeyL",label:"L"}],[{key:"f",code:"KeyF",label:"F"},{key:"h",code:"KeyH",label:"H"}],[{key:"q",code:"KeyQ",label:"Q"},{key:"e",code:"KeyE",label:"E"}],[{key:"z",code:"KeyZ",label:"Z"},{key:"x",code:"KeyX",label:"X"}],[{key:"u",code:"KeyU",label:"U"},{key:"o",code:"KeyO",label:"O"}],[{key:"n",code:"KeyN",label:"N"},{key:"m",code:"KeyM",label:"M"}]];function lc(e){let t=js[e]||js[0];return{left:{...t[0]},right:{...t[1]}}}function Qs(e,t,n){let o={type:"eggFarm",id:e.nextId++,col:t,row:n,level:1,nextSpawn:2,lastInterval:2,extraEggOutlet:0,price:Rt.eggFarm,builtSession:e.buildSession};return o.nextSpawn=Wt(o),o.lastInterval=o.nextSpawn,o}function Js(e,t,n,o){let r=o==="auto"?Te.autoScanner.price:Te.manualScanner.price,i=new Set(e.scanners.filter(c=>c.kind==="manual").map(c=>c.slot)),a=0;for(;i.has(a);)a+=1;return{type:"scanner",id:e.nextId++,kind:o,slot:o==="manual"?a:null,role:o==="manual"?a%2:0,col:t,row:n,level:1,queue:[],cooldown:0,price:r,builtSession:e.buildSession,autoMode:"standard",keys:o==="manual"?lc(a):null}}function cc(e,t,n){let o=n.x-t.x,r=n.y-t.y,i=o*o+r*r,a=i>0?Math.max(0,Math.min(1,((e.x-t.x)*o+(e.y-t.y)*r)/i)):0,c=t.x+o*a,h=t.y+r*a;return Math.hypot(e.x-c,e.y-h)}function uc(e){let t=[],n=e?.ownedCells?.size?[...e.ownedCells].map(o=>{let[r,i]=String(o).split(",").map(Number);return{col:r,row:i}}):Array.from({length:f.cols*f.rows},(o,r)=>({col:r%f.cols,row:Math.floor(r/f.cols)}));for(let o of n){let r=f.x+o.col*f.cell,i=f.y+o.row*f.cell,a=r+f.cell,c=i+f.cell;Ye(e,o.col-1,o.row)||t.push({side:"left",entry:o,a:{x:r,y:i},b:{x:r,y:c}}),Ye(e,o.col+1,o.row)||t.push({side:"right",entry:o,a:{x:a,y:i},b:{x:a,y:c}}),Ye(e,o.col,o.row-1)||t.push({side:"top",entry:o,a:{x:r,y:i},b:{x:a,y:i}}),Ye(e,o.col,o.row+1)||t.push({side:"bottom",entry:o,a:{x:r,y:c},b:{x:a,y:c}})}return t}function Eo(e,t=null){let n=uc(t);if(n.length){let L=n.map(K=>({...K,distance:cc(e,K.a,K.b)})).sort((K,j)=>K.distance-j.distance)[0];return{side:L.side,entry:{...L.entry}}}let o=(e.x-f.x)/f.cell,r=(e.y-f.y)/f.cell,i=Math.max(0,Math.min(f.cols-1,Math.round(o-.5))),a=Math.max(0,Math.min(f.rows-1,Math.round(r-.5))),c=Math.abs(e.x-f.x),h=Math.abs(e.x-(f.x+f.cols*f.cell)),g=Math.abs(e.y-f.y),C=Math.abs(e.y-(f.y+f.rows*f.cell)),P=[["left",c],["right",h],["top",g],["bottom",C]].sort((L,K)=>L[1]-K[1])[0][0];return P==="left"?{side:P,entry:{col:0,row:a}}:P==="right"?{side:P,entry:{col:f.cols-1,row:a}}:P==="top"?{side:P,entry:{col:i,row:0}}:{side:P,entry:{col:i,row:f.rows-1}}}function Ur(e){return e?.baseId||e?.id||""}function yn(e){return e?.gridPlaced&&(e.wCells=Math.max(1,e.wCells||3),e.hCells=Math.max(1,e.hCells||3),e.x=f.x+e.col*f.cell,e.y=f.y+e.row*f.cell,e.w=e.wCells*f.cell,e.h=e.hCells*f.cell,Ur(e)==="sausageMaker"&&(e.entry={col:e.col-1,row:e.row+Math.floor(e.hCells/2)},e.side="grid")),e}function Ro(e){if(!e?.gridPlaced)return[];let t=[];for(let n=0;n<(e.hCells||3);n+=1)for(let o=0;o<(e.wCells||3);o+=1)t.push({col:e.col+o,row:e.row+n});return t}function dc(e,t){if(Ur(e)!=="mixer")return;let n=e.baseW||e.w||168,o=e.baseH||e.h||110;e.baseW=n,e.baseH=o;let r=t==="top"||t==="bottom";e.w=r?Math.max(n,o):Math.min(n,o),e.h=r?Math.min(n,o):Math.max(n,o)}function Po(e,t="input"){if(!e)return null;let n=Ur(e);return n==="mixer"?e.side==="top"||e.side==="bottom"?t==="input"?{x:e.x+e.w/2,y:e.y}:{x:e.x+e.w/2,y:e.y+e.h}:t==="input"?{x:e.x,y:e.y+e.h/2}:{x:e.x+e.w,y:e.y+e.h/2}:n==="sausageMaker"&&e.gridPlaced?t==="input"?{x:e.x,y:e.y+e.h/2}:{x:e.x+e.w,y:e.y+e.h/2}:{x:e.x+e.w/2,y:e.y+e.h/2}}function Ao(e,t,n){dc(e,n);let o={x0:f.x+t.col*f.cell,y0:f.y+t.row*f.cell,x1:f.x+(t.col+1)*f.cell,y1:f.y+(t.row+1)*f.cell},r={x:(o.x0+o.x1)/2,y:(o.y0+o.y1)/2};return e.entry={...t},e.side=n,n==="left"?(e.x=o.x0-e.w-18,e.y=r.y-e.h/2):n==="right"?(e.x=o.x1+18,e.y=r.y-e.h/2):n==="top"?(e.x=r.x-e.w/2,e.y=o.y0-e.h-18):(e.x=r.x-e.w/2,e.y=o.y1+18),e}function Zs(e,t,n,o){let{entry:r,side:i}=Eo(n,e),a=Te[t],c=a?{name:a.name,w:a.body?.w||168,h:a.body?.h||110,price:a.price}:{name:t[0].toUpperCase()+t.slice(1),w:168,h:110,price:o},h={type:"facility",id:t,name:c.name,x:0,y:0,w:c.w,h:c.h,baseW:c.w,baseH:c.h,level:1,price:o||c.price,builtSession:e.buildSession};return Ao(h,r,i)}function ea(e,t,n,o,r){let i=Te[t],a={type:"facility",id:t,name:i?.name||t,baseId:t,col:n,row:o,wCells:t==="sausageMaker"?3:1,hCells:t==="sausageMaker"?3:1,gridPlaced:!0,level:1,price:r||i?.price||0,builtSession:e.buildSession};return yn(a)}function jt(e){return e?[{col:e.col,row:e.row},{col:e.col+1,row:e.row},{col:e.col,row:e.row+1},{col:e.col+1,row:e.row+1}]:[]}function ta(e,t,n){return{type:"maintenanceRoom",id:e.nextId++,col:t,row:n,price:Rt.maintenanceRoom,builtSession:e.buildSession,wCells:2,hCells:2}}function fc(e){return Math.random()({x:i.x,y:i.y})),r=o[0];return{id:e.nextId++,sex:n,x:r.x,y:r.y,route:o,targetIndex:o.length>1?1:o.length,stage:t.stage||"belt",scannerId:t.scannerId??null,nextScannerId:null,pendingScannerId:null,radius:n==="poop"?15:17,bob:Math.random()*Math.PI*2,queueIndex:-1,queueRoute:null}}var pc=!0,yc={chickMale:"./assets/images/chick_male.png",chickFemale:"./assets/images/chick_female.png",poop:"./assets/images/poop.png",conveyorStraightH:"./assets/dark_factory/facilities/conveyor/conveyor_straight_h_16.png",conveyorStraightV:"./assets/dark_factory/facilities/conveyor/conveyor_straight_v_16.png",conveyorTurnNe:"./assets/dark_factory/facilities/conveyor/conveyor_turn_ne_16.png",conveyorTurnNw:"./assets/dark_factory/facilities/conveyor/conveyor_turn_nw_16.png",conveyorTurnSe:"./assets/dark_factory/facilities/conveyor/conveyor_turn_se_16.png",conveyorTurnSw:"./assets/dark_factory/facilities/conveyor/conveyor_turn_sw_16.png",boostConveyorStraightH:"./assets/dark_factory/facilities/conveyor/boost_conveyor_straight_h_16.png",boostConveyorStraightV:"./assets/dark_factory/facilities/conveyor/boost_conveyor_straight_v_16.png",boostConveyorTurnNe:"./assets/dark_factory/facilities/conveyor/boost_conveyor_turn_ne_16.png",boostConveyorTurnNw:"./assets/dark_factory/facilities/conveyor/boost_conveyor_turn_nw_16.png",boostConveyorTurnSe:"./assets/dark_factory/facilities/conveyor/boost_conveyor_turn_se_16.png",boostConveyorTurnSw:"./assets/dark_factory/facilities/conveyor/boost_conveyor_turn_sw_16.png",scannerManual:"./assets/dark_factory/facilities/machines/manual_scanner_32x48.png",scannerAuto:"./assets/dark_factory/facilities/machines/auto_scanner_32x48.png",eggFarm:"./assets/dark_factory/facilities/machines/egg_farm_16.png",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",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"},Pe={};for(let[e,t]of Object.entries(yc)){let n=new Image;n.loaded=!1,n.onerror=()=>{n.loaded=!1},pc&&t&&(n.onload=()=>{n.loaded=!0},n.src=t),Pe[e]=n}function qr(e){return e.frameCache||(e.frameCache={}),e.frameCache.draw||(e.frameCache.draw={}),e.frameCache.draw}function Yr(e,t){let n=document.createElement("canvas");return n.width=Math.max(1,Math.ceil(e)),n.height=Math.max(1,Math.ceil(t)),n}function sa(e,t,n,o){e.imageSmoothingEnabled=!1,e.clearRect(0,0,t.width,t.height),Sc(e,t,n),e.save();let r=n.shake.time>0?z(-n.shake.strength,n.shake.strength):0,i=n.shake.time>0?z(-n.shake.strength,n.shake.strength):0,a=n.view?.scale||1;e.translate((n.view?.x||0)+r,(n.view?.y||0)+i),e.scale(a,a),Mc(e,n),xc(e,n),Ic(e,n),Uc(e,n),jc(e,n),Qc(e,n),Wc(e,n),Kc(e,n),mc(e,n),ou(e,n,o.activeQueuedChick),fu(e,n),lu(e,t,n),au(e,n,o.selectedObject,o.selectedTitle),cu(e,n),uu(e,n),du(e,n),e.restore(),yu(e,n,t)}function hc(e){if(e.type==="eggFarm"){let n=$(e.ref.col,e.ref.row);return{x:n.x-34,y:n.y-34,w:68,h:68,cx:n.x,cy:n.y}}let t=e.ref;return{x:t.x,y:t.y,w:t.w,h:t.h,cx:t.x+t.w/2,cy:t.y+t.h/2}}function mc(e,t){if(t.phase!=="build")return;let n=an(t);for(let o of n){let r=hc(o);e.save(),e.fillStyle="rgba(196,36,36,.22)",e.strokeStyle=b.danger,e.lineWidth=5,e.setLineDash([10,6]),X(e,r.x,r.y,r.w,r.h,!0,!0),e.setLineDash([]),e.fillStyle=b.danger,e.strokeStyle=b.white,e.lineWidth=4,e.font="900 16px ui-monospace, monospace",e.textAlign="center",e.strokeText("!",r.cx,r.y+24),e.fillText("!",r.cx,r.y+24),e.font="900 11px ui-monospace, monospace",e.strokeText("NO ROUTE",r.cx,r.y+r.h-8),e.fillText("NO ROUTE",r.cx,r.y+r.h-8),e.restore()}}function X(e,t,n,o,r,i=!0,a=!0){e.beginPath(),e.rect(t,n,o,r),i&&e.fill(),a&&e.stroke()}function Hr(e,t,n,o,r=b.ink){e.save(),e.font="900 12px ui-monospace, monospace",e.textAlign="center",e.lineWidth=4,e.strokeStyle=b.white,e.fillStyle=r,e.strokeText(o,t,n),e.fillText(o,t,n),e.restore()}function Qt(e,t,n,o,r,i){if(!t?.loaded)return!1;let a=e.imageSmoothingEnabled;return e.imageSmoothingEnabled=!1,e.drawImage(t,Math.round(n),Math.round(o),Math.round(r),Math.round(i)),e.imageSmoothingEnabled=a,!0}function bc(e,t,n,o,r,i,a=null,c=1){e.fillStyle=i,e.fillRect(Math.round(t),Math.round(n),Math.round(o),Math.round(r)),a&&(e.strokeStyle=a,e.lineWidth=c,e.strokeRect(Math.round(t)+.5,Math.round(n)+.5,Math.round(o)-1,Math.round(r)-1))}function vc(e,t){let n=(Math.imul(e+37,73856093)^Math.imul(t+71,19349663))>>>0;return n^=n>>>13,n>>>0}function wc(e,t,n,o,r){let i=vc(o,r);if(i%67!==0)return;let a=10+(i>>>4)%Math.max(1,f.cell-22),c=10+(i>>>9)%Math.max(1,f.cell-22),h=(i>>>14)%16*Math.PI/16;e.save(),e.globalAlpha=.38,e.fillStyle="#4a8d37",e.strokeStyle="rgba(30,66,27,.34)",e.lineWidth=1,e.beginPath();for(let C=0;C<8;C+=1){let P=h+C*Math.PI/4,L=3.5+(i>>>C+2&3),K=t+a+Math.cos(P)*L*1.35,j=n+c+Math.sin(P)*L*.75;C===0?e.moveTo(K,j):e.lineTo(K,j)}e.closePath(),e.fill(),e.stroke();let g=4+(i>>>24)%5;for(let C=0;C>>C+8&3)*.18,L=10+(i>>>C+11&10),K=.9+(i>>>C+17&3)*.35;e.beginPath(),e.arc(t+a+Math.cos(P)*L,n+c+Math.sin(P)*L*.72,K,0,Math.PI*2),e.fill()}e.fillStyle="rgba(151,218,101,.26)",e.beginPath(),e.arc(t+a-2,n+c-2,1.7,0,Math.PI*2),e.fill(),e.restore()}function oa(e,t){e.fillStyle=b.bg,e.fillRect(0,0,t.width,t.height),e.save(),e.globalAlpha=.85;for(let n=0;na.col)),o=Math.max(...t.map(a=>a.col)),r=Math.min(...t.map(a=>a.row)),i=Math.max(...t.map(a=>a.row));return{x:f.x+n*f.cell-6,y:f.y+r*f.cell-6,w:(o-n+1)*f.cell+12,h:(i-r+1)*f.cell+12}}function Mc(e,t){let n=qr(t),o=gc(t);if(!n.grid||n.grid.signature!==o){let a=Cc(t),c=Yr(a.w,a.h),h=c.getContext("2d");h.translate(-a.x,-a.y),kc(h,t),n.grid={signature:o,bounds:a,layer:c}}let{bounds:r,layer:i}=n.grid;e.drawImage(i,r.x,r.y)}function xc(e,t){if(t.phase!=="build"||t.cardDraft?.pending||t.cardTargetPick?.pending)return;let n=ro(t),o=t.cash>=n,r=i=>{let a=dt(i);return Object.values(t.facilities||{}).some(c=>{let h=c?.baseId||c?.id;return["mixer","trash","truck"].includes(h)?!(a.x+a.w<=c.x||c.x+c.w<=a.x||a.y+a.h<=c.y||c.y+c.h<=a.y):!1})};for(let i of Ut(t)){let a=r(i),c=dt(i);e.save(),e.fillStyle=a?"rgba(112,130,118,.34)":o?"rgba(118,226,142,.36)":"rgba(255,255,255,.18)",e.strokeStyle=a?"#708278":o?"#6bd579":b.muted,e.lineWidth=5,e.setLineDash([16,10]),X(e,c.x,c.y,c.w,c.h,!0,!0),e.setLineDash([]);let h=tr(i),g=h.cx,C=h.cy;e.fillStyle="rgba(255,255,255,.94)",e.strokeStyle=b.ink,e.lineWidth=4,X(e,h.x,h.y,h.w,h.h,!0,!0),e.fillStyle=a?"#7c9285":o?b.green:b.danger,e.font="900 16px ui-monospace, monospace",e.textAlign="center";let P=i.direction==="left"?"BUY LEFT LOT":i.direction==="right"?"BUY RIGHT LOT":i.direction==="down"?"BUY LOWER LOT":"BUY UPPER LOT";e.fillText(P,g,C-12),e.fillStyle=b.ink,e.font="900 16px ui-monospace, monospace",e.fillText(`${x(n)} / 10\xD710`,g,C+15),e.restore()}}function kt(e,t,n,o,r,i,a=0){let c=un(i);c<.5||(e.save(),e.globalAlpha=Math.min(.62,.12+c*.54),e.fillStyle="#6b4a23",a>0?(e.beginPath(),e.roundRect?.(t,n,o,r,a),e.roundRect?e.fill():X(e,t,n,o,r,!0,!1)):X(e,t,n,o,r,!0,!1),e.globalAlpha=Math.min(.9,.25+c*.6),e.strokeStyle="#3a2a16",e.lineWidth=Math.max(2,Math.floor(2+c*5)),e.setLineDash([5,6]),X(e,t+3,n+3,o-6,r-6,!1,!0),e.restore())}function Wr(e,t){let n=e.componentLookup?.get(t);return(n?e.congestion.get(n):null)?.ratio||0}function Tc(e,t,n={}){let o=Wn(e,t).sort(),i=n.kind==="boostConveyor"?"boostConveyor":"conveyor",a=c=>o.includes(c);if(o.length===2){if(a("left")&&a("right"))return Pe[`${i}StraightH`];if(a("up")&&a("down"))return Pe[`${i}StraightV`];if(a("up")&&a("right"))return Pe[`${i}TurnNe`];if(a("up")&&a("left"))return Pe[`${i}TurnNw`];if(a("down")&&a("right"))return Pe[`${i}TurnSe`];if(a("down")&&a("left"))return Pe[`${i}TurnSw`]}return o.length===1?a("up")||a("down")?Pe[`${i}StraightV`]:Pe[`${i}StraightH`]:a("up")||a("down")?Pe[`${i}StraightV`]:Pe[`${i}StraightH`]}function Ec(e){let t=Object.entries(Pe).filter(([o])=>o.includes("conveyor")||o.includes("Conveyor")).map(([o,r])=>`${o}:${r.loaded?1:0}`).join("|"),n=[...e.conveyorTiles].sort().map(o=>{let r=e.conveyorMeta.get(o)||{};return`${o}:${r.kind||""}:${r.dir||""}:${(r.outDirs||[]).join(",")}:${r.branchMode||""}`}).join("|");return`${e.routingVersion||0};${t};${n}`}function Rc(e){if(!e.conveyorTiles?.size)return{x:0,y:0,w:1,h:1};let t=[...e.conveyorTiles].map(re),n=Math.min(...t.map(a=>a.col)),o=Math.max(...t.map(a=>a.col)),r=Math.min(...t.map(a=>a.row)),i=Math.max(...t.map(a=>a.row));return{x:f.x+n*f.cell-36,y:f.y+r*f.cell-36,w:(o-n+1)*f.cell+72,h:(i-r+1)*f.cell+72}}function Pc(e){let t=[],n=new Set;for(let i of e.conveyorTiles){let{col:a,row:c}=re(i);for(let h of fe){let g={col:a+h.dc,row:c+h.dr},C=I(g.col,g.row);if(!e.conveyorTiles.has(C))continue;let P=[i,C].sort().join("|");n.has(P)||(n.add(P),t.push({fromKey:i,toKey:C,a:$(a,c),b:$(g.col,g.row)}))}}let o=Nc(e),r=[...e.conveyorTiles].map(i=>{let a=re(i),c=$(a.col,a.row),h=e.conveyorMeta.get(i)||{},g=Wn(e,i);return{k:i,c,meta:h,connectedDirs:g,branchDirs:Dc(e,i,g),markers:o.get(i)||[],sprite:Tc(e,i,h)}});return{edges:t,tiles:r}}function Ac(e,t){e.save(),e.lineCap="round",e.lineJoin="round";for(let n of[{width:31,color:"#0b0c0f",alpha:1},{width:23,color:"#444b56",alpha:1},{width:15,color:"#171a1f",alpha:1}]){e.globalAlpha=n.alpha,e.strokeStyle=n.color,e.lineWidth=n.width;for(let o of t.edges)e.beginPath(),e.moveTo(o.a.x,o.a.y),e.lineTo(o.b.x,o.b.y),e.stroke()}e.globalAlpha=1;for(let n of t.tiles){let{c:o,meta:r,sprite:i}=n;e.fillStyle="rgba(0,0,0,.35)",e.strokeStyle="rgba(255,184,39,.34)",e.lineWidth=1,X(e,o.x-20,o.y-20,40,40,!0,!0),Qt(e,i,o.x-19,o.y-19,38,38)||(e.fillStyle=r.kind==="boostConveyor"?"#315e92":"#454b55",e.strokeStyle=b.ink,e.lineWidth=2,X(e,o.x-18,o.y-18,36,36,!0,!0))}e.restore()}function Ic(e,t){let n=qr(t),o=Ec(t);if(!n.conveyors||n.conveyors.signature!==o){let c=Pc(t),h=Rc(t),g=Yr(h.w,h.h),C=g.getContext("2d");C.translate(-h.x,-h.y),Ac(C,c),n.conveyors={signature:o,bounds:h,layer:g,data:c}}let{bounds:r,layer:i,data:a}=n.conveyors;e.drawImage(i,r.x,r.y),e.save(),e.lineCap="round",e.lineJoin="round";for(let c of a.edges){let h=Math.max(Wr(t,c.fromKey),Wr(t,c.toKey)),g=Math.max(0,(h-.5)/.5);e.strokeStyle=In(b.green,b.danger,g),e.globalAlpha=h>.5?.35+g*.55:.8,e.lineWidth=6,e.beginPath(),e.moveTo(c.a.x,c.a.y),e.lineTo(c.b.x,c.b.y),e.stroke()}e.globalAlpha=1;for(let c of a.tiles){let{k:h,c:g,meta:C}=c,P=Wr(t,h);P>.5&&(e.fillStyle="rgba(196,36,36,.28)",e.strokeStyle=b.danger,e.lineWidth=3,X(e,g.x-20,g.y-20,40,40,!0,!0)),$c(e,t,g,h,C,P),kt(e,g.x-18,g.y-18,36,36,{meta:C}),gt(e,t,"conveyor",h,g.x,g.y,38,38),_c(e,g,c.markers),Oc(e,g,C,c.branchDirs,c.connectedDirs),P>.5&&Hr(e,g.x,g.y-15,`${Math.floor(P*100)}%`,b.danger)}e.restore()}function $c(e,t,n,o,r={},i=0){let a=r.kind==="boostConveyor",h=[...new Set([...Array.isArray(r.outDirs)?r.outDirs:[],r.dir].filter(Boolean))][0]||"right",g=h==="up"||h==="down",C=h==="left"||h==="up",P=pn(t,o)*_n(t,o),K=(typeof performance<"u"?performance.now()/1e3:0)*P,j=((C?-K:K)%10+10)%10;e.save(),e.beginPath(),e.rect(n.x-18,n.y-18,36,36),e.clip();let Z=a?"rgba(20,126,124,.62)":"rgba(42,47,54,.72)";if(e.fillStyle=Z,e.fillRect(n.x-18,n.y-18,36,36),e.strokeStyle=i>.5?"rgba(238,83,61,.90)":"rgba(184,190,198,.92)",e.lineWidth=4,g)for(let Q=n.y-28+j;Qe.conveyorTiles.has(I(n.col+o.dc,n.row+o.dr))).map(o=>o.name)}function la(e,t,n){let o=fe.find(a=>a.name===n);if(!o)return!1;let r=e.conveyorMeta?.get?.(I(t.col+o.dc,t.row+o.dr));return r?[...new Set([...Array.isArray(r.outDirs)?r.outDirs:[],r.dir].filter(Boolean))].includes(o.opposite):!1}function ca(e,t,n,o=Wn(e,t)){if(o.length<3)return n;let r=re(t),i=new Set(o.filter(h=>la(e,r,h))),a=o.filter(h=>!i.has(h));if(i.size)return a;let c=n.filter(h=>o.includes(h));return c.length>=2?[...new Set(c)]:o}function Fc(e,t,n,o=Wn(e,t),r="random"){let i=ca(e,t,n,o);return o.length>=3&&r&&r!=="random"&&i.includes(r)?[r]:i}function Dc(e,t,n=Wn(e,t)){let o=e.conveyorMeta?.get?.(t)||{},r=[...new Set([...Array.isArray(o.outDirs)?o.outDirs:[],o.dir].filter(Boolean))];return n.length<3?[]:ca(e,t,r,n)}function Bc(e,t,n){let o=re(t),r=fe.filter(c=>e.conveyorTiles.has(I(o.col+c.dc,o.row+c.dr))).map(c=>c.name),i=[...new Set([...Array.isArray(n.outDirs)?n.outDirs:[],n.dir].filter(Boolean))].filter(c=>r.includes(c));if(r.length===1&&(!i.length||i.includes(r[0]))&&la(e,o,r[0]))return[];let a=r;return i.length?i:a}function Oc(e,t,n,o,r=o){if(r.length<3||o.length<2)return;e.save();let i=o.includes(n.branchMode)?n.branchMode:"random",a=i!=="random";if(e.lineJoin="round",e.lineCap="round",e.lineWidth=2,e.fillStyle="rgba(18,21,26,.94)",e.strokeStyle=a?b.warn:b.green,e.lineWidth=4,X(e,t.x-18,t.y-18,36,36,!0,!0),e.strokeStyle="#ffd36a",e.lineWidth=3,e.fillStyle="#020304",i!=="random"){let c=fe.find(h=>h.name===i);Un(e,t.x,t.y-4,c?.angle||0,8,5)}else e.beginPath(),e.moveTo(t.x-10,t.y),e.lineTo(t.x-2,t.y),e.lineTo(t.x+8,t.y-8),e.moveTo(t.x-2,t.y),e.lineTo(t.x+8,t.y+8),e.stroke(),Un(e,t.x+9,t.y-9,-Math.PI/4,4,3),Un(e,t.x+9,t.y+9,Math.PI/4,4,3);i==="random"&&(e.font="900 9px ui-monospace, monospace",e.textAlign="center",e.textBaseline="middle",e.lineWidth=3,e.strokeStyle="#020304",e.fillStyle=b.warn,e.strokeText("RND",t.x,t.y+11),e.fillText("RND",t.x,t.y+11)),e.restore()}function Lc(e,t,n){if(!n)return[];let o=re(t),r=fe.filter(c=>e.conveyorTiles.has(I(o.col+c.dc,o.row+c.dr))).map(c=>c.name),i=[...new Set([...Array.isArray(n.outDirs)?n.outDirs:[],n.dir].filter(Boolean))].filter(c=>r.includes(c));return Fc(e,t,i.length?i:Bc(e,t,n),r,n.branchMode||"random").map(c=>fe.find(h=>h.name===c)).filter(Boolean)}function Nc(e){let t=new Map;for(let[n,o]of e.conveyorMeta||[]){let r=Lc(e,n,o);r.length&&t.set(n,r)}return t}function _c(e,t,n){let o=n.slice(0,4);o.length&&(e.save(),e.globalAlpha=1,o.forEach(r=>{let i=t.x+r.dc*11,a=t.y+r.dr*11;e.fillStyle="rgba(255,205,83,.96)",Un(e,i,a,r.angle,9,7),e.fillStyle="#020304",Un(e,i,a,r.angle,6.5,4.6)}),e.restore())}function Un(e,t,n,o,r=11,i=8){e.save(),e.translate(t,n),e.rotate(o),e.beginPath(),e.moveTo(r,0),e.lineTo(-r*.72,-i),e.lineTo(-r*.72,i),e.closePath(),e.fill(),e.restore()}function Gc(e,t,n,o,r){let i=ge(t),a=null;if(n==="inputA"&&(a={x:i.x,y:i.y-40}),n==="left"&&(a={x:i.x-42,y:i.y+10}),n==="right"&&(a={x:i.x+42,y:i.y+10}),!a)return;let c=n==="inputA"?{x:o.x,y:a.y}:{x:a.x,y:o.y};e.save(),e.lineCap="square",e.lineJoin="bevel",e.strokeStyle="#050609",e.lineWidth=15,e.beginPath(),e.moveTo(o.x,o.y),e.lineTo(c.x,c.y),e.lineTo(a.x,a.y),e.stroke(),e.strokeStyle="#596473",e.lineWidth=9,e.beginPath(),e.moveTo(o.x,o.y),e.lineTo(c.x,c.y),e.lineTo(a.x,a.y),e.stroke(),e.strokeStyle=r||"#7b8795",e.lineWidth=3,e.beginPath(),e.moveTo(o.x,o.y),e.lineTo(c.x,c.y),e.lineTo(a.x,a.y),e.stroke(),e.fillStyle=r||"#7b8795",e.strokeStyle="#050609",e.lineWidth=3,X(e,a.x-9,a.y-7,18,14,!0,!0),e.restore()}function Uc(e,t){e.save(),e.lineWidth=2,e.font="900 12px ui-monospace, monospace",e.textAlign="center";for(let n of t.scanners){let o=ur(n),r=[{type:"inputA",p:Fe(n,"inputA"),label:"IN",color:b.green},{type:"left",p:Fe(n,"left"),label:fr(o.left),color:pr(o.left)},{type:"right",p:Fe(n,"right"),label:fr(o.right),color:pr(o.right)}];for(let i of r){if(!i.p)continue;let a=$(i.p.col,i.p.row);Gc(e,n,i.type,a,i.color);let c=Gi(t,n,i.type||(i.label==="IN"?"inputA":null))||t.conveyorTiles.has(I(i.p.col,i.p.row));e.fillStyle=c?In("#1a1d23",i.color,.24):"#3a1e22",e.strokeStyle=c?i.color:b.danger,X(e,a.x-18,a.y-12,36,24,!0,!0),e.fillStyle=b.white,e.fillText(i.label,a.x,a.y+4)}}e.restore()}function Wc(e,t){for(let n of t.eggFarms)Hc(e,n,t)}function Hc(e,t,n){let o=$(t.col,t.row);e.save(),e.fillStyle="#5a6470",e.strokeStyle=b.ink,e.lineWidth=3,X(e,o.x-27,o.y-27,54,54,!0,!0),e.fillStyle="#6f5130",e.strokeStyle="#2f2112",e.lineWidth=3,e.beginPath(),e.ellipse(o.x,o.y+3,19,12,0,0,Math.PI*2),e.fill(),e.stroke(),e.strokeStyle="#a77b4b",e.lineWidth=2;for(let c=-16;c<=16;c+=6)e.beginPath(),e.moveTo(o.x-18,o.y+c*.1),e.quadraticCurveTo(o.x+c,o.y-10+Math.abs(c)*.2,o.x+18,o.y+c*.08),e.stroke();let r=[{x:-8,y:1},{x:0,y:-3},{x:8,y:2}];for(let c of r)e.fillStyle="#e8e2cf",e.strokeStyle="#8d8475",e.lineWidth=1.5,e.beginPath(),e.ellipse(o.x+c.x,o.y+c.y,4.8,6.2,.2,0,Math.PI*2),e.fill(),e.stroke();let i=1+Math.max(0,Number(t.extraEggOutlet)||0);e.fillStyle="rgba(255,255,255,.92)",e.strokeStyle=b.ink,e.lineWidth=2,X(e,o.x+8,o.y+11,30,18,!0,!0),e.fillStyle=b.ink,e.font="900 10px ui-monospace, monospace",e.textAlign="center",e.fillText(`O${i}`,o.x+23,o.y+23),e.fillStyle="#d5e35a",e.font="900 9px ui-monospace, monospace",e.fillText(`L${t.level}`,o.x-13,o.y-15);let a=Math.max(0,Math.min(1,t.shutterProgress||0));if(a>0){let c=54*a;e.fillStyle="rgba(10,12,15,.86)",X(e,o.x-27,o.y-27,54,c,!0,!1),e.strokeStyle="rgba(230,237,242,.64)",e.lineWidth=2;for(let h=o.y-23;h=1&&(e.fillStyle=b.white,e.font="900 12px ui-monospace, monospace",e.fillText("SHUT",o.x,o.y+4))}kt(e,o.x-28,o.y-28,56,56,t),gt(e,n,"eggFarm",t.id,o.x,o.y,68,68),e.restore()}function Kc(e,t){for(let n of t.scanners)qc(e,n,t)}function qc(e,t,n){let o=ge(t);if(e.save(),t.kind==="manual"&&(n.manualCombo?.count||0)>=10){let h=n.manualCombo.count;e.save(),e.globalAlpha=Math.min(.55,.16+h/120),e.strokeStyle=h>=30?b.warn:b.green,e.lineWidth=Math.min(12,4+Math.floor(h/10)),e.shadowColor=e.strokeStyle,e.shadowBlur=Math.min(24,8+h),X(e,o.x-48,o.y-48,96,96,!1,!0),e.restore()}let r=t.kind==="auto"?Pe.scannerAuto:Pe.scannerManual;if(Qt(e,r,o.x-44,o.y-44,88,88)){t.kind==="auto"?kt(e,o.x-44,o.y-44,88,88,t):ra(e,t,o),gt(e,n,"scanner",t.id,o.x,o.y,96,96),e.restore();return}e.fillStyle=t.kind==="auto"?"#d8ffe2":b.white,e.strokeStyle=b.ink,e.lineWidth=4,X(e,o.x-44,o.y-44,88,88,!0,!0),e.fillStyle=t.kind==="auto"?b.green:b.ink,e.font="900 16px ui-monospace, monospace",e.textAlign="center",e.fillText(t.kind==="auto"?"AUTO":`S${(t.slot??0)+1}`,o.x,o.y-24),e.font="900 9px ui-monospace, monospace";let i=t.keys?.left?.label||(t.slot===0?"A":"Left"),a=t.keys?.right?.label||(t.slot===0?"D":"Right");e.fillText(t.kind==="auto"?"2.1 FARMS":`${i}/${a}`,o.x,o.y-8);let c=t.queue.length;t.kind==="manual"?ra(e,t,o):(e.fillStyle=c>0?b.green:b.muted,e.font="900 10px ui-monospace, monospace",e.fillText(`Q:${c} CD:${t.cooldown.toFixed(1)}`,o.x,o.y+35),kt(e,o.x-44,o.y-44,88,88,t)),gt(e,n,"scanner",t.id,o.x,o.y,96,96),e.restore()}function ra(e,t,n){if(t.kind!=="manual")return;let r=(t.slot??0)===0?[{side:"left",text:"A"},{side:"right",text:"D"}]:[{side:"left",text:"\u2190"},{side:"right",text:"\u2192"}];r[0].text=t.keys?.left?.label||r[0].text,r[1].text=t.keys?.right?.label||r[1].text;let i=t.keyPressTime>0?t.keyPressSide:null,a=n.x-30,c=n.y+8;e.save(),e.lineWidth=3,e.fillStyle="#f7fff5",e.strokeStyle=b.ink,X(e,a-4,c-4,68,25,!0,!0),e.font="900 19px ui-monospace, monospace",e.textAlign="center";for(let h=0;h MEAT","meat","connect OUT to sausage machine"),kt(e,n.x,n.y,n.w,n.h,n),gt(e,t,"facility",n.id,n.x+n.w/2,n.y+n.h/2,n.w+12,n.h+12),e.restore()}function eu(e,t,n=t.facilities.trash){e.save(),zr(e,n),Hn(e,n.x,n.y,n.w,n.h,"#29342d",b.ink,b.wasteGreen),Fo(e,n,b.wasteGreen),e.fillStyle=b.white,e.font="900 14px ui-monospace, monospace",e.textAlign="left",e.fillText(`SHREDDER L${n.level}`,n.x+14,n.y+18),e.fillStyle="#181d20",e.strokeStyle="#9fd473",e.lineWidth=4,X(e,n.x+38,n.y+36,92,22,!0,!0),e.fillStyle="#262d30",e.strokeStyle="#0d1014",X(e,n.x+46,n.y+58,76,18,!0,!0),e.fillStyle="#111418";for(let o=0;o<7;o+=1){let r=n.x+51+o*10;e.beginPath(),e.moveTo(r,n.y+58),e.lineTo(r+5,n.y+68),e.lineTo(r+10,n.y+58),e.closePath(),e.fill()}e.fillStyle="#6f7e8d",X(e,n.x+54,n.y+27,16,9,!0,!0),X(e,n.x+98,n.y+27,16,9,!0,!0),Io(e,n.x+14,n.y+n.h-42,n.w-28,"SEND POOP","poop","shredder"),kt(e,n.x,n.y,n.w,n.h,n),gt(e,t,"facility",n.id,n.x+n.w/2,n.y+n.h/2,n.w+12,n.h+12),e.restore()}function tu(e,t,n){e.save(),Hn(e,n.x,n.y,n.w,n.h,"#3b3029",b.ink,b.sausageOrange),Fo(e,n,b.sausageOrange),e.fillStyle=b.white,e.font="900 14px ui-monospace, monospace",e.textAlign="left",e.fillText(`SAUSAGE L${n.level}`,n.x+14,n.y+18),e.fillStyle=b.muted,e.font="900 10px ui-monospace, monospace",e.fillText(`2 MEAT -> 1 | ${x(Ze(t))}`,n.x+14,n.y+34),e.fillStyle="#211915",e.strokeStyle=b.sausageOrange,e.lineWidth=4,X(e,n.x+20,n.y+43,n.w-40,38,!0,!0);for(let o=0;o<3;o+=1)Do(e,n.x+n.w/2-28+o*28,n.y+62,9);e.fillStyle=b.meatRed,e.fillRect(n.x,n.y+n.h/2-8,8,16),e.fillStyle=b.sausageOrange,e.fillRect(n.x+n.w-8,n.y+n.h/2-8,8,16),Io(e,n.x+10,n.y+n.h-42,n.w-20,"2 MEAT -> SAUSAGE","sausage",`buffer ${n.meatBuffer||0}/2`),kt(e,n.x,n.y,n.w,n.h,n),gt(e,t,"facility",n.id,n.x+n.w/2,n.y+n.h/2,n.w+12,n.h+12),e.restore()}function nu(e,t,n=t.facilities.truck){e.save(),zr(e,n),Hn(e,n.x,n.y,n.w,n.h,"#343128",b.ink,b.truckPink),Fo(e,n,b.truckPink),e.fillStyle=b.white,e.font="900 14px ui-monospace, monospace",e.textAlign="left",e.fillText(`TRUCK L${n.level}`,n.x+14,n.y+18),e.fillStyle=b.muted,e.font="900 10px ui-monospace, monospace",e.fillText(`UNIT ${x(Ve(t))}`,n.x+14,n.y+34),zc(e,Pe.truck,n,2);let o=Kr(t);Io(e,n.x+15,n.y+50,n.w-30,`SEND ${o.toUpperCase()}`,o,t.eventOffer&&!t.eventActive?"next event":"truck cargo"),e.fillStyle="rgba(39,60,36,.9)",X(e,n.x+13,n.y+88,n.w-26,Math.max(18,n.h-103),!0,!1);for(let r of t.truckCargo)r.sex==="poop"?ua(e,n.x+r.x,n.y+r.y):r.sex==="sausage"?Do(e,n.x+r.x,n.y+r.y,7):jr(e,n.x+r.x,n.y+r.y,r.sex,r.sex==="male");kt(e,n.x,n.y,n.w,n.h,n),gt(e,t,"facility",n.id,n.x+n.w/2,n.y+n.h/2,n.w+12,n.h+12),e.restore()}function ou(e,t,n){let o=n(0),r=n(1);for(let i of t.chicks){let a=o&&o.id===i.id||r&&r.id===i.id||i.queueIndex===0;ru(e,i,a,t)}}function ru(e,t,n,o){let r=o.componentLookup&&iu(o,t)>.8,i=t.y+Math.sin(t.bob)*(r?5:1.5);if(e.save(),n&&(e.strokeStyle=b.green,e.lineWidth=5,e.beginPath(),e.arc(t.x,i,t.radius+8,0,Math.PI*2),e.stroke()),r&&(e.strokeStyle=b.danger,e.lineWidth=3,e.beginPath(),e.arc(t.x,i,t.radius+12+Math.sin(t.bob*2)*4,0,Math.PI*2),e.stroke()),t.sex==="poop")Xr(e,t.x,i,t.radius);else if(t.sex==="meat")da(e,t.x,i,t.radius);else if(t.sex==="sausage")Do(e,t.x,i,t.radius);else{let a=t.sex==="male"?Pe.chickMale:Pe.chickFemale;if(!Qt(e,a,t.x-t.radius,i-t.radius,t.radius*2,t.radius*2)){let g=t.sex==="male"?b.male:b.female,C=t.sex==="male"?"#164f8f":"#8b2252";e.fillStyle=g,e.strokeStyle=b.ink,e.lineWidth=5,e.beginPath(),e.arc(t.x,i,t.radius,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="rgba(255,255,255,.24)",e.beginPath(),e.arc(t.x-5,i-7,t.radius*.36,0,Math.PI*2),e.fill(),e.fillStyle=b.ink,e.beginPath(),e.arc(t.x-6,i-5,2.5,0,Math.PI*2),e.arc(t.x+6,i-5,2.5,0,Math.PI*2),e.fill(),e.fillStyle="#ffaa2e",e.beginPath(),e.moveTo(t.x,i+2),e.lineTo(t.x+9,i+6),e.lineTo(t.x,i+10),e.closePath(),e.fill(),e.strokeStyle=C,e.lineWidth=3,t.sex==="male"?(e.beginPath(),e.moveTo(t.x-8,i-t.radius-2),e.lineTo(t.x+8,i-t.radius-2),e.stroke()):(e.beginPath(),e.moveTo(t.x-9,i-t.radius+2),e.lineTo(t.x,i-t.radius-6),e.lineTo(t.x+9,i-t.radius+2),e.stroke())}e.fillStyle=t.sex==="male"?"#dff1ff":"#fff0f7",e.strokeStyle="#050609",e.lineWidth=3,e.font=`900 ${Math.max(10,Math.round(t.radius*.72))}px ui-monospace, monospace`,e.textAlign="center";let h=t.sex==="male"?"\u2642":"\u2640";e.strokeText(h,t.x+t.radius*.54,i+t.radius*.7),e.fillText(h,t.x+t.radius*.54,i+t.radius*.7)}e.restore()}function iu(e,t){let n=null,o=1/0;for(let[r,i]of e.componentLookup.entries()){let a=re(r),c=$(a.col,a.row),h=Math.hypot(t.x-c.x,t.y-c.y);hia(o.type,o.id)===ia(t,n))}function gt(e,t,n,o,r,i,a,c){su(t,n,o)&&(e.save(),e.fillStyle="rgba(34,185,79,.18)",e.strokeStyle=b.green,e.lineWidth=(t.multiSelected||[]).length>1?6:4,e.setLineDash((t.multiSelected||[]).length>1?[]:[8,5]),X(e,r-a/2,i-c/2,a,c,!0,!0),e.restore())}function au(e,t,n,o){if(t.phase==="build"&&(t.multiSelected||[]).length>1){e.save(),e.fillStyle="rgba(255,255,255,.96)",e.strokeStyle=b.ink,e.lineWidth=3,X(e,54-t.view.x,66-t.view.y,214,42,!0,!0),e.fillStyle=b.ink,e.font="900 11px ui-monospace, monospace",e.fillText(`${t.multiSelected.length} ITEMS SELECTED`,65-t.view.x,83-t.view.y),e.fillStyle=b.muted,e.font="900 11px ui-monospace, monospace",e.fillText("drag any selected item to move group",65-t.view.x,99-t.view.y),e.restore();return}}function lu(e,t,n){if(!n.cardTargetPick?.pending)return;let o=Gr(n);e.save(),e.fillStyle="rgba(0,0,0,.66)",e.beginPath();let r=n.view?.scale||1;e.rect(-(n.view?.x||0)/r,-(n.view?.y||0)/r,t.width/r,t.height/r);for(let g of o){let C=g.bounds,P=8;e.rect(C.x-P,C.y-P,C.w+P*2,C.h+P*2)}e.fill("evenodd");let i=n.cardTargetPick?.mode==="blockedCell";if(!o.length){e.fillStyle="rgba(255,255,255,.95)",e.strokeStyle=b.ink,e.lineWidth=3,X(e,(260-(n.view?.x||0))/r,(96-(n.view?.y||0))/r,420/r,42/r,!0,!0),e.fillStyle=b.danger,e.font="900 12px ui-monospace, monospace",e.textAlign="center",e.fillText(i?"NO BLOCKED CELL. PRESS ESC.":"NO VALID UPGRADE TARGET. PRESS ESC.",(470-(n.view?.x||0))/r,(122-(n.view?.y||0))/r),e.restore();return}e.fillStyle="rgba(255,255,255,.95)",e.strokeStyle=b.ink,e.lineWidth=3;let a=Math.max(1,n.cardTargetPick?.remaining||1),c=Math.max(0,n.cardTargetPick?.used||0),h=n.cardTargetPick?.mode==="autoScannerMenu"?"AUTO SCANNER ROUTE SETTINGS. CLICK EMPTY AREA TO CLOSE.":i?`DYNAMITE: CLICK ANY BLOCKED TILE (${a} LEFT). ESC ${c>0?"FINISHES":"CANCELS"}.`:"CLICK AN UPGRADEABLE MACHINE. PRESS ESC TO CANCEL.";X(e,(216-(n.view?.x||0))/r,(96-(n.view?.y||0))/r,560/r,42/r,!0,!0),e.fillStyle=b.ink,e.font="900 12px ui-monospace, monospace",e.textAlign="center",e.fillText(h,(496-(n.view?.x||0))/r,(122-(n.view?.y||0))/r),e.restore()}function cu(e,t){if(t.phase!=="build"||!t.selectionBox)return;let n=t.selectionBox,o=Math.min(n.x1,n.x2),r=Math.min(n.y1,n.y2),i=Math.abs(n.x2-n.x1),a=Math.abs(n.y2-n.y1);e.save(),e.fillStyle="rgba(34,185,79,.12)",e.strokeStyle=b.green,e.lineWidth=2,e.setLineDash([7,4]),X(e,o,r,i,a,!0,!0),e.restore()}function uu(e,t){let n=[...t.effects].sort((o,r)=>(o.priority??Ie.ambient)-(r.priority??Ie.ambient));for(let o of n){let r=Math.max(0,o.life/o.maxLife);e.save(),e.globalAlpha=r,o.type==="meat"?(e.fillStyle=b.green,X(e,o.x-o.size/2,o.y-o.size/2,o.size*1.5,o.size,!0,!1)):o.type==="sludge"?(e.fillStyle=b.poop,X(e,o.x-o.size/2,o.y-o.size/2,o.size*1.4,o.size,!0,!1)):o.type==="spawn"?(e.strokeStyle=b.green,e.lineWidth=3,e.beginPath(),e.arc(o.x,o.y,o.size+(1-r)*20,0,Math.PI*2),e.stroke()):o.type==="spark"?(e.fillStyle=o.color||b.danger,X(e,o.x-o.size/2,o.y-o.size/2,o.size,o.size,!0,!1)):o.type==="smoke"?(e.fillStyle="rgba(16,32,21,.24)",e.beginPath(),e.arc(o.x,o.y,o.size*(1.2-r*.2),0,Math.PI*2),e.fill()):o.type==="shockwave"?(e.strokeStyle=o.color||b.danger,e.lineWidth=4,e.beginPath(),e.arc(o.x,o.y,o.size+(1-r)*(o.maxSize||44),0,Math.PI*2),e.stroke()):o.type==="scannerPulse"?(e.strokeStyle=o.color||b.green,e.lineWidth=5,e.setLineDash([8,6]),e.beginPath(),e.arc(o.x,o.y,o.size+(1-r)*38,0,Math.PI*2),e.stroke()):o.type==="shred"?(e.fillStyle=o.color||b.ink,e.save(),e.translate(o.x,o.y),e.rotate(o.rot||0),X(e,-o.w/2,-o.h/2,o.w,o.h,!0,!1),e.restore()):o.type==="load"?(e.strokeStyle=o.color||b.green,e.lineWidth=3,e.beginPath(),e.moveTo(o.x-14,o.y),e.lineTo(o.x+14,o.y),e.moveTo(o.x,o.y-14),e.lineTo(o.x,o.y+14),e.stroke()):o.type==="erase"?(e.strokeStyle=b.ink,e.lineWidth=3,e.setLineDash([4,4]),X(e,o.x-o.size/2,o.y-o.size/2,o.size,o.size,!1,!0)):o.type==="rage"?(e.fillStyle=o.color||b.danger,e.font="900 16px ui-monospace, monospace",e.textAlign="center",e.fillText("!",o.x,o.y)):o.type==="flyingChick"&&(o.sex==="poop"?Xr(e,o.x,o.y,o.radius||11):jr(e,o.x,o.y,o.sex,o.sex==="male")),e.restore()}}function du(e,t){e.save(),e.font="900 19px ui-monospace, monospace",e.textAlign="center";for(let n of t.floatingTexts){let o=Math.max(0,n.life/n.maxLife);e.globalAlpha=o,e.lineWidth=5,e.strokeStyle=b.white,e.fillStyle=n.color,e.strokeText(n.text,n.x,n.y),e.fillText(n.text,n.x,n.y)}e.restore()}function fu(e,t){let n=t.repairmen&&t.repairmen.length?t.repairmen:t.repairman?[t.repairman]:[];for(let o of n)pu(e,t,o)}function pu(e,t,n){if(!!!n?.active)return;if(e.save(),Qt(e,Pe.repairman,n.x-18,n.y-18,36,36)){n.target?.label&&Hr(e,n.x,n.y-24,n.target.label,b.green),e.restore();return}e.lineWidth=3,e.fillStyle="#fff0d1",e.strokeStyle=b.ink,e.beginPath(),e.arc(n.x,n.y,17,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="#ffd6e7",e.beginPath(),e.arc(n.x-6,n.y-2,3.2,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(n.x+6,n.y-2,3.2,0,Math.PI*2),e.fill();let r=!!t.cardEffects?.laborExploitation;e.fillStyle=r?b.danger:b.ink,e.beginPath(),e.arc(n.x-5,n.y-5,r?2.8:2.2,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(n.x+5,n.y-5,r?2.8:2.2,0,Math.PI*2),e.fill(),e.strokeStyle=b.ink,e.lineWidth=r?3:2,e.beginPath(),e.arc(n.x,n.y+(r?0:1),r?8:7,.1*Math.PI,.9*Math.PI),e.stroke(),e.fillStyle=b.green,X(e,n.x-9,n.y+14,18,8,!0,!0),e.strokeStyle=b.ink,e.lineWidth=3,e.beginPath(),e.moveTo(n.x-11,n.y+18),e.lineTo(n.x-20,n.y+24),e.stroke(),e.beginPath(),e.moveTo(n.x+11,n.y+18),e.lineTo(n.x+20,n.y+24),e.stroke(),e.fillStyle=b.ink,e.font="900 11px ui-monospace, monospace",e.textAlign="center",e.fillText("FIX",n.x,n.y+33),n.target?.label&&Hr(e,n.x,n.y-24,n.target.label,b.green),e.restore()}function yu(e,t,n){e.save(),e.font="900 19px ui-monospace, monospace",e.textAlign="left",e.fillStyle=b.ink,e.font="900 19px ui-monospace, monospace",e.fillStyle=b.ink;let o=[];t.phase==="running"&&o.push(t.timeLeft<=0?"TIME UP: FARMS SHUT. CLEAR LINE.":"A/D: S1 \u2190/\u2192: S2"),t.phase==="build"&&o.push(`BUILD: ${t.buildTool||"SELECT"} | connect 1 EGG route to any exit`);let r=76;for(let i of o)e.strokeStyle=b.white,e.lineWidth=4,e.strokeText(i,24,r),e.fillText(i,24,r),r+=20;e.restore()}function fa({game:e,canvasPoint:t,updatePanels:n,equipmentAtPoint:o,fail:r,pointInGrid:i,rectOfFacility:a,rectsOverlap:c}){function h(T){return T?T.type==="conveyor"?`conveyor:${T.id||T.oldKey}`:T.type==="facility"?`facility:${T.id||T.ref?.id}`:T.type==="maintenanceRoom"?`maintenanceRoom:${T.id||T.ref?.id}`:`${T.type}:${T.id||T.ref?.id}`:""}function g(T){return T?T.type==="conveyor"?{type:"conveyor",id:T.oldKey}:{type:T.type,id:T.ref.id}:null}function C(T){let M=h(g(T));return(e.multiSelected||[]).some(q=>h(q)===M)}function P(T){return T?T.type==="eggFarm"?e.eggFarms.find(M=>M.id===T.id)||null:T.type==="scanner"?e.scanners.find(M=>M.id===T.id)||null:T.type==="facility"?e.facilities[T.id]||null:T.type==="maintenanceRoom"?(e.maintenanceRooms||[]).find(M=>M.id===T.id)||null:T.type==="conveyor"&&e.conveyorTiles.has(T.id)?{type:"conveyor",id:T.id}:null:null}function L(){let T=[];for(let M of e.conveyorTiles){let q=re(M),ne=$(q.col,q.row);T.push({type:"conveyor",id:M,center:ne,cell:q})}for(let M of e.eggFarms)T.push({type:"eggFarm",id:M.id,ref:M,center:$(M.col,M.row),cell:{col:M.col,row:M.row}});for(let M of e.scanners)T.push({type:"scanner",id:M.id,ref:M,center:ge(M),cell:{col:M.col,row:M.row}});for(let M of e.maintenanceRooms||[]){let q=$(M.col,M.row);T.push({type:"maintenanceRoom",id:M.id,ref:M,center:{x:q.x+f.cell/2,y:q.y+f.cell/2},cell:{col:M.col,row:M.row}})}for(let M of Object.values(e.facilities)){let q={x:M.x+M.w/2,y:M.y+M.h/2},ne=M.entry?$(M.entry.col,M.entry.row):q;T.push({type:"facility",id:M.id,ref:M,center:q,portCenter:ne})}return T}function K(T,M){let q=Math.min(M.x1,M.x2),ne=Math.max(M.x1,M.x2),oe=Math.min(M.y1,M.y2),se=Math.max(M.y1,M.y2);return T.x>=q&&T.x<=ne&&T.y>=oe&&T.y<=se}function j(T){let M=[];for(let q of L())(K(q.center,T)||q.portCenter&&K(q.portCenter,T))&&M.push({type:q.type,id:q.id});return e.multiSelected=M,e.selected=M[0]||null,n(),M.length}function Z(){e.multiSelected=[],e.selected=null,n()}function Q(T){let M=t(T);return e.selectionBox={x1:M.x,y1:M.y,x2:M.x,y2:M.y},e.multiSelected=[],e.selected=null,n(),!0}function ke(T){if(!e.selectionBox)return;let M=t(T);e.selectionBox.x2=M.x,e.selectionBox.y2=M.y}function pe(){if(!e.selectionBox)return 0;let T=e.selectionBox,M=Math.abs(T.x2-T.x1),q=Math.abs(T.y2-T.y1);return e.selectionBox=null,M<6&&q<6?(Z(),0):j(T)}function le(T){let M=g(T);return M?(e.multiSelected||[]).length&&C(T)?[...e.multiSelected]:[M]:[]}function ue(T){let M=P(T);if(!M)return null;if(T.type==="conveyor"){let q=re(T.id);return{...T,obj:M,oldKey:T.id,currentKey:T.id,meta:e.conveyorMeta.get(T.id)||{price:He("conveyor"),builtSession:null},col:q.col,row:q.row}}return T.type==="eggFarm"||T.type==="scanner"||T.type==="maintenanceRoom"?{...T,obj:M,col:M.col,row:M.row}:T.type==="facility"?M.gridPlaced?{...T,obj:M,gridPlaced:!0,col:M.col,row:M.row,x:M.x,y:M.y,w:M.w,h:M.h,entry:M.entry?{...M.entry}:null,side:M.side,center:{x:M.x+M.w/2,y:M.y+M.h/2}}:{...T,obj:M,gridPlaced:!1,x:M.x,y:M.y,w:M.w,h:M.h,entry:M.entry?{...M.entry}:null,side:M.side,center:{x:M.x+M.w/2,y:M.y+M.h/2}}:null}function D(T){let M=t(T),q=o(M);if(!q)return!1;let ne=le(q),oe=ne.map(ue).filter(Boolean);return oe.length?(e.groupDrag={start:M,selections:ne,origins:oe,historySnapshot:On(e),committed:!1,lastDCol:0,lastDRow:0},e.multiSelected=ne,e.selected=ne[0]||null,n(),!0):!1}function U(){!e.groupDrag||e.groupDrag.committed||(e.undoStack.push(e.groupDrag.historySnapshot),e.redoStack=[],e.groupDrag.committed=!0)}function ee(T,M,q){let ne=I(T,M),oe=it(e,T,M);if(oe&&!q.has(`eggFarm:${oe.id}`))return!0;let se=st(e,T,M);if(se&&!q.has(`scanner:${se.id}`))return!0;let Se=(e.maintenanceRooms||[]).find(Ce=>jt(Ce).some(B=>B.col===T&&B.row===M));if(Se&&!q.has(`maintenanceRoom:${Se.id}`))return!0;let be=pt(e,T,M);return!!(be&&!q.has(`facility:${be.id}`)||e.conveyorTiles.has(ne)&&!q.has(`conveyor:${ne}`))}function O(T,M,q){for(let ne of Object.values(e.facilities||{})){if(q.has(`facility:${ne.id}`))continue;let oe=nt(e,ne),se=Xe(e,ne);if(oe&&oe.col===T&&oe.row===M||se&&se.col===T&&se.row===M)return!0}return!1}function G(T,M,q,ne){let oe=it(e,M,q);if(oe&&!ne.has(`eggFarm:${oe.id}`))return!0;let se=st(e,M,q);if(se&&!ne.has(`scanner:${se.id}`))return!0;let Se=(e.maintenanceRooms||[]).find(Ce=>jt(Ce).some(B=>B.col===M&&B.row===q));if(Se&&!ne.has(`maintenanceRoom:${Se.id}`))return!0;let be=pt(e,M,q);if(be&&be.id!==T.id&&!ne.has(`facility:${be.id}`))return!0;for(let Ce of Object.values(e.facilities||{})){if(Ce.id===T.id||ne.has(`facility:${Ce.id}`))continue;let B=nt(e,Ce),ye=Xe(e,Ce);if(B&&B.col===M&&B.row===q||ye&&ye.col===M&&ye.row===q)return!0}return!1}function Y(T,M,q){let ne=[];for(let oe of e.groupDrag.origins){if(oe.type!=="facility"||oe.gridPlaced)continue;let se={x:oe.center.x+T,y:oe.center.y+M},{entry:Se,side:be}=Eo(se,e);if(!i(Se.col,Se.row)||e.blockedCells?.has?.(I(Se.col,Se.row)))return!0;let Ce={...oe.obj,entry:{...Se},side:be};Ao(Ce,Se,be),ne.push({id:oe.id,rect:a(Ce)})}for(let oe of ne)for(let se of Object.values(e.facilities))if(!q.has(`facility:${se.id}`)&&c(oe.rect,a(se),12))return!0;for(let oe=0;oeI(q.col,q.row)));for(let q of["left","right"]){let ne=Fe(T,q),oe=I(ne.col,ne.row);if(!e.conveyorTiles.has(oe))continue;let se=e.conveyorMeta.get(oe);if(!se)continue;let Se=fe.find(B=>B.name===q),be=fe.filter(B=>{let ye=I(ne.col+B.dc,ne.row+B.dr);return e.conveyorTiles.has(ye)&&!M.has(ye)}),Ce=be.find(B=>B.name===Se?.name)||be[0]||Se;Ce&&(se.dir=Ce.name,se.outDirs=[Ce.name],se.branchMode="random")}}function De(T){if(!e.groupDrag)return;let M=t(T),q=M.x-e.groupDrag.start.x,ne=M.y-e.groupDrag.start.y;if(Math.hypot(q,ne)<3)return;let oe=Math.round(q/f.cell),se=Math.round(ne/f.cell),Se=new Set(e.groupDrag.selections.map(h)),be=new Set;for(let B of e.groupDrag.origins){if(B.type==="facility"&&!B.gridPlaced)continue;let ye=B.col+oe,Be=B.row+se,de=B.type==="scanner"?ft({col:ye,row:Be}):B.type==="maintenanceRoom"?jt({col:ye,row:Be}):B.type==="facility"?Ro({...B.obj,col:ye,row:Be}):[{col:ye,row:Be}];for(let Oe of de){if(!i(Oe.col,Oe.row))return r("Selection outside grid");if(e.blockedCells?.has?.(I(Oe.col,Oe.row)))return r("Selection hits blocked ground");let ct=I(Oe.col,Oe.row);if(be.has(ct))return r("Selection overlap");if(ee(Oe.col,Oe.row,Se))return r("Cell occupied");if(B.type!=="conveyor"&&O(Oe.col,Oe.row,Se))return r("Machine port must remain open");be.add(ct)}if(B.type==="facility"&&B.gridPlaced){let Oe={...B.obj,col:ye,row:Be},ct={col:ye-1,row:Be+Math.floor((Oe.hCells||3)/2)},xt={col:ye+(Oe.wCells||3),row:Be+Math.floor((Oe.hCells||3)/2)};for(let Le of[ct,xt]){if(!i(Le.col,Le.row)||e.blockedCells?.has?.(I(Le.col,Le.row)))return r("Machine port outside usable grid");if(G(B.obj,Le.col,Le.row,Se))return r("Machine port is occupied")}}}if(Y(q,ne,Se))return r("Facility overlap");U();let Ce=e.groupDrag.origins.filter(B=>B.type==="conveyor");for(let B of Ce){let ye=B.currentKey||B.oldKey;e.conveyorTiles.delete(ye),e.conveyorMeta.delete(ye)}for(let B of Ce){let ye=I(B.col+oe,B.row+se);e.conveyorTiles.add(ye),e.conveyorMeta.set(ye,B.meta||{price:He("conveyor"),builtSession:null,uses:0}),B.currentKey=ye}for(let B of e.groupDrag.origins)if(B.type==="eggFarm")B.obj.col=B.col+oe,B.obj.row=B.row+se;else if(B.type==="scanner")B.obj.col=B.col+oe,B.obj.row=B.row+se,ie(B.obj);else if(B.type==="maintenanceRoom")B.obj.col=B.col+oe,B.obj.row=B.row+se;else if(B.type==="facility"&&B.gridPlaced)B.obj.col=B.col+oe,B.obj.row=B.row+se,yn(B.obj);else if(B.type==="facility"){let ye={x:B.center.x+q,y:B.center.y+ne},{entry:Be,side:de}=Eo(ye,e);Ao(B.obj,Be,de)}e.groupDrag.selections=e.groupDrag.origins.map(B=>B.type==="conveyor"?{type:"conveyor",id:B.currentKey||B.oldKey}:{type:B.type,id:B.id}),e.multiSelected=[...e.groupDrag.selections],e.selected=e.multiSelected[0]||null,Me(e),n()}function me(){e.groupDrag&&(Me(e),e.groupDrag=null,n())}return{selectionFromHit:g,startSelectionBox:Q,updateSelectionBox:ke,finishSelectionBox:pe,startGroupDrag:D,updateGroupDrag:De,finishGroupDrag:me}}function pa({game:e,ui:t,canvas:n,canvasPoint:o,onUpdatePanels:r}){function i(){r&&r()}let a=null,c=!1,h=null,g=!1,C=["random","up","right","down","left"],P={random:"RND",up:"\u25B2",right:"\u25B6",down:"\u25BC",left:"\u25C0"},L={w:40,h:40,y:0};function K(){return ro(e)}function j(){e.gridCols=f.cols,e.gridRows=f.rows,e.gridOwnedCells=Ti(e)}function Z(s){if(s){f.y-=s*f.cell,f.rows+=s,e.gridChunkOriginY=(e.gridChunkOriginY||0)-Math.floor(s/10),e.ownedCells=io(e.ownedCells,s),e.blockedCells=io(e.blockedCells,s),e.conveyorTiles=io(e.conveyorTiles,s),e.conveyorMeta=Ai(e.conveyorMeta,s);for(let l of e.eggFarms||[])l.row+=s;for(let l of e.scanners||[])l.row+=s;for(let l of Object.values(e.facilities||{}))l.gridPlaced?(l.row+=s,yn(l)):l.entry&&(l.entry.row+=s)}}function Q(s){if(s){f.x-=s*f.cell,f.cols+=s,e.gridChunkOriginX=(e.gridChunkOriginX||0)-Math.floor(s/10),e.ownedCells=so(e.ownedCells,s),e.blockedCells=so(e.blockedCells,s),e.conveyorTiles=so(e.conveyorTiles,s),e.conveyorMeta=Ii(e.conveyorMeta,s);for(let l of e.eggFarms||[])l.col+=s;for(let l of e.scanners||[])l.col+=s;for(let l of e.maintenanceRooms||[])l.col+=s;for(let l of Object.values(e.facilities||{}))l.gridPlaced?(l.col+=s,yn(l)):l.entry&&(l.entry.col+=s)}}function ke(s){e.ownedCells=e.ownedCells||new Set;for(let l of Pi(s))e.ownedCells.add(I(l.col,l.row))}function pe(s){return e.phase!=="build"||e.cardDraft?.pending||e.cardTargetPick?.pending?null:Ut(e).map(l=>D(l)).find(l=>l&&Ri(l,s))||null}function le(s){let l=dt(s);return{x:l.x,y:l.y,w:l.w,h:l.h}}function ue(s){let l=le(s);return Object.values(e.facilities||{}).some(y=>{let m=y?.baseId||y?.id;return!eo.includes(m)||y.gridPlaced?!1:ie(l,Y(y),0)})}function D(s){if(!s)return null;let l=ue(s);return{...s,disabled:l,disabledReason:l?"Move edge facilities before buying this lot.":""}}function U(s="right"){if(s&&typeof s=="object"&&Number.isFinite(s.chunkX)&&Number.isFinite(s.chunkY))return s;let l=["left","right","up","down"].includes(s)?s:"right";return Ei(e,l)}function ee(s="right"){let l=U(s);if(e.phase!=="build"||e.cardDraft?.pending||e.cardTargetPick?.pending)return{ok:!1,reason:"Cannot expand now."};if(!l)return{ok:!1,reason:"Invalid expansion lot."};if(ue(l)){let qe="Move edge facilities before buying this lot.";return de(qe),{ok:!1,reason:qe}}let y=l.direction||"right",m=K();if(e.cashf.cols&&(f.cols=N.colStart+N.cols),N.rowStart+N.rows>f.rows&&(f.rows=N.rowStart+N.rows),ke(N),$i(e,N.chunkX,N.chunkY),e.gridExpansionPurchases=(e.gridExpansionPurchases||0)+1,e.gridExpansionPurchasesByDirection[y]=(e.gridExpansionPurchasesByDirection[y]||0)+1,j();let _=wi(e.gridExpansionPurchases,{base:V.map.blockedRatio??0,increment:V.map.blockedExpansionIncrement??.0075,max:V.map.blockedRatioMax??.12}),we=ki({seed:`${e.runSeed}:grid-expansion:${N.chunkX},${N.chunkY}:${e.gridExpansionPurchases}`,colStart:N.colStart,colEnd:N.colStart+N.cols,rowStart:N.rowStart,rowEnd:N.rowStart+N.rows,targetRatio:_});for(let qe of we)e.blockedCells.add(qe);Me(e);let Ne=N.cols*N.rows,Ge=dt(N);return H(e,Ge.x+Ge.w/2,Ge.y+Ge.h/2,`${y.toUpperCase()} +${Ne} CELLS -${x(m)}`,b.green),i(),{ok:!0,direction:y,cost:m,addedCells:Ne,blocked:we.size,blockedRatio:_,chunkX:N.chunkX,chunkY:N.chunkY}}function O(s,l){return s>=0&&s=0&&l!l.has(m.id)&&ie(y,Y(m),12))}function me(s,l,y=null){if(!O(s,l))return!1;if(G(s,l))return!0;let m=I(s,l),v=it(e,s,l);if(v&&!(y?.type==="eggFarm"&&y.ref?.id===v.id))return!0;let R=st(e,s,l);if(R&&!(y?.type==="scanner"&&y.ref?.id===R.id))return!0;let N=M(e,s,l);if(N&&!(y?.type==="maintenanceRoom"&&y.ref?.id===N.id))return!0;let _=pt(e,s,l);return!!(_&&!(y?.type==="facility"&&y.ref?.id===_.id)||e.conveyorTiles.has(m)&&!(y?.type==="conveyor"&&y.oldKey===m))}function T(s,l,y=null){return Object.values(e.facilities||{}).find(m=>{if(y?.type==="facility"&&y.ref?.id===m.id)return!1;let v=nt(e,m),R=Xe(e,m);return v&&v.col===s&&v.row===l||R&&R.col===s&&R.row===l})||null}function M(s,l,y){return(s.maintenanceRooms||[]).find(m=>jt(m).some(v=>v.col===l&&v.row===y))||null}function q(s,l,y=null){let m={col:s,row:l};for(let v of jt(m)){if(!O(v.col,v.row)||G(v.col,v.row))return!0;let R=I(v.col,v.row);if(it(e,v.col,v.row)||st(e,v.col,v.row))return!0;let we=M(e,v.col,v.row);if(we&&!(y?.type==="maintenanceRoom"&&y.ref?.id===we.id)||pt(e,v.col,v.row)||T(v.col,v.row,y)||e.conveyorTiles.has(R))return!0}return!1}function ne(s,l,y=null){let m={col:s,row:l};for(let v of ft(m)){if(!O(v.col,v.row)||G(v.col,v.row))return!0;let R=I(v.col,v.row);if(it(e,v.col,v.row))return!0;let _=st(e,v.col,v.row);if(_&&!(y?.type==="scanner"&&y.ref?.id===_.id)||pt(e,v.col,v.row)||T(v.col,v.row,y)||e.conveyorTiles.has(R))return!0}return!1}function oe(s,l,y,m=null){let v={baseId:s,id:s,gridPlaced:!0,col:l,row:y,wCells:s==="sausageMaker"?3:1,hCells:s==="sausageMaker"?3:1};for(let R of Ro(v))if(!O(R.col,R.row)||G(R.col,R.row)||me(R.col,R.row,m)||T(R.col,R.row,m))return!0;if(s==="sausageMaker"){let R={col:l-1,row:y+1},N={col:l+3,row:y+1};if(!O(R.col,R.row)||!O(N.col,N.row)||G(R.col,R.row)||G(N.col,N.row))return!0;for(let _ of[R,N]){let we=pt(e,_.col,_.row);if(we&&we.id!==m?.ref?.id||it(e,_.col,_.row)||st(e,_.col,_.row)||M(e,_.col,_.row)||T(_.col,_.row,m))return!0}}return!1}function se(){return e.selected?e.selected.type==="eggFarm"?e.eggFarms.find(s=>s.id===e.selected.id)||null:e.selected.type==="scanner"?e.scanners.find(s=>s.id===e.selected.id)||null:e.selected.type==="conveyor"?e.conveyorTiles.has(e.selected.id)?{type:"conveyor",id:e.selected.id}:null:e.selected.type==="facility"?e.facilities[e.selected.id]||null:e.selected.type==="maintenanceRoom"&&(e.maintenanceRooms||[]).find(s=>s.id===e.selected.id)||null:null}function Se(s){return s?s.type==="eggFarm"?`${Pt("eggFarm")} #${s.id}`:s.type==="scanner"?`${s.kind==="auto"?Pt("autoScanner"):Pt("manualScanner")} #${s.id}`:s.type==="conveyor"?e.conveyorMeta.get(s.id)?.kind==="boostConveyor"?Pt("boostConveyor"):Pt("conveyor"):s.type==="facility"?s.name:s.type==="maintenanceRoom"?`${Pt("maintenanceRoom")} #${s.id}`:"Equipment":"Equipment"}function be(s){return s?.type==="conveyor"?e.conveyorMeta?.get(s.oldKey||s.ref?.id)?.price||yo(s):yo(s)}function Ce(){return!!e.cardEffects?.usedMachineActive}function B(){return Ce()?{usedMachine:!0,noRefund:!0,durabilityBaseMultiplier:.6}:{usedMachine:!1,noRefund:!1,durabilityBaseMultiplier:1}}function ye(s){let l=B();return Object.assign(s,l),s}function Be(s){let l=s?.ref||s||{};if(s?.type==="eggFarm"||l.type==="eggFarm")return e.eggFarms.length<=1?"Cannot sell the last EGG FARM.":"";if((s?.type==="facility"||l.type==="facility")&&["mixer","trash","truck"].includes(l.baseId||l.id)){let y=l.baseId||l.id;if(Object.values(e.facilities).filter(v=>(v.baseId||v.id)===y).length<=1)return`Cannot sell the last ${l.shortName||l.name||l.id}.`}return""}function de(s){t.buildStatus.textContent=s,H(e,n.width/2-e.view.x,86-e.view.y,s.toUpperCase(),b.danger)}function Oe(s){if(!s)return null;let l=I(s.col,s.row);return e.conveyorMeta.get(l)||null}function ct(s){return s?(Array.isArray(s.outDirs)||(s.outDirs=[]),s.outDirs=[...new Set(s.outDirs.filter(l=>["right","left","down","up"].includes(l)))],C.includes(s.branchMode)||(s.branchMode="random"),s.kind||(s.kind="conveyor"),s.speedMultiplier||(s.speedMultiplier=s.kind==="boostConveyor"?2:1),s.outDirs):[]}function xt(s){return s?fe.filter(l=>e.conveyorTiles.has(I(s.col+l.dc,s.row+l.dr))).map(l=>l.name):[]}function Le(s){return xt(s).length>=3}function qn(s,l){let y=fe.find(_=>_.name===l);if(!s||!y)return!1;let m=I(s.col+y.dc,s.row+y.dr),v=e.conveyorMeta.get(m);if(!v)return!1;let R=y.opposite;return[...new Set([...Array.isArray(v.outDirs)?v.outDirs:[],v.dir].filter(Boolean))].includes(R)}function wn(s,l){let y=xt(s);if(y.length!==1)return null;let m=y[0];return l.includes(m)&&qn(s,m)?m:null}function No(s){if(!e.conveyorTiles.has(s))return[];let l=re(s),y=e.conveyorMeta.get(s),m=ct(y),v=xt(l),R=[...new Set([...m,y?.dir].filter(Boolean))].filter(Ge=>v.includes(Ge)),N=v,_=R.length?R:N,we=wn(l,_),Ne=R.length?R.filter(Ge=>Ge!==we):N.filter(Ge=>Ge!==we);return y&&(y.outDirs=m.filter(Ge=>v.includes(Ge)),y.dir&&!v.includes(y.dir)&&(y.dir=y.outDirs[0]||null),y.branchMode!=="random"&&!v.includes(y.branchMode)&&(y.branchMode="random")),Ne}function Yn(s){if(!e.conveyorTiles.has(s))return[];let l=re(s),y=xt(l);return Le(l)?y:No(s)}function Lt(s){let l=re(s),y=Yn(s);if(y.length<3)return[];let m=e.conveyorMeta.get(s)||{},v=new Set(y.filter(_=>qn(l,_))),R=y.filter(_=>!v.has(_));if(v.size)return R.length>=2?R:[];let N=[...new Set([...Array.isArray(m.outDirs)?m.outDirs:[],m.dir].filter(Boolean))].filter(_=>y.includes(_));return N.length>=2?N:y}function Sn(s,l){let y=ut(s,l);if(!y)return!1;let m=I(s.col,s.row),v=I(l.col,l.row),R=e.conveyorMeta.get(m),N=e.conveyorMeta.get(v);if(!R||!N)return!1;let _=ct(R),we=!_.includes(y)||R.dir!==y||!N.dir;return _.includes(y)||_.push(y),R.dir=y,N.dir||(N.dir=y),we}function Tt(s){if(kn(s).length===0)return Vn(s);let l=_o(s);return l.length<2?Vn(s):(_e(e),zn(l),Me(e),i(),!0)}function Vn(s){if(!s)return!1;let l=I(s.col,s.row),y=e.conveyorMeta.get(l);if(!y||Le(s))return!1;let m=wn(s,fe.map(_=>_.name)),v=fe.map(_=>_.name).filter(_=>_!==m),R=y.dir&&v.includes(y.dir)?y.dir:null,N=v[(Math.max(-1,v.indexOf(R))+1)%v.length];return _e(e),y.dir=N,y.outDirs=[N],y.branchMode="random",Me(e),i(),!0}function kn(s){return s?fe.map(l=>({col:s.col+l.dc,row:s.row+l.dr})).filter(l=>e.conveyorTiles.has(I(l.col,l.row))):[]}function _o(s){if(!s||!e.conveyorTiles.has(I(s.col,s.row))||Le(s))return[];let l=kn(s);if(!l.length||l.length>2)return[];let y=R=>{let N=[],_=new Set([I(s.col,s.row)]),we=s,Ne=R,Ge=0;for(;Ne&&Ge<200;){Ge+=1;let qe=I(Ne.col,Ne.row);if(_.has(qe)||(_.add(qe),N.push(Ne),Le(Ne)))break;let ri=kn(Ne).filter(ii=>!(ii.col===we.col&&ii.row===we.row));if(ri.length!==1)break;we=Ne,Ne=ri[0]}return N},m=l[0]?y(l[0]).reverse():[],v=l[1]?y(l[1]):[];return[...m,s,...v]}function gn(s,l){return!!s&&[...new Set([...Array.isArray(s.outDirs)?s.outDirs:[],s.dir].filter(Boolean))].includes(l)}function zn(s){let l=0,y=0;for(let v=0;vy?[...s].reverse():s;for(let v=0;v=2){let v=m[m.length-2],R=m[m.length-1];if(!Le(R)){let N=e.conveyorMeta.get(I(R.col,R.row)),_=ut(v,R);N&&_&&(N.dir=_,N.outDirs=[_],N.branchMode="random")}}}function Cn(){return V.facilities[e.buildTool==="boostConveyor"?"boostConveyor":"conveyor"]}function Xn(s,l=null){if(!s)return{ok:!1,reason:ce.fail.outOfGrid};let{col:y,row:m}=s,v=I(y,m);if(e.conveyorTiles.has(v))return{ok:!1,exists:!0};if(G(y,m))return{ok:!1,reason:"Cannot build on blocked ground."};if(me(y,m))return{ok:!1,reason:ce.fail.cellOccupied};let R=Cn(),N=R.id||"conveyor",_=He(N,e);if(e.cash<_)return{ok:!1,reason:ce.fail.notEnoughCash};_e(e),Dt(e,_),e.conveyorTiles.add(v);let we=B();return e.conveyorMeta.set(v,{price:_,builtSession:e.buildSession,uses:0,durability:wo(e,"conveyor",we.durabilityBaseMultiplier),maintenanceType:"conveyor",kind:N,speedMultiplier:R.speedMultiplier||1,dir:l||null,outDirs:[],branchMode:"random",...we}),e.selected={type:"conveyor",id:v},H(e,$(y,m).x,$(y,m).y,`-${x(_)}`,b.ink),{ok:!0,cell:s,key:v}}function Mn(s){if(!s)return de(ce.fail.outOfGrid);let l=I(s.col,s.row),y=e.conveyorTiles.has(l),m=a;if(m&&m.col===s.col&&m.row===s.row)return;if(m&&(g=!0),y){m&&Sn(m,s)&&(c||(_e(e),c=!0),Me(e)),e.selected={type:"conveyor",id:l},a={col:s.col,row:s.row};return}let v=m?ut(m,s):null,R=Xn(s,v);if(!R.ok)return de(R.reason||ce.fail.cellOccupied);m&&Sn(m,s),a={col:s.col,row:s.row},Me(e)}function Zt(s,l){if(!s||!l)return l;let y=l.col-s.col,m=l.row-s.row;return y===0&&m===0?s:Math.abs(y)>=Math.abs(m)?{col:s.col+Math.sign(y),row:s.row}:{col:s.col,row:s.row+Math.sign(m)}}function Go(s){c=!1,h=s?{col:s.col,row:s.row}:null,g=!1,a=null,Mn(s)}function Uo(s){if(!s)return;if(!a)return Mn(s);let l=a,y=0;for(;(l.col!==s.col||l.row!==s.row)&&y<64;){let m=Zt(l,s);if(Mn(m),l=a||m,y+=1,!a)break}}function Nt(){!g&&h&&e.conveyorTiles.has(I(h.col,h.row))&&Tt(h),h=null,g=!1,a=null,c=!1}function jn(s){if(!s)return de(ce.fail.outOfGrid);let{col:l,row:y}=s;if(e.buildTool==="conveyor"||e.buildTool==="boostConveyor"){let m=Xn(s,null);if(!m.ok)return de(m.reason||ce.fail.cellOccupied);Me(e);return}if(G(l,y))return de("Cannot build on blocked ground.");if(T(l,y)&&!["conveyor","boostConveyor"].includes(e.buildTool))return de("Machine port must remain open for a conveyor.");if(e.buildTool==="maintenanceRoom")return q(l,y)?de(ce.fail.cellOccupied):p(l,y);if(to.includes(e.buildTool))return oe(e.buildTool,l,y)?de("3x3 machine or its ports do not fit here."):k(l,y,e.buildTool);if(e.buildTool==="manualScanner"||e.buildTool==="autoScanner"?ne(l,y):me(l,y))return de(ce.fail.cellOccupied);e.buildTool==="eggFarm"?Wo(l,y):e.buildTool==="manualScanner"?d(l,y,"manual"):e.buildTool==="autoScanner"&&d(l,y,"auto")}function xn(s){let l=tt(s.x,s.y);if(["conveyor","boostConveyor","eggFarm","manualScanner","autoScanner","maintenanceRoom",...to].includes(e.buildTool))return jn(l);if(eo.includes(e.buildTool))return S(s,e.buildTool)}function Wo(s,l){let y=He("eggFarm",e);if(e.cashR.kind==="manual").length>=8)return de("Manual Scanner limit reached (8 max).");let m=y==="auto"?He("autoScanner",e):He("manualScanner",e);if(e.cashv.id!==l.ref.id),vt(e,m.x,m.y),Me(e)}if(l.type==="scanner"){let m=ge(l.ref);A(l,m.x,m.y),e.scanners=e.scanners.filter(v=>v.id!==l.ref.id),vt(e,m.x,m.y),Me(e)}if(l.type==="facility"){let m=l.ref;A(l,m.x+m.w/2,m.y+m.h/2),delete e.facilities[m.id],vt(e,m.x+m.w/2,m.y+m.h/2),Me(e)}if(l.type==="maintenanceRoom"){let m=l.ref,v=w(m);A(l,v.x,v.y),e.maintenanceRooms=(e.maintenanceRooms||[]).filter(R=>R.id!==m.id),vt(e,v.x,v.y)}e.selected=null,e.multiSelected=[],i()}function W(s){let l=Object.values(e.facilities).find(_=>{if(s.x>=_.x&&s.x<=_.x+_.w&&s.y>=_.y&&s.y<=_.y+_.h)return!0;if(!_.entry)return!1;let Ne=$(_.entry.col,_.entry.row);return Math.abs(s.x-Ne.x)<=f.cell*.46&&Math.abs(s.y-Ne.y)<=f.cell*.46});if(l)return{type:"facility",ref:l};let y=tt(s.x,s.y);if(!y)return null;let m=it(e,y.col,y.row);if(m)return{type:"eggFarm",ref:m};let v=st(e,y.col,y.row);if(v)return{type:"scanner",ref:v};let R=M(e,y.col,y.row);if(R)return{type:"maintenanceRoom",ref:R};let N=I(y.col,y.row);return e.conveyorTiles.has(N)?{type:"conveyor",oldKey:N,ref:{type:"conveyor",id:N}}:null}let ae=fa({game:e,canvasPoint:o,updatePanels:i,equipmentAtPoint:W,fail:de,pointInGrid:O,rectOfFacility:Y,rectsOverlap:ie});function ve(s){return null}function en(){let s=se();s&&(s.type==="conveyor"?E($(...Object.values(re(s.id)))):s.type==="eggFarm"?E($(s.col,s.row)):s.type==="scanner"?E(ge(s)):s.type==="facility"?E({x:s.x+s.w/2,y:s.y+s.h/2}):s.type==="maintenanceRoom"&&E(w(s)))}function ze(s,l,y="primary-button"){let m=document.createElement("button");return m.type="button",m.className=y,m.textContent=s,m.addEventListener("click",l),m}function _t(){e.cardTargetPick?.mode==="autoScannerMenu"&&(e.cardTargetPick=null),t.modal.classList.remove("visible","equipment-popover"),t.modal.style.removeProperty("--popover-x"),t.modal.style.removeProperty("--popover-y")}function Tn(s){t.modalTitle.textContent="Auto Scanner",e.cardTargetPick={pending:!0,mode:"autoScannerMenu",cardId:null,targetKeys:[`scanner:${s.id}`],remaining:1},t.modalBody.innerHTML=` +
+

Left / right output routing for this AUTO scanner.

+

Current: ${s.role===0?"Male left / others right":"Poop left / others right"}

+

Cooldown: ${St(s).toFixed(1)}s.

+
`,t.modalActions.innerHTML="";let l=ze("Male Left / Others Right",()=>{_e(e),s.role=0,_t(),i()},s.role===0?"facility-action warn":"facility-action"),y=ze("Poop Left / Others Right",()=>{_e(e),s.role=1,_t(),i()},s.role===1?"facility-action warn":"facility-action");t.modalActions.append(l,y,ze("Close",_t,"facility-action")),Ho(s),t.modal.classList.add("visible","equipment-popover"),i()}function Jr(s){return s?.label||s?.key||"?"}function $a(s){let l=s.key===" "?"Space":s.key.length===1?s.key.toUpperCase():s.key.replace(/^Arrow/,"");return{key:s.key.toLowerCase(),code:s.code,label:l}}function Fa(s,l,y){for(let m of e.scanners.filter(v=>v.kind==="manual"))for(let v of["left","right"]){if(m.id===s.id&&v===l)continue;let R=m.keys?.[v];if(R&&(R.code===y.code||R.key===y.key))return!0}return!1}function En(s,l=""){if(!s||s.kind!=="manual")return;t.modalTitle.textContent=`Manual Scanner #${s.id}`;let y=Jr(s.keys?.left),m=Jr(s.keys?.right),v=Da(s);t.modalBody.innerHTML=` +
+

Left route key: ${y}

+

Right route key: ${m}

+ ${l?`

${l}

`:""} + ${v?`

${v}

`:""} +
`,t.modalActions.innerHTML="",t.modalActions.appendChild(ze("Set Left Key",()=>Zr(s,"left"),"facility-action warn")),t.modalActions.appendChild(ze("Set Right Key",()=>Zr(s,"right"),"facility-action warn")),t.modalActions.appendChild(ze("Close",_t,"facility-action")),Ho(s),t.modal.classList.add("visible","equipment-popover")}function Da(s){let l=[{side:"left",binding:s.keys?.left},{side:"right",binding:s.keys?.right}];if(l[0].binding&&l[1].binding&&(l[0].binding.code===l[1].binding.code||l[0].binding.key===l[1].binding.key))return"Left and right keys conflict.";for(let y of l)if(y.binding)for(let m of e.scanners.filter(v=>v.kind==="manual"&&v.id!==s.id)){if(m.keys?.left&&(m.keys.left.code===y.binding.code||m.keys.left.key===y.binding.key))return`Key conflicts with Manual Scanner #${m.id} left.`;if(m.keys?.right&&(m.keys.right.code===y.binding.code||m.keys.right.key===y.binding.key))return`Key conflicts with Manual Scanner #${m.id} right.`}return""}function Zr(s,l){t.modalBody.innerHTML=`

Press a key for ${l.toUpperCase()} route.

Esc cancels.

`,t.modalActions.innerHTML="";let y=m=>{if(m.preventDefault(),m.stopPropagation(),window.removeEventListener("keydown",y,!0),m.key==="Escape")return En(s);let v=$a(m),R=Fa(s,l,v);_e(e),s.keys=s.keys||{left:null,right:null},s.keys[l]=v,i(),En(s,R?`${v.label} assigned, but another scanner already uses it.`:`${l.toUpperCase()} set to ${v.label}.`)};window.addEventListener("keydown",y,!0)}function Ba(){let s=se();if(!(!s||s.type!=="scanner"))return s.kind==="auto"?Tn(s):En(s)}function Oa(s){if(!s)return{x:80,y:80};if(s.type==="conveyor"){let l=re(s.id);return $(l.col,l.row)}return s.type==="eggFarm"?$(s.col,s.row):s.type==="scanner"?ge(s):s.type==="facility"?{x:s.x+s.w/2,y:s.y+10}:{x:80,y:80}}function Ho(s){let l=n.getBoundingClientRect(),y=Oa(s),m=e.view?.scale||1,v=l.left+(y.x*m+(e.view?.x||0))/n.width*l.width+18,R=l.top+(y.y*m+(e.view?.y||0))/n.height*l.height-12,N=Math.max(12,window.innerWidth-274),_=Math.max(12,window.innerHeight-220);t.modal.style.setProperty("--popover-x",`${Math.max(12,Math.min(N,v))}px`),t.modal.style.setProperty("--popover-y",`${Math.max(12,Math.min(_,R))}px`)}function ei(){let s=se();if(!s)return;let l=oi(s);if(t.modalTitle.textContent=Se(s),t.modalBody.innerHTML=` +
${l.map(v=>`

${v}

`).join("")}
+ ${["mixer","truck"].includes(s.baseId||s.id)?'

Income/Fine = ceil(base x 1.10^upgrades)

':""}`,t.modalActions.innerHTML="",s.type==="scanner"&&s.kind==="auto"&&t.modalActions.appendChild(ze("Auto Menu",()=>Tn(s),"facility-action warn")),s.type==="scanner"&&s.kind==="manual"&&t.modalActions.appendChild(ze("Set Keys",()=>En(s),"facility-action warn")),s.type==="conveyor"&&Ko(s.id)&&Lt(s.id).length>=2){let v=e.conveyorMeta.get(s.id)?.branchMode||"random";t.modalActions.appendChild(ze(`Branch: ${ti(v)}`,()=>{qo(s.id),e.selected={type:"conveyor",id:s.id},ei()},"facility-action warn branch-action"))}let y=s.type==="conveyor"?{type:"conveyor",oldKey:s.id,ref:s}:{type:s.type,ref:s},m=Be(y);if(m){let v=ze(m,()=>{},"facility-action danger disabled");v.disabled=!0,t.modalActions.appendChild(v)}else{let v=$t(y,e),R=v.sameBuild?`Remove / Refund ${x(v.amount)}`:`Sell ${x(v.amount)}`;t.modalActions.appendChild(ze(R,()=>{en(),_t()},"facility-action danger"))}Ho(s),t.modal.classList.add("visible","equipment-popover")}function La(s){e.phase==="build"&&(Nt(),e.buildTool=e.buildTool===s?null:s,e.selected=null,e.multiSelected=[],e.selectionBox=null,i())}function Na(){let s=[];for(let[l,y]of Object.entries(e.facilities))if(s.push({id:`facility:${l}`,price:be({type:"facility",ref:y}),x:y.x,y:y.y,w:y.w,h:y.h}),y.entry){let m=$(y.entry.col,y.entry.row);s.push({id:`facilityPort:${l}`,price:be({type:"facility",ref:y}),x:m.x-18,y:m.y-18,w:36,h:36})}for(let l of e.maintenanceRooms||[]){let y=w(l);s.push({id:`maintenanceRoom:${l.id}`,price:be({type:"maintenanceRoom",ref:l}),x:y.x-f.cell,y:y.y-f.cell,w:f.cell*2,h:f.cell*2})}for(let l of e.eggFarms){let y=$(l.col,l.row);s.push({id:`eggFarm:${l.id}`,price:be({type:"eggFarm",ref:l}),x:y.x-24,y:y.y-24,w:48,h:48})}for(let l of e.scanners){let y=ge(l);s.push({id:`scanner:${l.id}`,price:be({type:"scanner",ref:l}),x:y.x-50,y:y.y-73,w:100,h:146})}for(let l of e.conveyorTiles){let y=re(l),m=$(y.col,y.row),v=e.conveyorMeta.get(l)||{};s.push({id:`conveyor:${l}`,price:v.price||He(v.kind==="boostConveyor"?"boostConveyor":"conveyor",e),x:m.x-22,y:m.y-22,w:44,h:44})}return s}function ti(s){return P[s]||P.random}function Vu(s){return Yn(s)}function Ko(s){return e.conveyorTiles.has(s)?Le(re(s)):!1}function ni(s){if(e.phase!=="build"||e.cardDraft?.pending||e.cardTargetPick?.pending)return null;for(let l of e.conveyorTiles){if(!Ko(l)||Lt(l).length<2)continue;let y=re(l),m=$(y.col,y.row),v=m.x-L.w/2,R=m.y+L.y-L.h/2;if(s.x>=v&&s.x<=v+L.w&&s.y>=R&&s.y<=R+L.h)return l}return null}function _a(s){let l=ni(s);return l?qo(l):!1}function qo(s){let l=e.conveyorMeta.get(s);if(!l)return!1;let y=Lt(s);if(y.length<2)return!1;_e(e);let m=C.includes(l.branchMode)?l.branchMode:"random",v=["random",...fe.map(R=>R.name).filter(R=>y.includes(R))];return l.branchMode=v[(Math.max(0,v.indexOf(m))+1)%v.length],Me(e),e.selected=null,e.multiSelected=[],i(),!0}function oi(s){Re(e);let l=[],y=s?.type==="facility"?s.baseId||s.id:"";if(s.type==="eggFarm"){let[m,v]=$n(s);l.push(`Price: ${x(be(s))}`);{let R=Be({type:s.type,ref:s});l.push(R?`Sale: blocked (${R})`:`Sale value: ${x($t({type:s.type,ref:s},e).amount)}`)}l.push(`Level: ${s.level}/4`),l.push(`Random interval: ${m.toFixed(2)}-${v.toFixed(2)}s`),l.push(`Durability: ${Nn(s)}% | Spawn delay x${go(s).toFixed(2)}`)}else if(s.type==="scanner")l.push(`Price: ${x(be(s))}`),l.push(`Sale value: ${x($t({type:s.type,ref:s},e).amount)}`),l.push(`Type: ${s.kind.toUpperCase()} / Standard`),s.kind==="auto"&&l.push(`Role: ${s.role===0?"Male left / Others right":"Poop left / Others right"}`),l.push(`Queue: ${s.queue.length}`),s.kind==="auto"&&l.push(`Cooldown: ${s.cooldown.toFixed(1)}s / ${St(s,e).toFixed(1)}s`),s.kind==="auto"&&l.push(`Durability: ${Nn(s)}% | Delay x${ko(s).toFixed(2)}`),s.kind==="manual"&&l.push("Durability: none | Always normal");else if(s.type==="conveyor"){let m=e.conveyorMeta.get(s.id),v=e.componentLookup?.get(s.id),R=v?e.congestion.get(v):null;l.push(`Price: ${x(be({type:"conveyor",oldKey:s.id,ref:s}))}`),l.push(`Cell: ${s.id}`),l.push(`Type: ${m?.kind==="boostConveyor"?"BOOST BELT":"Normal Belt"} | Speed x${Number(m?.speedMultiplier||1).toFixed(1)}`),l.push(`Congestion: ${R?Math.floor(R.ratio*100):0}%`),l.push(`Durability: ${Nn({meta:m})}% | Speed x${So({meta:m}).toFixed(2)}`),Ko(s.id)?Lt(s.id).length>=2?(l.push(`Branch mode: ${ti(m?.branchMode||"random")}`),l.push("Click the Branch button or the on-belt triangle to cycle RANDOM / fixed exit.")):l.push("Branch mode: unavailable \u2014 fewer than two outgoing conveyors."):l.push("Branch mode: needs 3+ conveyor connections."),l.push(`Sale value: ${x($t({type:"conveyor",oldKey:s.id,ref:s},e).amount)}`)}else if(s.type==="maintenanceRoom")l.push(`Price: ${x(be({type:"maintenanceRoom",ref:s}))}`),l.push(`Sale value: ${x($t({type:"maintenanceRoom",ref:s},e).amount)}`),l.push("Size: 2\xD72 grid cells"),l.push("Dispatch: 1 repairman during each production day");else if(s.type==="facility"){l.push(`Price: ${x(be(s))}`);{let m=Be({type:s.type,ref:s});l.push(m?`Sale: blocked (${m})`:`Sale value: ${x($t({type:s.type,ref:s},e).amount)}`)}if(l.push(["mixer","truck","trash","sausageMaker"].includes(y)?`Level: ${s.level} / no cap`:`Level: ${s.level}`),y==="truck"&&l.push("Durability: none | Always normal"),["mixer","trash","sausageMaker"].includes(y)&&l.push(`Durability: ${Nn(s)}% | Extra delay ${Fr(e,s).toFixed(2)}s`),y==="mixer"){l.push(`Output: 1 meat per non-poop chick | meat buffer ${s.meatBuffer||0}`),l.push(`Meat quality: x${yt(e,"mixer").toFixed(3)} to sausage value`);let m=Xe(e,s);l.push(`OUT: ${m?`${m.col},${m.row}`:"none"} | conveyor ${It(e,s)?"connected":"missing"}`),l.push(`Fallback sale: ${x(xe.income.mixer)} when OUT is missing or blocked`),l.push(`Poop fine: -${x(Dn(e))}`)}if(y==="truck"&&(l.push(`Income: ${x(Ve(e))} per target cargo | upgrade x${yt(e,"truck").toFixed(3)}`),l.push(`Poop shipment fine: -${x(Bn(e))}`)),y==="trash"){let m=mt(e),v=ht(m),R=qt(m);l.push(`Bonus cards: ${m}/${cn()}`),l.push(`Expected poop bonus: ${x(v)} | hit chance ${Math.round(R*100)}%`),l.push("Winning payouts roll 1x / 2x / 4x / 10x, with jackpot odds rising per card."),l.push("With enough upgrades, poop can out-earn male/female routes.")}if(y==="sausageMaker"){let m=nt(e,s),v=Xe(e,s);l.push("Size: 3\xD73 grid cells | IN left / OUT right"),l.push(`Recipe: 2 meat -> 1 sausage | meat buffer ${s.meatBuffer||0} | sausage buffer ${s.sausageBuffer||0}`),l.push(`Sausage sale value: ${x(Ze(e))}`),l.push(`IN: ${m?`${m.col},${m.row}`:"none"} | OUT: ${v?`${v.col},${v.row}`:"none"} | conveyor ${It(e,s)?"connected":"missing"}`)}s.entry&&l.push(`Receiver: edge cell ${s.entry.col},${s.entry.row} (${s.side})`)}return l}function Ga(s){if(!s||e.phase!=="build")return"";let l=an(e).find(y=>y.type===s.type&&y.id===s.id);return l?l.message||"Not connected to conveyor route":""}function Ua(s){return s?s.type==="conveyor"&&e.conveyorMeta.get(s.id)?.kind==="boostConveyor"?"Moves chicks twice as fast. Wears down like a normal belt.":s.type==="conveyor"?"A stubborn belt tile. It only cares about the next cell.":s.type==="eggFarm"?"A tiny gatehouse producing questionable eggs on schedule.":s.type==="scanner"&&s.kind==="auto"?"A compact 2\xD72 scanner sized for roughly 2.1 level-1 farms.":s.type==="scanner"?"A manual checkpoint. The operator is the algorithm.":s.type==="facility"&&(s.baseId||s.id)==="mixer"?"Chicks become meat when OUT is clear; a blocked OUT falls back to direct sale.":s.type==="facility"&&(s.baseId||s.id)==="trash"?"Poop goes in. Sometimes coins come out, for reasons best left unaudited.":s.type==="facility"&&(s.baseId||s.id)==="truck"?"The shipping endpoint. Correct cargo and sausages pay; wrong cargo complains.":s.type==="facility"&&(s.baseId||s.id)==="sausageMaker"?"A 3\xD73 in-grid machine with one meat inlet on the left and one sausage outlet on the right.":s.type==="maintenanceRoom"?"A cramped 2\xD72 storage and break room with lockers, spare parts, a bench and one dispatched repairman.":"Factory equipment.":""}return{buildAtCell:jn,buildAtPoint:xn,eraseAtPoint:E,equipmentAtPoint:W,startSelectionBox:ae.startSelectionBox,updateSelectionBox:ae.updateSelectionBox,finishSelectionBox:ae.finishSelectionBox,startGroupDrag:ae.startGroupDrag,updateGroupDrag:ae.updateGroupDrag,finishGroupDrag:ae.finishGroupDrag,selectedObject:se,selectedTitle:Se,equipmentPrice:be,selectedUpgradeCost:ve,selectedInfoLines:oi,flavorText:Ua,disconnectedWarningFor:Ga,gridExpansionCost:K,buyGridExpansion:ee,expansionOfferAtPoint:pe,beginConveyorDrag:Go,continueConveyorDrag:Uo,endConveyorDrag:Nt,cycleSingleConveyorDirection:Tt,branchSwitcherAtPoint:ni,cycleBranchModeAtPoint:_a,cycleBranchModeForKey:qo,branchSelectableExitDirsForKey:Lt,removeSelected:en,switchScannerRole:Ba,showManualScannerMenu:En,showAutoScannerMenu:Tn,showSelectedMenu:ei,setBuildTool:La,fail:de,isEquipmentCell:me,equipmentHitBoxes:Na,routeFromFarmToScanner:Kt}}function ya({game:e,ui:t,build:n,startGame:o,beginCardDraft:r,activeQueuedChick:i}){function a(O){return String(O).replace(/[&<>"']/g,G=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[G])}function c(){return Math.round(pn(e))}function h(){if(e.phase!=="build"||e.cardDraft?.pending||e.cardTargetPick?.pending)return!1;let O=!1;for(let G of Qo)if(O=!0,e.cash>=He(G,e))return!1;return O}function g(){C(),P(),K(),L()}function C(){for(let[G,Y]of Object.entries(t.buttons))Y?.classList?.contains("tool-button")&&Y.classList.toggle("active",e.buildTool===G);for(let G of Qo){let Y=t.buttons[G];if(!Y)continue;let ie=He(G,e),De=Y.querySelector("span");De&&(De.textContent=G==="conveyor"||G==="boostConveyor"?`${x(ie)} / tile`:x(ie));let me=G==="manualScanner"&&e.scanners.filter(M=>M.kind==="manual").length>=8,T=e.cashKt(e,Y)?.scannerId).length,G=fo(e);t.turnSummary.innerHTML=[`Seed: ${a(e.runSeed||"none")} | Grid box: ${e.gridRows||10}x${e.gridCols||20} | Owned: ${e.gridOwnedCells||e.ownedCells?.size||(e.gridRows||10)*(e.gridCols||20)} cells | Blocked: ${e.blockedCells?.size||0} cells`,`Penalties: wrong shipment -${x(kr(e))} | Mixer poop -${x(Dn(e))} | Shipment poop -${x(Bn(e))}`,`Maintenance: worst ${Br(e).worst.percent}% dirty (${Br(e).worst.label}) | Rooms: ${(e.maintenanceRooms||[]).length} | Repairmen: ${(e.repairmen||[]).length}`,`Card draft: ${e.cardDraft?.pending?Math.max(1,e.cardDraft.picksRemaining||1)+" pick(s) left":"ready after each day"} | Farms with scanner route: ${O}/${e.eggFarms.length}`,e.cardEffects?.usedMachineActive?'Procurement: USED MACHINE MODE / no refunds / 60% durability':"",G.length?`Blocked: ${G[0]}`:`${ce.status.allPortsConnected}`].filter(Boolean).join("
")}function j(){C(),t.shell.classList.toggle("phase-running",e.phase==="running"),t.shell.classList.toggle("phase-build",e.phase==="build"),t.shell.classList.toggle("phase-gameover",e.phase==="gameover"),t.shell.classList.toggle("phase-title",e.phase==="title"),t.shell.classList.toggle("phase-loanDecision",e.phase==="loanDecision"),t.money.textContent=x(e.cash),t.money.classList.toggle("cash-negative",e.cash<0),t.money.classList.toggle("cash-positive",e.cash>Rn),t.turn.textContent=e.turn,t.timeLeft.textContent=e.phase==="running"&&e.timeLeft<=0&&e.shutdownTimeLeft>0?`+${e.shutdownTimeLeft.toFixed(1)}s`:`${Math.max(0,e.timeLeft).toFixed(1)}s`,t.timeLeft.closest?.(".hud-card")?.classList.toggle("time-critical",e.phase==="running"&&(e.timeLeft<=10||e.timeLeft<=0&&e.shutdownTimeLeft>0)),t.phase.textContent=Q(),t.comboCount&&(t.comboCount.textContent=e.manualCombo?.count||0),Z(),t.turnProfit.textContent=x(e.stats.profit),t.turnProfit.classList.toggle("cash-negative",e.stats.profit<0),t.turnProfit.classList.toggle("cash-positive",e.stats.profit>0),t.mixerCount.textContent=e.stats.mixerCount,t.truckFemaleCount.textContent=e.stats.truckFemale,t.truckMaleCount.textContent=e.stats.truckMale,t.poopCount.textContent=e.stats.poopSpawned,t.buttons.s1Left.disabled=e.phase!=="running"||!i(0),t.buttons.s1Right.disabled=e.phase!=="running"||!i(0),t.buttons.s2Left.disabled=e.phase!=="running"||!i(1),t.buttons.s2Right.disabled=e.phase!=="running"||!i(1);let O=e.scanners.find(me=>me.kind==="manual"&&me.slot===0),G=e.scanners.find(me=>me.kind==="manual"&&me.slot===1);O&&(t.buttons.s1Left.textContent=`S1 ${O.keys?.left?.label||"A"} -> Mixer`,t.buttons.s1Right.textContent=`S1 ${O.keys?.right?.label||"D"} -> S2`),G&&(t.buttons.s2Left.textContent=`S2 ${G.keys?.left?.label||"Left"} -> Waste`,t.buttons.s2Right.textContent=`S2 ${G.keys?.right?.label||"Right"} -> Truck`);let Y=Math.max(0,Math.floor(e.lastResult?.profit??e.stats?.profit??0)),ie=Cr(e,Math.max(1,Number(e.turn)||1),Y).amount,De=e.phase==="build"&&ie>0?`NEXT DAYFAIRIES -${x(ie)}`:`${ce.actions.nextDay}`;t.buttons.nextTurn.dataset.renderedHtml!==De&&(t.buttons.nextTurn.innerHTML=De,t.buttons.nextTurn.dataset.renderedHtml=De),t.buttons.nextTurn.disabled=e.phase!=="build"||!!e.cardDraft?.pending||!!e.cardTargetPick?.pending}function Z(){if(!t.eventBrief||!t.speedBrief)return;t.speedBrief.textContent=`BELT SPEED: ${c()} px/s`,t.speedBrief.title=`Conveyor speed does not increase by day. Degraded belts reduce speed. Cap: ${tn}px/s.`;let O=e.eventActive,G=e.eventOffer;O?(t.eventBrief.textContent=`ACTIVE EVENT: ${O.title} | ${O.route}`,t.eventBrief.className="event-brief active"):e.phase==="build"&&G?(t.eventBrief.textContent=`NEXT DAY EVENT: ${G.title} | ${G.route}${G.targetAmount?` | Target ${G.targetAmount}`:""}${G.reward?` | Reward ${x(G.reward)}`:""}`,t.eventBrief.className="event-brief next"):e.phase==="build"&&e.turn+1
\u2642Male->Mixer -> MeatS1: A
\u2640Female->TruckS1: D, then S2: right
\u{1F4A9}Poop->ShredderS1: D, then S2: left

Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.

',t.modalActions.innerHTML="",t.modalActions.appendChild(D(ce.actions.startGame,o,"primary-button")),t.modal.classList.remove("equipment-popover","gameover-modal"),t.modal.classList.add("visible")}function pe(O){let G=me=>`${me>0?"+":""}${x(me)}`,Y=me=>`${me>0?"-":""}${x(me)}`,ie=O.profit>=0?"cash-positive":"cash-negative",De=(O.mixerPoopFine||0)+(O.truckPoopFine||0)+(O.maleTruckFine||0)+(O.explosionDamage||0)+(O.cardRerollCost||0);t.modalTitle.textContent=`Day ${O.turn} Settlement`,t.modalBody.innerHTML=` +
Net result${x(O.profit)}
+
+
Chick shipment income${G(O.chickShipmentIncome||0)}
+
Poop shipment income${G(O.poopShipmentIncome||0)}
+
Sausage shipment income${G(O.sausageRevenue||0)}
+
Meat / sausages${O.meatProduced||0} meat -> ${O.sausageProduced||0} made / ${O.sausageShipped||0} shipped
+
Composter income${G(O.composterIncome||0)}
+
DUD refund income${G(O.dudRefundIncome||0)}
+
Manual combo bonus${G(O.manualComboBonus||0)}
+
Chemical subsidy${G(O.chemicalWeaponSubsidy||0)}
+
Event bonus${G(O.eventBonus||0)}
+
Penalties${Y(De)}
+
Fairies fee${Y(O.fairiesTribute?.amount||O.fairiesTribute||0)}
+
+

Income ${x(O.revenue)} - losses ${x(O.penalty)} = ${x(O.profit)}

`,t.modalActions.innerHTML="",t.modalActions.appendChild(D("Choose Upgrade Card",r||U,"primary-button")),t.modal.classList.remove("equipment-popover","gameover-modal"),t.modal.classList.add("visible")}function le(O,G){let Y=Math.max(0,e.cardEffects?.rescueLoanCharges||0);t.modalTitle.textContent="Emergency Loan Available",t.modalBody.innerHTML=` +

End-of-day cash is below ${x(0)}.

+
+
Current money${x(e.cash)}
+
Emergency Loan+${x(500)}
+
Cards remaining${Y}
+
+

Use one Emergency Loan to gain ${x(500)} immediately. If cash is still negative, another decision will be shown if another card remains.

`,t.modalActions.innerHTML="",t.modalActions.appendChild(D("Accept Game Over",G,"facility-action danger")),t.modalActions.appendChild(D("Use Emergency Loan",O,"primary-button")),t.modal.classList.remove("equipment-popover","gameover-modal"),t.modal.classList.add("visible")}function ue(){let O=e.totals.processed,G=O?Math.round(e.totals.correct/O*100):0,Y=ms(e);t.modalTitle.textContent=ce.phases.gameover,t.modalBody.innerHTML=`

Your cash went negative.

+
Final Score${Y.score}
Survival${e.totals.turnsCompleted} days
Final Cash${x(e.cash)}
Accuracy${G}%
+
Score breakdown +
Formula
Base = Cash + Total revenue + Factory value \xD70.5 + Event bonus \xD70.5 = ${x(Y.base)}
Daily earned = ceil(Base \xF7 Days) = ${x(Y.dailyEarned)}
Score = max(0, floor(Base + Daily earned + Correct bonus - Total outflow - Day penalty)) = ${Y.score}
+
Factory Value${x(Y.factoryValue)}
Total Revenue${x(e.totals.revenue)}
Daily Earned${x(Y.dailyEarned)} / day
Total Outflow${x(e.totals.penalty)}
Fairies Tribute${x(e.totals.fairiesTribute||0)}
Correct Sorts${e.totals.correct}
Auto Sorted${e.totals.autoSorted}
Poop Control${e.totals.poopTrash} trashed / ${e.totals.poopTruck} shipped / ${e.totals.poopMixer} mixer
Explosion Penalty${x(e.totals.explosionDamage)}
Event Bonus${x(e.totals.eventBonus)}
Day Penalty-${Y.dayPenalty}
Events${e.totals.eventSuccess} success / ${e.totals.eventFailed} failed
+
`,t.modalActions.innerHTML="",t.modalActions.appendChild(D(ce.actions.restart||"Restart",o,"primary-button restart-button")),t.modal.classList.remove("equipment-popover"),t.modal.classList.add("visible","gameover-modal")}function D(O,G,Y="primary-button"){let ie=document.createElement("button");return ie.type="button",ie.className=Y,ie.textContent=O,ie.addEventListener("click",G),ie}function U(){t.modal.classList.remove("visible","equipment-popover","gameover-modal"),t.modal.style.removeProperty("--popover-x"),t.modal.style.removeProperty("--popover-y")}function ee(){}return{updatePanels:g,updateUI:j,showTitle:ke,showTurnResult:pe,showEmergencyLoanDecision:le,showGameOver:ue,hideModal:U,checkGameOver:ee}}function Qr(e){e.totals.processed+=1}function hn(e){e.stats.correct+=1,e.totals.correct+=1}function rt(e){e.stats.mistakes+=1,e.totals.mistakes+=1}function ha(e){e.stats.autoSorted+=1,e.totals.autoSorted+=1}function ma(e){e.stats.poopSpawned+=1}function ba(e){e.stats.mixerCount+=1,Qr(e)}function va(e){e.stats.trashCount+=1,Qr(e)}function wa(e,t){t==="female"?e.stats.truckFemale+=1:t==="male"&&(e.stats.truckMale+=1),Qr(e)}function Bo(e,t){let n=t==="truck"?"poopTruck":t==="trash"?"poopTrash":t==="mixer"?"poopMixer":null;n&&(e.stats[n]+=1,e.totals[n]+=1)}function Sa({game:e,currentConveyorSpeed:t,onGameOverCheck:n}){function o(){return e.frameCache||(e.frameCache={frameId:-1,routingVersion:-1,chickRevision:0,spatialRevision:-1,spatial:new Map,congestionFrame:-1,componentChicks:new Map,manualMonitorSignature:"",draw:{}}),e.frameCache}function r(){let d=o();d.chickRevision=(d.chickRevision||0)+1,d.congestionFrame=-1}function i(){o().congestionFrame=-1}function a(d){let p=o();p.frameId!==d&&(p.frameId=d,p.spatialRevision=-1,p.congestionFrame=-1)}function c(d,p){return`${Math.floor(d/f.cell)},${Math.floor(p/f.cell)}`}function h(){let d=o();if(d.spatialRevision===d.chickRevision)return d.spatial;let p=new Map;for(let w of e.chicks){if(w.stage==="flying")continue;let k=c(w.x,w.y);p.has(k)||p.set(k,[]),p.get(k).push(w)}return d.spatial=p,d.spatialRevision=d.chickRevision,p}function g(d){let p=h(),w=Math.floor(d.x/f.cell),k=Math.floor(d.y/f.cell),S=[];for(let A=k-1;A<=k+1;A+=1)for(let E=w-1;E<=w+1;E+=1){let W=p.get(`${E},${A}`);W&&S.push(...W)}return S}function C(d){return Wt(d)*go(d)*Hs(e)/Qi(e)}function P(d){let p=$(d.col,d.row),w=1+Ks(e,d),k=K(d,w);if(!k.length)return L(d,p),!0;let S=0,A=0;for(let E of k){let W=$(E.col,E.row),ae=j(W);if(ae){ae.stoppedTimer=.85,A+=1;continue}let ve=na(e,{stage:"belt",scannerId:null,route:[W]});ve.sex==="poop"&&ma(e),e.chicks.push(ve),r(),Rs(e,d),S+=1}return S>0?(vs(e,p.x,p.y),S>1&&H(e,p.x,p.y-28,`x${S}`,b.green),!0):A===0}function L(d,p){(d.noRouteNoticeCooldown||0)<=0&&(H(e,p.x,p.y-24,"NO BELT: PAUSED",b.warn),d.noRouteNoticeCooldown=2.5)}function K(d,p){let k=fe.map(S=>({col:d.col+S.dc,row:d.row+S.dr})).filter(S=>$e(S.col,S.row)&&e.conveyorTiles.has(I(S.col,S.row))).map(S=>({p:S,blocked:le(null,$(S.col,S.row))?1:0,roll:Math.random()}));return k.sort((S,A)=>S.blocked-A.blocked||S.roll-A.roll),k.slice(0,Math.max(1,p||1)).map(S=>S.p)}function j(d){return g(d).find(p=>Math.hypot(p.x-d.x,p.y-d.y)0;if(e.timeLeft=Math.max(0,e.timeLeft-d),k&&e.timeLeft<=0&&p(),e.timeLeft<=0){e.shutdownTimeLeft>0&&(e.shutdownTimeLeft=Math.max(0,e.shutdownTimeLeft-d));for(let S of e.eggFarms)S.shutterProgress=Math.min(1,(S.shutterProgress||0)+d*2.8)}if(k&&e.timeLeft>0){for(let S of e.eggFarms)if(S.noRouteNoticeCooldown>0&&(S.noRouteNoticeCooldown=Math.max(0,S.noRouteNoticeCooldown-d)),S.nextSpawn-=d,S.nextSpawn<=0){let A=P(S);S.nextSpawn=A?C(S):.15,S.lastInterval=S.nextSpawn}}for(let S=e.chicks.length-1;S>=0;S-=1){let A=e.chicks[S];A.bob+=d*7,A.stoppedTimer>0&&(A.stoppedTimer=Math.max(0,A.stoppedTimer-d)),A.stage!=="queued"&&ke(A,S,d)}Oe(d),Nt(e.frameId),jn(),e.timeLeft<=0&&(e.chicks.length===0?w():e.shutdownTimeLeft<=0&&Q())}function Q(){if(e.cleanupBlown)return;e.cleanupBlown=!0,e.cleanupTimer=1;let d=[...e.chicks];for(let p of d)Tr(e,p.sex,p.x,p.y,-Math.PI/2,!0);e.chicks=[];for(let p of e.scanners)p.queue=[];if(r(),Nt(e.frameId),d.length){let p=d.reduce((k,S)=>k+S.x,0)/d.length,w=d.reduce((k,S)=>k+S.y,0)/d.length;at(e,p,w,b.warn,58),H(e,p,w-24,"CLEANUP",b.warn)}}function ke(d,p,w){if(pe(d)){d.stoppedTimer=.35;return}if(B(d,w),!(d.route&&d.targetIndex=d.route.length)return!1;let p=d.route[d.targetIndex];return le(d,p)}function le(d,p){if(!p)return!1;for(let w of g(p))if(!(d&&w.id===d.id)&&Math.hypot(w.x-p.x,w.y-p.y)1)&&d.push({x:p.x,y:p.y})}function D(d){let p=e.conveyorMeta?.get?.(d)||{},w=[];Array.isArray(p.outDirs)&&w.push(...p.outDirs),p.dir&&w.push(p.dir);let k=[...new Set(w)].filter(ve=>fe.some(en=>en.name===ve)),S=re(d),A=fe.filter(ve=>De(S,ve.name,null)).map(ve=>ve.name),E=k.filter(ve=>A.includes(ve)),W=fe.filter(ve=>e.conveyorTiles.has(I(S.col+ve.dc,S.row+ve.dr))).map(ve=>ve.name);if(W.length===1&&(!E.length||E.includes(W[0]))&&me(S,W[0]))return E.filter(ve=>ve!==W[0]);let ae=A;return E.length?E:ae}function U(d){return fe.find(p=>p.name===d)||null}function ee(d,p){return e.scanners.find(w=>w.col<=d&&d<=w.col+1&&w.row-1<=p&&p<=w.row)||null}function O(d,p){let w=U(p);if(!w)return null;let k=ee(d.col+w.dc,d.row+w.dr);if(!k)return null;let S=Fe(k,"inputA");return S&&S.col===d.col&&S.row===d.row?k:null}function G(d){return Object.entries(e.facilities||{}).find(([,p])=>p?.entry&&p.entry.col===d.col&&p.entry.row===d.row)||null}function Y(d){return d?.baseId||d?.id||""}function ie(d,p){return d?.gridPlaced&&Y(d)==="sausageMaker"?p==="right":d.side==="left"&&p==="left"||d.side==="right"&&p==="right"||d.side==="top"&&p==="up"||d.side==="bottom"&&p==="down"}function De(d,p,w=null){let k=U(p);if(!k)return null;let S={col:d.col+k.dc,row:d.row+k.dr};if($e(S.col,S.row)&&e.conveyorTiles.has(I(S.col,S.row)))return{type:"conveyor",dirName:p,target:$(S.col,S.row),nextCell:S};let A=O(d,p);if(A&&!["meat","sausage"].includes(w?.sex))return{type:"scanner",dirName:p,target:ge(A),scanner:A};let E=G(d);return E&&ie(E[1],p)?{type:"facility",dirName:p,facilityId:E[0],facility:E[1],target:$(d.col,d.row)}:null}function me(d,p){let w=U(p);if(!w)return!1;let k=e.conveyorMeta?.get?.(I(d.col+w.dc,d.row+w.dr));return k?[...new Set([...Array.isArray(k.outDirs)?k.outDirs:[],k.dir].filter(Boolean))].includes(w.opposite):!1}function T(d){return fe.filter(p=>$e(d.col+p.dc,d.row+p.dr)&&e.conveyorTiles.has(I(d.col+p.dc,d.row+p.dr))).map(p=>p.name)}function M(d,p,w="random"){let k=T(d);if(k.length<3)return p;let S=new Set(k.filter(W=>me(d,W))),A=k.filter(W=>!S.has(W)),E=S.size?A:[...new Set(p.filter(W=>k.includes(W)))];return!E.length&&!S.size&&(E=k),w&&w!=="random"&&w!=="pass"&&E.length>=2&&E.includes(w)?[w]:E}function q(d,p){return p&&(p.type==="facility"||!le(d,p.target))}function ne(d,p,w,k="random"){let S=w.map(ae=>De(p,ae,d)).filter(Boolean);if(!S.length)return null;let E=S.filter(ae=>ae.type!=="facility").filter(ae=>!le(d,ae.target)),W=E.length?E:S.filter(ae=>ae.type==="facility");return W.length?W[Math.floor(Math.random()*W.length)]:null}function oe(d,p){let w=[];ue(w,{x:d.x,y:d.y}),ue(w,p),d.route=w.length?w:[{x:d.x,y:d.y}],d.targetIndex=d.route.length>1?1:d.route.length}function se(d,p){let w=tt(d.x,d.y);if(!w)return Zt(p,"OFF GRID");let k=I(w.col,w.row);if(!e.conveyorTiles.has(k))return Zt(p,"OFF BELT");let S=e.conveyorMeta?.get(k)||{},A=M(w,D(k),S.branchMode||"random");if(!A.length){d.stoppedTimer=.25;return}let E=ne(d,w,A,S.branchMode||"random");if(!E){d.stoppedTimer=.25;return}if(E.type==="facility"){let W=Y(E.facility);W==="mixer"?Vn(p,E.facility):W==="truck"?kn(p,E.facility):W==="trash"?Mn(p,E.facility):W==="sausageMaker"?Xn(p,E.facility):Zt(p,"DONE");return}E.type==="scanner"&&(d.pendingScannerId=E.scanner.id),d.prevConveyorCell=k,oe(d,E.target)}function Se(d){let p=tt(d.x,d.y);if(!p)return null;let w=I(p.col,p.row);return e.conveyorTiles.has(w)?w:null}function be(d){let p=Se(d)||Se(d.route?.[d.targetIndex]||d);return t(p)}function Ce(d,p){let w=Se(p);!w||d.lastWearKey===w||(d.lastWearKey=w,Es(e,w))}function B(d,p){let w=be(d)*p;for(;w>0&&d.targetIndex=E?(d.x=k.x,d.y=k.y,Ce(d,k),d.targetIndex+=1,w-=E,i()):(d.x+=S/E*w,d.y+=A/E*w,w=0,i())}}function ye(d,p,w){d.stage="queued",d.route=null,d.queueRoute=w?w.map(k=>({x:k.x,y:k.y})):[ge(p)],d.scannerId=p.id,p.queue.includes(d.id)||p.queue.push(d.id),r(),Be(p)}function Be(d){d.queue=d.queue.filter(w=>e.chicks.some(k=>k.id===w&&k.stage==="queued"));let p=f.cell*.86*qs(e);d.queue.forEach((w,k)=>{let S=e.chicks.find(A=>A.id===w);S&&(de(S,S.queueRoute||[ge(d)],k*p),S.queueIndex=k)})}function de(d,p,w){if(!p||!p.length)return;if(p.length===1||w<=0){let A=p[p.length-1];d.x=A.x,d.y=A.y,i();return}let k=w;for(let A=p.length-1;A>0;A-=1){let E=p[A-1],W=p[A],ae=Math.hypot(W.x-E.x,W.y-E.y);if(k<=ae){let ve=1-k/ae;d.x=E.x+(W.x-E.x)*ve,d.y=E.y+(W.y-E.y)*ve,i();return}k-=ae}let S=p[0];d.x=S.x,d.y=S.y,i()}function Oe(d){for(let p of e.scanners){if(p.cooldown>0&&(p.cooldown=Math.max(0,p.cooldown-d)),p.keyPressTime>0&&(p.keyPressTime=Math.max(0,p.keyPressTime-d)),Be(p),p.kind!=="auto"||p.cooldown>0||!p.queue.length)continue;let w=p.queue[0],k=e.chicks.findIndex(S=>S.id===w);if(k<0){p.queue.shift();continue}Sn(k,dr(p,e.chicks[k]),!0)&&(Ps(e,p),p.cooldown=St(p,e),ha(e))}}function ct(d){let p=ar(e,d);return!p||!p.queue.length?null:e.chicks.find(w=>w.id===p.queue[0])||null}function xt(d,p){if(e.phase!=="running")return;let w=ar(e,d);Le(w,p)}function Le(d,p){if(e.phase!=="running"||!d||d.kind!=="manual"||(d.keyPressTime=.18,d.keyPressSide=p,!d.queue.length))return;let w=e.chicks.findIndex(k=>k.id===d.queue[0]);w>=0&&Sn(w,p,!1)}function qn(d,p,w){return d.kind==="manual"&&dr(d,p)===w}function wn(d){let p=e.manualCombo?.count||0;if(e.manualCombo||(e.manualCombo={count:0,lastBonus:0}),e.manualCombo.count=0,e.manualCombo.lastBonus=0,e.stats.manualComboFailure=(e.stats.manualComboFailure||0)+1,e.totals.manualComboFailure=(e.totals.manualComboFailure||0)+1,p>=10){let w=ge(d);Yt(e,10,.25),H(e,w.x,w.y-36,"COMBO RESET",b.danger)}}function No(d){e.manualCombo||(e.manualCombo={count:0,lastBonus:0}),e.manualCombo.count=Math.max(0,e.manualCombo.count||0)+1,e.stats.manualComboSuccess=(e.stats.manualComboSuccess||0)+1,e.totals.manualComboSuccess=(e.totals.manualComboSuccess||0)+1;let p=e.manualCombo.count;if(e.manualCombo.lastBonus=0,p<10)return 0;let w=Math.max(1,Math.floor(p/10)),k=e.scanners.filter(W=>W.kind==="manual").length,S=Math.min(1,(20+k*10)/100),A=0;for(let W=0;W0){je(e,E),e.stats.manualComboBonus=(e.stats.manualComboBonus||0)+E,e.totals.manualComboBonus=(e.totals.manualComboBonus||0)+E,e.manualCombo.lastBonus=E;let W=ge(d);H(e,W.x,W.y-42,`COMBO +${x(E)}`,b.green)}return E}function Yn(d,p){let w=Fe(d,p);if(!w)return null;let k=I(w.col,w.row);if(e.conveyorTiles.has(k))return{...w};let A=(Je(e).scannerPorts.get(d.id)?.[p]?.keys||[]).filter(Boolean);if(!A.length)return null;let E=A.map(re).sort((ae,ve)=>{let en=$(ae.col,ae.row),ze=$(ve.col,ve.row),_t=le(null,en)?1:0,Tn=le(null,ze)?1:0;return _t-Tn});return E.find(ae=>ae.col===w.col&&ae.row===w.row)||E[0]||null}function Lt(d,p,w,k){let S=[];ue(S,{x:d.x,y:d.y});let A=Fe(p,w);return A&&ue(S,$(A.col,A.row)),ue(S,$(k.col,k.row)),S}function Sn(d,p,w){let k=e.chicks[d];if(!k||k.stage!=="queued")return!1;let S=Ht(e,k.scannerId);if(!S)return!1;let A=Yn(S,p);if(!A)return!w&&S.kind==="manual"&&wn(S),H(e,k.x,k.y-16,p==="left"?"NO LEFT BELT":"NO RIGHT BELT",b.danger),!1;!w&&S.kind==="manual"&&(qn(S,k,p)?No(S):wn(S)),S.queue=S.queue.filter(W=>W!==k.id),k.stage="belt",k.route=Lt(k,S,p,A),k.targetIndex=k.route.length>1?1:k.route.length,k.scannerId=null,k.nextScannerId=null,k.pendingScannerId=null,k.queueRoute=null,k.queueIndex=-1,r();let E=w?b.green:p==="left"?b.mixerBlue:b.truckPink;return ws(e,ge(S).x,ge(S).y,E),H(e,k.x,k.y-20,w?"AUTO":p.toUpperCase(),E),!0}function Tt(d){let p=e.chicks[d];return p?(e.chicks.splice(d,1),r(),p):null}function Vn(d,p=e.facilities.mixer){if(p?.processingCooldown>0){let E=e.chicks[d];E&&(E.stoppedTimer=.25);return}let w=Tt(d);if(!w)return;let{x:k,y:S}=w;if(ba(e),Mo(e,p),xo(e,p),w.sex==="meat"||w.sex==="sausage"){rt(e),H(e,k,S-18,"CHICKS ONLY",b.warn);return}if(w.sex==="poop"){let E=cs(e);Bo(e,"mixer"),rt(e),ks(e,k,S),H(e,k,S-18,`-${x(E)}`,b.danger);return}w.sex==="male"?hn(e):rt(e);let A=Xe(e,p);if(!A||!It(e,p)||!gn(A)){let E=ls(e);H(e,k,S-18,`DIRECT +${x(E)}`,b.green);return}p.meatBuffer=Math.max(0,p.meatBuffer||0)+1,e.stats.meatProduced=(e.stats.meatProduced||0)+1,e.totals.meatProduced=(e.totals.meatProduced||0)+1,Ss(e,k,S),H(e,k,S-18,"MEAT +1",b.meatRed),Cn()}function kn(d,p=e.facilities.truck){let w=Tt(d);if(!w)return;let{x:k,y:S}=w;Go(w.sex,p),Cs(e,k,S,w.sex),wa(e,w.sex);let A=ln(e),E=Zi(e,w.sex);if(w.sex==="sausage"){e.stats.sausageShipped=(e.stats.sausageShipped||0)+1,e.totals.sausageShipped=(e.totals.sausageShipped||0)+1,H(e,k,S-18,`SAUSAGE +${x(Ze(e))}`,b.sausageOrange);return}if(w.sex==="meat"){rt(e),H(e,k,S-18,"RAW MEAT REJECT",b.warn);return}if(w.sex==="poop"){if(Bo(e,"truck"),E)hn(e),H(e,k,S-18,`+${x(Ve(e))}`,b.green);else{let W=us(e);rt(e),H(e,k,S-18,`-${x(W)}`,b.danger)}return}if(w.sex==="female"){E?(hn(e),H(e,k,S-18,`+${x(Ve(e))}`,b.green)):(rt(e),H(e,k,S-18,A==="male"?"REJECT":"NO SALE",b.warn));return}if(E){hn(e),H(e,k,S-18,`+${x(Ve(e))}`,b.green);return}if(es(e)){let W=ds(e);rt(e),H(e,k,S-18,`-${x(W)}`,b.danger)}else rt(e),H(e,k,S-18,"REJECT",b.warn)}function _o(d,p){let w=$(p.col,p.row);return{id:e.nextId++,sex:d,x:w.x,y:w.y,route:[{x:w.x,y:w.y}],targetIndex:1,stage:"belt",scannerId:null,nextScannerId:null,pendingScannerId:null,radius:d==="sausage"?11:10,bob:Math.random()*Math.PI*2,queueIndex:-1,queueRoute:null,stoppedTimer:0}}function gn(d){if(!d||!e.conveyorTiles.has(I(d.col,d.row)))return!1;let p=$(d.col,d.row);return!le(null,p)}function zn(d,p,w){let k=Xe(e,d);if(!k||!It(e,d)||!gn(k)||Math.max(0,d[w]||0)<=0)return!1;d[w]-=1,e.chicks.push(_o(p,k)),r();let S=$(k.col,k.row);return H(e,S.x,S.y-16,p==="meat"?"MEAT OUT":"SAUSAGE OUT",p==="meat"?b.meatRed:b.sausageOrange),!0}function Cn(){for(let d of Object.values(e.facilities||{})){let p=Y(d);p==="mixer"&&zn(d,"meat","meatBuffer"),p==="sausageMaker"&&zn(d,"sausage","sausageBuffer")}}function Xn(d,p){if(p?.processingCooldown>0){let A=e.chicks[d];A&&(A.stoppedTimer=.25);return}let w=Tt(d);if(!w)return;let{x:k,y:S}=w;if(w.sex!=="meat"){rt(e),H(e,k,S-18,"MEAT ONLY",b.warn);return}Mo(e,p),xo(e,p),p.meatBuffer=Math.max(0,p.meatBuffer||0)+1,p.meatBuffer>=2?(p.meatBuffer-=2,p.sausageBuffer=Math.max(0,p.sausageBuffer||0)+1,e.stats.sausageProduced=(e.stats.sausageProduced||0)+1,e.totals.sausageProduced=(e.totals.sausageProduced||0)+1,H(e,k,S-18,"SAUSAGE +1",b.sausageOrange)):H(e,k,S-18,"MEAT 1/2",b.meatRed),Cn()}function Mn(d,p=e.facilities.trash){if(p?.processingCooldown>0){let W=e.chicks[d];W&&(W.stoppedTimer=.25);return}let w=Tt(d);if(!w)return;let{x:k,y:S}=w;gs(e,k,S,w.sex),va(e),Mo(e,p),xo(e,p);let A={amount:0},E="";if(w.sex==="poop"){A=is(e),E=A.amount>0?` ${A.tier==="jackpot"?"JACKPOT ":"+"}${x(A.amount)}`:" MISS",Bo(e,"trash"),hn(e);let W=Math.ceil(Math.max(0,Number(e.cardEffects?.composter)||0)*3*po(e));if(W>0){let ae=je(e,W);e.stats.composterIncome=(e.stats.composterIncome||0)+ae,e.totals.composterIncome=(e.totals.composterIncome||0)+ae,H(e,k,S-36,`COMPOST +${x(ae)}`,b.green)}H(e,k,S-18,`CLEAN${E}`,A.amount>0?b.green:b.green)}else rt(e),H(e,k,S-18,A.amount>0?`WASTE${E}`:"WASTE",A.amount>0?b.green:b.ink)}function Zt(d,p){let w=Tt(d);w&&H(e,w.x,w.y-12,p,b.muted)}function Go(d,p=e.facilities.truck){let w=p||e.facilities.truck;w&&(e.truckCargo.push({sex:d,x:z(22,w.w-22),y:z(66,w.h-24)}),e.truckCargo.length>45&&e.truckCargo.shift())}function Uo(d){let p=tt(d.x,d.y);if(p){let S=I(p.col,p.row);if(e.conveyorTiles.has(S))return S}if(!p)return null;let w=null,k=1/0;for(let S of[p,...fe.map(A=>({col:p.col+A.dc,row:p.row+A.dr}))]){if(!$e(S.col,S.row))continue;let A=I(S.col,S.row);if(!e.conveyorTiles.has(A))continue;let E=$(S.col,S.row),W=Math.hypot(d.x-E.x,d.y-E.y);Ww&&d.ratio=p){let k=xn(d.id);if(!k.length||k.some(E=>E.stage!=="queued"&&E.stoppedTimer<=.2))continue;let S=e.lastExplodedComponent.get(d.id)||0,A=performance.now();A-S>900&&(e.lastExplodedComponent.set(d.id,A),Wo(d))}}}function xn(d){return Nt(e.frameId||0),e.frameCache?.componentChicks?.get(d)||[]}function Wo(d){let p=xn(d.id);if(!p.length)return;for(let S of p)Tr(e,S.sex,S.x,S.y);e.chicks=e.chicks.filter(S=>!p.some(A=>A.id===S.id));for(let S of e.scanners)S.queue=S.queue.filter(A=>e.chicks.some(E=>E.id===A));r();for(let S of d.cells){let A=re(S),E=$(A.col,A.row);at(e,E.x,E.y,b.danger),bt(e,E.x,E.y,6),mo(e,E.x,E.y,4)}Yt(e,18,.75);let w=re(d.cells[0]),k=$(w.col,w.row);e.stats.explosions=(e.stats.explosions||0)+1,e.totals.explosions=(e.totals.explosions||0)+1,H(e,k.x,k.y-22,`${Math.round(wr(e)*100)}% JAM EXPLOSION`,b.danger)}return{activeQueuedChick:ct,beginFrame:a,currentSpawnDelay:C,sortSlot:xt,updateCongestion:Nt,sortScanner:Le,updateRunning:Z}}var he=document.getElementById("gameCanvas"),hu=he.getContext("2d"),F={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"),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"),hoverTooltip:document.getElementById("hoverTooltip"),manualScannerMonitor:document.getElementById("manualScannerMonitor"),debug:{panel:document.getElementById("debugPanel"),infiniteCash:document.getElementById("debugInfiniteCash"),dayInput:document.getElementById("debugDayInput"),setDay:document.getElementById("debugSetDayButton"),zeroTimer:document.getElementById("debugZeroTimerButton"),cardSelect:document.getElementById("debugCardSelect"),targetSelect:document.getElementById("debugTargetSelect"),eventSelect:document.getElementById("debugEventSelect"),grantCard:document.getElementById("debugGrantCardButton"),grantAllCards:document.getElementById("debugGrantAllCardsButton"),readout:document.getElementById("debugReadout")},buttons:{s1Left:document.getElementById("scanner1MixerButton"),s1Right:document.getElementById("scanner1TruckButton"),s2Left:document.getElementById("scanner2MixerButton"),s2Right:document.getElementById("scanner2TruckButton"),nextTurn:document.getElementById("nextTurnButton"),conveyor:document.getElementById("buildConveyorButton"),boostConveyor:document.getElementById("buildBoostConveyorButton"),eggFarm:document.getElementById("buildEggFarmButton"),autoScanner:document.getElementById("buildAutoScannerButton"),manualScanner:document.getElementById("buildManualScannerButton"),mixer:document.getElementById("buildMixerButton"),trash:document.getElementById("buildTrashButton"),truck:document.getElementById("buildTruckButton"),sausageMaker:document.getElementById("buildSausageMakerButton"),maintenanceRoom:document.getElementById("buildMaintenanceRoomButton"),erase:document.getElementById("eraseButton"),undo:document.getElementById("undoButton"),redo:document.getElementById("redoButton")}};function mu(){let e={conveyor:"conveyor",boostConveyor:"boostConveyor",eggFarm:"eggFarm",autoScanner:"autoScanner",manualScanner:"manualScanner",mixer:"mixer",trash:"trash",truck:"truck",sausageMaker:"sausageMaker",maintenanceRoom:"maintenanceRoom"};for(let[t,n]of Object.entries(e))F.buttons[t]&&(F.buttons[t].innerHTML=hi(n))}function bu(e){let t=new Set(e.tags||[]);return["upgradeEgg","crowdedFarming","hatchingFeed","extraEggOutlet"].includes(e.id)||t.has("EGG")?"Production / EGG":["upgradeTrash","poopRateBooster","composter","chemicalWeaponSubsidy"].includes(e.id)||t.has("POOP")?"Poop / Shredder":["upgradeAutoScanner","autoScannerTuning","scannerQueueSpacing"].includes(e.id)||t.has("SCANNER")?"Scanner":["bearing","dynamite"].includes(e.id)||t.has("CONVEYOR")?"Conveyor / Map":["safetyCover","preventiveMaintenance","durabilityCoating","sparePartsBin","laborExploitation"].includes(e.id)||t.has("MAINTENANCE")?"Maintenance / Risk":["dudFilter","dudRefund","freeReroll","extraCards"].includes(e.id)||t.has("CARD")?"Card Engine":t.has("ULTRA RARE")?"Ultra Rare":t.has("ECONOMY")||["upgradeMixer","upgradeTruck","upgradeSausageMaker","recyclingSubsidy","usedMachine","newMachine","flattery","legalWork","rescueLoan"].includes(e.id)?"Economy / Shipment":"Other"}function vu(){if(!F.debug?.panel||!F.debug.cardSelect)return;let e=["Production / EGG","Poop / Shredder","Scanner","Conveyor / Map","Maintenance / Risk","Card Engine","Economy / Shipment","Ultra Rare","Other"],t=new Map(e.map(n=>[n,[]]));for(let n of fn){let o=bu(n);t.has(o)||t.set(o,[]),t.get(o).push(n)}F.debug.cardSelect.innerHTML="";for(let n of e){let o=t.get(n)||[];if(!o.length)continue;let r=document.createElement("optgroup");r.label=n;for(let i of o){let a=document.createElement("option");a.value=i.id;let c=i.rarity==="ultraRare"?"UR":i.rarity==="rare"?"R":"C";a.textContent=`[${c}] ${i.title}`,r.appendChild(a)}F.debug.cardSelect.appendChild(r)}F.debug.cardSelect.addEventListener("change",Jt),wu(),Jt()}function wu(){if(!F.debug?.eventSelect)return;F.debug.eventSelect.innerHTML="";let e=document.createElement("option");e.value="__roll__",e.textContent="Roll normally",F.debug.eventSelect.appendChild(e);let t=document.createElement("option");t.value="__none__",t.textContent="No event",F.debug.eventSelect.appendChild(t);for(let n of Fn){let o=document.createElement("option");o.value=n.key,o.textContent=n.title,F.debug.eventSelect.appendChild(o)}F.debug.eventSelect.value=u.debugNextEventKey||"__roll__",F.debug.eventSelect.addEventListener("change",ku)}function Ma(){let e=u.debugNextEventKey||"__roll__";return e==="__none__"?null:e==="__roll__"?Vi(u):hr(u,e)}function Jt(){if(!F.debug?.targetSelect)return;let e=F.debug.cardSelect?.value,t=Xt(e),n=t?Ys(u,t):[];if(F.debug.targetSelect.innerHTML="",!n.length){let r=document.createElement("option");r.value="__none__",r.textContent=t?"No target required":"No card selected",F.debug.targetSelect.appendChild(r),F.debug.targetSelect.disabled=!0;return}let o=document.createElement("option");o.value="__auto__",o.textContent=t?.id==="dynamite"?"Auto: first 10 blocked cells":"Auto: first valid target",F.debug.targetSelect.appendChild(o);for(let r of n){let i=document.createElement("option");i.value=r.key,i.textContent=r.label,F.debug.targetSelect.appendChild(i)}F.debug.targetSelect.disabled=!1}function Su(){let e=Math.max(1,Math.floor(Number(F.debug.dayInput?.value)||1));u.turn=e,F.debug.dayInput&&(F.debug.dayInput.value=String(e)),H(u,he.width/2-u.view.x,86-u.view.y,`DAY ${e}`,b.green),J.updatePanels(),J.updateUI(),Jt(),Ot(`Day set to ${e}.`)}function ku(){u.debugNextEventKey=F.debug.eventSelect?.value||"__roll__",u.phase==="build"&&(u.eventOffer=Ma(),J.updatePanels(),J.updateUI());let e=F.debug.eventSelect?.selectedOptions?.[0]?.textContent||"Roll normally";Ot(`Next event: ${e}.`)}function gu(){let e=u.phase==="running"&&(u.timeLeft<=0||u.shutdownTimeLeft>0||u.cleanupTimer!==null);u.timeLeft=0,u.cleanupTimer!==null&&(u.cleanupTimer=0),e?u.shutdownTimeLeft=0:u.phase==="running"&&Ea(),H(u,he.width/2-u.view.x,86-u.view.y,"TIMER 0",b.warn),J.updatePanels(),J.updateUI(),Ot("Timer set to 0.")}function Cu(){let e=F.debug.cardSelect?.value,t=F.debug.targetSelect?.disabled?void 0:F.debug.targetSelect?.value,n=Nr(u,e,{targetKey:t});F.debug.grantCard&&(F.debug.grantCard.textContent=n.ok?"Granted":"Failed",window.setTimeout(()=>{F.debug.grantCard&&(F.debug.grantCard.textContent="Grant Selected")},900)),Ae?.updateCongestion?.(u.frameId||0),J.updatePanels(),J.updateUI(),Jt(),Ot(n.reason)}function Mu(){let t=fn.map(n=>Nr(u,n,{targetKey:"__auto__"})).filter(n=>n.ok).length;Ae?.updateCongestion?.(u.frameId||0),J.updatePanels(),J.updateUI(),Jt(),Ot(`Granted ${t}/${fn.length} cards.`)}function xu(){F.debug?.infiniteCash?.checked&&u.cash<999999&&(u.cash=999999,u.totals.maxCash=Math.max(u.totals.maxCash,u.cash))}function Ot(e=""){F.debug?.readout&&(F.debug.readout.textContent=[e,`D${u.turn}`,x(u.cash),`Combo ${u.manualCombo?.count||0}`,`Event ${u.eventActive?.title||u.eventOffer?.title||"none"}`,`Next ${F.debug?.eventSelect?.selectedOptions?.[0]?.textContent||"Roll normally"}`,`Rescue ${u.cardEffects?.rescueLoanCharges||0}`,`Chem ${u.cardEffects?.chemicalWeaponSubsidy||0}`].filter(Boolean).join(" | "))}var u=rr(),te,J,Ae,bn;function Tu(e=null){let t=pn(u,e);return e?t*_n(u,e):t}function Eu(){Object.assign(u,rr()),u.phase="running",u.view={x:0,y:0,scale:1},ir(u),Pa(),Re(u),J.hideModal(),Ae.updateCongestion(u.frameId||0),J.updatePanels(),J.updateUI(),Ot()}function Ru(){if(u.phase!=="build")return;if(u.cardDraft?.pending||u.cardTargetPick?.pending){te.fail(u.cardTargetPick?.pending?"Choose upgrade target first.":"Choose an upgrade card first."),J.updatePanels();return}let e=fo(u);if(e.length){Du(),te.fail(`Cannot start: ${e[0]}`),J.updatePanels();return}Re(u),Ui(u),Fs(u);let t=Math.max(0,Math.floor(u.lastResult?.profit??u.stats?.profit??0)),n=ys(u,Math.max(1,u.turn||1),t);u.lastStartFees={fairiesTribute:n.amount||0,tribute:n},n.amount>0&&H(u,he.width/2-u.view.x,116-u.view.y,`FAIRIES -${x(n.amount)}`,b.danger),zi(u),u.phase="running",u.turn+=1,u.timeLeft=ji(u),u.shutdownTimeLeft=0,u.cleanupTimer=null,u.cleanupBlown=!1,u.chicks=[],u.effects=[],u.floatingTexts=[],u.shake={time:0,strength:0},u.truckCargo=[],Pa(),u.stats=or(),u.stats.fairiesTribute=u.lastStartFees?.fairiesTribute||0,u.stats.fairiesTribute>0&&(u.stats.penalty+=u.stats.fairiesTribute,u.stats.profit-=u.stats.fairiesTribute);for(let o of u.scanners)o.queue=[],o.cooldown=0;for(let o of u.eggFarms)o.nextSpawn=Ae.currentSpawnDelay(o),o.lastInterval=o.nextSpawn,o.shutterProgress=0,o.shutterSparked=!1;u.buildTool=null,u.selected=null,u.groupDrag=null,u.selectionBox=null,u.pan=null,Ae.updateCongestion(u.frameId||0),J.updatePanels(),J.updateUI()}function Pu(){if(u.phase!=="running")return;let e=ps(u),t=u.eventActive?{...u.eventActive}:null,n=os(u),o=hs(u);if(e.adjusted>0){let i=u.facilities.truck,a=i?i.x+i.w/2:he.width/2-u.view.x,c=i?i.y+26:96-u.view.y;H(u,a,c,`${e.target==="poop"?"POOP SHIP":"SHIP"} +${x(e.adjusted)}`,b.green)}n?.bonus>0&&H(u,he.width/2-u.view.x,96-u.view.y,`EVENT +${x(n.bonus)}`,b.green),o.amount>0&&H(u,he.width/2-u.view.x,116-u.view.y,`CHEM SUBSIDY +${x(o.amount)}`,b.green),Xi(u),u.totals.turnsCompleted+=1;let r=ns(t);if(r!==1&&(u.pendingFairiesTributeMultiplier=r),u.lastResult={...u.stats,ship:e,event:n,chemical:o,rescue:{amount:0,used:0,remaining:u.cardEffects?.rescueLoanCharges||0},fairiesTribute:{amount:u.stats.fairiesTribute||0},tributeMultiplier:r,turn:u.turn,cash:u.cash},u.cash<0){if((u.cardEffects?.rescueLoanCharges||0)>0){u.phase="loanDecision",J.showEmergencyLoanDecision(Ta,Oo);return}u.phase="gameover",J.showGameOver();return}xa()}function xa(){u.phase="build",u.truckCargo=[],u.chicks=[],u.shutdownTimeLeft=0,u.cleanupTimer=null,u.cleanupBlown=!1,u.buildSession+=1,u.undoStack=[],u.redoStack=[],u.timeLeft=0;for(let e of u.scanners)e.queue=[];for(let e of u.eggFarms)e.shutterProgress=0,e.shutterSparked=!1;Ds(u),u.eventOffer=Ma(),bn&&bn.prepareDraft(),u.cardTargetPick=null,u.buildTool=null,u.groupDrag=null,u.selectionBox=null,u.pan=null,J.updatePanels(),J.showTurnResult(u.lastResult)}function Ta(){if((u.cardEffects?.rescueLoanCharges||0)<=0)return Oo();u.cardEffects.rescueLoanCharges-=1,u.stats.rescueLoan=(u.stats.rescueLoan||0)+500,u.totals.rescueLoan=(u.totals.rescueLoan||0)+500;let e=je(u,500);u.lastResult={...u.lastResult||{},...u.stats,rescue:{amount:(u.lastResult?.rescue?.amount||0)+e,used:(u.lastResult?.rescue?.used||0)+1,remaining:u.cardEffects.rescueLoanCharges},cash:u.cash},H(u,he.width/2-u.view.x,176-u.view.y,`RESCUE +${x(e)}`,b.green),u.cash>=0?xa():(u.cardEffects?.rescueLoanCharges||0)>0?(u.phase="loanDecision",J.showEmergencyLoanDecision(Ta,Oo)):Oo()}function Oo(){u.phase="gameover",J.showGameOver()}function Ea(){u.shutdownTimeLeft<=0&&(u.shutdownTimeLeft=u.shutdownGraceSeconds||10);for(let e of u.eggFarms){if(e.shutterSparked)continue;e.shutterProgress=.02,e.shutterSparked=!0;let t=$(e.col,e.row);at(u,t.x,t.y,b.ink),bt(u,t.x,t.y+10,10),H(u,t.x,t.y-34,"SHUT +10s",b.ink)}}function Ra(e){u.lastTimestamp||(u.lastTimestamp=e);let t=Math.min((e-u.lastTimestamp)/1e3,.05);u.lastTimestamp=e,u.frameId=(u.frameId||0)+1,Ae.beginFrame?.(u.frameId),u.phase==="running"&&Ae.updateRunning(t,{closeFarmShutters:Ea,completeTurn:Pu}),xs(u,t,he,te.equipmentHitBoxes),Ae.updateCongestion(u.frameId),sa(hu,he,u,{activeQueuedChick:Ae.activeQueuedChick,selectedObject:te.selectedObject,selectedTitle:te.selectedTitle}),xu(),J.updateUI(),Ia(),Ot(),requestAnimationFrame(Ra)}function Kn(){return u.view?.scale||1}function vn(){u.view||(u.view={x:0,y:0,scale:1}),u.view.scale==null&&(u.view.scale=1)}function Lo(e){let t=he.getBoundingClientRect();return{x:(e.clientX-t.left)*he.width/t.width,y:(e.clientY-t.top)*he.height/t.height}}function Bt(e){let t=Lo(e),n=Kn();return{x:(t.x-(u.view?.x||0))/n,y:(t.y-(u.view?.y||0))/n}}function Au(e){let t=Kn();return{x:e.x*t+(u.view?.x||0),y:e.y*t+(u.view?.y||0)}}function Iu(){let e={left:f.x-90,top:f.y-130,right:f.x+f.cols*f.cell+260,bottom:f.y+f.rows*f.cell+220};if(u.phase==="build")for(let t of Ut(u)){let n=dt(t);e.left=Math.min(e.left,n.x-60),e.top=Math.min(e.top,n.y-80),e.right=Math.max(e.right,n.x+n.w+80),e.bottom=Math.max(e.bottom,n.y+n.h+80)}return e}function ka(e,t){if(e<=t)return[e,t];let n=(e+t)/2;return[n,n]}function Ct(){vn();let e=Kn(),t=Iu(),[n,o]=ka(he.width-160-t.right*e,110-t.left*e),[r,i]=ka(he.height-150-t.bottom*e,150-t.top*e);u.view.x=An(u.view.x,n,o),u.view.y=An(u.view.y,r,i)}function Pa(){vn(),u.view.scale=1;let e=u.eggFarms?.[0],t=e?$(e.col,e.row):{x:f.x,y:f.y};u.view.x=280-t.x,u.view.y=190-t.y,Ct()}function $u(e){if(!e)return!1;vn();let t=Kn();return u.view.x=he.width/2-e.x*t,u.view.y=he.height/2-e.y*t,Ct(),!0}function Fu(e){let t=e?.ref;return t?e.type==="facility"?{x:t.x+t.w/2,y:t.y+t.h/2}:e.type==="eggFarm"?$(t.col,t.row):e.type==="scanner"?{x:$(t.col,t.row).x+f.cell/2,y:$(t.col,t.row).y}:e.type==="maintenanceRoom"?{x:$(t.col,t.row).x+f.cell/2,y:$(t.col,t.row).y+f.cell/2}:null:null}function Du(){let e=an(u),t=e.find(o=>o.type==="facility")||e[0]||Bu(),n=Fu(t);$u(n)&&t&&(u.selected={type:t.type,id:t.id})}function Bu(){let e=Object.values(u.facilities||{}).find(o=>o?.entry&&u.conveyorTiles.has(I(o.entry.col,o.entry.row)))||Object.values(u.facilities||{})[0];if(e)return{type:"facility",id:e.id,ref:e};let t=u.scanners?.find(o=>o);if(t)return{type:"scanner",id:t.id,ref:t};let n=u.eggFarms?.[0];return n?{type:"eggFarm",id:n.id,ref:n}:null}function Ou(e){let t=Lo(e);vn(),u.pan={start:t,viewX:u.view.x,viewY:u.view.y,moved:!1}}function Lu(e){if(!u.pan)return;let t=Lo(e);Math.hypot(t.x-u.pan.start.x,t.y-u.pan.start.y)>5&&(u.pan.moved=!0),vn(),u.view.x=u.pan.viewX+t.x-u.pan.start.x,u.view.y=u.pan.viewY+t.y-u.pan.start.y,Ct()}function Nu(e){e.preventDefault(),vn();let t=Lo(e),n=Bt(e),o=Math.exp(-e.deltaY*.0012),r=An(Kn()*o,.55,1.8);u.view.scale=r,u.view.x=t.x-n.x*r,u.view.y=t.y-n.y*r,Ct(),Mt()}function _u(e){return e?e.type==="conveyor"?{type:"conveyor",id:e.oldKey}:{type:e.type,id:e.ref.id}:null}function Gu(e){return e?e.type==="conveyor"?{type:"conveyor",id:e.oldKey}:e.ref||null:null}function Mt(){F.hoverTooltip&&(F.hoverTooltip.classList.remove("visible"),F.hoverTooltip.setAttribute("aria-hidden","true"),u.hover=null)}function Uu(e,t){if(!F.hoverTooltip||u.phase!=="build"||u.cardTargetPick?.pending||u.groupDrag||u.selectionBox||u.pan)return Mt();let n=Gu(t);if(!n)return Mt();let o=te.selectedTitle(n),r=te.flavorText?te.flavorText(n):"",i=te.disconnectedWarningFor?te.disconnectedWarningFor(n):"",a=te.selectedInfoLines(n).slice(0,7);F.hoverTooltip.innerHTML=`${o}${i?`${i}`:r?`${r}`:""}${a.map(g=>`${g}`).join("")}`;let c=Math.min(window.innerWidth-330,e.clientX+18),h=Math.min(window.innerHeight-220,e.clientY+18);F.hoverTooltip.style.left=`${Math.max(8,c)}px`,F.hoverTooltip.style.top=`${Math.max(8,h)}px`,F.hoverTooltip.classList.add("visible"),F.hoverTooltip.setAttribute("aria-hidden","false"),u.hover={type:t.type,id:t.oldKey||t.ref?.id||null}}function Wu(e){if(u.phase!=="build")return Mt();Uu(e,te.equipmentAtPoint(Bt(e)))}function Aa(e){Mt();let t=Bt(e),n=te.equipmentAtPoint(t),o=_u(n);if(!o){u.selected=null,u.multiSelected=[],J.updatePanels();return}u.selected=o,u.multiSelected=[o],J.updatePanels();let r=te.selectedObject();r?.type==="scanner"&&r.kind==="manual"&&te.showManualScannerMenu(r),r?.type==="scanner"&&r.kind==="auto"&&te.showAutoScannerMenu(r)}function mn(){u.buildTool=null,u.selected=null,u.multiSelected=[],u.selectionBox=null,u.cardTargetPick?.mode==="autoScannerMenu"&&(u.cardTargetPick=null),J.hideModal(),Mt(),J.updatePanels(),J.updateUI()}function ga(e){return String(e).replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t])}function Hu(e){let t=e?.queue?.[0];return t==null?null:u.chicks.find(n=>n.id===t)||null}function Ku(e){if(!e)return'WAIT';let t=e.sex==="poop"?"POOP":e.sex==="male"?"M MALE":"F FEMALE";return`${t}`}function qu(e,t){let n=new Set(u.eggFarms.map(C=>I(C.col,C.row))),o=new Map(u.scanners.map(C=>[I(C.col,C.row),C])),r=Fe(e,"inputA"),i=r?I(r.col,r.row):null,a=new Set(Object.values(u.facilities||{}).map(C=>C.entry?I(C.entry.col,C.entry.row):null).filter(Boolean)),c=t?.sex||"",h=c==="poop"?"P":c==="male"?"M":c==="female"?"F":"",g=[];for(let C=e.row-2;C<=e.row+2;C+=1)for(let P=e.col-2;P<=e.col+2;P+=1){let L=I(P,C),K=["mini-cell"],j="";P===e.col&&C===e.row?(K.push("scanner"),c&&K.push(c),j=h||"S"):L===i?(K.push("input"),j="IN"):n.has(L)?(K.push("egg"),j="E"):o.has(L)?(K.push("scanner-other"),j="S"):a.has(L)?(K.push("port"),j="P"):u.conveyorTiles.has(L)?(K.push("belt"),j="."):u.blockedCells?.has?.(L)&&(K.push("blocked"),j="X"),g.push(`${j}`)}return`
${g.join("")}
`}function Yu(e){let t=Au($(e.col,e.row)),n=76;return t.x<-n||t.y<-n||t.x>he.width+n||t.y>he.height+n}function Ia(){if(!F.manualScannerMonitor)return;let e=u.frameCache||(u.frameCache={}),t=u.view||{},n=[u.phase,Math.round((t.x||0)*10),Math.round((t.y||0)*10),Math.round((t.scale||1)*100),u.chicks.length,u.scanners.map(r=>[r.id,r.kind,r.col,r.row,r.queue?.join(",")||"",r.keyPressSide||"",Math.ceil((r.keyPressTime||0)*10)].join(":")).join("|")].join(";");if(e.manualMonitorSignature===n)return;if(e.manualMonitorSignature=n,u.phase!=="running"){F.manualScannerMonitor.classList.remove("visible"),F.manualScannerMonitor.innerHTML="";return}let o=u.scanners.filter(r=>r.kind==="manual"&&Yu(r));if(!o.length){F.manualScannerMonitor.classList.remove("visible"),F.manualScannerMonitor.innerHTML="";return}F.manualScannerMonitor.innerHTML=o.map(r=>{let i=Hu(r),a=r.keys?.left?.label||(r.slot===0?"A":"Left"),c=r.keys?.right?.label||(r.slot===0?"D":"Right"),h=r.role===0?"MIXER":"WASTE",g=r.role===0?"S2":"TRUCK",C=i?"":" disabled";return` +
+
S${(r.slot??0)+1}OFFSCREEN / Q:${r.queue.length}
+
+ IN${Ku(i)}SCANNER +
+ ${qu(r,i)} +
+ + +
+
`}).join(""),F.manualScannerMonitor.classList.add("visible")}F.manualScannerMonitor?.addEventListener("click",e=>{let t=e.target.closest("button[data-scanner-id]");if(!t||t.disabled)return;let n=u.scanners.find(o=>o.id===Number(t.dataset.scannerId));n&&(Ae.sortScanner(n,t.dataset.side),Ia())});he.addEventListener("contextmenu",e=>e.preventDefault());he.addEventListener("wheel",Nu,{passive:!1});he.addEventListener("pointerdown",e=>{if(!["build","running"].includes(u.phase))return;if(e.button===2||e.button===1||u.phase==="running"&&e.button===0){he.setPointerCapture(e.pointerId),Ou(e);return}if(u.phase!=="build"||e.button!==0)return;he.setPointerCapture(e.pointerId);let t=Bt(e);if(u.cardTargetPick?.mode==="autoScannerMenu"){mn();return}if(u.cardTargetPick?.pending){bn.chooseTargetAtPoint(t);return}let n=te.expansionOfferAtPoint?.(t);if(n){te.buyGridExpansion(n),Ct(),J.updatePanels(),J.updateUI();return}if(te.cycleBranchModeAtPoint?.(t)){J.updatePanels(),J.updateUI();return}let o=tt(t.x,t.y),r=te.equipmentAtPoint(t);if(!u.buildTool&&r?.type==="conveyor"&&o&&te.cycleSingleConveyorDirection?.(o)){J.updatePanels(),J.updateUI();return}if(u.buildTool==="erase"){if(!te.equipmentAtPoint(t)){mn();return}te.eraseAtPoint(t);return}if(u.buildTool){let i=tt(t.x,t.y);if(!i&&!["mixer","trash","truck"].includes(u.buildTool)){mn();return}if(u.buildTool==="conveyor"||u.buildTool==="boostConveyor"){te.beginConveyorDrag(i);return}if(te.equipmentAtPoint(t)){u.buildTool=null,Aa(e);return}te.buildAtPoint(t);return}te.startGroupDrag(e)||te.startSelectionBox(e)});he.addEventListener("pointermove",e=>{if(u.pan&&e.buttons&7&&Lu(e),u.phase!=="build"){Mt();return}Wu(e),u.groupDrag&&e.buttons&1&&te.updateGroupDrag(e),u.selectionBox&&e.buttons&1&&te.updateSelectionBox(e),(u.buildTool==="conveyor"||u.buildTool==="boostConveyor")&&e.buttons&1&&te.continueConveyorDrag(tt(Bt(e).x,Bt(e).y)),u.buildTool==="erase"&&e.buttons&1&&te.eraseAtPoint(Bt(e))});he.addEventListener("pointerup",e=>{u.selectionBox?te.finishSelectionBox():u.groupDrag&&(u.groupDrag.committed||Aa(e),te.finishGroupDrag()),(e.button===2||e.button===1)&&u.pan&&!u.pan.moved&&u.buildTool&&u.phase==="build"&&mn(),(u.buildTool==="conveyor"||u.buildTool==="boostConveyor")&&te.endConveyorDrag?.(),u.groupDrag=null,u.pan=null;try{he.releasePointerCapture(e.pointerId)}catch{}});he.addEventListener("pointerleave",Mt);he.addEventListener("pointercancel",()=>{te?.endConveyorDrag?.(),u.groupDrag=null,u.selectionBox=null,u.pan=null,Mt()});document.addEventListener("pointerdown",e=>{!F.modal.classList.contains("visible")||!F.modal.classList.contains("equipment-popover")||e.target.closest("#modal .modal-card")||mn()},!0);F.buttons.s1Left.addEventListener("click",()=>Ae.sortSlot(0,"left"));F.buttons.s1Right.addEventListener("click",()=>Ae.sortSlot(0,"right"));F.buttons.s2Left.addEventListener("click",()=>Ae.sortSlot(1,"left"));F.buttons.s2Right.addEventListener("click",()=>Ae.sortSlot(1,"right"));F.buttons.nextTurn.addEventListener("click",Ru);F.buttons.conveyor.addEventListener("click",()=>te.setBuildTool("conveyor"));F.buttons.boostConveyor.addEventListener("click",()=>te.setBuildTool("boostConveyor"));F.buttons.eggFarm.addEventListener("click",()=>te.setBuildTool("eggFarm"));F.buttons.autoScanner.addEventListener("click",()=>te.setBuildTool("autoScanner"));F.buttons.manualScanner.addEventListener("click",()=>te.setBuildTool("manualScanner"));F.buttons.mixer.addEventListener("click",()=>te.setBuildTool("mixer"));F.buttons.trash.addEventListener("click",()=>te.setBuildTool("trash"));F.buttons.truck.addEventListener("click",()=>te.setBuildTool("truck"));F.buttons.sausageMaker.addEventListener("click",()=>te.setBuildTool("sausageMaker"));F.buttons.maintenanceRoom.addEventListener("click",()=>te.setBuildTool("maintenanceRoom"));F.buttons.erase.addEventListener("click",()=>te.setBuildTool("erase"));F.debug.setDay?.addEventListener("click",Su);F.debug.zeroTimer?.addEventListener("click",gu);F.debug.grantCard?.addEventListener("click",Cu);F.debug.grantAllCards?.addEventListener("click",Mu);F.buttons.undo.addEventListener("click",()=>{Mr(u)&&(Ct(),J.updatePanels(),J.updateUI(),Jt())});F.buttons.redo.addEventListener("click",()=>{xr(u)&&(Ct(),J.updatePanels(),J.updateUI(),Jt())});function Ca(e,t,n){let o=e.keys?.[t];return o?o.code&&n.code===o.code||o.key&&n.key.toLowerCase()===o.key:!1}window.addEventListener("keydown",e=>{if(e.repeat)return;let t=e.key.toLowerCase();if(u.phase==="running")for(let n of u.scanners.filter(o=>o.kind==="manual")){if(Ca(n,"left",e)){e.preventDefault(),Ae.sortScanner(n,"left");return}if(Ca(n,"right",e)){e.preventDefault(),Ae.sortScanner(n,"right");return}}u.phase==="build"&&(e.ctrlKey||e.metaKey)&&t==="z"&&(e.preventDefault(),Mr(u)&&(Ct(),J.updatePanels(),J.updateUI())),u.phase==="build"&&(e.ctrlKey||e.metaKey)&&(t==="y"||e.shiftKey&&t==="z")&&(e.preventDefault(),xr(u)&&(Ct(),J.updatePanels(),J.updateUI())),e.key==="Escape"&&u.cardTargetPick?.pending&&(e.preventDefault(),u.cardTargetPick.mode==="autoScannerMenu"?mn():bn.cancelTargetPick())});te=pa({game:u,ui:F,canvas:he,canvasPoint:Bt,onUpdatePanels:()=>J?.updatePanels()});Ae=Sa({game:u,currentConveyorSpeed:Tu,onGameOverCheck:()=>J?.checkGameOver()});bn=Xs({game:u,ui:F,onUpdatePanels:()=>J?.updatePanels()});J=ya({game:u,ui:F,build:te,startGame:Eu,beginCardDraft:()=>bn.showDraft(),activeQueuedChick:Ae.activeQueuedChick});mu();vu();ir(u);Re(u);Ae.updateCongestion(u.frameId||0);J.updatePanels();J.updateUI();J.showTitle();Ot();requestAnimationFrame(Ra); diff --git a/index.html b/index.html index 905b0d5..3197a04 100644 --- a/index.html +++ b/index.html @@ -6,13 +6,13 @@ - Chick Sorter v27.20 — Card and Expansion Patch + Chick Sorter v28.21 — Boost Draw Hotfix
-
-
v27.20 VERIFIED
+
+
v28.21 VERIFIED
@@ -34,14 +34,12 @@
NO ACTIVE EVENT
BELT: 52 px/s
COMBO0
-
TRUCK TARGET: FEMALE
-

CHICK SORTER v27.20

-

Build, expand, connect at least one valid EGG route, then start the next day.

+

CHICK SORTER v28.21

@@ -53,11 +51,12 @@ - + +
@@ -94,6 +93,7 @@ +
@@ -110,6 +110,6 @@ - + diff --git a/package-lock.json b/package-lock.json index f368279..2c9dd91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index d948287..8d03f46 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/core/balance.js b/src/core/balance.js index 5713af2..2de476e 100644 --- a/src/core/balance.js +++ b/src/core/balance.js @@ -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 } diff --git a/src/core/config.js b/src/core/config.js index dfa135f..60aa59a 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -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', diff --git a/src/core/entities.js b/src/core/entities.js index 75d699e..9faf1dc 100644 --- a/src/core/entities.js +++ b/src/core/entities.js @@ -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 [ diff --git a/src/core/gridExpansion.js b/src/core/gridExpansion.js index b52f328..febbe9f 100644 --- a/src/core/gridExpansion.js +++ b/src/core/gridExpansion.js @@ -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)); } diff --git a/src/core/state.js b/src/core/state.js index 783cd54..0e87cde 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -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)); } diff --git a/src/core/text.js b/src/core/text.js index fb14317..1835764 100644 --- a/src/core/text.js +++ b/src/core/text.js @@ -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 `${equipmentName(id)}${buildToolPriceText(id)}${flavor ? `${flavor}` : ''}`; + const icon = buildToolIconSrc(id); + return `${icon ? `` : ''}${equipmentName(id)}${buildToolPriceText(id)}${flavor ? `${flavor}` : ''}`; } export function routeLabel(dest) { diff --git a/src/game.js b/src/game.js index 6915b84..2b73711 100644 --- a/src/game.js +++ b/src/game.js @@ -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 'WAIT'; - 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 `${label}`; } @@ -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(`${label}`); } @@ -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() { ${miniScannerGrid(scanner, chick)}
- - + +
`; }).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); diff --git a/src/render/draw.js b/src/render/draw.js index 343ccd4..e5111b2 100644 --- a/src/render/draw.js +++ b/src/render/draw.js @@ -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; diff --git a/src/systems/buildSystem.js b/src/systems/buildSystem.js index 403f2a3..425d4bc 100644 --- a/src/systems/buildSystem.js +++ b/src/systems/buildSystem.js @@ -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, diff --git a/src/systems/cards.js b/src/systems/cards.js index fc36b60..0b982cb 100644 --- a/src/systems/cards.js +++ b/src/systems/cards.js @@ -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 `
${icon.alt || ''}
`; } - return ``; + return ``; } function cardButton(card, index) { diff --git a/src/systems/chickSystem.js b/src/systems/chickSystem.js index 1662557..c39320b 100644 --- a/src/systems/chickSystem.js +++ b/src/systems/chickSystem.js @@ -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, diff --git a/src/systems/economy.js b/src/systems/economy.js index 89a094a..0733147 100644 --- a/src/systems/economy.js +++ b/src/systems/economy.js @@ -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 = {}) { diff --git a/src/systems/effects.js b/src/systems/effects.js index ebd444f..963f0af 100644 --- a/src/systems/effects.js +++ b/src/systems/effects.js @@ -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 }); diff --git a/src/systems/events.js b/src/systems/events.js index 47ec63e..523ff62 100644 --- a/src/systems/events.js +++ b/src/systems/events.js @@ -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; } diff --git a/src/systems/history.js b/src/systems/history.js index 83d421a..c86e9c0 100644 --- a/src/systems/history.js +++ b/src/systems/history.js @@ -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 || []); diff --git a/src/systems/maintenance.js b/src/systems/maintenance.js index 6321b38..f343832 100644 --- a/src/systems/maintenance.js +++ b/src/systems/maintenance.js @@ -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; diff --git a/src/systems/routing.js b/src/systems/routing.js index 99a74c2..c9074e5 100644 --- a/src/systems/routing.js +++ b/src/systems/routing.js @@ -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; -} diff --git a/src/systems/selectionSystem.js b/src/systems/selectionSystem.js index 0aaab89..5f501b6 100644 --- a/src/systems/selectionSystem.js +++ b/src/systems/selectionSystem.js @@ -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); } } diff --git a/src/systems/uiSystem.js b/src/systems/uiSystem.js index ebe08c6..180ef58 100644 --- a/src/systems/uiSystem.js +++ b/src/systems/uiSystem.js @@ -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 ? `NEXT DAYFAIRIES -${yen(nextTribute)}` : `${TEXT.actions.nextDay}`; - 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 = `
Male->MixerS1: A
Female->TruckS1: D, then S2: right
💩Poop->ShredderS1: D, then S2: left

Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.

`; + ui.modalBody.innerHTML = `
Male->Mixer -> MeatS1: A
Female->TruckS1: D, then S2: right
💩Poop->ShredderS1: D, then S2: left

Build phase: connect at least one EGG through a scanner to Mixer, Shredder, or Truck before Next Day. One day is one run.

`; 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
Chick shipment income${positive(r.chickShipmentIncome || 0)}
Poop shipment income${positive(r.poopShipmentIncome || 0)}
-
Mixer income${positive(r.mixerRevenue || 0)}
+
Sausage shipment income${positive(r.sausageRevenue || 0)}
+
Meat / sausages${r.meatProduced || 0} meat -> ${r.sausageProduced || 0} made / ${r.sausageShipped || 0} shipped
Composter income${positive(r.composterIncome || 0)}
DUD refund income${positive(r.dudRefundIncome || 0)}
Manual combo bonus${positive(r.manualComboBonus || 0)}
diff --git a/styles.css b/styles.css index ec641aa..0ea5e5c 100644 --- a/styles.css +++ b/styles.css @@ -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; }