Skip to content

Commit 4de0c03

Browse files
committed
feat(planner): add temporary production campaigns
Model finite production runs with quantity, duration, and confidence while keeping their derived demand in the ordinary factory solve. Present campaign totals compactly in the block editor and support completing or reactivating campaigns.\n\nCloses #160
1 parent b5b1d2b commit 4de0c03

33 files changed

Lines changed: 1180 additions & 113 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { DatabaseSync } from "node:sqlite";
2+
import { expect, test } from "@playwright/test";
3+
import { activeProjectDbFile, addGoal, createBlock, expectUndoTop } from "./helpers";
4+
5+
test("temporary campaign derives a finite target and can be completed and reactivated", async ({
6+
page,
7+
}) => {
8+
const id = await createBlock(page);
9+
await addGoal(page, "iron plate", "Iron plate");
10+
11+
await page.locator('button[aria-label^="Add a recipe that makes "]').click();
12+
const recipePicker = page.getByRole("dialog", { name: /Recipes that make/ });
13+
await recipePicker.locator('[data-recipe-candidate="iron-plate"]').click();
14+
await expect(recipePicker).toBeHidden();
15+
16+
await page.getByRole("button", { name: "Make this block temporary", exact: true }).click();
17+
const quantity = page.getByRole("button", { name: "Make 3,600", exact: true });
18+
await expect(quantity).toBeVisible();
19+
await quantity.click();
20+
const quantityInput = page.locator('input[inputmode="decimal"]:focus');
21+
await quantityInput.fill("5");
22+
await quantityInput.press("Enter");
23+
24+
const durationUnit = page.getByTitle(
25+
"Campaign duration unit — click to cycle seconds / minutes / hours",
26+
);
27+
await expect(durationUnit).toHaveText("h");
28+
await durationUnit.click();
29+
await expect(durationUnit).toHaveText("s");
30+
await durationUnit.click();
31+
await expect(durationUnit).toHaveText("min");
32+
const duration = page.getByRole("textbox", { name: "Campaign duration" });
33+
await duration.fill("30");
34+
await duration.press("Enter");
35+
36+
const ironImport = page.getByRole("button", { name: /^Iron ore 40 total/ });
37+
await expect(ironImport).toBeVisible();
38+
await expect(ironImport.locator("[data-campaign-rate]")).toHaveText("0.022/s");
39+
const balance = page.locator('[data-slot="card"]').filter({ hasText: "Block balance" });
40+
await expect(balance.getByText(/^avg /)).toHaveCount(0);
41+
await expect(balance.getByText("<0.01", { exact: true }).first()).toBeVisible();
42+
43+
await page.getByRole("combobox", { name: "Campaign confidence" }).click();
44+
await page.getByRole("option", { name: "90% confidence" }).click();
45+
const goal = page.locator('[data-goal="iron-plate"]');
46+
await expect(goal).toContainText(/Make 5/);
47+
await expect(goal).not.toContainText(/plan |\/s/);
48+
await expectUndoTop(page, /Set campaign confidence|Edit block/);
49+
50+
await page.getByRole("button", { name: "Complete temporary campaign", exact: true }).click();
51+
await expect(
52+
page.getByRole("button", { name: "Reactivate temporary campaign", exact: true }),
53+
).toBeVisible();
54+
await expect(page.getByRole("status").filter({ hasText: /removed from factory planning/ })).toBeVisible();
55+
56+
const completedDb = new DatabaseSync(activeProjectDbFile(), { readOnly: true });
57+
try {
58+
const row = completedDb.prepare("SELECT enabled, data FROM blocks WHERE id = ?").get(id) as {
59+
enabled: number;
60+
data: string;
61+
};
62+
const data = JSON.parse(row.data) as {
63+
campaign?: { completedAt?: string; duration?: number };
64+
};
65+
expect(row.enabled).toBe(0);
66+
expect(data.campaign?.duration).toBe(1800);
67+
expect(data.campaign?.completedAt).toBeTruthy();
68+
} finally {
69+
completedDb.close();
70+
}
71+
72+
await page
73+
.getByRole("button", { name: "Reactivate temporary campaign", exact: true })
74+
.click();
75+
await expect(
76+
page.getByRole("button", { name: "Complete temporary campaign", exact: true }),
77+
).toBeVisible();
78+
await expect(page.getByRole("status").filter({ hasText: /campaign reactivated/ })).toBeVisible();
79+
80+
const activeDb = new DatabaseSync(activeProjectDbFile(), { readOnly: true });
81+
try {
82+
const row = activeDb.prepare("SELECT enabled, data FROM blocks WHERE id = ?").get(id) as {
83+
enabled: number;
84+
data: string;
85+
};
86+
const data = JSON.parse(row.data) as { campaign?: { completedAt?: string } };
87+
expect(row.enabled).toBe(1);
88+
expect(data.campaign?.completedAt).toBeUndefined();
89+
} finally {
90+
activeDb.close();
91+
}
92+
});

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export function BalanceCard({
6464
const blockName = useStore(doc.store, (s) => s.blockName);
6565
const supplyPriority = useStore(doc.store, (s) => s.supplyPriority ?? 0);
6666
const supplyPriorities = useStore(doc.store, (s) => s.supplyPriorities ?? {});
67+
const campaign = useStore(doc.store, (s) => s.campaign);
6768
const spoilables = useSpoilables();
6869
const showImports = !!res && (res.displayImports.length > 0 || res.displayExports.length === 0);
6970
const showExports = !!res?.displayExports.length;
@@ -338,6 +339,7 @@ export function BalanceCard({
338339
kind={f.kind}
339340
display={res.display?.[f.name]}
340341
rate={f.rate}
342+
total={campaign ? f.rate * campaign.duration : undefined}
341343
temp={f.temp}
342344
link="import"
343345
craftable={producible.has(f.name)}
@@ -441,6 +443,7 @@ export function BalanceCard({
441443
kind={f.kind}
442444
display={res.display?.[f.name]}
443445
rate={f.rate}
446+
total={campaign ? f.rate * campaign.duration : undefined}
444447
temp={f.temp}
445448
link="export"
446449
fuel={fuelSet.has(f.name)}

app/src/components/block/doc-store.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,40 @@ describe("goals", () => {
145145
expect(doc.store.state.goals[0].rate).toBe(7);
146146
expect(doc.store.state.goals[1].rate).toBe(0.5);
147147
});
148+
149+
it("converts goals into a finite temporary campaign and re-derives its rates", () => {
150+
const doc = seeded();
151+
doc.makeTemporary(3600);
152+
153+
expect(doc.store.state.campaign).toEqual({
154+
duration: 3600,
155+
confidence: "expected",
156+
quantities: { "iron-plate": 7200, "copper-plate": 1800 },
157+
});
158+
doc.setCampaignQuantity("iron-plate", 1);
159+
doc.setCampaignConfidence("90");
160+
expect(doc.store.state.goals[0].rate * 3600).toBeCloseTo(Math.log(10), 5);
161+
162+
doc.setCampaignDuration(7200);
163+
expect(doc.store.state.goals[0].rate * 7200).toBeCloseTo(Math.log(10), 5);
164+
doc.setCampaignCompletedAt("2026-07-15T00:00:00.000Z");
165+
expect(solveInputOf(doc.store.state).campaign?.completedAt).toBe("2026-07-15T00:00:00.000Z");
166+
});
167+
168+
it("keeps campaign quantities aligned when goals change", () => {
169+
const doc = seeded();
170+
doc.makeTemporary();
171+
doc.changeGoalItem("iron-plate", "steel-plate");
172+
doc.removeGoal("copper-plate");
173+
doc.addGoal("stone-brick");
174+
175+
expect(doc.store.state.campaign?.quantities).toEqual({
176+
"steel-plate": 7200,
177+
"stone-brick": 1,
178+
});
179+
doc.makeOngoing();
180+
expect(doc.store.state.campaign).toBeNull();
181+
});
148182
});
149183

150184
describe("recipes", () => {
@@ -342,4 +376,16 @@ describe("solveInputOf", () => {
342376
doc2.hydrate(saved, "Iron");
343377
expect(solveInputOf(doc2.store.state)).toEqual(saved);
344378
});
379+
380+
it("round-trips temporary campaign intent", () => {
381+
const doc = seeded();
382+
doc.makeTemporary(7200);
383+
doc.setCampaignQuantity("iron-plate", 3);
384+
doc.setCampaignConfidence("95");
385+
const saved = solveInputOf(doc.store.state);
386+
387+
const reloaded = createBlockDocStore();
388+
reloaded.hydrate(saved, "Iron");
389+
expect(solveInputOf(reloaded.store.state)).toEqual(saved);
390+
});
345391
});

0 commit comments

Comments
 (0)