diff --git a/README.md b/README.md
index c775c432..dd393187 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ it. Build/release details: [`docs/desktop.md`](docs/desktop.md).
- **Design production blocks** — set output goals + rates, pick recipes/machines/
modules, and PyOps solves the run-rates and building counts for the whole chain
(cyclic loops, fluid temperatures, byproducts, spoilage). Pin counts, route
- byproducts, fold chains into sub-blocks.
+ 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.
- **Explore the data** — a searchable catalogue with a recipe explorer (producers/
diff --git a/app/e2e/mut/extract-recipe.e2e.ts b/app/e2e/mut/extract-recipe.e2e.ts
new file mode 100644
index 00000000..9afa6460
--- /dev/null
+++ b/app/e2e/mut/extract-recipe.e2e.ts
@@ -0,0 +1,26 @@
+import { expect, test } from "@playwright/test";
+import { addGoal, blockNameInput, createBlock } from "./helpers";
+
+test("extract a recipe row into a dedicated block from the row icon menu", async ({ page }) => {
+ await createBlock(page);
+ await addGoal(page, "iron plate", "Iron plate");
+
+ await page.locator('button[title^="click to add a recipe that makes this goal"]').click();
+ const platePicker = page.getByRole("dialog", { name: /Recipes that make/ });
+ await platePicker.getByRole("button", { name: /Iron plate/ }).first().click();
+ await expect(platePicker).toBeHidden();
+
+ await page.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ }).first().click();
+ const orePicker = page.getByRole("dialog", { name: /Recipes that make/ });
+ await orePicker.getByRole("button", { name: /Iron ore/ }).first().click();
+ await expect(orePicker).toBeHidden();
+
+ const rowIcons = page.locator("[data-recipe-row-icon]");
+ await expect(rowIcons).toHaveCount(2);
+ await rowIcons.nth(1).click({ button: "right" });
+ await page.getByRole("menuitem", { name: "Extract into new block" }).click();
+
+ await expect(blockNameInput(page)).toHaveValue("Iron ore", { timeout: 15_000 });
+ await expect(page.locator("[data-recipe-row-icon]")).toHaveCount(1);
+ await expect(page.getByRole("status").filter({ hasText: /Extracted "Iron ore"/ })).toBeVisible();
+});
diff --git a/app/src/components/block/recipe-row.tsx b/app/src/components/block/recipe-row.tsx
index 336162e1..425d6bb4 100644
--- a/app/src/components/block/recipe-row.tsx
+++ b/app/src/components/block/recipe-row.tsx
@@ -141,7 +141,14 @@ export function RecipeRow({
>
-
+ {
+ e.preventDefault();
+ open.rowMenu(e, name);
+ }}
+ >
void;
onJoinGroup: (groupId: number) => void;
onLeaveGroup: () => void;
+ onExtractToBlock: () => void;
/** open the pin editor (#91): fixed/cap counts, input shares */
onOpenPins: () => void;
onClose: () => void;
@@ -42,7 +44,12 @@ export function RowMenu({
{display}
- Pins — count / cap / route…
+
+ Pins — count / cap / route…
+
+
+ Extract into new block
+
{currentGroup == null ? (
<>
diff --git a/app/src/components/context-menu.tsx b/app/src/components/context-menu.tsx
index ae0ca313..d00deaf7 100644
--- a/app/src/components/context-menu.tsx
+++ b/app/src/components/context-menu.tsx
@@ -47,7 +47,7 @@ function ContextMenu({
side="bottom"
sideOffset={0}
alignOffset={0}
- className={cn("min-w-48", className)}
+ className={cn("min-w-48 bg-popover/95 shadow-lg ring-foreground/20", className)}
onContextMenu={(e) => {
e.preventDefault();
onClose();
diff --git a/app/src/lib/block-doc.test.ts b/app/src/lib/block-doc.test.ts
index 6fadc556..5f4349c4 100644
--- a/app/src/lib/block-doc.test.ts
+++ b/app/src/lib/block-doc.test.ts
@@ -5,7 +5,7 @@
*/
import { describe, expect, it } from "vite-plus/test";
import type { BlockData } from "../db/schema.ts";
-import { withRecipeSet } from "./block-doc.ts";
+import { extractRecipeToBlockDocs, withRecipeSet } from "./block-doc.ts";
const doc = (): BlockData => ({
goals: [{ name: "iron-plate", rate: 2 }],
@@ -64,3 +64,36 @@ describe("withRecipeSet (#12)", () => {
expect(next.rowGroups).toEqual(original.rowGroups);
});
});
+
+describe("extractRecipeToBlockDocs", () => {
+ it("moves one recipe's row config into a new block and prunes the source", () => {
+ const next = extractRecipeToBlockDocs(doc(), "molten-iron", [{ name: "molten-iron", rate: 4 }]);
+
+ expect(next.source.recipes).toEqual(["iron-plate", "helper"]);
+ expect(next.source.machines).toEqual({ "iron-plate": "furnace" });
+ expect(next.source.modules).toBeUndefined();
+ expect(next.source.pins).toEqual([
+ { kind: "share", recipe: "helper", item: "iron-plate", share: 0.5 },
+ ]);
+ expect(next.source.made).toEqual(["iron-plate"]);
+
+ expect(next.extracted).toEqual({
+ goals: [{ name: "molten-iron", rate: 4 }],
+ recipes: ["molten-iron"],
+ machines: { "molten-iron": "foundry" },
+ modules: { "molten-iron": ["prod-1", "prod-1"] },
+ pins: [{ kind: "count", recipe: "molten-iron", count: 3 }],
+ });
+ });
+
+ it("keeps made claims when another remaining recipe still produces the product", () => {
+ const next = extractRecipeToBlockDocs(
+ doc(),
+ "molten-iron",
+ [{ name: "molten-iron", rate: 4 }],
+ ["molten-iron"],
+ );
+
+ expect(next.source.made).toEqual(["iron-plate", "molten-iron"]);
+ });
+});
diff --git a/app/src/lib/block-doc.ts b/app/src/lib/block-doc.ts
index 8355eba0..112fde10 100644
--- a/app/src/lib/block-doc.ts
+++ b/app/src/lib/block-doc.ts
@@ -7,7 +7,7 @@
* Pure module (no db, no React) — usable from the server apply path
* (`setBlockRecipesFn`) and unit-testable in isolation, like `lib/goals.ts`.
*/
-import type { BlockData } from "../db/schema.ts";
+import type { BlockData, Goal } from "../db/schema.ts";
function pruneRecord(
rec: Record | undefined,
@@ -58,3 +58,52 @@ export function withRecipeSet>(doc: T, recipes: str
}
return next;
}
+
+function pickRecipeValue(rec: Record | undefined, recipe: string): V | undefined {
+ return rec && recipe in rec ? rec[recipe] : undefined;
+}
+
+/** Split one recipe out of a block doc into a new one-recipe block doc. The
+ * caller provides the extracted block's goals, usually from the selected row's
+ * current solved product rates. Per-row configuration for the recipe moves with
+ * it; the source doc is the normal recipe-set prune plus any extracted products
+ * removed from `goals`/`made` when no remaining recipe still produces them. */
+export function extractRecipeToBlockDocs>(
+ doc: T,
+ recipe: string,
+ goals: Goal[],
+ producedByRemaining: readonly string[] = [],
+): { source: T; extracted: BlockData } {
+ const productNames = new Set(goals.map((g) => g.name));
+ const stillProduced = new Set(producedByRemaining);
+ const source = withRecipeSet(
+ {
+ ...doc,
+ goals: (doc.goals ?? []).filter(
+ (g) => !productNames.has(g.name) || stillProduced.has(g.name),
+ ),
+ made: doc.made?.filter((name) => !productNames.has(name) || stillProduced.has(name)),
+ },
+ (doc.recipes ?? []).filter((name) => name !== recipe),
+ );
+ if (source.made?.length === 0) delete source.made;
+
+ const extracted: BlockData = {
+ goals: goals.map((g) => ({ ...g })),
+ recipes: [recipe],
+ };
+ const machine = pickRecipeValue(doc.machines, recipe);
+ if (machine) extracted.machines = { [recipe]: machine };
+ const fuel = pickRecipeValue(doc.fuels, recipe);
+ if (fuel) extracted.fuels = { [recipe]: fuel };
+ const modules = pickRecipeValue(doc.modules, recipe);
+ if (modules) extracted.modules = { [recipe]: [...modules] };
+ const beacons = pickRecipeValue(doc.beacons, recipe);
+ if (beacons?.length)
+ extracted.beacons = { [recipe]: beacons.map((b) => ({ ...b, modules: [...b.modules] })) };
+ const reactorLayout = pickRecipeValue(doc.reactorLayouts, recipe);
+ if (reactorLayout) extracted.reactorLayouts = { [recipe]: { ...reactorLayout } };
+ const pins = (doc.pins ?? []).filter((p) => p.recipe === recipe);
+ if (pins.length) extracted.pins = pins.map((p) => ({ ...p }));
+ return { source, extracted };
+}
diff --git a/app/src/routes/block.$id.tsx b/app/src/routes/block.$id.tsx
index f83a909c..8465d7f1 100644
--- a/app/src/routes/block.$id.tsx
+++ b/app/src/routes/block.$id.tsx
@@ -6,6 +6,7 @@ import { ActiveEditorRefContext } from "./block";
import { createBlockDocStore, solveInputOf } from "../components/block/doc-store.ts";
import {
bridgeShowBlockFn,
+ extractRecipeBlockFn,
goodInfoFn,
itemWeightsFn,
loadBlockFn,
@@ -135,6 +136,19 @@ function Block({ blockId }: { blockId: number }) {
const createGroupFromRow = (recipe: string) => setRenamingGroup(doc.createGroupFromRow(recipe)); // name it right away
const removeFromGroup = doc.removeFromGroup;
+ const extractRecipeToBlock = async (recipe: string) => {
+ if (doc.store.state.dirty) await persist();
+ const out = await extractRecipeBlockFn({ data: { blockId, recipe } });
+ if (!out.ok) {
+ toast({ message: "Could not extract that recipe into a new block." });
+ return;
+ }
+ void qc.invalidateQueries({ queryKey: ["blocks"] });
+ void qc.invalidateQueries({ queryKey: ["factory"] });
+ void qc.invalidateQueries({ queryKey: ["undoStatus"] });
+ toast({ message: `Extracted "${out.name}" into a new block.` });
+ void navigate({ to: "/block/$id", params: { id: String(out.id) } });
+ };
const [pickFor, setPickFor] = useState<{ name: string; mode: "produce" | "consume" } | null>(
null,
);
@@ -869,6 +883,7 @@ function Block({ blockId }: { blockId: number }) {
onNewGroup={() => createGroupFromRow(rowMenu.name)}
onJoinGroup={(gid) => doc.joinRecipeToGroup(rowMenu.name, gid)}
onLeaveGroup={() => removeFromGroup(rowMenu.name)}
+ onExtractToBlock={() => void extractRecipeToBlock(rowMenu.name)}
onClose={() => setRowMenu(null)}
/>
)}
diff --git a/app/src/server/factorio.ts b/app/src/server/factorio.ts
index 18691f38..983522f4 100644
--- a/app/src/server/factorio.ts
+++ b/app/src/server/factorio.ts
@@ -9,7 +9,7 @@ import {
primaryRate,
withPrimaryRate,
} from "../lib/goals";
-import { withRecipeSet } from "../lib/block-doc";
+import { extractRecipeToBlockDocs, withRecipeSet } from "../lib/block-doc";
/**
* Server functions exposing the query layer to the client. Server-only modules
@@ -517,6 +517,69 @@ export const setBlockRecipesFn = createServerFn({ method: "POST" })
return { ok: true };
});
+/** Break one recipe row out into a new supplier block. The new block is sized to
+ * the selected row's current product rates and carries that row's machine/fuel/
+ * module/beacon/pin setup; the source block drops the recipe and imports those
+ * products from the factory instead. */
+export const extractRecipeBlockFn = createServerFn({ method: "POST" })
+ .validator((d: { blockId: number; recipe: string }) => d)
+ .handler(async ({ data }) => {
+ const row = q.getBlock(data.blockId);
+ if (!row) return { ok: false as const, reason: "missing-block" as const };
+ const input = normalizeBlockData(row.data as SolveInput) as SolveInput;
+ if (!input.recipes.includes(data.recipe))
+ return { ok: false as const, reason: "missing-recipe" as const };
+
+ const solved = await computeBlock(input);
+ const solvedRow = solved.rows.find((r) => r.recipe === data.recipe);
+ const goals =
+ solvedRow?.products
+ .filter((p) => p.rate > 1e-9)
+ .map((p) => ({ name: p.name, rate: p.rate })) ?? [];
+ if (!solvedRow || goals.length === 0)
+ return { ok: false as const, reason: "unsolved-recipe" as const };
+
+ const goalNames = new Set(goals.map((g) => g.name));
+ const producedByRemaining = new Set();
+ for (const recipe of input.recipes) {
+ if (recipe === data.recipe) continue;
+ for (const product of q.getRecipe(recipe)?.products ?? [])
+ if (goalNames.has(product.name)) producedByRemaining.add(product.name);
+ }
+ const { source, extracted } = extractRecipeToBlockDocs(input, data.recipe, goals, [
+ ...producedByRemaining,
+ ]);
+ const extractedInput = extracted as SolveInput;
+ const sourceSolve = await computeBlock(source);
+ const extractedSolve = await computeBlock(extractedInput);
+ if (extractedSolve.broken) return { ok: false as const, reason: "broken-extract" as const };
+
+ const primary = goals[0]!;
+ const primaryKind = q.getFluid(primary.name) ? "fluid" : "item";
+ const primaryDisplay = solved.display[primary.name] ?? primary.name;
+ const recipeDisplay = solved.recipeDisplay[data.recipe] ?? data.recipe;
+ const sourcePrimary = source.goals[0]?.name;
+ const sourceIcon = source.icon ?? {
+ kind: sourcePrimary ? (q.getFluid(sourcePrimary) ? "fluid" : "item") : row.iconKind,
+ name: sourcePrimary ?? row.iconName,
+ };
+
+ await captureSnapshot(data.blockId, { kind: "auto", label: "before recipe extract" });
+ const newId = await withUndoAction(`Extract "${recipeDisplay}" to new block`, async () => {
+ await persistBlock(
+ { id: row.id, name: row.name, iconKind: sourceIcon.kind, iconName: sourceIcon.name },
+ source,
+ sourceSolve,
+ );
+ return persistBlock(
+ { name: primaryDisplay, iconKind: primaryKind, iconName: primary.name },
+ extractedInput,
+ extractedSolve,
+ );
+ });
+ return { ok: true as const, id: newId, name: primaryDisplay };
+ });
+
/* ── Projects (one sqlite db per mod list) ──────────────────────────────────── */
export const listProjectsFn = createServerFn({ method: "GET" }).handler(async () =>