Skip to content

Commit c4c7435

Browse files
committed
feat(scenario): balance factory iteratively
Keep terminal goals pinned while repeatedly adjusting intermediate production and sink goals against freshly solved block flows. Probe valid zero-rate producers so idle configured chains can start. Closes #147
1 parent 056d06a commit c4c7435

11 files changed

Lines changed: 935 additions & 102 deletions

app/e2e/mut/rebalance.e2e.ts

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
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, goto, toast, undoButton, 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+
// Most seeded runs match the installed mods and have no prompt.
12+
}
13+
}
14+
515
test("what-if target stays editable while the factory re-solves", async ({ page }) => {
616
await goto(page, "/factory/scenario");
17+
await dismissDataDriftPrompt(page);
718

819
const firstDemand = page.getByRole("spinbutton").first();
920
await expect(firstDemand).toBeVisible();
@@ -22,8 +33,8 @@ test("what-if target stays editable while the factory re-solves", async ({ page
2233
});
2334

2435
/**
25-
* What-if "Apply all" (whole-factory re-balance): overriding a final product's
26-
* target surfaces the per-block changes, and Apply all commits every one of them
36+
* Applying a Scenario target: overriding a final product's target surfaces the
37+
* per-block changes, and Apply scenario commits every one of them
2738
* in a single undoable step. Drives the real flow end-to-end against the isolated
2839
* mut server, then undoes the factory-wide change so the shared scratch db is left
2940
* as it was.
@@ -32,11 +43,12 @@ test("what-if target stays editable while the factory re-solves", async ({ page
3243
* floor (rounding), so the test forces a genuine change by DOUBLING a demand — far
3344
* above the floor — rather than depending on the seed happening to be imbalanced.
3445
*/
35-
test("what-if 'apply all' re-balances the factory in one undoable step", async ({ page }) => {
46+
test("applying a scenario re-balances the factory in one undoable step", async ({ page }) => {
3647
// applying + undoing a whole-factory re-balance re-solves every touched block, so
3748
// give the round trips generous room over the default per-test budget
3849
test.setTimeout(120_000);
3950
await goto(page, "/factory/scenario");
51+
await dismissDataDriftPrompt(page);
4052

4153
// the Final products card is the only place with numeric (spinbutton) inputs
4254
const firstDemand = page.getByRole("spinbutton").first();
@@ -45,13 +57,13 @@ test("what-if 'apply all' re-balances the factory in one undoable step", async (
4557
// 2× + 1 guarantees a real increase even if the current target reads 0
4658
await firstDemand.fill(String(current * 2 + 1));
4759

48-
// the doubled demand cascades into real per-block changes → Apply all enables
49-
const applyAll = page.getByRole("button", { name: "Apply all" });
60+
// the doubled demand cascades into real per-block changes → Apply scenario enables
61+
const applyAll = page.getByRole("button", { name: "Apply scenario" });
5062
await expect(applyAll).toBeEnabled({ timeout: 15_000 });
5163
await applyAll.click();
5264

5365
// confirm dialog summarizes the change; commit it
54-
const dialog = page.getByRole("dialog", { name: /Re-balance the whole factory/ });
66+
const dialog = page.getByRole("dialog", { name: /Apply this factory scenario/ });
5567
await expect(dialog).toBeVisible();
5668
await dialog.getByRole("button", { name: /^Apply \d+ change/ }).click();
5769
// the apply iterates the solve to convergence across every touched block, so the
@@ -72,14 +84,68 @@ test("what-if 'apply all' re-balances the factory in one undoable step", async (
7284
await expect(undoButton(page)).toBeEnabled();
7385
await undoButton(page).click();
7486
await expect(toast(page, /Undid: Re-balance factory/)).toBeVisible({ timeout: 30_000 });
87+
});
88+
89+
test("Scenario can start a valid zero-rate producer", async ({ page }) => {
90+
test.setTimeout(120_000);
91+
// Tar's current byproducts can eventually cover all Coke demand. Disable that
92+
// competing incidental source in the scratch project so this test isolates
93+
// the idle configured Coke producer rather than supply substitution.
94+
const setup = new DatabaseSync(activeProjectDbFile());
95+
const tarEnabled = (setup.prepare("SELECT enabled FROM blocks WHERE id = 38").get() as {
96+
enabled: number;
97+
}).enabled;
98+
try {
99+
setup.prepare("UPDATE blocks SET enabled = 0 WHERE id = 38").run();
100+
} finally {
101+
setup.close();
102+
}
75103

104+
try {
105+
await goto(page, "/factory/scenario");
106+
await dismissDataDriftPrompt(page);
107+
108+
const change = page.getByRole("link", { name: /^Coke / });
109+
await expect(change).toContainText("0");
110+
await expect(change).toContainText("start");
111+
112+
await page.getByRole("button", { name: "Balance factory", exact: true }).click();
113+
const dialog = page.getByRole("dialog", { name: /Balance the whole factory/ });
114+
await dialog.getByRole("button", { name: /^Apply \d+ change/ }).click();
115+
await expect(dialog).toBeHidden({ timeout: 45_000 });
116+
await expect(undoButton(page)).toHaveAccessibleName(/Undo: Re-balance factory/, {
117+
timeout: 15_000,
118+
});
119+
120+
const saved = new DatabaseSync(activeProjectDbFile(), { readOnly: true });
121+
try {
122+
const row = saved.prepare("SELECT data FROM blocks WHERE name = 'Coke'").get() as {
123+
data: string;
124+
};
125+
const doc = JSON.parse(row.data) as { goals: { name: string; rate: number }[] };
126+
expect(doc.goals.find((goal) => goal.name === "coke")?.rate).toBeGreaterThan(0);
127+
} finally {
128+
saved.close();
129+
}
130+
131+
await undoButton(page).click();
132+
await expect(toast(page, /Undid: Re-balance factory/)).toBeVisible({ timeout: 30_000 });
133+
} finally {
134+
const restore = new DatabaseSync(activeProjectDbFile());
135+
try {
136+
restore.prepare("UPDATE blocks SET enabled = ? WHERE id = 38").run(tarEnabled);
137+
} finally {
138+
restore.close();
139+
}
140+
}
76141
});
77142

78143
test("Scenario applies a secondary consume goal independently", async ({ page }) => {
79144
test.setTimeout(120_000);
80145
// Resolve the copied project's existing projections before inserting the two
81146
// self-contained test blocks and their cached factory boundary flows.
82147
await goto(page, "/factory/scenario");
148+
await dismissDataDriftPrompt(page);
83149

84150
const sourceName = uniqueName("Acetaldehyde surplus");
85151
const sinkName = uniqueName("Secondary sink");
@@ -120,12 +186,12 @@ test("Scenario applies a secondary consume goal independently", async ({ page })
120186
}
121187

122188
await goto(page, "/factory/scenario");
123-
const change = page.getByRole("link", { name: /^Acetaldehyde / });
189+
const change = page.getByRole("link", { name: /^Acetaldehyde -2/ });
124190
await expect(change).toContainText("-2");
125191
await expect(change).toContainText("-10");
126192

127-
await page.getByRole("button", { name: "Apply all" }).click();
128-
const dialog = page.getByRole("dialog", { name: /Re-balance the whole factory/ });
193+
await page.getByRole("button", { name: "Balance factory", exact: true }).click();
194+
const dialog = page.getByRole("dialog", { name: /Balance the whole factory/ });
129195
await dialog.getByRole("button", { name: /^Apply \d+ change/ }).click();
130196
await expect(dialog).toBeHidden({ timeout: 45_000 });
131197
await expect(toast(page, /Re-balanced \d+ block/)).toBeVisible({ timeout: 15_000 });

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ 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 */
29+
/** true when this changes a specific goal instead of an opaque block rate */
3030
goal?: boolean;
31+
/** Starts a valid producer whose current goal is zero. */
32+
activation?: boolean;
3133
};
3234

3335
// Query families the apply touches — everything a block rate change feeds. Mirrors
@@ -46,12 +48,12 @@ const REFRESH_KEYS = [
4648
const MOVERS_SHOWN = 6;
4749

4850
/**
49-
* "Apply all" for the what-if page: commit the whole-factory re-balance the LP
50-
* found — set every listed goal to its required rate in one undo step. Opens a
51+
* Commit the whole-factory balance pass — set every listed goal to its
52+
* required rate in one undo step. Opens a
5153
* (non-destructive) confirm summarizing the biggest movers first, since it's a
5254
* factory-wide write; the result is fully reversible via the undo toast.
5355
*
54-
* `status` is the LP solve status: a non-Optimal solve means the target can't be
56+
* `status` is the balance status: a non-Optimal result means the target can't be
5557
* met with the current blocks, so applying its partial result could make things
5658
* worse — the button disables and says why instead.
5759
*/
@@ -71,6 +73,7 @@ export function RebalanceAllButton({
7173
const [applying, setApplying] = useState(false);
7274

7375
const notOptimal = status != null && status !== "Optimal";
76+
const applyingScenario = Object.keys(overrides).length > 0;
7477
const disabledReason =
7578
changed.length === 0
7679
? "Nothing to re-balance — every block already meets demand"
@@ -122,7 +125,7 @@ export function RebalanceAllButton({
122125

123126
const button = (
124127
<Button size="sm" disabled={disabledReason != null || applying} onClick={() => setOpen(true)}>
125-
{applying ? "applying…" : "Apply all"}
128+
{applying ? "applying…" : applyingScenario ? "Apply scenario" : "Balance factory"}
126129
</Button>
127130
);
128131

@@ -142,7 +145,9 @@ export function RebalanceAllButton({
142145
<Dialog open={open} onOpenChange={(o) => !applying && setOpen(o)}>
143146
<DialogContent className="max-w-lg">
144147
<DialogHeader>
145-
<DialogTitle>Re-balance the whole factory?</DialogTitle>
148+
<DialogTitle>
149+
{applyingScenario ? "Apply this factory scenario?" : "Balance the whole factory?"}
150+
</DialogTitle>
146151
</DialogHeader>
147152

148153
<div className="flex min-h-0 flex-col gap-3 p-3">
@@ -168,7 +173,7 @@ export function RebalanceAllButton({
168173
</span>
169174
</span>
170175
<span className="w-16 shrink-0 text-right text-muted-foreground tabular-nums">
171-
×{b.scale}
176+
{b.activation ? "start" : ${b.scale}`}
172177
</span>
173178
</div>
174179
))}

app/src/routes/factory_.scenario.tsx

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function WhatIf() {
5050
});
5151
const r = wf.data;
5252
const changed = (r?.goalChanges ?? [])
53-
.filter((goal) => Math.abs(goal.scale - 1) > SCALE_EPS)
53+
.filter((goal) => goal.activation || Math.abs(goal.scale - 1) > SCALE_EPS)
5454
.map((goal) => ({ ...goal, name: goal.display }));
5555

5656
return (
@@ -77,7 +77,8 @@ function WhatIf() {
7777
<p>
7878
<span className="text-foreground">vs Overview.</span> Overview shows your factory as
7979
it is now; Scenario is a sandbox — it doesn&apos;t change anything until you open a
80-
block and apply.
80+
block, select <span className="text-foreground">Balance factory</span>, or apply an
81+
edited scenario.
8182
</p>
8283
<div>
8384
<div className="font-semibold text-foreground">How to use it</div>
@@ -99,7 +100,8 @@ function WhatIf() {
99100
</li>
100101
<li>
101102
<span className="text-foreground">Raw inputs</span> — what the new target draws
102-
in from outside (current vs projected).
103+
in from outside (current vs projected), including a required good no enabled
104+
block can currently supply.
103105
</li>
104106
<li>
105107
<span className="text-foreground">Overproduced</span> — anything the target
@@ -133,9 +135,9 @@ function WhatIf() {
133135
<p>
134136
<span className="text-foreground">The solve pill</span> (next to this button)
135137
reports the whole-factory solve. <span className="text-foreground">Optimal</span>{" "}
136-
means it found a consistent set of rates; any other status means the target
137-
can&apos;t be met with the current recipes and blockstreat it as a prompt to add
138-
a producer or relax the target, and open the affected block to see why.
138+
means it found a settled set of rates; any other status means an affected block
139+
became infeasible or the passes did not convergeopen those blocks to inspect
140+
conflicting goals or pins.
139141
</p>
140142
</HelpButton>
141143
</>
@@ -245,7 +247,7 @@ function WhatIf() {
245247
{rateLabel(b.good ?? "", b.requiredRate)}
246248
</StatCell>
247249
<StatCell label="×scale" w="md:w-20" className="text-muted-foreground">
248-
×{b.scale}
250+
{b.activation ? "start" : ${b.scale}`}
249251
</StatCell>
250252
</span>
251253
</Link>
@@ -294,15 +296,9 @@ function WhatIf() {
294296
to="/block/$id"
295297
params={{ id: String(x.absorb.id) }}
296298
className="ml-2 bg-muted/60 px-1.5 py-0.5 text-sm text-primary hover:bg-muted"
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-
}
299+
title={`change ${x.absorb.name}'s ${x.display} consume goal to absorb the surplus`}
302300
>
303-
{"goalRate" in x.absorb
304-
? `→ ${x.display} goal ${rateLabel(x.good, x.absorb.goalRate)}/s`
305-
: `→ ${x.absorb.name} ×${x.absorb.scale}`}
301+
{x.display} goal {rateLabel(x.good, x.absorb.goalRate)}/s
306302
</Link>
307303
) : (
308304
<Tooltip content="no block consumes this yet">

0 commit comments

Comments
 (0)