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
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/
Expand Down
26 changes: 26 additions & 0 deletions app/e2e/mut/extract-recipe.e2e.ts
Original file line numberDiff line numberDiff line change
@@ -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();
});
9 changes: 8 additions & 1 deletion app/src/components/block/recipe-row.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -141,7 +141,14 @@ export function RecipeRow({
>
<GripVertical className="size-4" />
</span>
<span className={off ? "opacity-40" : undefined}>
<span
data-recipe-row-icon={name}
className={off ? "opacity-40" : undefined}
onContextMenu={(e) => {
e.preventDefault();
open.rowMenu(e, name);
}}
>
<Icon kind="recipe" name={name} size="md" noHover />
</span>
<span
Expand Down
11 changes: 9 additions & 2 deletions app/src/components/block/row-menu.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { Layers, X } from "lucide-react";
import { Layers, Pin, SplitSquareHorizontal, X } from "lucide-react";
import { ContextMenu, ContextMenuItem } from "#/components/context-menu.tsx";
import { Icon } from "../../lib/icons";
import type { RowGroup } from "../../lib/row-groups";
Expand All@@ -15,6 +15,7 @@ export function RowMenu({
onNewGroup,
onJoinGroup,
onLeaveGroup,
onExtractToBlock,
onOpenPins,
onClose,
}: {
Expand All@@ -28,6 +29,7 @@ export function RowMenu({
onNewGroup: () => void;
onJoinGroup: (groupId: number) => void;
onLeaveGroup: () => void;
onExtractToBlock: () => void;
/** open the pin editor (#91): fixed/cap counts, input shares */
onOpenPins: () => void;
onClose: () => void;
Expand All@@ -42,7 +44,12 @@ export function RowMenu({
<Icon kind="recipe" name={recipe} size="sm" noTitle noHover />
<span className="truncate">{display}</span>
</div>
<ContextMenuItem onClick={act(onOpenPins)}>Pins — count / cap / route…</ContextMenuItem>
<ContextMenuItem onClick={act(onOpenPins)}>
<Pin className="size-3.5" /> Pins — count / cap / route…
</ContextMenuItem>
<ContextMenuItem onClick={act(onExtractToBlock)}>
<SplitSquareHorizontal className="size-3.5" /> Extract into new block
</ContextMenuItem>
<div className="my-1 border-t border-border" />
{currentGroup == null ? (
<>
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/context-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand Down
35 changes: 34 additions & 1 deletion app/src/lib/block-doc.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 }],
Expand DownExpand Up@@ -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"]);
});
});
51 changes: 50 additions & 1 deletion app/src/lib/block-doc.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<V>(
rec: Record<string, V> | undefined,
Expand DownExpand Up@@ -58,3 +58,52 @@ export function withRecipeSet<T extends Partial<BlockData>>(doc: T, recipes: str
}
return next;
}

function pickRecipeValue<V>(rec: Record<string, V> | 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<T extends Partial<BlockData>>(
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 };
}
15 changes: 15 additions & 0 deletions app/src/routes/block.$id.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { ActiveEditorRefContext } from "./block";
import { createBlockDocStore, solveInputOf } from "../components/block/doc-store.ts";
import {
bridgeShowBlockFn,
extractRecipeBlockFn,
goodInfoFn,
itemWeightsFn,
loadBlockFn,
Expand DownExpand Up@@ -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,
);
Expand DownExpand Up@@ -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)}
/>
)}
Expand Down
65 changes: 64 additions & 1 deletion app/src/server/factorio.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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<string>();
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 () =>
Expand Down
Loading