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 () =>