Skip to content

Commit 8c8ba4d

Browse files
committed
fix(scenario): rebalance individual block goals
1 parent 7682313 commit 8c8ba4d

11 files changed

Lines changed: 497 additions & 66 deletions

File tree

app/e2e/mut/rebalance.e2e.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { DatabaseSync } from "node:sqlite";
12
import { expect, test } from "@playwright/test";
2-
import { goto, toast, undoButton } from "./helpers";
3+
import { activeProjectDbFile, goto, toast, undoButton, uniqueName } from "./helpers";
34

45
test("what-if target stays editable while the factory re-solves", async ({ page }) => {
56
await goto(page, "/factory/scenario");
@@ -13,7 +14,7 @@ test("what-if target stays editable while the factory re-solves", async ({ page
1314
// preserving both the in-progress value and keyboard focus throughout them.
1415
await expect(firstDemand).toBeFocused();
1516
await expect(firstDemand).toHaveValue("12.34");
16-
await expect(page.getByText("Block changes", { exact: false }).first()).toBeVisible({
17+
await expect(page.getByText("Goal changes", { exact: false }).first()).toBeVisible({
1718
timeout: 15_000,
1819
});
1920
await expect(firstDemand).toBeFocused();
@@ -52,13 +53,14 @@ test("what-if 'apply all' re-balances the factory in one undoable step", async (
5253
// confirm dialog summarizes the change; commit it
5354
const dialog = page.getByRole("dialog", { name: /Re-balance the whole factory/ });
5455
await expect(dialog).toBeVisible();
55-
await dialog.getByRole("button", { name: /^Apply to \d+ block/ }).click();
56+
await dialog.getByRole("button", { name: /^Apply \d+ change/ }).click();
5657
// the apply iterates the solve to convergence across every touched block, so the
5758
// dialog can sit in its "applying…" state for a while before it closes
5859
await expect(dialog).toBeHidden({ timeout: 45_000 });
5960

60-
// the whole batch reports as one action, with an Undo affordance
61-
await expect(toast(page, /Re-balanced \d+ block/)).toBeVisible({ timeout: 30_000 });
61+
// the whole batch reports as one undoable action. The success toast is brief
62+
// and can expire while the final query invalidations settle; the undo label is
63+
// the durable proof that the write completed.
6264
await expect(undoButton(page)).toHaveAccessibleName(/Undo: Re-balance factory/, {
6365
timeout: 15_000,
6466
});
@@ -70,4 +72,89 @@ test("what-if 'apply all' re-balances the factory in one undoable step", async (
7072
await expect(undoButton(page)).toBeEnabled();
7173
await undoButton(page).click();
7274
await expect(toast(page, /Undid: Re-balance factory/)).toBeVisible({ timeout: 30_000 });
75+
76+
});
77+
78+
test("Scenario applies a secondary consume goal independently", async ({ page }) => {
79+
test.setTimeout(120_000);
80+
// Resolve the copied project's existing projections before inserting the two
81+
// self-contained test blocks and their cached factory boundary flows.
82+
await goto(page, "/factory/scenario");
83+
84+
const sourceName = uniqueName("Acetaldehyde surplus");
85+
const sinkName = uniqueName("Secondary sink");
86+
const db = new DatabaseSync(activeProjectDbFile());
87+
let sourceId: number;
88+
let sinkId: number;
89+
try {
90+
db.exec("PRAGMA busy_timeout = 5000");
91+
sourceId = Number(
92+
db
93+
.prepare("INSERT INTO blocks (name, data, solve_status) VALUES (?, ?, 'solved')")
94+
.run(
95+
sourceName,
96+
JSON.stringify({ goals: [{ name: "water", rate: 1 }], recipes: [] }),
97+
).lastInsertRowid,
98+
);
99+
sinkId = Number(
100+
db
101+
.prepare("INSERT INTO blocks (name, data, solve_status) VALUES (?, ?, 'solved')")
102+
.run(
103+
sinkName,
104+
JSON.stringify({
105+
goals: [
106+
{ name: "water", rate: -1 },
107+
{ name: "acetaldehyde", rate: -2 },
108+
],
109+
recipes: ["spoil-acetaldehyde"],
110+
}),
111+
).lastInsertRowid,
112+
);
113+
const insertFlow = db.prepare(
114+
"INSERT INTO block_flows (block_id, item, kind, role, rate) VALUES (?, ?, ?, ?, ?)",
115+
);
116+
insertFlow.run(sourceId, "acetaldehyde", "item", "byproduct", 10);
117+
insertFlow.run(sinkId, "acetaldehyde", "item", "import", 2);
118+
} finally {
119+
db.close();
120+
}
121+
122+
await goto(page, "/factory/scenario");
123+
const change = page.getByRole("link", { name: /^Acetaldehyde / });
124+
await expect(change).toContainText("-2");
125+
await expect(change).toContainText("-10");
126+
127+
await page.getByRole("button", { name: "Apply all" }).click();
128+
const dialog = page.getByRole("dialog", { name: /Re-balance the whole factory/ });
129+
await dialog.getByRole("button", { name: /^Apply \d+ change/ }).click();
130+
await expect(dialog).toBeHidden({ timeout: 45_000 });
131+
await expect(toast(page, /Re-balanced \d+ block/)).toBeVisible({ timeout: 15_000 });
132+
await expect(undoButton(page)).toHaveAccessibleName(/Undo: Re-balance factory/, {
133+
timeout: 15_000,
134+
});
135+
136+
const saved = new DatabaseSync(activeProjectDbFile(), { readOnly: true });
137+
try {
138+
const row = saved.prepare("SELECT data FROM blocks WHERE id = ?").get(sinkId) as {
139+
data: string;
140+
};
141+
const doc = JSON.parse(row.data) as { goals: { name: string; rate: number }[] };
142+
expect(doc.goals.find((goal) => goal.name === "acetaldehyde")?.rate).toBe(-10);
143+
expect(doc.goals.find((goal) => goal.name === "water")?.rate).toBe(-1);
144+
} finally {
145+
saved.close();
146+
}
147+
148+
await undoButton(page).click();
149+
await expect(toast(page, /Undid: Re-balance factory/)).toBeVisible({ timeout: 30_000 });
150+
151+
const cleanup = new DatabaseSync(activeProjectDbFile());
152+
try {
153+
cleanup.exec("PRAGMA busy_timeout = 5000");
154+
cleanup.prepare("DELETE FROM block_flows WHERE block_id IN (?, ?)").run(sourceId, sinkId);
155+
cleanup.prepare("DELETE FROM block_machines WHERE block_id IN (?, ?)").run(sourceId, sinkId);
156+
cleanup.prepare("DELETE FROM blocks WHERE id IN (?, ?)").run(sourceId, sinkId);
157+
} finally {
158+
cleanup.close();
159+
}
73160
});

app/src/components/whatif/rebalance-all-button.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type BlockChange = {
2626
requiredRate: number;
2727
scale: number;
2828
delta: number;
29+
/** true when this changes one secondary goal instead of the block's anchor */
30+
goal?: boolean;
2931
};
3032

3133
// Query families the apply touches — everything a block rate change feeds. Mirrors
@@ -45,7 +47,7 @@ const MOVERS_SHOWN = 6;
4547

4648
/**
4749
* "Apply all" for the what-if page: commit the whole-factory re-balance the LP
48-
* found — set every listed block to its required rate in one undo step. Opens a
50+
* found — set every listed goal to its required rate in one undo step. Opens a
4951
* (non-destructive) confirm summarizing the biggest movers first, since it's a
5052
* factory-wide write; the result is fully reversible via the undo toast.
5153
*
@@ -132,7 +134,7 @@ export function RebalanceAllButton({
132134
<span className="inline-flex">{button}</span>
133135
</Tooltip>
134136
) : (
135-
<Tooltip content="Set every listed block to its required rate in one step — undoable">
137+
<Tooltip content="Set every listed goal to its required rate in one step — undoable">
136138
{button}
137139
</Tooltip>
138140
)}
@@ -145,14 +147,14 @@ export function RebalanceAllButton({
145147

146148
<div className="flex min-h-0 flex-col gap-3 p-3">
147149
<DialogDescription>
148-
Sets {changed.length} block{changed.length === 1 ? "" : "s"} to the rate the solve
149-
found and re-solves each. This is a single undo step.
150+
Applies {changed.length} block or goal change{changed.length === 1 ? "" : "s"} the
151+
solve found and re-solves each affected block. This is a single undo step.
150152
</DialogDescription>
151153

152154
<div className="max-h-64 min-h-0 overflow-auto border border-border">
153155
{movers.slice(0, MOVERS_SHOWN).map((b) => (
154156
<div
155-
key={b.id}
157+
key={`${b.id}-${b.goal ? b.good : "primary"}`}
156158
className="flex items-center gap-2 border-b border-border px-3 py-1.5 text-sm last:border-b-0"
157159
>
158160
<span className="min-w-0 flex-1 truncate">{b.name}</span>
@@ -185,7 +187,7 @@ export function RebalanceAllButton({
185187
<Button onClick={apply} disabled={applying}>
186188
{applying
187189
? "applying…"
188-
: `Apply to ${changed.length} block${changed.length === 1 ? "" : "s"}`}
190+
: `Apply ${changed.length} change${changed.length === 1 ? "" : "s"}`}
189191
</Button>
190192
</DialogFooter>
191193
</DialogContent>

app/src/db/queries.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,6 +2005,7 @@ export function blocksWithFlows(): {
20052005
name: string;
20062006
rate: number;
20072007
priority?: number;
2008+
goals: { name: string; rate: number; stock?: boolean }[];
20082009
flows: (BlockFlow & { priority?: number })[];
20092010
}[] {
20102011
const bs = db
@@ -2021,6 +2022,11 @@ export function blocksWithFlows(): {
20212022
id: b.id,
20222023
name: b.name,
20232024
rate: primaryRate(data),
2025+
goals: data.goals.map((goal) => ({
2026+
name: goal.name,
2027+
rate: goal.rate,
2028+
...(goal.stock != null ? { stock: true } : {}),
2029+
})),
20242030
priority: blockPriority,
20252031
flows: (flowsByBlock.get(b.id) ?? []).map((flow) => ({
20262032
...flow,

app/src/routes/factory_.scenario.tsx

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ import { rateLabel } from "../lib/format";
3636
// so treat it as balanced. Matches the server's re-balance convergence tolerance.
3737
const SCALE_EPS = 0.01;
3838

39-
/** Factory what-if: the whole factory solved as one block. Set a final
40-
* product's rate and see the per-block scale changes needed to satisfy every
41-
* demand/consumption — your work list to scale each block (or ignore). */
39+
/** Factory what-if: the whole factory solved as one system. Set a final
40+
* product's rate and see the per-goal changes needed to satisfy every
41+
* demand/consumption. */
4242
function WhatIf() {
4343
const [overrides, setOverrides] = useState<Record<string, number>>({});
4444
const wf = useQuery({
@@ -49,7 +49,9 @@ function WhatIf() {
4949
placeholderData: keepPreviousData,
5050
});
5151
const r = wf.data;
52-
const changed = (r?.blocks ?? []).filter((b) => Math.abs(b.scale - 1) > SCALE_EPS);
52+
const changed = (r?.goalChanges ?? [])
53+
.filter((goal) => Math.abs(goal.scale - 1) > SCALE_EPS)
54+
.map((goal) => ({ ...goal, name: goal.display }));
5355

5456
return (
5557
<div className="p-4 font-mono text-foreground">
@@ -69,7 +71,7 @@ function WhatIf() {
6971
<p>
7072
Scenario solves your{" "}
7173
<span className="text-foreground">whole factory as one system</span>. Set a target
72-
rate on any final product and it shows the per-block changes needed to satisfy every
74+
rate on any final product and it shows the goal changes needed to satisfy every
7375
downstream demand — a speculative &quot;if I wanted N/s of X, what changes?&quot;
7476
</p>
7577
<p>
@@ -85,9 +87,9 @@ function WhatIf() {
8587
drive the cascade.
8688
</li>
8789
<li>
88-
<span className="text-foreground">Block changes</span> — your work list: each
89-
block&apos;s current rate, the required rate, and the ×scale to get there. Click
90-
a block to open its editor.
90+
<span className="text-foreground">Goal changes</span> — your work list: each
91+
affected good&apos;s current target, required target, and ×scale. The block name
92+
shows where that goal lives; click the row to open it.
9193
</li>
9294
<li>
9395
<span className="text-foreground">Supply priority</span> — when several blocks
@@ -121,11 +123,11 @@ function WhatIf() {
121123
Say your automation-science block currently runs at{" "}
122124
<span className="text-foreground">0.5/s</span> and you want{" "}
123125
<span className="text-foreground">1/s</span>. Set its target to 1 and the cascade
124-
updates: <span className="text-foreground">Block changes</span> lists that block
125-
at <span className="text-foreground">×2</span>, plus every upstream block that
126-
feeds it rescaled to match; <span className="text-foreground">Raw inputs</span>{" "}
127-
shows the new draw (current vs projected) so you can check a mine or import can
128-
keep up. Nothing is saved until you open a listed block and apply its new rate.
126+
updates: <span className="text-foreground">Goal changes</span> lists that good at
127+
<span className="text-foreground">×2</span>, plus every upstream goal that feeds
128+
it; <span className="text-foreground">Raw inputs</span> shows the new draw
129+
(current vs projected) so you can check a mine or import can keep up. Nothing is
130+
saved until you apply the listed goal changes.
129131
</p>
130132
</div>
131133
<p>
@@ -145,7 +147,7 @@ function WhatIf() {
145147
<Card>
146148
<CardHeader>
147149
<CardTitle className="normal-case">Final products</CardTitle>
148-
<InfoHint content="Set a target rate to see the per-block changes." />
150+
<InfoHint content="Set a target rate to see the per-goal changes." />
149151
</CardHeader>
150152
<div className="divide-y divide-border">
151153
{(r?.demands ?? []).map((d) => {
@@ -191,10 +193,10 @@ function WhatIf() {
191193
</div>
192194
</Card>
193195

194-
{/* Block changes — the work list */}
196+
{/* Goal changes — the work list */}
195197
<Card className="lg:col-span-2">
196198
<CardHeader className="justify-between">
197-
<CardTitle className="normal-case">Block changes ({changed.length})</CardTitle>
199+
<CardTitle className="normal-case">Goal changes ({changed.length})</CardTitle>
198200
<div className="flex items-center gap-3">
199201
<RebalanceAllButton
200202
changed={changed}
@@ -205,7 +207,7 @@ function WhatIf() {
205207
</div>
206208
</CardHeader>
207209
<StatTableHeader
208-
lead="block"
210+
lead="good"
209211
cols={[
210212
{ label: "current/s", w: "w-24" },
211213
{ label: "required/s", w: "w-24" },
@@ -220,17 +222,17 @@ function WhatIf() {
220222
</div>
221223
) : changed.length === 0 ? (
222224
<Callout tone="success" variant="strip">
223-
already balanced for these demands — no block changes needed
225+
already balanced for these demands — no goal changes needed
224226
</Callout>
225227
) : (
226228
changed.map((b) => (
227229
<Link
228-
key={b.id}
230+
key={`${b.id}-${b.goal ? b.good : "primary"}`}
229231
to="/block/$id"
230232
params={{ id: String(b.id) }}
231233
className="flex flex-col gap-1 border-t border-border px-3 py-2 text-sm hover:bg-muted md:flex-row md:items-center md:py-1.5"
232234
>
233-
<span className="min-w-0 flex-1 truncate text-primary underline">{b.name}</span>
235+
<span className="min-w-0 flex-1 truncate text-primary underline">{b.display}</span>
234236
<span className="grid grid-cols-3 gap-x-3 md:flex">
235237
<StatCell label="current/s" w="md:w-24" className="text-muted-foreground">
236238
{rateLabel(b.good ?? "", b.currentRate)}
@@ -292,9 +294,15 @@ function WhatIf() {
292294
to="/block/$id"
293295
params={{ id: String(x.absorb.id) }}
294296
className="ml-2 bg-muted/60 px-1.5 py-0.5 text-sm text-primary hover:bg-muted"
295-
title={`scale ${x.absorb.name} to absorb the surplus`}
297+
title={
298+
"goalRate" in x.absorb
299+
? `change ${x.absorb.name}'s ${x.display} consume goal to absorb the surplus`
300+
: `scale ${x.absorb.name} to absorb the surplus`
301+
}
296302
>
297-
{x.absorb.name} ×{x.absorb.scale}
303+
{"goalRate" in x.absorb
304+
? `→ ${x.display} goal ${rateLabel(x.good, x.absorb.goalRate)}/s`
305+
: `→ ${x.absorb.name} ×${x.absorb.scale}`}
298306
</Link>
299307
) : (
300308
<Tooltip content="no block consumes this yet">

app/src/server/agent-tools.server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1287,7 +1287,13 @@ export const whatIf = tool({
12871287
overproduced: result.overproduced.map((o) => ({
12881288
...o,
12891289
absorb: o.absorb
1290-
? { blockId: o.absorb.id, name: o.absorb.name, scale: o.absorb.scale }
1290+
? "goalRate" in o.absorb
1291+
? {
1292+
blockId: o.absorb.id,
1293+
name: o.absorb.name,
1294+
goalRate: o.absorb.goalRate,
1295+
}
1296+
: { blockId: o.absorb.id, name: o.absorb.name, scale: o.absorb.scale }
12911297
: null,
12921298
})),
12931299
};

0 commit comments

Comments
 (0)