Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions app/e2e/mut/supply-priority.e2e.ts
Original file line number Diff line number Diff line change
@@ -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();
});
15 changes: 15 additions & 0 deletions app/src/components/block/balance-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -402,6 +405,18 @@ export function BalanceCard({
onCtxMenu(e, { name: f.name, kind: f.kind, link: "export" })
}
/>
<OutputPriorityOverride
inherited={supplyPriority}
value={supplyPriorities[f.name]}
onChange={(priority) => {
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 */}
Expand Down
92 changes: 92 additions & 0 deletions app/src/components/block/block-help-content.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-3">
<p>
A block is one production unit: choose its goals and recipes, then PyOps calculates the
buildings and boundary flows.
</p>
<ol className="list-decimal space-y-1 pl-5 text-sm">
<li>Add one or more goal goods and set their rates.</li>
<li>Add the recipes that make them.</li>
<li>Review imports, exports, buildings, and any solve warnings.</li>
</ol>

<details className={sectionClass}>
<summary className={summaryClass}>Goals and rates</summary>
<div className="mt-2 space-y-2">
<p>
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.
</p>
<p>
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.
</p>
</div>
</details>

<details className={sectionClass}>
<summary className={summaryClass}>Supply priority</summary>
<div className="mt-2 space-y-2">
<p className="flex items-center gap-2">
<ArrowUp className="size-4 text-success" /> Preferred suppliers are used first.
</p>
<p className="flex items-center gap-2">
<Minus className="size-4 text-muted-foreground" /> Normal suppliers follow.
</p>
<p className="flex items-center gap-2">
<ArrowDown className="size-4 text-warning" /> Fallback suppliers fill what remains.
</p>
<p>
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.
</p>
</div>
</details>

<details className={sectionClass}>
<summary className={summaryClass}>How solving works</summary>
<div className="mt-2 space-y-2">
<p>
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.
</p>
<p>
An infeasible result means the selected goals, marks, or pins conflict. The balance card
identifies the conflict and offers relevant fixes.
</p>
</div>
</details>

<details className={sectionClass}>
<summary className={summaryClass}>Spoilage</summary>
<p className="mt-2">
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.
</p>
</details>

<details className={sectionClass}>
<summary className={summaryClass}>Sub-blocks and toolbar</summary>
<div className="mt-2 space-y-2">
<p>
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.
</p>
<p>
The toolbar provides copy, share-file export, snapshots, in-game display, construction
cost, and block enable/disable controls.
</p>
</div>
</details>
</div>
);
}
124 changes: 2 additions & 122 deletions app/src/components/block/block-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +23,7 @@ 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";
import { InfoHint } from "#/components/info-hint.tsx";
import { Icon } from "../../lib/icons";
import { Legend } from "./legend.tsx";
Expand Down Expand Up @@ -284,127 +284,7 @@ export function BlockToolbar({
<Legend cls={linkStyle.export} label="export" />
</span>
<HelpButton title="What is a block?">
<p>
A block is <span className="text-foreground">one production unit you design</span>: 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).
</p>
<div>
<div className="font-semibold text-foreground">Goals</div>
<p className="mt-1">
A block can target several products at once — each goal has a{" "}
<span className="text-foreground">target rate</span> and the block is sized so that good
comes out at exactly that rate. Click a goal&apos;s rate to edit it, and click its unit
to cycle <span className="text-foreground">/s → /min → /h</span> — 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:{" "}
<span className="text-foreground">right-click a goal → Keep in stock</span> turns it
into a buffer goal (&quot;keep 100 on hand&quot;) 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 <span className="text-info">↻ stock</span>. So a single
&quot;logistics&quot; block can make belts @10/s, undergrounds @4/s and splitters @2/s
side by side. The first goal <span className="text-info">names the block</span>, anchors
the scale tools, and is the default icon;{" "}
<Star className="inline size-3.5 text-foreground" /> moves a goal to the front. Click
the icon next to the block&apos;s name to pick any item or fluid as its icon instead. A
good you don&apos;t target isn&apos;t a goal — it falls out as a byproduct (export).
</p>
<p className="mt-1">
A goal is a floor, not an exact pin: the solver builds the cheapest plan making{" "}
<span className="text-foreground">at least</span> that rate, so when one recipe makes
two goals in a fixed ratio, the tight one lands exactly and the other&apos;s extra
simply exports. Only a genuinely impossible ask reads{" "}
<span className="text-destructive">infeasible</span> — and the balance card then names
the exact goals, marks, and pins in conflict, each with a one-click fix.
</p>
</div>
<p>
<span className="text-foreground">How it solves.</span> Adding a producer through an
item&apos;s chip marks that good <span className="text-success">made in this block</span>:
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&apos;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.
</p>
<div>
<div className="font-semibold text-foreground">Spoilage</div>
<p className="mt-1">
A stopwatch on a recipe means at least one product can spoil; each product shows its own
spoil time. Use <span className="text-foreground">Estimate incidental spoilage</span> on
a good to record the rough rate expected while production is backed up. The estimate
does not change the block&apos;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.
</p>
</div>
<p>
<span className="text-foreground">You drive it, not an optimizer.</span> You choose the
recipes, pin building counts, and split a good between competing consumers; PyOps solves
the system you describe. <span className="text-foreground">Right-click</span> 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&apos;s role.
</p>
<div>
<div className="font-semibold text-foreground">Sub-blocks</div>
<p className="mt-1">
<span className="text-foreground">Right-click a recipe&apos;s name</span> 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 <span className="text-foreground">net flows</span> (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).
</p>
</div>
<div>
<div className="font-semibold text-foreground">Toolbar (next to the name)</div>
<ul className="mt-1 space-y-1.5">
<li className="flex items-start gap-2">
<Copy className="mt-0.5 size-4 shrink-0 text-foreground" />
<span>copies this block&apos;s recipe/module setup to the clipboard;</span>
</li>
<li className="flex items-start gap-2">
<Download className="mt-0.5 size-4 shrink-0 text-foreground" />
<span>
exports this block as a shareable JSON file — import it into any project from{" "}
<span className="text-foreground">Settings → Backup &amp; share</span>;
</span>
</li>
<li className="flex items-start gap-2">
<History className="mt-0.5 size-4 shrink-0 text-foreground" />
<span>
<span className="text-foreground">Snapshots</span> — this block&apos;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);
</span>
</li>
<li className="flex items-start gap-2">
<Gamepad2 className="mt-0.5 size-4 shrink-0 text-foreground" />
<span>
shows this block as an in-game build sheet — click a building there for a configured
blueprint;
</span>
</li>
<li className="flex items-start gap-2">
<Hammer className="mt-0.5 size-4 shrink-0 text-foreground" />
<span>
<span className="text-foreground">Building summary</span> — 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).
</span>
</li>
</ul>
</div>
<p>
Per-machine <span className="text-foreground">modules / beacons</span> are tuned in the
block body to cut building count. The Cybersyn request-combinator generator now lives in
the in-game mod panel.
</p>
<BlockHelpContent />
</HelpButton>
</div>
);
Expand Down
16 changes: 16 additions & 0 deletions app/src/components/block/doc-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export type BlockDocState = {
* server keeps deriving from it; dropped from the doc after adoption */
dispositions: Record<string, Disposition>;
spoilRates: Record<string, number>;
supplyPriority: number;
supplyPriorities: Record<string, number>;
rowGroups: RowGroup[];
recipeGroups: GroupAssign;
machines: Record<string, string>;
Expand All @@ -77,6 +79,8 @@ const EMPTY: BlockDocState = {
pins: [],
dispositions: {},
spoilRates: {},
supplyPriority: 0,
supplyPriorities: {},
rowGroups: [],
recipeGroups: {},
machines: {},
Expand All @@ -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() } : {}),
Expand Down Expand Up @@ -156,6 +162,8 @@ export function createBlockDocStore() {
pins: d.pins ?? [],
dispositions: (d.dispositions ?? {}) as Record<string, Disposition>,
spoilRates: d.spoilRates ?? {},
supplyPriority: d.supplyPriority ?? 0,
supplyPriorities: d.supplyPriorities ?? {},
machines: d.machines ?? {},
fuels: d.fuels ?? {},
modules: d.modules ?? {},
Expand Down Expand Up @@ -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) =>
Expand Down
Loading
Loading