Skip to content

Commit b8f7102

Browse files
committed
feat(agent): multi-goal + keep-in-stock goals, and module-fill the building bill
Let the assistant propose a block with multiple output goals, each optionally a keep-in-stock goal (#38): a goal carries `stock` (amount to keep on hand) and `window` (refill seconds, default 600), and the solver rate is derived as stock/window. This gives building/mall-supply blocks a sane primitive — "keep 80 vrauks-paddock on hand" seeded from the building bill — instead of a fabricated per-second rate the model kept balking at and deferring. goals[0] still anchors naming/sizing; the legacy target+rate shorthand is unchanged. The draft return carries the full goals array so the chat apply path (single block and plan) persists stock goals into BlockData instead of flattening them to {name, rate}. Also fix buildingBill's machine counts: it did a bare computeBlock with no module pass, so for Py's near-useless-unmoduled creature/farm buildings it over-counted ~10-15x and disagreed with submitBlock's own module-filled counts. Extract the two-pass module-fill solve into a shared solveWithModuleFill used by both; a test now asserts they agree. Prompt: mall/supply blocks use keep-in-stock goals seeded from the bill, the recursion into the machines' own intermediates (circuits, small parts) is the deliverable rather than a stop signal, and "No deferring" now names the machine/fuel/sink-block case explicitly.
1 parent 7fca79c commit b8f7102

6 files changed

Lines changed: 627 additions & 116 deletions

File tree

app/src/routes/assistant.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ import { ShowInGameButton } from "#/components/assistant/show-in-game-button.tsx
3737
import { HelpButton } from "#/components/help-drawer.tsx";
3838
import { SidebarShell } from "#/components/sidebar-shell.tsx";
3939
import { ItemHover, RecipeHover, TechHover } from "#/lib/recipe-card";
40-
import { formatRate } from "#/lib/format";
40+
import { formatQty, formatRate } from "#/lib/format";
41+
import { STOCK_WINDOW_DEFAULT } from "#/lib/goals";
4142
import { toast } from "#/lib/toast-store";
4243
import {
4344
aiConfigFn,
@@ -1353,11 +1354,17 @@ function Prose({ text }: { text: string }) {
13531354
/* ── Block draft preview ──────────────────────────────────────────────────── */
13541355

13551356
type GoodRate = { good: string; rate?: number | null };
1357+
// One output goal (#38): either a throughput `rate` or a keep-in-stock `stock`
1358+
// (+ refill `window`, seconds) — the shape persisted into BlockData.goals.
1359+
type DraftGoal = { name: string; rate: number; stock?: number; window?: number };
13561360
type Draft = {
13571361
name?: string;
13581362
target: string;
13591363
targetDisplay?: string;
13601364
rate: number;
1365+
// Full goal set from the draft (#38) — goals[0] is always target/rate, kept
1366+
// in sync for back-compat with older cached drafts that predate this field.
1367+
goals?: DraftGoal[];
13611368
recipes: string[];
13621369
modules?: Record<string, string[]>;
13631370
machines?: Record<string, string>;
@@ -1419,6 +1426,14 @@ type PlanDraftData = {
14191426

14201427
const fmtRate = (r?: number | null) => (r != null ? formatRate(r) : "");
14211428

1429+
/** The goals to persist for a drafted block (#38): the draft's full `goals`
1430+
* array when present, else the legacy single target/rate synthesized into one
1431+
* — covers a stale cached draft from before this field existed. */
1432+
function draftGoals(draft: Draft): DraftGoal[] {
1433+
if (draft.goals?.length) return draft.goals;
1434+
return [{ name: draft.target, rate: draft.rate }];
1435+
}
1436+
14221437
function refRow(label: ReactNode, items: string[] | undefined, prefer?: "recipe", warn?: boolean) {
14231438
return items && items.length ? (
14241439
<div className="mt-2.5">
@@ -1457,6 +1472,32 @@ function rateRow(label: ReactNode, entries: GoodRate[] | undefined) {
14571472
) : null;
14581473
}
14591474

1475+
const fmtWindow = (s: number) => (s >= 3600 ? `${s / 3600}h` : `${s / 60}m`);
1476+
1477+
/** Every output goal (#38), shown only when a block has more than one — the
1478+
* common single-target case keeps its existing header-only display. A stock
1479+
* goal reads as "keep N (refill Xm)" instead of a rate. */
1480+
function goalsRow(goals: DraftGoal[] | undefined) {
1481+
if (!goals || goals.length < 2) return null;
1482+
return (
1483+
<div className="mt-2.5">
1484+
<div className="text-xs uppercase tracking-wide text-muted-foreground">output goals</div>
1485+
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1.5">
1486+
{goals.map((g) => (
1487+
<span key={g.name} className="inline-flex items-center gap-1">
1488+
<Ref name={g.name} />
1489+
<span className="text-xs text-muted-foreground">
1490+
{g.stock != null
1491+
? `keep ${formatQty(g.stock)} (refill ${fmtWindow(g.window ?? STOCK_WINDOW_DEFAULT)})`
1492+
: fmtRate(g.rate)}
1493+
</span>
1494+
</span>
1495+
))}
1496+
</div>
1497+
</div>
1498+
);
1499+
}
1500+
14601501
/** The shared body of a block draft / update card: recipes, imports, sub-blocks,
14611502
* byproducts, TURD, invalid recipes — everything below the header + action button. */
14621503
function DraftRows({ draft }: { draft: Draft }) {
@@ -1466,6 +1507,7 @@ function DraftRows({ draft }: { draft: Draft }) {
14661507
}));
14671508
return (
14681509
<>
1510+
{goalsRow(draft.goals)}
14691511
{refRow(`${draft.recipes.length} recipes`, draft.recipes, "recipe")}
14701512
{rateRow("imports (external)", externalImports)}
14711513
{draft.importsFromBlocks && draft.importsFromBlocks.length > 0 && (
@@ -1584,7 +1626,7 @@ function BlockDraft({
15841626
data: {
15851627
name: `${draft.targetDisplay ?? draft.target} (drafted)`,
15861628
data: {
1587-
goals: [{ name: draft.target, rate: draft.rate }],
1629+
goals: draftGoals(draft),
15881630
recipes: draft.recipes,
15891631
...(draft.modules && Object.keys(draft.modules).length
15901632
? { modules: draft.modules }
@@ -1814,7 +1856,7 @@ function PlanDraft({
18141856
const res = await saveBlockFn({
18151857
data: {
18161858
name: draft.name ?? `${draft.targetDisplay ?? draft.target} (drafted)`,
1817-
data: { goals: [{ name: draft.target, rate: draft.rate }], recipes: draft.recipes },
1859+
data: { goals: draftGoals(draft), recipes: draft.recipes },
18181860
},
18191861
});
18201862
made.push(res);
@@ -1954,6 +1996,7 @@ function PlanBlockPreview({
19541996
return (
19551997
<div className="mt-2 space-y-2 text-sm">
19561998
{draft.notes && <p className="text-muted-foreground">{draft.notes}</p>}
1999+
{goalsRow(draft.goals)}
19572000
<div>
19582001
<div className="text-xs uppercase tracking-wide text-muted-foreground">recipes</div>
19592002
<div className="mt-1 flex flex-wrap gap-1.5">

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { buildingBill, submitBlock } from "./agent-tools.server.ts";
2020
type Draft = {
2121
ok: boolean;
2222
buildings: { recipe: string; machine: string; count: number }[];
23+
modules?: Record<string, string[]>;
2324
};
2425

2526
type Bill = {
@@ -118,3 +119,78 @@ describe("solved building counts (draft buildings + buildingBill)", () => {
118119
expect(res.machines[0].count).toBe(13); // ceil(12.8) from the iron-plate block alone
119120
});
120121
});
122+
123+
/**
124+
* buildingBill used to skip the two-pass module-fill solve submitBlock already
125+
* did (via a bare `computeBlock({goals, recipes})`), so for a moduled machine
126+
* it reported a much bigger, UNMODULED count that disagreed with submitBlock's
127+
* own draft for the exact same recipe/rate — the root cause behind the AI
128+
* assistant reporting nonsensical "3520 vrauk paddocks" for Py's near-useless-
129+
* unmoduled creature/farm buildings. Both tools now share `solveWithModuleFill`.
130+
*
131+
* Fixture: stone-furnace with 2 module slots and one +20%-speed module
132+
* (unlocked via its own start-enabled crafting recipe) — no efficiency module,
133+
* so the auto-fill algorithm's only lever is speed, which shaves the whole
134+
* building count deterministically (see module-fill.test.ts).
135+
*/
136+
describe("buildingBill agrees with submitBlock's module-filled machine counts", () => {
137+
let fx: TestDb;
138+
139+
beforeEach(async () => {
140+
fx = await makeTestDb();
141+
fx.db.exec(`
142+
INSERT INTO items (name, display) VALUES
143+
('iron-ore','Iron ore'),('iron-plate','Iron plate'),
144+
('stone','Stone'),('stone-furnace','Stone furnace'),
145+
('speed-module-1','Speed module');
146+
147+
INSERT INTO recipes (name, kind, category, energy_required, enabled, hidden) VALUES
148+
('iron-plate','real','smelting',3.2,1,0),
149+
('craft-stone-furnace','real','crafting',0.5,1,0),
150+
('craft-speed-module-1','real','crafting',5,1,0);
151+
INSERT INTO recipe_ingredients (recipe, idx, kind, name, amount) VALUES
152+
('iron-plate',0,'item','iron-ore',1),
153+
('craft-stone-furnace',0,'item','stone',5),
154+
('craft-speed-module-1',0,'item','iron-plate',5);
155+
INSERT INTO recipe_products (recipe, idx, kind, name, amount) VALUES
156+
('iron-plate',0,'item','iron-plate',1),
157+
('craft-stone-furnace',0,'item','stone-furnace',1),
158+
('craft-speed-module-1',0,'item','speed-module-1',1);
159+
160+
-- 2 module slots (vanilla stone-furnace has none — this fixture gives it
161+
-- slots on purpose, standing in for a moduled Py building)
162+
INSERT INTO crafting_machines
163+
(name, display, kind, crafting_speed, module_slots, energy_usage_w, energy_source)
164+
VALUES
165+
('stone-furnace','Stone furnace','furnace',1,2,90000,'electric');
166+
INSERT INTO machine_categories (machine, category) VALUES
167+
('stone-furnace','smelting');
168+
INSERT INTO modules (name, category, hidden, eff_speed, eff_productivity, eff_consumption)
169+
VALUES ('speed-module-1','speed',0,0.2,0,0.5);
170+
`);
171+
fx.db.close();
172+
switchDatabase(fx.file);
173+
});
174+
175+
afterEach(() => fx.cleanup());
176+
177+
it("submitBlock's draft auto-fills the furnace's module slots", async () => {
178+
const res = await draft({ target: "iron-plate", rate: 4, recipes: ["iron-plate"] });
179+
expect(res.ok).toBe(true);
180+
// unmoduled this would be 12.8 (rate 4 × 3.2s/craft ÷ speed 1 — see the
181+
// sibling describe block above); with 2 speed-1 modules (+40% combined)
182+
// the solved count drops to 12.8 / 1.4 = 9.142857...
183+
expect(res.buildings[0].count).toBeCloseTo(12.8 / 1.4, 2);
184+
expect(res.modules?.["iron-plate"]).toEqual(["speed-module-1", "speed-module-1"]);
185+
});
186+
187+
it("buildingBill's whole-machine count matches submitBlock's module-filled count, not the bare unmoduled one", async () => {
188+
const d = await draft({ target: "iron-plate", rate: 4, recipes: ["iron-plate"] });
189+
const b = await bill([{ target: "iron-plate", rate: 4, recipes: ["iron-plate"] }]);
190+
expect(b.skipped).toEqual([]);
191+
expect(b.machines).toHaveLength(1);
192+
// ceil(9.142857) = 10 — NOT ceil(12.8) = 13, the pre-fix (unmoduled) answer
193+
expect(b.machines[0].count).toBe(Math.ceil(d.buildings[0].count - 1e-9));
194+
expect(b.machines[0].count).toBe(10);
195+
});
196+
});
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* Multi-goal + keep-in-stock block drafts (#38): submitBlock/submitPlan used to
3+
* accept only a single `target`+`rate` throughput target. A construction/mall
4+
* block ("keep 80 vrauks-paddock on hand") has no honest per-second rate to
5+
* give it — the assistant would either fabricate one or refuse. `blockDraftInput`
6+
* now also accepts a `goals` array, each entry EITHER a throughput `rate` OR a
7+
* keep-in-stock `stock` (+ optional `window`, default 600s), with the solver
8+
* rate DERIVED as stock/window. `goals[0]` still anchors `target`/`rate`/
9+
* `targetDisplay` for back-compat with the UI card and reviseBlock/submitPlan.
10+
*
11+
* Fixture: two independent one-step smelting recipes (iron-plate, copper-plate)
12+
* on an unmoduled stone-furnace, so the solved building count is exactly
13+
* rate × energyRequired ÷ speed with no module-fill noise.
14+
*/
15+
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
16+
import { switchDatabase } from "../db/index.server.ts";
17+
import { type TestDb, makeTestDb } from "../db/test-helpers.ts";
18+
import { buildingBill, submitBlock, submitPlan } from "./agent-tools.server.ts";
19+
20+
type Goal = { name: string; rate: number; stock?: number; window?: number };
21+
type Draft = {
22+
ok: boolean;
23+
target: string;
24+
targetDisplay?: string;
25+
rate: number;
26+
goals: Goal[];
27+
buildings: { recipe: string; machine: string; count: number }[];
28+
byproducts: { good: string; rate: number | null }[];
29+
};
30+
31+
// The tool schemas' real input type defaults `window` to 600 (so its OUTPUT
32+
// type requires the field); these test wrappers accept the looser hand-written
33+
// literal shape a caller would actually send (window omittable) and cast at
34+
// the boundary, same as the AI SDK would after applying the schema's default.
35+
type DraftInput = Parameters<NonNullable<typeof submitBlock.execute>>[0];
36+
type PlanInput = Parameters<NonNullable<typeof submitPlan.execute>>[0];
37+
type BillInput = Parameters<NonNullable<typeof buildingBill.execute>>[0];
38+
39+
const draft = async (input: Record<string, unknown>): Promise<Draft> =>
40+
(await submitBlock.execute!(input as DraftInput, {
41+
toolCallId: "test",
42+
messages: [],
43+
})) as Draft;
44+
45+
const plan = async (input: Record<string, unknown>): Promise<{ ok: boolean; blocks: Draft[] }> =>
46+
(await submitPlan.execute!(input as PlanInput, { toolCallId: "test", messages: [] })) as {
47+
ok: boolean;
48+
blocks: Draft[];
49+
};
50+
51+
const bill = async (
52+
input: Record<string, unknown>,
53+
): Promise<{ machines: { entity: string; count: number }[]; skipped: unknown[] }> =>
54+
(await buildingBill.execute!(input as BillInput, { toolCallId: "test", messages: [] })) as {
55+
machines: { entity: string; count: number }[];
56+
skipped: unknown[];
57+
};
58+
59+
describe("submitBlock/submitPlan/buildingBill: multi-goal + keep-in-stock goals (#38)", () => {
60+
let fx: TestDb;
61+
62+
beforeEach(async () => {
63+
fx = await makeTestDb();
64+
fx.db.exec(`
65+
INSERT INTO items (name, display) VALUES
66+
('iron-ore','Iron ore'),('iron-plate','Iron plate'),
67+
('copper-ore','Copper ore'),('copper-plate','Copper plate');
68+
69+
INSERT INTO recipes (name, kind, category, energy_required, enabled, hidden) VALUES
70+
('iron-plate','real','smelting',3.2,1,0),
71+
('copper-plate','real','smelting',3.2,1,0);
72+
INSERT INTO recipe_ingredients (recipe, idx, kind, name, amount) VALUES
73+
('iron-plate',0,'item','iron-ore',1),
74+
('copper-plate',0,'item','copper-ore',1);
75+
INSERT INTO recipe_products (recipe, idx, kind, name, amount) VALUES
76+
('iron-plate',0,'item','iron-plate',1),
77+
('copper-plate',0,'item','copper-plate',1);
78+
79+
INSERT INTO crafting_machines
80+
(name, display, kind, crafting_speed, module_slots, energy_usage_w, energy_source)
81+
VALUES
82+
('stone-furnace','Stone furnace','furnace',1,0,90000,'electric');
83+
INSERT INTO machine_categories (machine, category) VALUES
84+
('stone-furnace','smelting');
85+
`);
86+
fx.db.close();
87+
switchDatabase(fx.file);
88+
});
89+
90+
afterEach(() => fx.cleanup());
91+
92+
it("backward compat: the legacy target+rate shorthand still works unchanged", async () => {
93+
const res = await draft({ target: "iron-plate", rate: 4, recipes: ["iron-plate"] });
94+
expect(res.ok).toBe(true);
95+
expect(res.target).toBe("iron-plate");
96+
expect(res.rate).toBe(4);
97+
expect(res.goals).toEqual([{ name: "iron-plate", rate: 4 }]);
98+
expect(res.buildings[0].count).toBeCloseTo(12.8, 2);
99+
});
100+
101+
it("a stock goal derives its solver rate as stock/window and carries stock/window in the draft", async () => {
102+
const res = await draft({
103+
goals: [{ name: "iron-plate", stock: 80, window: 200 }],
104+
recipes: ["iron-plate"],
105+
});
106+
expect(res.ok).toBe(true);
107+
// goals[0] still anchors target/rate/targetDisplay for back-compat
108+
expect(res.target).toBe("iron-plate");
109+
expect(res.rate).toBeCloseTo(0.4, 5); // 80/200
110+
expect(res.goals).toEqual([{ name: "iron-plate", rate: 0.4, stock: 80, window: 200 }]);
111+
// solved against the DERIVED rate (0.4/s), not a fabricated continuous one:
112+
// 0.4/s x 3.2s/craft = 1.28 furnaces
113+
expect(res.buildings[0].count).toBeCloseTo(1.28, 2);
114+
});
115+
116+
it("a stock goal without an explicit window defaults to 600s (#38 STOCK_WINDOW_DEFAULT)", async () => {
117+
const res = await draft({
118+
goals: [{ name: "iron-plate", stock: 60 }],
119+
recipes: ["iron-plate"],
120+
});
121+
expect(res.goals).toEqual([{ name: "iron-plate", rate: 0.1, stock: 60, window: 600 }]);
122+
});
123+
124+
it("supports multiple goals in one block — a rate goal and a stock goal together", async () => {
125+
const res = await draft({
126+
goals: [
127+
{ name: "iron-plate", rate: 4 },
128+
{ name: "copper-plate", stock: 80, window: 400 },
129+
],
130+
recipes: ["iron-plate", "copper-plate"],
131+
});
132+
expect(res.ok).toBe(true);
133+
expect(res.goals).toEqual([
134+
{ name: "iron-plate", rate: 4 },
135+
{ name: "copper-plate", rate: 0.2, stock: 80, window: 400 },
136+
]);
137+
const byRecipe = Object.fromEntries(res.buildings.map((b) => [b.recipe, b.count]));
138+
expect(byRecipe["iron-plate"]).toBeCloseTo(12.8, 2); // 4 x 3.2
139+
expect(byRecipe["copper-plate"]).toBeCloseTo(0.64, 2); // 0.2 x 3.2
140+
// both goals are solver targets, not surplus — neither shows up as a byproduct
141+
expect(res.byproducts.map((b) => b.good)).not.toContain("iron-plate");
142+
expect(res.byproducts.map((b) => b.good)).not.toContain("copper-plate");
143+
});
144+
145+
it("submitPlan carries each block's full goals array, including a stock goal", async () => {
146+
const res = await plan({
147+
title: "Mall",
148+
objective: "keep some buildings on hand",
149+
blocks: [
150+
{
151+
name: "Mall block",
152+
goals: [
153+
{ name: "iron-plate", stock: 80, window: 400 },
154+
{ name: "copper-plate", rate: 1 },
155+
],
156+
recipes: ["iron-plate", "copper-plate"],
157+
},
158+
],
159+
});
160+
expect(res.ok).toBe(true);
161+
expect(res.blocks).toHaveLength(1);
162+
const b = res.blocks[0];
163+
expect(b.target).toBe("iron-plate"); // goals[0] anchors naming/sizing
164+
expect(b.rate).toBeCloseTo(0.2, 5);
165+
expect(b.goals).toEqual([
166+
{ name: "iron-plate", rate: 0.2, stock: 80, window: 400 },
167+
{ name: "copper-plate", rate: 1 },
168+
]);
169+
});
170+
171+
it("buildingBill accepts the same goals array shape and reflects the derived stock rate", async () => {
172+
const res = await bill({
173+
blocks: [
174+
{ goals: [{ name: "iron-plate", stock: 80, window: 200 }], recipes: ["iron-plate"] },
175+
],
176+
});
177+
expect(res.skipped).toEqual([]);
178+
expect(res.machines).toHaveLength(1);
179+
// 0.4/s x 3.2s = 1.28 fractional furnaces -> ceil = 2
180+
expect(res.machines[0].entity).toBe("stone-furnace");
181+
expect(res.machines[0].count).toBe(2);
182+
});
183+
});

0 commit comments

Comments
 (0)