From 94638200580f63150950c86431b1e76fb1c64f00 Mon Sep 17 00:00:00 2001 From: ApocDev Date: Fri, 10 Jul 2026 16:58:20 -0400 Subject: [PATCH 1/3] feat(bridge): refresh built machines automatically --- app/src/server/agent-tools.server.ts | 2 +- app/src/server/bridge/server.ts | 4 +- docs/ai-assistant.md | 7 +-- docs/bridge.md | 3 ++ mod/control.lua | 68 ++++++++++++++++++++++++++-- 5 files changed, 75 insertions(+), 9 deletions(-) diff --git a/app/src/server/agent-tools.server.ts b/app/src/server/agent-tools.server.ts index 9a85c4f9..60b214e9 100644 --- a/app/src/server/agent-tools.server.ts +++ b/app/src/server/agent-tools.server.ts @@ -1315,7 +1315,7 @@ export const logisticsFor = tool({ export const blockBuildStatus = tool({ description: - "Built-vs-required MACHINE status for blocks that ALREADY EXIST, from the last synced game state — the answer to 'what's left to build for the coke block' / 'which blocks are under-built'. Works OFFLINE: reads the block's cached solved machine requirement (block_machines, CEILED to whole buildings — same source submitBlock's `buildings` field reports) against the synced built-machine snapshot (built_machines); no live bridge call, no re-solve. STALE the moment the player places/removes something in-game until their next save-load or Sync in the PyOps panel — check `syncedAt` and say how old it is if it matters, or if it's null say no sync has ever happened. Pass `blockId` (a factoryBlocks id) for one block's full breakdown (returned even if fully built or the block is disabled, and `limit` is ignored); omit it to list up to `limit` ENABLED blocks with a shortfall, worst-missing first. Each `recipes` row is the machine + recipe + required whole-building count + built count + missing delta; `built`/`missing` come back null on a row whose machine type never reports a recipe to the game (boilers/generators/reactors/offshore-pumps — e.g. a local heat-source reactor) — those are instead summarized once per machine in `machineFallback` (requiredTotal/builtTotal/missing), since the game can't tell you which recipe it's running. Built counts are FORCE-WIDE, not block-scoped: if two blocks share the exact same machine+recipe, each independently compares against the same built count. For a NEW plan's cross-block machine bill use buildingBill instead — this tool audits blocks that already exist.", + "Built-vs-required MACHINE status for blocks that ALREADY EXIST, from the last synced game state — the answer to 'what's left to build for the coke block' / 'which blocks are under-built'. Works OFFLINE: reads the block's cached solved machine requirement (block_machines, CEILED to whole buildings — same source submitBlock's `buildings` field reports) against the synced built-machine snapshot (built_machines); no live bridge call, no re-solve. While the game bridge is connected, build/mine/death/recipe changes refresh this snapshot automatically within about one second, without opening the in-game panel; check `syncedAt` if freshness matters, or if it's null say no sync has ever happened. Pass `blockId` (a factoryBlocks id) for one block's full breakdown (returned even if fully built or the block is disabled, and `limit` is ignored); omit it to list up to `limit` ENABLED blocks with a shortfall, worst-missing first. Each `recipes` row is the machine + recipe + required whole-building count + built count + missing delta; `built`/`missing` come back null on a row whose machine type never reports a recipe to the game (boilers/generators/reactors/offshore-pumps — e.g. a local heat-source reactor) — those are instead summarized once per machine in `machineFallback` (requiredTotal/builtTotal/missing), since the game can't tell you which recipe it's running. Built counts are FORCE-WIDE, not block-scoped: if two blocks share the exact same machine+recipe, each independently compares against the same built count. For a NEW plan's cross-block machine bill use buildingBill instead — this tool audits blocks that already exist.", inputSchema: z.object({ blockId: z .number() diff --git a/app/src/server/bridge/server.ts b/app/src/server/bridge/server.ts index 9a235139..85038174 100644 --- a/app/src/server/bridge/server.ts +++ b/app/src/server/bridge/server.ts @@ -167,8 +167,8 @@ export function ensureBridge(): BridgeRuntime { } /** Send a message to the last peer we heard from (the connected mod). Returns - * false if no peer is known yet. Used to ask the mod to push its state on demand - * (the mod receives this only while its panel is open and polling). */ + * false if no peer is known yet. Used to ask the mod to push its state on demand; + * the mod polls for these messages independently of its panel visibility. */ export function sendToPeer(msg: BridgeResponse): boolean { const r = globalRef.__pyopsBridge; if (!r?.socket || !r.lastPeer) return false; diff --git a/docs/ai-assistant.md b/docs/ai-assistant.md index 5c639408..90ece454 100644 --- a/docs/ai-assistant.md +++ b/docs/ai-assistant.md @@ -307,9 +307,10 @@ count }[]` (fractional, module/TURD-beacon effects folded in — the same machine, the required WHOLE-building count (ceiled from `block_machines`' cached solved count — the same source `buildings` reports), the built count from `built_machines`, and the missing delta. Works entirely offline (no - bridge round-trip, no re-solve) — the answer is only as fresh as the last - save-load/Sync in the PyOps panel, so the tool returns `syncedAt`/ - `syncedCount` and its description tells the agent to flag staleness. Pass a + bridge round-trip, no re-solve). While the bridge is connected, build, mine, + death, and recipe changes refresh the snapshot within about one second without + opening the in-game panel; the tool still returns `syncedAt`/`syncedCount` so + the agent can flag stale offline data. Pass a `blockId` (a `factoryBlocks` id) for one block's full breakdown, even fully built or disabled (`limit` is ignored in this mode); omit it to list up to `limit` (1–30, default 10) **enabled** blocks with a shortfall, worst-missing diff --git a/docs/bridge.md b/docs/bridge.md index b63f4ba1..b8f9ca4a 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -27,6 +27,9 @@ on any page. The same tab hosts the companion-mod installer (see below). as the solver's synthetic `mine-` recipe, so built-vs-required lines up per ore), and item production stats — pushed on connect and on relevant in-game events, always as the full authoritative set (no delta merging on the app side). + Machine build/mine/death/recipe changes are debounced into a full refresh within + one second, independent of whether the in-game panel is open; a once-per-minute + reconciliation catches changes made by scripts that do not raise those events. - **Commands → game:** show a production-block panel in-game (`cmd.show_block`) and close it again (`cmd.hide_block`), locate producers/consumers/storage (`cmd.locate`, relayed to the diff --git a/mod/control.lua b/mod/control.lua index 6bcb9a96..e1113284 100644 --- a/mod/control.lua +++ b/mod/control.lua @@ -251,6 +251,10 @@ local MACHINE_TYPES = { "reactor", "offshore-pump", } +local MACHINE_TYPE_SET = {} +for _, machine_type in pairs(MACHINE_TYPES) do + MACHINE_TYPE_SET[machine_type] = true +end -- Types whose entities expose a craftable recipe we can read (assemblers, chem -- plants/refineries are all "assembling-machine"; furnaces auto-pick one). Mining -- drills have no recipe but DO have a resource target — those are keyed by the @@ -266,9 +270,10 @@ for status_name, value in pairs(defines.entity_status) do end -- Push how many of each machine the player has actually placed, keyed by the --- recipe each is set to craft. One full scan of the force's entities across all --- surfaces (a follow-up will maintain this incrementally via on_built/on_mined). --- Authoritative full snapshot, like research/TURD — the app replaces, never merges. +-- recipe each is set to craft. This is an authoritative full snapshot, like +-- research/TURD — the app replaces, never merges. Entity events below debounce +-- these scans so bursts of construction trigger one refresh rather than one per +-- entity; a slow reconciliation catches changes made by scripts we cannot observe. local function send_built_machines(player) local force = player.force if not (force and force.valid) then @@ -320,6 +325,17 @@ local function send_built_machines(player) send_request(player, "state.built", { force = force.name, machines = machines }) end +local built_state_dirty = false + +local function mark_built_state_dirty(event) + local entity = event.entity or event.created_entity or event.destination + -- A destroyed entity may already be invalid by the time its event is handled. + -- Refresh conservatively in that case; valid non-machine entities can be ignored. + if not (entity and entity.valid) or MACHINE_TYPE_SET[entity.type] then + built_state_dirty = true + end +end + -- Live production statistics. Factorio 2.0 exposes flow stats per surface, so we -- sum each good's rate across all surfaces. For production statistics, category -- "input" = produced, "output" = consumed; get_flow_count(count=false) is @@ -863,6 +879,30 @@ script.on_event(defines.events.on_player_joined_game, function(event) end end) +-- Keep built-vs-required counts live without coupling them to the PyOps panel. +-- Construction bursts are coalesced by the timer below. Settings paste and GUI +-- close cover recipe changes, because built counts are keyed by machine + recipe. +script.on_event({ + defines.events.on_built_entity, + defines.events.on_robot_built_entity, + defines.events.on_space_platform_built_entity, + defines.events.script_raised_built, + defines.events.script_raised_revive, + defines.events.on_player_mined_entity, + defines.events.on_robot_mined_entity, + defines.events.on_space_platform_mined_entity, + defines.events.on_entity_died, + defines.events.script_raised_destroy, + defines.events.on_entity_cloned, + defines.events.on_entity_settings_pasted, +}, mark_built_state_dirty) + +script.on_event(defines.events.on_gui_closed, function(event) + if event.entity and event.entity.valid and MACHINE_TYPE_SET[event.entity.type] then + built_state_dirty = true + end +end) + script.on_event(defines.events.on_lua_shortcut, function(event) if event.prototype_name ~= SHORTCUT_NAME then return @@ -1120,6 +1160,28 @@ script.on_nth_tick(600, function() end end) +-- Flush event-driven machine changes after at most one second. Also reconcile +-- once a minute so remote scripts or recipe changes without a dedicated event do +-- not leave the app stale. The full scan is intentionally not run per entity. +local BUILT_RECONCILE_TICKS = 60 * 60 +script.on_nth_tick(60, function(event) + if not udp_available then + return + end + if not built_state_dirty and event.tick % BUILT_RECONCILE_TICKS ~= 0 then + return + end + + local sent = false + for _, player in pairs(game.connected_players) do + send_built_machines(player) + sent = true + end + if sent then + built_state_dirty = false + end +end) + script.on_event(defines.events.on_udp_packet_received, function(event) local player = get_player(event) if not player then From d071a7d2cb83598c798f55550f8ff5d122c1659d Mon Sep 17 00:00:00 2001 From: ApocDev Date: Fri, 10 Jul 2026 21:51:03 -0400 Subject: [PATCH 2/3] feat(planner): add supply priority controls Add block-level priority presets, optional numeric and per-output overrides, and contextual help for the factory allocation workflow.\n\nRefs #140 --- README.md | 3 +- app/src/components/block/balance-card.tsx | 15 + .../components/block/block-help-content.tsx | 92 +++++++ app/src/components/block/block-toolbar.tsx | 256 +++++++++--------- app/src/components/block/doc-store.ts | 16 ++ app/src/components/block/goal-card.tsx | 11 +- .../block/output-priority-override.tsx | 49 ++++ .../block/supply-priority-control.tsx | 109 ++++++++ app/src/components/ui/tooltip.tsx | 8 +- app/src/db/schema.ts | 6 + app/src/lib/supply-priority.ts | 23 ++ app/src/routes/settings.tsx | 19 ++ app/src/routes/whatif.tsx | 16 ++ app/src/server/block-compute.server.ts | 4 + docs/design.md | 2 +- docs/solver.md | 13 + 16 files changed, 517 insertions(+), 125 deletions(-) create mode 100644 app/src/components/block/block-help-content.tsx create mode 100644 app/src/components/block/output-priority-override.tsx create mode 100644 app/src/components/block/supply-priority-control.tsx create mode 100644 app/src/lib/supply-priority.ts diff --git a/README.md b/README.md index a02cf4c0..08bdc91e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ it. Build/release details: [`docs/desktop.md`](docs/desktop.md). (cyclic loops, fluid temperatures, byproducts, spoilage). Pin counts, route byproducts, fold chains into sub-blocks, or extract a recipe into its own block. - **Balance the whole factory** — every block's imports/exports roll into one - ledger (deficits, surpluses, built-vs-required machines), with what-if. + ledger (deficits, surpluses, built-vs-required machines), with what-if. Supply + priorities let recovery blocks feed demand before dedicated fallback production. - **Explore the data** — a searchable catalogue with a recipe explorer (producers/ consumers ranked and availability-grouped) and a dependency-tree explorer. - **Track TURD & research** — Py's tech upgrades are first-class; pick a path and diff --git a/app/src/components/block/balance-card.tsx b/app/src/components/block/balance-card.tsx index 671318fe..8456c865 100644 --- a/app/src/components/block/balance-card.tsx +++ b/app/src/components/block/balance-card.tsx @@ -10,6 +10,7 @@ import { fmtTemp } from "../../lib/format"; import { ItemChip, type Link as ItemLink } from "./item-chip.tsx"; import { LogiTag } from "./logi-tag.tsx"; import { SushiPlanner, type SushiPlannerFlow } from "./sushi-planner.tsx"; +import { OutputPriorityOverride } from "./output-priority-override.tsx"; import type { BlockDocStore } from "./doc-store.ts"; import type { LogiView, SolveResult } from "./solve-view.ts"; import { num } from "./format.ts"; @@ -57,6 +58,8 @@ export function BalanceCard({ onOpenSpoilDialog: (name: string) => void; }) { const blockName = useStore(doc.store, (s) => s.blockName); + const supplyPriority = useStore(doc.store, (s) => s.supplyPriority ?? 0); + const supplyPriorities = useStore(doc.store, (s) => s.supplyPriorities ?? {}); const spoilables = useSpoilables(); // One mixed loop candidate: every solid item touching a belt in this block — // imports, exports, AND internal row-to-row flows all ride the same loop in @@ -402,6 +405,18 @@ export function BalanceCard({ onCtxMenu(e, { name: f.name, kind: f.kind, link: "export" }) } /> + { + doc.setOutputSupplyPriority(f.name, priority); + doc.note( + priority == null + ? `Inherit block supply priority for "${res.display?.[f.name] ?? f.name}"` + : `Override supply priority for "${res.display?.[f.name] ?? f.name}"`, + ); + }} + /> {/* incidental-spoil risk (#20): a SURPLUS spoilable is the one that actually sits around long enough to rot — stacked under the chip so it doesn't widen the export grid */} diff --git a/app/src/components/block/block-help-content.tsx b/app/src/components/block/block-help-content.tsx new file mode 100644 index 00000000..7b34abe8 --- /dev/null +++ b/app/src/components/block/block-help-content.tsx @@ -0,0 +1,92 @@ +import { ArrowDown, ArrowUp, Minus } from "lucide-react"; + +const sectionClass = "border-t border-border pt-2"; +const summaryClass = "cursor-pointer text-sm font-semibold text-foreground"; + +export function BlockHelpContent() { + return ( +
+

+ A block is one production unit: choose its goals and recipes, then PyOps calculates the + buildings and boundary flows. +

+
    +
  1. Add one or more goal goods and set their rates.
  2. +
  3. Add the recipes that make them.
  4. +
  5. Review imports, exports, buildings, and any solve warnings.
  6. +
+ +
+ Goals and rates +
+

+ Each goal is a minimum output rate. Click its rate to edit it and its unit to cycle + between /s, /min, and /h. The first goal names the block and anchors factory scaling. +

+

+ Right-click a goal to keep a quantity in stock instead of treating it as continuous + throughput. Extra output beyond a goal becomes an export. +

+
+
+ +
+ Supply priority +
+

+ Preferred suppliers are used first. +

+

+ Normal suppliers follow. +

+

+ Fallback suppliers fill what remains. +

+

+ Priority chooses between blocks supplying the same good. It never scales a block solely + to manufacture an incidental byproduct. Advanced mode allows numeric tiers and + per-export overrides; exports inherit the block priority by default. +

+
+
+ +
+ How solving works +
+

+ You choose the recipes and constraints. PyOps finds the lowest machine-time rates that + satisfy them, including cyclic chains. Marking a good made here prevents importing it; + unmarked consumption may cross the block boundary as an import. +

+

+ An infeasible result means the selected goals, marks, or pins conflict. The balance card + identifies the conflict and offers relevant fixes. +

+
+
+ +
+ Spoilage +

+ Stopwatch markers identify spoilable products. Incidental spoilage estimates add the spoil + result to exports without changing the nominal recipe solve. For demand-driven spoilage, + add the actual spoiling recipe and make its result a goal. +

+
+ +
+ Sub-blocks and toolbar +
+

+ Right-click a recipe name to group related rows into a collapsible sub-block. Grouping + changes presentation unless you explicitly promote it to a separately solved module. +

+

+ The toolbar provides copy, share-file export, snapshots, in-game display, construction + cost, and block enable/disable controls. +

+
+
+
+ ); +} diff --git a/app/src/components/block/block-toolbar.tsx b/app/src/components/block/block-toolbar.tsx index e58b7711..f1476017 100644 --- a/app/src/components/block/block-toolbar.tsx +++ b/app/src/components/block/block-toolbar.tsx @@ -24,6 +24,11 @@ import { } from "#/components/ui/sheet.tsx"; import { Tooltip } from "#/components/ui/tooltip.tsx"; import { HelpButton } from "#/components/help-drawer.tsx"; +import { BlockHelpContent } from "./block-help-content.tsx"; + +// Kept temporarily during the UI review so the rewritten help can be compared +// against the previous long-form copy before that reference text is deleted. +const SHOW_LEGACY_BLOCK_HELP: boolean = false; import { InfoHint } from "#/components/info-hint.tsx"; import { Icon } from "../../lib/icons"; import { Legend } from "./legend.tsx"; @@ -284,127 +289,136 @@ export function BlockToolbar({ -

- A block is one production unit you design: pick - the recipes to make one or more goal goods, and the solver works out how many of each - building you need (fractional counts and all). -

-
-
Goals
-

- A block can target several products at once — each goal has a{" "} - target rate and the block is sized so that good - comes out at exactly that rate. Click a goal's rate to edit it, and click its unit - to cycle /s → /min → /h — enter science as - 10/min or a slow bootstrap as 0.5/h; the unit sticks per goal while the solver works in - per-second underneath. Not everything is throughput:{" "} - right-click a goal → Keep in stock turns it - into a buffer goal ("keep 100 on hand") with a refill window (default 10m, - click to cycle) — machines are sized to rebuild the buffer within the window, and the - factory ledger badges the flow ↻ stock. So a single - "logistics" block can make belts @10/s, undergrounds @4/s and splitters @2/s - side by side. The first goal names the block, anchors - the scale tools, and is the default icon;{" "} - moves a goal to the front. Click - the icon next to the block's name to pick any item or fluid as its icon instead. A - good you don't target isn't a goal — it falls out as a byproduct (export). -

-

- A goal is a floor, not an exact pin: the solver builds the cheapest plan making{" "} - at least that rate, so when one recipe makes - two goals in a fixed ratio, the tight one lands exactly and the other's extra - simply exports. Only a genuinely impossible ask reads{" "} - infeasible — and the balance card then names - the exact goals, marks, and pins in conflict, each with a one-click fix. -

-
-

- How it solves. Adding a producer through an - item's chip marks that good made in this block: - production must cover consumption, surplus exports, and it never imports. Everything else - is free — consumption imports, and an incidental byproduct just offsets the import - (it's never scaled up to cover demand). The solver picks the cheapest run-rates - (fewest machine-seconds) satisfying the goals, marks, and pins — it handles cyclic recipe - chains, and identical inputs always solve identically. -

-
-
Spoilage
-

- A stopwatch on a recipe means at least one product can spoil; each product shows its own - spoil time. Use Estimate incidental spoilage on - a good to record the rough rate expected while production is backed up. The estimate - does not change the block's nominal recipes, machines, or imports: its spoil result - appears in Exports as an ordinary byproduct at the estimated rate. Factory-wide - balancing pools that current surplus, but byproduct demand never scales the source - block. If that result is also an intentional goal, the estimate appears beneath the goal - instead of being repeated in Exports. For demand-driven spoilage, add the actual - spoiling recipe and make its result a goal. -

-
-

- You drive it, not an optimizer. You choose the - recipes, pin building counts, and split a good between competing consumers; PyOps solves - the system you describe. Right-click any item for - its actions — make it a goal, lock it as a sizing input, mark it made-in-block or import - it instead, or locate it in game; the colored legend shows each item's role. -

-
-
Sub-blocks
-

- Right-click a recipe's name to start a - sub-block — a named, collapsible group of rows. Add more rows from the same menu or by - dragging them onto the header; collapse it and the whole chain reads as one line showing - its net flows (what goes in, what comes out — - intermediates cancel), machines and power. Display-only: the solve is exactly the same - expanded, collapsed, or dissolved. Drag the header to move the whole chain; double-click - its name to rename; × ungroups (the rows stay). -

-
-
-
Toolbar (next to the name)
-
    -
  • - - copies this block's recipe/module setup to the clipboard; -
  • -
  • - - - exports this block as a shareable JSON file — import it into any project from{" "} - Settings → Backup & share; - -
  • -
  • - - - Snapshots — this block's restore - points: take a named snapshot before a big refactor, diff any snapshot against the - current state, and restore it (automatic snapshots are also taken before deletes, - restores, and resizes); - -
  • -
  • - - - shows this block as an in-game build sheet — click a building there for a configured - blueprint; - -
  • -
  • - - - Building summary — opens a drawer listing - the buildings and the one-time materials to construct this block (a shopping list, - kept out of the way of the per-second flows). - -
  • -
-
-

- Per-machine modules / beacons are tuned in the - block body to cut building count. The Cybersyn request-combinator generator now lives in - the in-game mod panel. -

+ + {SHOW_LEGACY_BLOCK_HELP && ( + <> +

+ A block is one production unit you design: + pick the recipes to make one or more goal goods, and the solver works out how many of + each building you need (fractional counts and all). +

+
+
Goals
+

+ A block can target several products at once — each goal has a{" "} + target rate and the block is sized so that + good comes out at exactly that rate. Click a goal's rate to edit it, and click + its unit to cycle /s → /min → /h — enter + science as 10/min or a slow bootstrap as 0.5/h; the unit sticks per goal while the + solver works in per-second underneath. Not everything is throughput:{" "} + right-click a goal → Keep in stock turns it + into a buffer goal ("keep 100 on hand") with a refill window (default 10m, + click to cycle) — machines are sized to rebuild the buffer within the window, and + the factory ledger badges the flow ↻ stock. So a + single "logistics" block can make belts @10/s, undergrounds @4/s and + splitters @2/s side by side. The first goal{" "} + names the block, anchors the scale tools, and is + the default icon; moves a goal + to the front. Click the icon next to the block's name to pick any item or fluid + as its icon instead. A good you don't target isn't a goal — it falls out + as a byproduct (export). +

+

+ A goal is a floor, not an exact pin: the solver builds the cheapest plan making{" "} + at least that rate, so when one recipe + makes two goals in a fixed ratio, the tight one lands exactly and the other's + extra simply exports. Only a genuinely impossible ask reads{" "} + infeasible — and the balance card then + names the exact goals, marks, and pins in conflict, each with a one-click fix. +

+
+

+ How it solves. Adding a producer through an + item's chip marks that good{" "} + made in this block: production must cover + consumption, surplus exports, and it never imports. Everything else is free — + consumption imports, and an incidental byproduct just offsets the import (it's + never scaled up to cover demand). The solver picks the cheapest run-rates (fewest + machine-seconds) satisfying the goals, marks, and pins — it handles cyclic recipe + chains, and identical inputs always solve identically. +

+
+
Spoilage
+

+ A stopwatch on a recipe means at least one product can spoil; each product shows its + own spoil time. Use{" "} + Estimate incidental spoilage on a good to + record the rough rate expected while production is backed up. The estimate does not + change the block's nominal recipes, machines, or imports: its spoil result + appears in Exports as an ordinary byproduct at the estimated rate. Factory-wide + balancing pools that current surplus, but byproduct demand never scales the source + block. If that result is also an intentional goal, the estimate appears beneath the + goal instead of being repeated in Exports. For demand-driven spoilage, add the + actual spoiling recipe and make its result a goal. +

+
+

+ You drive it, not an optimizer. You choose + the recipes, pin building counts, and split a good between competing consumers; PyOps + solves the system you describe. Right-click{" "} + any item for its actions — make it a goal, lock it as a sizing input, mark it + made-in-block or import it instead, or locate it in game; the colored legend shows + each item's role. +

+
+
Sub-blocks
+

+ Right-click a recipe's name to start a + sub-block — a named, collapsible group of rows. Add more rows from the same menu or + by dragging them onto the header; collapse it and the whole chain reads as one line + showing its net flows (what goes in, what + comes out — intermediates cancel), machines and power. Display-only: the solve is + exactly the same expanded, collapsed, or dissolved. Drag the header to move the + whole chain; double-click its name to rename; × ungroups (the rows stay). +

+
+
+
Toolbar (next to the name)
+
    +
  • + + copies this block's recipe/module setup to the clipboard; +
  • +
  • + + + exports this block as a shareable JSON file — import it into any project from{" "} + Settings → Backup & share; + +
  • +
  • + + + Snapshots — this block's restore + points: take a named snapshot before a big refactor, diff any snapshot against + the current state, and restore it (automatic snapshots are also taken before + deletes, restores, and resizes); + +
  • +
  • + + + shows this block as an in-game build sheet — click a building there for a + configured blueprint; + +
  • +
  • + + + Building summary — opens a drawer + listing the buildings and the one-time materials to construct this block (a + shopping list, kept out of the way of the per-second flows). + +
  • +
+
+

+ Per-machine modules / beacons are tuned in + the block body to cut building count. The Cybersyn request-combinator generator now + lives in the in-game mod panel. +

+ + )}
); diff --git a/app/src/components/block/doc-store.ts b/app/src/components/block/doc-store.ts index f36186f6..5ce28ef6 100644 --- a/app/src/components/block/doc-store.ts +++ b/app/src/components/block/doc-store.ts @@ -54,6 +54,8 @@ export type BlockDocState = { * server keeps deriving from it; dropped from the doc after adoption */ dispositions: Record; spoilRates: Record; + supplyPriority: number; + supplyPriorities: Record; rowGroups: RowGroup[]; recipeGroups: GroupAssign; machines: Record; @@ -77,6 +79,8 @@ const EMPTY: BlockDocState = { pins: [], dispositions: {}, spoilRates: {}, + supplyPriority: 0, + supplyPriorities: {}, rowGroups: [], recipeGroups: {}, machines: {}, @@ -100,6 +104,8 @@ export function solveInputOf(s: BlockDocState): SolveInput { ...(disabledRecipes.length ? { disabledRecipes } : {}), ...(s.rowGroups.length ? { rowGroups: s.rowGroups, recipeGroups: s.recipeGroups } : {}), ...(Object.keys(s.spoilRates).length ? { spoilRates: s.spoilRates } : {}), + ...(s.supplyPriority !== 0 ? { supplyPriority: s.supplyPriority } : {}), + ...(Object.keys(s.supplyPriorities).length ? { supplyPriorities: s.supplyPriorities } : {}), // adopted docs persist `made` (and never dispositions); legacy docs keep // shipping their dispositions so the server can derive ...(s.made ? { made: [...s.made].sort() } : {}), @@ -156,6 +162,8 @@ export function createBlockDocStore() { pins: d.pins ?? [], dispositions: (d.dispositions ?? {}) as Record, spoilRates: d.spoilRates ?? {}, + supplyPriority: d.supplyPriority ?? 0, + supplyPriorities: d.supplyPriorities ?? {}, machines: d.machines ?? {}, fuels: d.fuels ?? {}, modules: d.modules ?? {}, @@ -256,6 +264,14 @@ export function createBlockDocStore() { setCustomIcon: (icon: { kind: string; name: string } | null) => edit(() => ({ customIcon: icon })), setBlockName: (blockName: string) => edit(() => ({ blockName })), + setSupplyPriority: (supplyPriority: number) => edit(() => ({ supplyPriority })), + setOutputSupplyPriority: (name: string, priority: number | null) => + edit((s) => ({ + supplyPriorities: + priority == null + ? withoutKey(s.supplyPriorities, name) + : { ...s.supplyPriorities, [name]: priority }, + })), /* ── recipes ── */ addRecipe: (name: string) => diff --git a/app/src/components/block/goal-card.tsx b/app/src/components/block/goal-card.tsx index ec9cd971..017450d8 100644 --- a/app/src/components/block/goal-card.tsx +++ b/app/src/components/block/goal-card.tsx @@ -10,6 +10,7 @@ import { EditableStock } from "./editable-stock.tsx"; import { LogiTag } from "./logi-tag.tsx"; import type { BlockDocStore } from "./doc-store.ts"; import type { LogiView, SolveResult } from "./solve-view.ts"; +import { SupplyPriorityControl } from "./supply-priority-control.tsx"; /** The Goal card: goals as compact stacked cells (icon over rate) so many fit — * a block can target several products at once. Each goal has a target rate (a @@ -39,11 +40,19 @@ export function GoalCard({ onOpenGoalPicker: () => void; }) { const goals = useStore(doc.store, (s) => s.goals); + const supplyPriority = useStore(doc.store, (s) => s.supplyPriority ?? 0); const target = goals[0]?.name ?? ""; return ( - + Goal + { + doc.setSupplyPriority(priority); + doc.note("Set block supply priority"); + }} + />
diff --git a/app/src/components/block/output-priority-override.tsx b/app/src/components/block/output-priority-override.tsx new file mode 100644 index 00000000..f8375198 --- /dev/null +++ b/app/src/components/block/output-priority-override.tsx @@ -0,0 +1,49 @@ +import { useSyncExternalStore } from "react"; +import { Input } from "#/components/ui/input.tsx"; +import { Button } from "#/components/ui/button.tsx"; +import { Tooltip } from "#/components/ui/tooltip.tsx"; +import { + getAdvancedSupplyPriorities, + subscribeAdvancedSupplyPriorities, +} from "#/lib/supply-priority.ts"; + +export function OutputPriorityOverride({ + inherited, + value, + onChange, +}: { + inherited: number; + value: number | undefined; + onChange: (value: number | null) => void; +}) { + const advanced = useSyncExternalStore( + subscribeAdvancedSupplyPriorities, + getAdvancedSupplyPriorities, + () => false, + ); + if (!advanced) return null; + + return ( + + + output + + onChange(event.target.value === "" ? null : Number(event.target.value) || 0) + } + className="h-7 w-16 px-1 text-right" + /> + {value != null && ( + + )} + + + ); +} diff --git a/app/src/components/block/supply-priority-control.tsx b/app/src/components/block/supply-priority-control.tsx new file mode 100644 index 00000000..47be8cdd --- /dev/null +++ b/app/src/components/block/supply-priority-control.tsx @@ -0,0 +1,109 @@ +import { useState, useSyncExternalStore } from "react"; +import { ArrowDown, ArrowUp, Check, Minus } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/ui/dropdown-menu.tsx"; +import { Button } from "#/components/ui/button.tsx"; +import { Input } from "#/components/ui/input.tsx"; +import { Tooltip } from "#/components/ui/tooltip.tsx"; +import { + getAdvancedSupplyPriorities, + subscribeAdvancedSupplyPriorities, + SUPPLY_PRIORITY, +} from "#/lib/supply-priority.ts"; + +const presets = [ + { + value: SUPPLY_PRIORITY.preferred, + label: "Preferred", + icon: ArrowUp, + color: "text-success", + help: "Preferred supply — use this block before Normal and Fallback suppliers.", + }, + { + value: SUPPLY_PRIORITY.normal, + label: "Normal", + icon: Minus, + color: "text-muted-foreground", + help: "Normal supply — use after Preferred and before Fallback suppliers.", + }, + { + value: SUPPLY_PRIORITY.fallback, + label: "Fallback", + icon: ArrowDown, + color: "text-warning", + help: "Fallback supply — use this block only after higher-priority suppliers.", + }, +] as const; + +export function SupplyPriorityControl({ + value, + onChange, +}: { + value: number; + onChange: (value: number) => void; +}) { + const normalized = Number.isFinite(value) ? value : SUPPLY_PRIORITY.normal; + const advanced = useSyncExternalStore( + subscribeAdvancedSupplyPriorities, + getAdvancedSupplyPriorities, + () => false, + ); + const [menuOpen, setMenuOpen] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); + const preset = presets.find((option) => option.value === normalized) ?? presets[1]; + const PriorityIcon = preset.icon; + + return advanced ? ( + onChange(Number(event.target.value) || 0)} + className="h-7 w-16 px-1 text-right" + /> + ) : ( + { + setMenuOpen(open); + if (open) setTooltipOpen(false); + }} + > + { + if (!menuOpen) setTooltipOpen(open); + }} + > + + + + + + {presets.map((option) => { + const Icon = option.icon; + return ( + onChange(option.value)}> + + {option.label} + {option.value === normalized && } + + ); + })} + + + ); +} diff --git a/app/src/components/ui/tooltip.tsx b/app/src/components/ui/tooltip.tsx index b370a414..1fcd40f2 100644 --- a/app/src/components/ui/tooltip.tsx +++ b/app/src/components/ui/tooltip.tsx @@ -32,6 +32,8 @@ function Tooltip({ delayDuration = 250, label = false, className, + open, + onOpenChange, }: { content: React.ReactNode; children: React.ReactNode; @@ -48,6 +50,10 @@ function Tooltip({ */ label?: boolean; className?: string; + /** Controlled state for composite controls that must suppress the tooltip + * while a menu, popover, or dialog launched by the trigger is open. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { if (content == null || content === "" || content === false) return <>{children}; const trigger = @@ -60,7 +66,7 @@ function Tooltip({ : children; return ( - + {trigger} ; + /** Factory-level preference for this block. Higher-priority blocks satisfy + * shared demand before lower-priority fallbacks. */ + supplyPriority?: number; + /** Optional per-good overrides for multiproduct blocks. Missing entries + * inherit the block-wide supplyPriority. */ + supplyPriorities?: Record; // Legacy per-item overrides (pre-#91). New docs write `made`/`pins` instead; // a doc carrying only dispositions has its made set derived server-side on // solve and adopted by the editor (lazy migration). diff --git a/app/src/lib/supply-priority.ts b/app/src/lib/supply-priority.ts new file mode 100644 index 00000000..15ae10c7 --- /dev/null +++ b/app/src/lib/supply-priority.ts @@ -0,0 +1,23 @@ +const KEY = "pyops.advancedSupplyPriorities"; +const EVENT = "pyops-supply-priority-preference"; + +export const SUPPLY_PRIORITY = { + preferred: 100, + normal: 0, + fallback: -100, +} as const; + +export function getAdvancedSupplyPriorities() { + return typeof localStorage !== "undefined" && localStorage.getItem(KEY) === "1"; +} + +export function setAdvancedSupplyPriorities(enabled: boolean) { + if (typeof localStorage !== "undefined") localStorage.setItem(KEY, enabled ? "1" : "0"); + if (typeof window !== "undefined") window.dispatchEvent(new Event(EVENT)); +} + +export function subscribeAdvancedSupplyPriorities(onChange: () => void) { + if (typeof window === "undefined") return () => undefined; + window.addEventListener(EVENT, onChange); + return () => window.removeEventListener(EVENT, onChange); +} diff --git a/app/src/routes/settings.tsx b/app/src/routes/settings.tsx index 229d0a3b..6a61b31b 100644 --- a/app/src/routes/settings.tsx +++ b/app/src/routes/settings.tsx @@ -43,6 +43,11 @@ import { subscribeNumberFormat, } from "../lib/format"; import { getTheme, setTheme, subscribeTheme, type ThemePref } from "../lib/theme"; +import { + getAdvancedSupplyPriorities, + setAdvancedSupplyPriorities, + subscribeAdvancedSupplyPriorities, +} from "../lib/supply-priority"; import { Select, SelectContent, @@ -389,6 +394,11 @@ function ModsCard({ function DisplayCard() { const compact = useSyncExternalStore(subscribeNumberFormat, getCompactNumbers, () => true); const theme = useSyncExternalStore(subscribeTheme, getTheme, () => "dark" as ThemePref); + const advancedPriorities = useSyncExternalStore( + subscribeAdvancedSupplyPriorities, + getAdvancedSupplyPriorities, + () => false, + ); return ( @@ -428,6 +438,15 @@ function DisplayCard() { setCompactNumbers(v)} /> +
); diff --git a/app/src/routes/whatif.tsx b/app/src/routes/whatif.tsx index 4027e599..834331b2 100644 --- a/app/src/routes/whatif.tsx +++ b/app/src/routes/whatif.tsx @@ -88,6 +88,12 @@ function WhatIf() { block's current rate, the required rate, and the ×scale to get there. Click a block to open its editor. +
  • + Supply priority — when several blocks + can supply the same good, Preferred blocks are used before Normal blocks, and + Fallback blocks fill what remains. Set a block's priority from the icon + beside its Goal heading. +
  • Raw inputs — what the new target draws in from outside (current vs projected). @@ -98,6 +104,16 @@ function WhatIf() {
  • +
    +
    Supply priority
    +

    + Priority chooses between competing suppliers; it does not replace block goals or + constraints. A block is never scaled solely to manufacture an incidental + byproduct. In Advanced supply priorities mode, numeric tiers are allowed and an + individual export can override its block's priority; otherwise every export + inherits the block setting. +

    +
    Worked example

    diff --git a/app/src/server/block-compute.server.ts b/app/src/server/block-compute.server.ts index 22095059..58ea12d0 100644 --- a/app/src/server/block-compute.server.ts +++ b/app/src/server/block-compute.server.ts @@ -236,6 +236,10 @@ export type SolveInput = { * it leaves the nominal recipe solve untouched and adds the item's spoil result * to the boundary byproducts after the solve. */ spoilRates?: Record; + /** factory allocation tier for this block; higher is preferred */ + supplyPriority?: number; + /** optional exported-good overrides; missing entries inherit supplyPriority */ + supplyPriorities?: Record; /** legacy per-item overrides (pre-#91 docs) — migrated to `made` on read; new * docs never write this */ dispositions?: Record; diff --git a/docs/design.md b/docs/design.md index 58706d53..c6fb4bce 100644 --- a/docs/design.md +++ b/docs/design.md @@ -140,7 +140,7 @@ Reach for these before writing markup: | Retryable error surface | `QueryError` (`components/query-error.tsx`) — a destructive `Callout` that names what failed with a "Retry" button (wire to `refetch`). For a whole-route (thrown) failure the router's `RouteError` (`components/route-error.tsx`, the root `errorComponent`) catches it; `RoutePending` (`components/route-pending.tsx`) is the root `pendingComponent`. | | Search box over a list | `FilterInput` (`components/filter-input.tsx`) + `useFilteredList` (`lib/use-filtered-list.ts`) + `FilterEmptyState` (`components/filter-empty-state.tsx`) — the one filtered-list anatomy (#87): localized display names first, internal names as a hidden fallback, results ranked by the command palette's scorer (`lib/command-search.ts`), and a "no matches for X" state with a clear-filter action. Don't hand-roll `useState` + `toLowerCase().includes(…)`. | | Loading placeholder | `Skeleton` (`ui/skeleton.tsx`). | -| Hover text tooltip | `Tooltip` (`ui/tooltip.tsx`) — a themed, keyboard-accessible (hover **and** focus, Escape-to-close) text bubble built on radix Tooltip. Use for explanatory/warning text and branded icon controls whose action isn't self-evident. Prefer it over the native `title` attribute, which can't be styled, is delayed, invisible on touch, and unreliable for screen readers. Plain full-name reveal on a truncated label (`title={display}` on a `.truncate` span) may stay on native `title`. | +| Hover text tooltip | `Tooltip` (`ui/tooltip.tsx`) — a themed, keyboard-accessible (hover **and** focus, Escape-to-close) text bubble built on radix Tooltip. Use for explanatory/warning text and branded icon controls whose action isn't self-evident. Do not use native `title` for controls: it can't be styled or suppressed with CSS and can overlap an opened menu. Give icon controls an `aria-label`; when the same trigger opens a menu/popover/dialog, control the tooltip's `open` state and force it closed while that surface is open. Native `title` is reserved for plain full-name reveal on non-interactive truncated text (`title={display}` on a `.truncate` span). | | Rich hover card | `CursorHover`/`CursorCard` (`lib/hover.tsx`) — a cursor-following portal card for rich detail (item/recipe/tech/module loadouts); `Icon` shows one by default. Pass surface-specific context through `extraText` so it joins that card. Never wrap a hover-enabled `Icon` in `Tooltip` or put a native `title` on its trigger; use `noHover` when a plain `Tooltip` genuinely owns the surface. Not for plain text — that's `Tooltip`. | | Right-click menu | `ContextMenu`/`ContextMenuItem` (`components/context-menu.tsx`) — a Radix `DropdownMenu` anchored at the pointer (Escape-to-close, focus/roving, `role="menu"`, click-away for free); items are icon+label rows. | | Keyboard shortcut | `useHotkey` (`lib/hotkeys.ts`) — the one global hotkey registry. `mod+` combos resolve to Cmd on macOS / Ctrl elsewhere; combos are suppressed while focus is in a text field unless the registration opts in with `allowInInputs` (reserved for app-global chords like Ctrl+K). Every registration carries a `description`, rendered by the shortcut help sheet (`components/shortcut-help-sheet.tsx` — `?` or the palette's "Keyboard shortcuts" entry). Don't hand-roll `window.addEventListener("keydown", …)` for shortcuts. | diff --git a/docs/solver.md b/docs/solver.md index 4731d6b5..e12a0a20 100644 --- a/docs/solver.md +++ b/docs/solver.md @@ -335,6 +335,19 @@ scaling, which is always feasible and matches "scale each block up/down to meet demand". It's report-only: it never writes; you adjust each block by hand (or ignore the suggestion). +Each block can carry a **supply priority**. The normal UI exposes three strict +tiers: Preferred, Normal, and Fallback. When several blocks can supply the same +good, the factory solve exhausts higher-priority available supply before using a +lower tier. Priority is an allocation preference, not a reason to run a block: an +incidental/byproduct export never causes its source block to scale solely to make +more of that byproduct. + +Advanced supply priorities expose arbitrary numeric tiers (higher numbers first). +A multiproduct block may also override its block-wide priority for one exported +good; exports without an override inherit the block setting. Equal priorities use +the ordinary factory objective. Priority does not change the block's goals, pins, +or internal recipe solve. + Two energy pseudo-goods stay **free boundaries** (never balanced across blocks): `pyops-electricity` (grid-distributed — matching it would create a power feedback loop) and `pyops-heat` (block-local by game rule). `pyops-fluid-fuel` is **not** From 0ae644162e1ad5c25ed86bd20402baa96bb94307 Mon Sep 17 00:00:00 2001 From: ApocDev Date: Fri, 10 Jul 2026 22:05:39 -0400 Subject: [PATCH 3/3] feat(solver): prioritize factory supply allocation Allocate shared demand through strict priority tiers, cap incidental supply at its available rate, and report the chosen suppliers in What-if.\n\nCloses #140 --- app/e2e/mut/supply-priority.e2e.ts | 31 ++++ app/src/components/block/block-toolbar.tsx | 134 ------------------ .../whatif/supply-allocation-card.tsx | 68 +++++++++ app/src/db/queries.server.ts | 28 +++- app/src/db/queries.test.ts | 8 +- app/src/routes/whatif.tsx | 2 + app/src/server/factorio.ts | 12 +- app/src/server/factory-rebalance.test.ts | 1 + app/src/server/factory-solve.server.ts | 128 ++++++++++++++--- app/src/server/factory-solve.test.ts | 75 ++++++++++ docs/solver.md | 8 +- 11 files changed, 327 insertions(+), 168 deletions(-) create mode 100644 app/e2e/mut/supply-priority.e2e.ts create mode 100644 app/src/components/whatif/supply-allocation-card.tsx diff --git a/app/e2e/mut/supply-priority.e2e.ts b/app/e2e/mut/supply-priority.e2e.ts new file mode 100644 index 00000000..d76c8880 --- /dev/null +++ b/app/e2e/mut/supply-priority.e2e.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { goto } from "./helpers"; + +test("block supply priority can be changed and persists", async ({ page }) => { + await goto(page, "/block"); + + const block = page + .getByRole("complementary") + .getByRole("button", { name: "Transport belt", exact: true }); + await expect(block).toBeVisible(); + await block.click(); + + const priority = page.getByRole("button", { name: /Block supply priority:/ }); + await expect(priority).toBeVisible(); + const original = (await priority.getAttribute("aria-label"))?.replace("Block supply priority: ", ""); + expect(original).toMatch(/Preferred|Normal|Fallback/); + const next = original === "Preferred" ? "Fallback" : "Preferred"; + + await priority.click(); + await page.getByRole("menuitem", { name: next }).click(); + await expect(priority).toHaveAccessibleName(`Block supply priority: ${next}`); + + // Block edits auto-save. Reloading proves the setting survived the server round trip. + await page.waitForTimeout(1_000); + await page.reload(); + await expect(page.getByRole("button", { name: `Block supply priority: ${next}` })).toBeVisible(); + + // Restore the scratch project for subsequent mutating specs. + await page.getByRole("button", { name: `Block supply priority: ${next}` }).click(); + await page.getByRole("menuitem", { name: original! }).click(); +}); diff --git a/app/src/components/block/block-toolbar.tsx b/app/src/components/block/block-toolbar.tsx index f1476017..68a97fec 100644 --- a/app/src/components/block/block-toolbar.tsx +++ b/app/src/components/block/block-toolbar.tsx @@ -9,7 +9,6 @@ import { History, Power, Sparkles, - Star, } from "lucide-react"; import { Badge } from "#/components/ui/badge.tsx"; import { Button } from "#/components/ui/button.tsx"; @@ -25,10 +24,6 @@ import { import { Tooltip } from "#/components/ui/tooltip.tsx"; import { HelpButton } from "#/components/help-drawer.tsx"; import { BlockHelpContent } from "./block-help-content.tsx"; - -// Kept temporarily during the UI review so the rewritten help can be compared -// against the previous long-form copy before that reference text is deleted. -const SHOW_LEGACY_BLOCK_HELP: boolean = false; import { InfoHint } from "#/components/info-hint.tsx"; import { Icon } from "../../lib/icons"; import { Legend } from "./legend.tsx"; @@ -290,135 +285,6 @@ export function BlockToolbar({ - {SHOW_LEGACY_BLOCK_HELP && ( - <> -

    - A block is one production unit you design: - pick the recipes to make one or more goal goods, and the solver works out how many of - each building you need (fractional counts and all). -

    -
    -
    Goals
    -

    - A block can target several products at once — each goal has a{" "} - target rate and the block is sized so that - good comes out at exactly that rate. Click a goal's rate to edit it, and click - its unit to cycle /s → /min → /h — enter - science as 10/min or a slow bootstrap as 0.5/h; the unit sticks per goal while the - solver works in per-second underneath. Not everything is throughput:{" "} - right-click a goal → Keep in stock turns it - into a buffer goal ("keep 100 on hand") with a refill window (default 10m, - click to cycle) — machines are sized to rebuild the buffer within the window, and - the factory ledger badges the flow ↻ stock. So a - single "logistics" block can make belts @10/s, undergrounds @4/s and - splitters @2/s side by side. The first goal{" "} - names the block, anchors the scale tools, and is - the default icon; moves a goal - to the front. Click the icon next to the block's name to pick any item or fluid - as its icon instead. A good you don't target isn't a goal — it falls out - as a byproduct (export). -

    -

    - A goal is a floor, not an exact pin: the solver builds the cheapest plan making{" "} - at least that rate, so when one recipe - makes two goals in a fixed ratio, the tight one lands exactly and the other's - extra simply exports. Only a genuinely impossible ask reads{" "} - infeasible — and the balance card then - names the exact goals, marks, and pins in conflict, each with a one-click fix. -

    -
    -

    - How it solves. Adding a producer through an - item's chip marks that good{" "} - made in this block: production must cover - consumption, surplus exports, and it never imports. Everything else is free — - consumption imports, and an incidental byproduct just offsets the import (it's - never scaled up to cover demand). The solver picks the cheapest run-rates (fewest - machine-seconds) satisfying the goals, marks, and pins — it handles cyclic recipe - chains, and identical inputs always solve identically. -

    -
    -
    Spoilage
    -

    - A stopwatch on a recipe means at least one product can spoil; each product shows its - own spoil time. Use{" "} - Estimate incidental spoilage on a good to - record the rough rate expected while production is backed up. The estimate does not - change the block's nominal recipes, machines, or imports: its spoil result - appears in Exports as an ordinary byproduct at the estimated rate. Factory-wide - balancing pools that current surplus, but byproduct demand never scales the source - block. If that result is also an intentional goal, the estimate appears beneath the - goal instead of being repeated in Exports. For demand-driven spoilage, add the - actual spoiling recipe and make its result a goal. -

    -
    -

    - You drive it, not an optimizer. You choose - the recipes, pin building counts, and split a good between competing consumers; PyOps - solves the system you describe. Right-click{" "} - any item for its actions — make it a goal, lock it as a sizing input, mark it - made-in-block or import it instead, or locate it in game; the colored legend shows - each item's role. -

    -
    -
    Sub-blocks
    -

    - Right-click a recipe's name to start a - sub-block — a named, collapsible group of rows. Add more rows from the same menu or - by dragging them onto the header; collapse it and the whole chain reads as one line - showing its net flows (what goes in, what - comes out — intermediates cancel), machines and power. Display-only: the solve is - exactly the same expanded, collapsed, or dissolved. Drag the header to move the - whole chain; double-click its name to rename; × ungroups (the rows stay). -

    -
    -
    -
    Toolbar (next to the name)
    -
      -
    • - - copies this block's recipe/module setup to the clipboard; -
    • -
    • - - - exports this block as a shareable JSON file — import it into any project from{" "} - Settings → Backup & share; - -
    • -
    • - - - Snapshots — this block's restore - points: take a named snapshot before a big refactor, diff any snapshot against - the current state, and restore it (automatic snapshots are also taken before - deletes, restores, and resizes); - -
    • -
    • - - - shows this block as an in-game build sheet — click a building there for a - configured blueprint; - -
    • -
    • - - - Building summary — opens a drawer - listing the buildings and the one-time materials to construct this block (a - shopping list, kept out of the way of the per-second flows). - -
    • -
    -
    -

    - Per-machine modules / beacons are tuned in - the block body to cut building count. The Cybersyn request-combinator generator now - lives in the in-game mod panel. -

    - - )}
    ); diff --git a/app/src/components/whatif/supply-allocation-card.tsx b/app/src/components/whatif/supply-allocation-card.tsx new file mode 100644 index 00000000..168998e0 --- /dev/null +++ b/app/src/components/whatif/supply-allocation-card.tsx @@ -0,0 +1,68 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowDown, ArrowUp, Minus } from "lucide-react"; +import { Card, CardHeader, CardTitle } from "#/components/ui/card.tsx"; +import { Icon } from "#/lib/icons.tsx"; +import { rateLabel } from "#/lib/format.ts"; + +export type SupplyAllocation = { + blockId: number; + blockName: string; + good: string; + display: string; + kind: string; + priority: number; + incidental: boolean; + rate: number; +}; + +const priorityIcon = (priority: number) => + priority > 0 + ? { Icon: ArrowUp, className: "text-success", label: `priority ${priority}` } + : priority < 0 + ? { Icon: ArrowDown, className: "text-warning", label: `priority ${priority}` } + : { Icon: Minus, className: "text-muted-foreground", label: "normal priority" }; + +export function SupplyAllocationCard({ rows }: { rows: SupplyAllocation[] }) { + if (rows.length === 0) return null; + return ( + + + Supply allocation + +
    + {rows.map((row) => { + const priority = priorityIcon(row.priority); + return ( +
    + + {row.display} + + {row.incidental && recovered} + + {row.blockName} + + + {rateLabel(row.good, row.rate)}/s + +
    + ); + })} +
    +
    + ); +} diff --git a/app/src/db/queries.server.ts b/app/src/db/queries.server.ts index 444a2e2c..aa8fd082 100644 --- a/app/src/db/queries.server.ts +++ b/app/src/db/queries.server.ts @@ -1988,7 +1988,13 @@ export function saveBlockRow( /** Every block with its current target rate and cached boundary flows — the input * to the factory-level what-if solve (each block becomes a fixed-ratio super-recipe). */ -export function blocksWithFlows() { +export function blocksWithFlows(): { + id: number; + name: string; + rate: number; + priority?: number; + flows: (BlockFlow & { priority?: number })[]; +}[] { const bs = db .select() .from(blocks) @@ -1996,12 +2002,20 @@ export function blocksWithFlows() { .orderBy(blocks.sortOrder, blocks.name) .all(); const flowsByBlock = enabledFlowsByBlock(); - return bs.map((b) => ({ - id: b.id, - name: b.name, - rate: primaryRate(normalizeBlockData(b.data)), - flows: flowsByBlock.get(b.id) ?? [], - })); + return bs.map((b) => { + const data = normalizeBlockData(b.data); + const blockPriority = data.supplyPriority ?? 0; + return { + id: b.id, + name: b.name, + rate: primaryRate(data), + priority: blockPriority, + flows: (flowsByBlock.get(b.id) ?? []).map((flow) => ({ + ...flow, + priority: data.supplyPriorities?.[flow.item] ?? blockPriority, + })), + }; + }); } /** Aggregate net production across all blocks — the factory over/under view. */ diff --git a/app/src/db/queries.test.ts b/app/src/db/queries.test.ts index 463985f0..7664cc51 100644 --- a/app/src/db/queries.test.ts +++ b/app/src/db/queries.test.ts @@ -977,10 +977,10 @@ describe("batched block projections", () => { expect(largeWhatIf.result.map((block) => block.id)).toEqual([1, ...addedIds]); expect(largeWhatIf.result.some((block) => block.id === disabledId)).toBe(false); expect(largeWhatIf.result[0].flows).toEqual([ - { item: "plate", kind: "item", role: "primary", rate: 10 }, - { item: "gear", kind: "item", role: "byproduct", rate: 2 }, - { item: "steel", kind: "item", role: "stock", rate: 3 }, - { item: "drill", kind: "item", role: "import", rate: -4 }, + { item: "plate", kind: "item", role: "primary", rate: 10, priority: 0 }, + { item: "gear", kind: "item", role: "byproduct", rate: 2, priority: 0 }, + { item: "steel", kind: "item", role: "stock", rate: 3, priority: 0 }, + { item: "drill", kind: "item", role: "import", rate: -4, priority: 0 }, ]); expect(largeFactory.result[0]).toEqual({ id: 1, diff --git a/app/src/routes/whatif.tsx b/app/src/routes/whatif.tsx index 834331b2..fa631234 100644 --- a/app/src/routes/whatif.tsx +++ b/app/src/routes/whatif.tsx @@ -17,6 +17,7 @@ import { PageHeader } from "#/components/page-header.tsx"; import { RebalanceAllButton } from "#/components/whatif/rebalance-all-button.tsx"; import { StatCell } from "#/components/stat-cell.tsx"; import { StatTableHeader } from "#/components/stat-table.tsx"; +import { SupplyAllocationCard } from "#/components/whatif/supply-allocation-card.tsx"; export const Route = createFileRoute("/whatif")({ component: () => ( @@ -252,6 +253,7 @@ function WhatIf() {
    + ({ ...g, display: display(g.good) })), raws: result.raws.map((g) => ({ ...g, display: display(g.good) })), overproduced: result.overproduced.map((g) => ({ ...g, display: display(g.good) })), + supplyAllocations: result.supplyAllocations.map((allocation) => ({ + ...allocation, + display: display(allocation.good), + })), }; }); @@ -402,7 +406,13 @@ export const applyFactoryRebalanceFn = createServerFn({ method: "POST" }) const r = await computeBlock(b.doc); b.latestResult = r; b.dirty = false; - b.flows = r.broken ? b.cachedFlows : boundaryFlows(goalFlows(b.doc), r); + const blockPriority = b.doc.supplyPriority ?? 0; + b.flows = r.broken + ? b.cachedFlows + : boundaryFlows(goalFlows(b.doc), r).map((flow) => ({ + ...flow, + priority: b.doc.supplyPriorities?.[flow.item] ?? blockPriority, + })); } return { id: b.id, name: b.name, rate: primaryRate(b.doc), flows: b.flows }; }), diff --git a/app/src/server/factory-rebalance.test.ts b/app/src/server/factory-rebalance.test.ts index 4ec56861..30430b8d 100644 --- a/app/src/server/factory-rebalance.test.ts +++ b/app/src/server/factory-rebalance.test.ts @@ -99,6 +99,7 @@ const factoryResult = ( demands: [], raws: [], overproduced: [], + supplyAllocations: [], }); beforeEach(() => { diff --git a/app/src/server/factory-solve.server.ts b/app/src/server/factory-solve.server.ts index 2ccbb812..6976b6b2 100644 --- a/app/src/server/factory-solve.server.ts +++ b/app/src/server/factory-solve.server.ts @@ -18,10 +18,19 @@ export type BlockWithFlows = { id: number; name: string; rate: number; - flows: { item: string; kind: string; role: string; rate: number }[]; + priority?: number; + flows: { item: string; kind: string; role: string; rate: number; priority?: number }[]; }; type GoodClass = "raw" | "demand" | "surplus" | "intermediate"; +type SupplyOffer = { + bi: number; + good: string; + rate: number; + priority: number; + incidental: boolean; + variable: string; +}; // Energy pseudo-goods that stay free boundaries: electricity is grid-distributed // (balancing it would create a power feedback loop) and heat is block-local by @@ -51,23 +60,27 @@ export async function factoryWhatIf( const primaryProducers = new Map>(); const anyProducers = new Map>(); const consumers = new Map>(); - // net flow of each good per block, at the block's CURRENT rate (scale 1) - const netByBlock = blocks.map((b) => { - const net = new Map(); + const offers: SupplyOffer[] = []; + blocks.forEach((b, bi) => { for (const f of b.flows) { kindOf.set(f.item, f.kind); if (f.role === "import") { addTo(consumers, f.item, b.id); bump(consumedTotal, f.item, f.rate); - bump(net, f.item, -f.rate); } else { addTo(anyProducers, f.item, b.id); if (f.role === "primary" || f.role === "stock") addTo(primaryProducers, f.item, b.id); bump(producedTotal, f.item, f.rate); - bump(net, f.item, f.rate); + offers.push({ + bi, + good: f.item, + rate: f.rate, + priority: f.priority ?? b.priority ?? 0, + incidental: f.role !== "primary" && f.role !== "stock", + variable: "", + }); } } - return net; }); const goods = [...kindOf.keys()]; @@ -105,39 +118,111 @@ export async function factoryWhatIf( const demandRate = (g: string) => demandOverrides[g] ?? producedTotal.get(g) ?? 0; const constraints: string[] = []; - // chain constraints: demands (net >= pinned), intermediates (net >= 0) + const allocOffers = offers + .filter((offer) => { + const cls = classify(offer.good); + return cls === "demand" || cls === "intermediate"; + }) + .map((offer, oi) => ({ ...offer, variable: `a${oi}` })); + + // Supply is allocated separately from block scaling. This is the critical + // distinction for priority: an incidental output can be consumed up to the + // amount its block naturally makes, but its allocation never makes that block + // productive/free-to-scale on its own. + for (const offer of allocOffers) { + constraints.push( + offer.incidental + ? `r${constraints.length}: + 1 ${offer.variable} <= ${offer.rate}` + : `r${constraints.length}: + 1 ${offer.variable} - ${offer.rate} s${offer.bi} <= 0`, + ); + } + + // chain constraints: allocated supply covers final demand or scaled consumers for (const g of goods) { const cls = classify(g); if (cls !== "demand" && cls !== "intermediate") continue; - const parts: string[] = []; + const parts = allocOffers + .filter((offer) => offer.good === g) + .map((offer) => `+ 1 ${offer.variable}`); blocks.forEach((_, bi) => { - const c = netByBlock[bi].get(g) ?? 0; - if (Math.abs(c) > 1e-9) parts.push(`${c >= 0 ? "+" : "-"} ${Math.abs(c)} s${bi}`); + const consumed = blocks[bi].flows + .filter((flow) => flow.item === g && flow.role === "import") + .reduce((sum, flow) => sum + flow.rate, 0); + if (consumed > 1e-9) parts.push(`- ${consumed} s${bi}`); }); if (parts.length) constraints.push( `r${constraints.length}: ${parts.join(" ")} >= ${cls === "demand" ? demandRate(g) : 0}`, ); } - // Objective: minimize total scaling. (Auto-scaling sinks inside the LP is unstable - // — rewarding byproduct intake makes the solve overproduce byproducts just to - // consume them. So sinks are pinned here and sized as a post-process suggestion.) - const obj = blocks.map((_, bi) => `+ 1 s${bi}`).join(" "); - // Only productive blocks (a demand/intermediate primary) are free to scale; sinks // and pure off-chain (power/utility) blocks are pinned at current. const bounds = blocks .map((_, bi) => (productive[bi] ? `0 <= s${bi} <= 1e7` : `1 <= s${bi} <= 1`)) .join("\n "); - const lp = `Minimize\n obj: ${obj}\nSubject To\n ${constraints.join("\n ")}\nBounds\n ${bounds}\nEnd`; - const highs = await highsLoader(); - const sol = highs.solve(lp); + const allocationBounds = allocOffers.map((offer) => `0 <= ${offer.variable} <= 1e12`).join("\n "); + const fixedTierConstraints: string[] = []; + const solveWithObjective = (objective: string) => { + const allConstraints = [...constraints, ...fixedTierConstraints]; + return highs.solve( + `Minimize\n obj: ${objective}\nSubject To\n ${allConstraints.join("\n ")}\nBounds\n ${bounds}\n ${allocationBounds}\nEnd`, + ); + }; + + // Strict lexicographic tiers: minimize use of the lowest priority first, lock + // that optimum, then continue upward. The numeric distance between tiers is + // intentionally irrelevant; only ordering matters. + const priorities = [...new Set(allocOffers.map((offer) => offer.priority))].sort((a, b) => a - b); + let sol: ReturnType | null = null; + for (const priority of priorities) { + const tier = allocOffers.filter((offer) => offer.priority === priority); + const objective = tier.map((offer) => `+ 1 ${offer.variable}`).join(" ") || "+ 0 s0"; + sol = solveWithObjective(objective); + if (sol.Status !== "Optimal") break; + const optimum = tier.reduce( + (sum, offer) => + sum + ((sol!.Columns[offer.variable] as { Primal?: number } | undefined)?.Primal ?? 0), + 0, + ); + // HiGHS may report an optimum a few ulps below the next model's feasible + // boundary. Leave a tiny scale-aware allowance so repeated/re-linearized + // What-if solves don't turn numerically infeasible. + const tolerance = Math.max(1e-5, Math.abs(optimum) * 1e-6); + fixedTierConstraints.push( + `r${constraints.length + fixedTierConstraints.length}: ${objective} <= ${+(optimum + tolerance).toPrecision(12)}`, + ); + } + if (!sol || sol.Status === "Optimal") { + const scaleObjective = blocks.map((_, bi) => `+ 1 s${bi}`).join(" "); + sol = solveWithObjective(scaleObjective); + } const scaleById = new Map(); blocks.forEach((b, bi) => - scaleById.set(b.id, (sol.Columns[`s${bi}`] as { Primal?: number } | undefined)?.Primal ?? 0), + scaleById.set(b.id, (sol!.Columns[`s${bi}`] as { Primal?: number } | undefined)?.Primal ?? 0), ); + const offersPerGood = new Map(); + for (const offer of allocOffers) bump(offersPerGood, offer.good, 1); + const supplyAllocations = allocOffers + .map((offer) => ({ + blockId: blocks[offer.bi].id, + blockName: blocks[offer.bi].name, + good: offer.good, + kind: kindOf.get(offer.good) ?? "item", + priority: offer.priority, + incidental: offer.incidental, + rate: round((sol!.Columns[offer.variable] as { Primal?: number } | undefined)?.Primal ?? 0), + })) + .filter( + (allocation) => + allocation.rate > 1e-6 && + ((offersPerGood.get(allocation.good) ?? 0) > 1 || + allocation.priority !== 0 || + allocation.incidental), + ) + .sort((a, b) => b.priority - a.priority || b.rate - a.rate); + // projected good flows at the solved scales const projProduced = new Map(); const projConsumed = new Map(); @@ -190,7 +275,7 @@ export async function factoryWhatIf( .sort((a, b) => b.projected - a.projected); return { - status: sol.Status, + status: sol!.Status, blocks: blockReport, demands: goods .filter((g) => classify(g) === "demand") @@ -209,5 +294,6 @@ export async function factoryWhatIf( })) .sort((a, b) => b.projected - a.projected), overproduced, + supplyAllocations, }; } diff --git a/app/src/server/factory-solve.test.ts b/app/src/server/factory-solve.test.ts index 8dd32442..dfc6de5d 100644 --- a/app/src/server/factory-solve.test.ts +++ b/app/src/server/factory-solve.test.ts @@ -200,4 +200,79 @@ describe("factoryWhatIf", () => { expect(byId(doubledScience.blocks, agarScience.id).scale).toBeCloseTo(2); expect(byId(doubledScience.blocks, biocrudSink.id).scale).toBeCloseTo(1); }); + + it("uses preferred supply before a fallback producer", async () => { + const preferred: BlockWithFlows = { + id: 30, + name: "recovered plates", + rate: 1, + priority: 100, + flows: [{ item: "plate", kind: "item", role: "primary", rate: 1, priority: 100 }], + }; + const fallback: BlockWithFlows = { + id: 31, + name: "ore smelting", + rate: 1, + priority: -100, + flows: [ + { item: "plate", kind: "item", role: "primary", rate: 1, priority: -100 }, + { item: "ore", kind: "item", role: "import", rate: 1 }, + ], + }; + const consumer: BlockWithFlows = { + id: 32, + name: "gears", + rate: 1, + flows: [ + { item: "gear", kind: "item", role: "primary", rate: 1 }, + { item: "plate", kind: "item", role: "import", rate: 2 }, + ], + }; + + const r = await factoryWhatIf([preferred, fallback, consumer]); + expect(byId(r.blocks, preferred.id).scale).toBeCloseTo(2); + expect(byId(r.blocks, fallback.id).scale).toBeCloseTo(0); + expect(r.supplyAllocations).toContainEqual( + expect.objectContaining({ blockId: preferred.id, good: "plate", rate: 2, priority: 100 }), + ); + }); + + it("uses a naturally produced preferred byproduct without scaling its source", async () => { + const recovery: BlockWithFlows = { + id: 40, + name: "science recovery", + rate: 1, + priority: 100, + flows: [ + { item: "science", kind: "item", role: "primary", rate: 1 }, + { item: "plate", kind: "item", role: "byproduct", rate: 1, priority: 100 }, + ], + }; + const mine: BlockWithFlows = { + id: 41, + name: "plate mine", + rate: 1, + priority: -100, + flows: [{ item: "plate", kind: "item", role: "primary", rate: 1, priority: -100 }], + }; + const consumer: BlockWithFlows = { + id: 42, + name: "gears", + rate: 1, + flows: [ + { item: "gear", kind: "item", role: "primary", rate: 1 }, + { item: "plate", kind: "item", role: "import", rate: 2 }, + ], + }; + + const r = await factoryWhatIf([recovery, mine, consumer]); + expect(byId(r.blocks, recovery.id).scale).toBeCloseTo(1); + expect(byId(r.blocks, mine.id).scale).toBeCloseTo(1); + expect(r.supplyAllocations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ blockId: recovery.id, good: "plate", rate: 1, incidental: true }), + expect.objectContaining({ blockId: mine.id, good: "plate", rate: 1 }), + ]), + ); + }); }); diff --git a/docs/solver.md b/docs/solver.md index e12a0a20..7c0b5fd9 100644 --- a/docs/solver.md +++ b/docs/solver.md @@ -340,7 +340,8 @@ tiers: Preferred, Normal, and Fallback. When several blocks can supply the same good, the factory solve exhausts higher-priority available supply before using a lower tier. Priority is an allocation preference, not a reason to run a block: an incidental/byproduct export never causes its source block to scale solely to make -more of that byproduct. +more of that byproduct. Its offer is capped at the block's currently cached +available rate; the remaining demand falls through to scalable primary suppliers. Advanced supply priorities expose arbitrary numeric tiers (higher numbers first). A multiproduct block may also override its block-wide priority for one exported @@ -348,6 +349,11 @@ good; exports without an override inherit the block setting. Equal priorities us the ordinary factory objective. Priority does not change the block's goals, pins, or internal recipe solve. +The What-if report includes **Supply allocation** rows naming the block, good, +allocated rate, priority direction, and whether the supply is recovered/incidental. +This makes a result such as “1/s recovered, 4/s from fallback mining” visible +instead of leaving the LP's route choice implicit. + Two energy pseudo-goods stay **free boundaries** (never balanced across blocks): `pyops-electricity` (grid-distributed — matching it would create a power feedback loop) and `pyops-heat` (block-local by game rule). `pyops-fluid-fuel` is **not**