Skip to content

Commit 62d7a49

Browse files
committed
fix(block): distinguish consume goals from imports
1 parent 8c8ba4d commit 62d7a49

7 files changed

Lines changed: 137 additions & 76 deletions

File tree

app/e2e/mut/goal-rates.e2e.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { DatabaseSync } from "node:sqlite";
2-
import { expect, test } from "@playwright/test";
2+
import { expect, type Page, test } from "@playwright/test";
33
import { activeProjectDbFile, expectUndoTop, goto, uniqueName } from "./helpers";
44

5+
async function dismissDataDriftPrompt(page: Page) {
6+
const ignore = page.getByRole("button", { name: "Ignore for now" });
7+
try {
8+
await expect(ignore).toBeVisible({ timeout: 2_000 });
9+
await ignore.click();
10+
} catch {
11+
// The prompt only appears when the copied project data differs from the
12+
// currently installed mods. Most test runs have no drift to dismiss.
13+
}
14+
}
15+
516
test("a secondary goal accepts and persists a negative consume rate", async ({ page }) => {
617
// Block 38's shape: Tar remains the primary sink while Shale oil is added as
718
// another goal and changed from the default production rate to consumption.
@@ -25,6 +36,7 @@ test("a secondary goal accepts and persists a negative consume rate", async ({ p
2536
}
2637

2738
await goto(page, `/block/${id}`);
39+
await dismissDataDriftPrompt(page);
2840
await page.getByRole("button", { name: "1", exact: true }).click();
2941
const input = page.locator("input:focus");
3042
await input.fill("-2.5");
@@ -49,3 +61,31 @@ test("a secondary goal accepts and persists a negative consume rate", async ({ p
4961
saved.close();
5062
}
5163
});
64+
65+
test("a consume goal is not repeated in the Block balance imports", async ({ page }) => {
66+
const data = {
67+
recipes: ["burn-fluid-kerosene"],
68+
goals: [
69+
{ name: "water", rate: -1 },
70+
{ name: "kerosene", rate: -5 },
71+
],
72+
};
73+
const db = new DatabaseSync(activeProjectDbFile());
74+
let id: number;
75+
try {
76+
db.exec("PRAGMA busy_timeout = 5000");
77+
const inserted = db
78+
.prepare("INSERT INTO blocks (name, data) VALUES (?, ?)")
79+
.run(uniqueName("Kerosene sink"), JSON.stringify(data));
80+
id = Number(inserted.lastInsertRowid);
81+
} finally {
82+
db.close();
83+
}
84+
85+
await goto(page, `/block/${id}`);
86+
await dismissDataDriftPrompt(page);
87+
await expect(page.getByRole("button", { name: /^Kerosene 5\/s · target/ })).toBeVisible();
88+
await expect(
89+
page.getByRole("button", { name: /^Kerosene 5\/s · (craftable|raw input)/ }),
90+
).toBeHidden();
91+
});

app/src/components/block/balance-card.tsx

Lines changed: 78 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export function BalanceCard({
6161
const supplyPriority = useStore(doc.store, (s) => s.supplyPriority ?? 0);
6262
const supplyPriorities = useStore(doc.store, (s) => s.supplyPriorities ?? {});
6363
const spoilables = useSpoilables();
64+
const showImports = !!res && (res.displayImports.length > 0 || res.displayExports.length === 0);
65+
const showExports = !!res?.displayExports.length;
6466
// One mixed loop candidate: every solid item touching a belt in this block —
6567
// imports, exports, AND internal row-to-row flows all ride the same loop in
6668
// the "everything on one belt" pattern. A good's belt rate is
@@ -302,89 +304,91 @@ export function BalanceCard({
302304
</div>
303305
)}
304306
<div
305-
className={`grid gap-4 p-3 ${res?.displayExports.length ? "grid-cols-2" : "grid-cols-1"}`}
307+
className={`grid gap-4 p-3 ${showImports && showExports ? "grid-cols-2" : "grid-cols-1"}`}
306308
>
307-
<div>
308-
<div className="mb-1 text-sm font-semibold text-warning">Imports</div>
309-
<div className="flex flex-wrap gap-x-3 gap-y-2">
310-
{res?.imports.length ? (
311-
res.imports.map((f) => (
312-
<span key={f.name} className="group flex flex-col items-start gap-1.5">
313-
<span className="inline-flex items-center gap-1.5">
314-
<ItemChip
315-
name={f.name}
316-
kind={f.kind}
317-
display={res.display?.[f.name]}
318-
rate={f.rate}
319-
link="import"
320-
craftable={producible.has(f.name)}
321-
fuel={fuelSet.has(f.name)}
322-
onClick={() => onMakeFor(f.name)}
323-
onContext={(e) =>
324-
onCtxMenu(e, { name: f.name, kind: f.kind, link: "import" })
325-
}
326-
/>
327-
{/* Locked-as-block-driver state (set via right-click → "Size block by this
309+
{showImports && (
310+
<div>
311+
<div className="mb-1 text-sm font-semibold text-warning">Imports</div>
312+
<div className="flex flex-wrap gap-x-3 gap-y-2">
313+
{res.displayImports.length ? (
314+
res.displayImports.map((f) => (
315+
<span key={f.name} className="group flex flex-col items-start gap-1.5">
316+
<span className="inline-flex items-center gap-1.5">
317+
<ItemChip
318+
name={f.name}
319+
kind={f.kind}
320+
display={res.display?.[f.name]}
321+
rate={f.rate}
322+
link="import"
323+
craftable={producible.has(f.name)}
324+
fuel={fuelSet.has(f.name)}
325+
onClick={() => onMakeFor(f.name)}
326+
onContext={(e) =>
327+
onCtxMenu(e, { name: f.name, kind: f.kind, link: "import" })
328+
}
329+
/>
330+
{/* Locked-as-block-driver state (set via right-click → "Size block by this
328331
input"): edit its rate inline + an unlock control. The toggle itself
329332
lives in the context menu, so non-locked rows stay uncluttered. */}
330-
{/* the block imports this while an in-block recipe
333+
{/* the block imports this while an in-block recipe
331334
produces it — usually the import-instead-of-make
332335
trap (block 27); one click claims it in-block */}
333-
{res.importedProducible?.includes(f.name) && (
334-
<button
335-
title="an enabled recipe in this block produces this good, but the plan imports it. Click to mark it made in-block (production must cover consumption)."
336-
onClick={() => {
337-
doc.markMade(f.name);
338-
doc.note(`Mark "${res.display?.[f.name] ?? f.name}" made in-block`);
339-
}}
340-
className="bg-warning/25 px-1.5 py-0.5 text-sm text-warning hover:brightness-110"
341-
>
342-
made here? · make in-block
343-
</button>
344-
)}
345-
{lockedInput === f.name && (
346-
<>
347-
<Tooltip content="locked rate — the block is sized to consume this much of this input">
348-
<Input
349-
type="number"
350-
value={lockedRate}
351-
step="0.01"
352-
min="0"
353-
autoFocus
354-
onChange={(e) => onLockedRateChange(Number(e.target.value) || 0)}
355-
className="h-7 w-16 border-info/60 px-1"
356-
/>
357-
</Tooltip>
358-
<Button
359-
variant="ghost"
360-
size="icon-xs"
361-
onClick={onUnlock}
362-
title="unlock — the Goal rate is editable again"
363-
className="text-info"
336+
{res.importedProducible?.includes(f.name) && (
337+
<button
338+
title="an enabled recipe in this block produces this good, but the plan imports it. Click to mark it made in-block (production must cover consumption)."
339+
onClick={() => {
340+
doc.markMade(f.name);
341+
doc.note(`Mark "${res.display?.[f.name] ?? f.name}" made in-block`);
342+
}}
343+
className="bg-warning/25 px-1.5 py-0.5 text-sm text-warning hover:brightness-110"
364344
>
365-
<Lock className="size-3.5" />
366-
</Button>
367-
</>
345+
made here? · make in-block
346+
</button>
347+
)}
348+
{lockedInput === f.name && (
349+
<>
350+
<Tooltip content="locked rate — the block is sized to consume this much of this input">
351+
<Input
352+
type="number"
353+
value={lockedRate}
354+
step="0.01"
355+
min="0"
356+
autoFocus
357+
onChange={(e) => onLockedRateChange(Number(e.target.value) || 0)}
358+
className="h-7 w-16 border-info/60 px-1"
359+
/>
360+
</Tooltip>
361+
<Button
362+
variant="ghost"
363+
size="icon-xs"
364+
onClick={onUnlock}
365+
title="unlock — the Goal rate is editable again"
366+
className="text-info"
367+
>
368+
<Lock className="size-3.5" />
369+
</Button>
370+
</>
371+
)}
372+
</span>
373+
{logi.resolved && f.kind === "item" && (
374+
<LogiTag
375+
resolved={logi.resolved}
376+
rate={f.rate}
377+
machineCount={0}
378+
showBelts={logi.showBelts}
379+
showInserters={logi.showInserters}
380+
launch={logi.launchInfo(f.name, f.rate)}
381+
/>
368382
)}
369383
</span>
370-
{logi.resolved && f.kind === "item" && (
371-
<LogiTag
372-
resolved={logi.resolved}
373-
rate={f.rate}
374-
machineCount={0}
375-
showBelts={logi.showBelts}
376-
showInserters={logi.showInserters}
377-
launch={logi.launchInfo(f.name, f.rate)}
378-
/>
379-
)}
380-
</span>
381-
))
382-
) : (
383-
<span className="text-sm text-muted-foreground">none</span>
384-
)}
384+
))
385+
) : (
386+
<span className="text-sm text-muted-foreground">none</span>
387+
)}
388+
</div>
385389
</div>
386-
</div>
387-
{!!res?.displayExports.length && (
390+
)}
391+
{showExports && (
388392
<div>
389393
<div className="mb-1 text-sm font-semibold text-surplus">Exports</div>
390394
<div className="flex flex-wrap gap-x-3 gap-y-2">

app/src/routes/block.$id.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,7 @@ function Block({ blockId }: { blockId: number }) {
674674
// goal, an unmade input (import), a surplus (export), or balanced in-block.
675675
const importSet = new Set(res?.imports.map((f) => f.name));
676676
const exportSet = new Set(res?.displayExports.map((f) => f.name));
677+
const goalSet = new Set(goalNames);
677678
// good → kind (item|fluid), gathered from every flow/recipe in the solve so goal
678679
// icons render correctly even for fluid goals (the target isn't in imports/exports).
679680
const kindMap = new Map<string, string>();
@@ -719,7 +720,7 @@ function Block({ blockId }: { blockId: number }) {
719720
const producible = new Set(res?.producible ?? []); // imports a recipe could make in-block
720721
const fuelSet = new Set(res?.fuelItems ?? []); // items consumed as fuel (folded into the balance)
721722
const linkOf = (name: string): ItemLink =>
722-
name === target
723+
goalSet.has(name)
723724
? "target"
724725
: importSet.has(name)
725726
? "import"

app/src/server/block-compute.server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,13 @@ export async function computeBlock(rawData: SolveInput) {
10521052
const rate = flow.rate - (incidentalGoalRates.get(flow.name) ?? 0);
10531053
return rate > EPS ? [{ ...flow, rate }] : [];
10541054
});
1055+
// A negative goal is itself the block's visible consume/import contract. Keep
1056+
// the canonical import in `imports` for factory projections and flow math,
1057+
// but do not repeat the same good in the editor's Imports list.
1058+
const negativeGoalNames = new Set(
1059+
data.goals.filter((goal) => goal.rate < 0).map((goal) => goal.name),
1060+
);
1061+
const displayImports = imports.filter((flow) => !negativeGoalNames.has(flow.name));
10551062
const fuelItems = [...fuelTotals.keys()]; // for the 🔥 tag in the UI
10561063
const burntItems = [...burntTotals.keys()]; // ash / depleted cells from burning
10571064

@@ -1194,6 +1201,7 @@ export async function computeBlock(rawData: SolveInput) {
11941201
return {
11951202
...result,
11961203
imports,
1204+
displayImports,
11971205
exports,
11981206
displayExports,
11991207
rows,

app/src/server/block-compute.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,7 @@ describe("fluid-fuel supplier designation (#115)", () => {
941941
const res = await computeBlock(input);
942942
expect(res.status).toBe("solved");
943943
expect(res.imports.find((f) => f.name === "kerosene")?.rate).toBeCloseTo(5);
944+
expect(res.displayImports.find((f) => f.name === "kerosene")).toBeUndefined();
944945
const flows = boundaryFlows(goalFlows(input), res);
945946
const kero = flows.filter((f) => f.item === "kerosene");
946947
expect(kero).toEqual([{ item: "kerosene", kind: "fluid", role: "import", rate: 5 }]); // exactly one

docs/development/solver.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ A negative goal represents a sink and becomes a net-consumption ceiling. Interna
5656
rates are per second. The editor's seconds/minutes/hours unit changes input and display
5757
only.
5858

59+
The solved good remains a canonical import for factory projections and flow graphs. In the
60+
block editor it is already represented by the negative goal, so `displayImports` removes it
61+
from the Block balance Imports list rather than rendering the same contract twice.
62+
5963
A stock goal stores an amount and replenishment window. The compute layer converts it to
6064
`amount / window` for solving, then tags its persisted factory flow as stock replenishment
6165
rather than continuous output.

docs/guide/blocks.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ Right-click a goal and select **Keep in stock instead (buffer, not throughput)**
3030
intent is to refill a quantity over time rather than sustain a continuous rate. Stock-only
3131
production appears separately in Factory.
3232

33+
A negative goal is already the block's visible import contract, so Block balance does not
34+
repeat that good under **Imports**. Other ingredients the block needs still appear there.
35+
3336
## Add recipes
3437

3538
Select a positive goal icon to open **Recipes that make _goal_**. A negative consume goal

0 commit comments

Comments
 (0)