Skip to content

Commit 7682313

Browse files
committed
fix(block): allow secondary consume goals
1 parent 21c2f06 commit 7682313

5 files changed

Lines changed: 97 additions & 7 deletions

File tree

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { DatabaseSync } from "node:sqlite";
2+
import { expect, test } from "@playwright/test";
3+
import { activeProjectDbFile, expectUndoTop, goto, uniqueName } from "./helpers";
4+
5+
test("a secondary goal accepts and persists a negative consume rate", async ({ page }) => {
6+
// Block 38's shape: Tar remains the primary sink while Shale oil is added as
7+
// another goal and changed from the default production rate to consumption.
8+
const data = {
9+
recipes: [],
10+
goals: [
11+
{ name: "tar", rate: -9.575 },
12+
{ name: "scrude", rate: 1 },
13+
],
14+
};
15+
const db = new DatabaseSync(activeProjectDbFile());
16+
let id: number;
17+
try {
18+
db.exec("PRAGMA busy_timeout = 5000");
19+
const inserted = db
20+
.prepare("INSERT INTO blocks (name, data) VALUES (?, ?)")
21+
.run(uniqueName("Secondary sink"), JSON.stringify(data));
22+
id = Number(inserted.lastInsertRowid);
23+
} finally {
24+
db.close();
25+
}
26+
27+
await goto(page, `/block/${id}`);
28+
await page.getByRole("button", { name: "1", exact: true }).click();
29+
const input = page.locator("input:focus");
30+
await input.fill("-2.5");
31+
await input.press("Enter");
32+
33+
await expect(page.getByRole("button", { name: "-2.5", exact: true })).toBeVisible();
34+
await expectUndoTop(page, /Set "Shale oil" rate/);
35+
36+
const addConsumer = page.getByRole("button", {
37+
name: "add a recipe that consumes Shale oil",
38+
});
39+
await expect(addConsumer).toBeVisible();
40+
await addConsumer.click();
41+
await expect(page.getByRole("dialog", { name: "Recipes that consume Shale oil" })).toBeVisible();
42+
43+
const saved = new DatabaseSync(activeProjectDbFile(), { readOnly: true });
44+
try {
45+
const row = saved.prepare("SELECT data FROM blocks WHERE id = ?").get(id) as { data: string };
46+
const parsed = JSON.parse(row.data) as { goals: { name: string; rate: number }[] };
47+
expect(parsed.goals.find((goal) => goal.name === "scrude")?.rate).toBe(-2.5);
48+
} finally {
49+
saved.close();
50+
}
51+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// @vitest-environment jsdom
2+
import { cleanup, fireEvent, render } from "@testing-library/react";
3+
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4+
import { EditableRate } from "./editable-rate.tsx";
5+
6+
afterEach(cleanup);
7+
8+
describe("EditableRate", () => {
9+
it("commits a negative rate for a consume goal", () => {
10+
const onChange = vi.fn();
11+
const { getByRole } = render(<EditableRate value={1} onChange={onChange} />);
12+
13+
fireEvent.click(getByRole("button", { name: "1" }));
14+
const input = getByRole("textbox");
15+
fireEvent.change(input, { target: { value: "-2.5" } });
16+
fireEvent.keyDown(input, { key: "Enter" });
17+
18+
expect(onChange).toHaveBeenCalledWith(-2.5);
19+
});
20+
21+
it("converts a negative display-window rate back to per-second", () => {
22+
const onChange = vi.fn();
23+
const { getByRole } = render(<EditableRate value={1} unit="min" onChange={onChange} />);
24+
25+
fireEvent.click(getByRole("button", { name: "60" }));
26+
const input = getByRole("textbox");
27+
fireEvent.change(input, { target: { value: "-150" } });
28+
fireEvent.keyDown(input, { key: "Enter" });
29+
30+
expect(onChange).toHaveBeenCalledWith(-2.5);
31+
});
32+
});

app/src/components/block/editable-rate.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,7 @@ export function EditableRate({
7070
const commit = () => {
7171
const parsed = parseRateInput(draft, power);
7272
// a power-unit value is already per-second — the display window doesn't apply
73-
if (parsed && parsed.value >= 0)
74-
onChange(parsed.perSecond ? parsed.value : parsed.value / factor);
73+
if (parsed) onChange(parsed.perSecond ? parsed.value : parsed.value / factor);
7574
setEditing(false);
7675
};
7776
return (

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function GoalCard({
5959
{goals.map((goal, i) => {
6060
const g = goal.name;
6161
const isFirst = i === 0;
62+
const consumes = goal.rate < 0;
6263
const kind = kindOf(g);
6364
const goalMissing = res?.missing?.goods.includes(g) ?? false;
6465
const incidentalRate =
@@ -67,14 +68,14 @@ export function GoalCard({
6768
.filter((spoilage) => spoilage.result === g)
6869
.reduce((sum, spoilage) => sum + spoilage.rate, 0) ?? 0)
6970
: 0;
70-
// declared but no recipe in the block makes it — fixable, not broken.
71+
// declared but no recipe in the block makes/consumes it — fixable, not broken.
7172
// Suppressed on a broken block: the missing-refs banner already
7273
// explains why nothing's being made there.
7374
const goalUnmade = !goalMissing && !res?.broken && (res?.unmade?.includes(g) ?? false);
7475
const extraText = goalMissing ? (
7576
"No longer exists in the current data."
7677
) : goalUnmade ? (
77-
"No recipe in this block makes it. Click to add one."
78+
`No recipe in this block ${consumes ? "consumes" : "makes"} it. Click to add one.`
7879
) : (
7980
<div className="space-y-1">
8081
<div>
@@ -129,8 +130,8 @@ export function GoalCard({
129130
</button>
130131
</div>
131132
<button
132-
onClick={() => (isFirst && goal.rate < 0 ? onUseFor(g) : onMakeFor(g))}
133-
aria-label={`add a recipe that makes ${res?.display?.[g] ?? g}`}
133+
onClick={() => (consumes ? onUseFor(g) : onMakeFor(g))}
134+
aria-label={`add a recipe that ${consumes ? "consumes" : "makes"} ${res?.display?.[g] ?? g}`}
134135
>
135136
<Icon kind={kind} name={g} size="lg" extraText={extraText} />
136137
</button>

docs/guide/blocks.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Select **New block**, then use **+ goal** to choose an item or fluid.
2020

2121
- The first goal starts at `1/s`, names the block, and becomes its scaling anchor.
2222
- Select a displayed rate to edit it. Select its unit to work in `/s`, `/min`, or `/h`.
23+
- Enter a negative rate when the block should consume that item or fluid rather than produce
24+
it. For example, `-10/min` makes the block consume at least 10 per minute.
2325
- Add more goals when the same production unit must guarantee several outputs.
2426
- Right-click a non-primary goal and select **Move to front (names the block)** to make it
2527
the primary goal.
@@ -30,7 +32,8 @@ production appears separately in Factory.
3032

3133
## Add recipes
3234

33-
Select a goal icon to open **Recipes that make _goal_**. Within a recipe row:
35+
Select a positive goal icon to open **Recipes that make _goal_**. A negative consume goal
36+
instead opens **Recipes that consume _goal_**. Within a recipe row:
3437

3538
- Select an ingredient chip to find recipes that make that ingredient.
3639
- Select a product chip to find recipes that consume that product.
@@ -69,6 +72,10 @@ The **Block balance** card summarizes:
6972
- goods entering as imports;
7073
- goods leaving as goals, surplus, or byproducts.
7174

75+
Select an exported good to add a recipe that consumes its surplus inside the block. The
76+
selected consumer runs as part of the chain, including when one of its products feeds back
77+
into the block's consume goal.
78+
7279
The recipe table explains how that result was produced. **Table** is the editing view;
7380
**Flow** is a read-only diagram of the same solved recipes and goods.
7481

0 commit comments

Comments
 (0)