Skip to content

Commit fdd43ca

Browse files
committed
feat(planner)!: module auto-fill becomes suggest + explicit apply
Ambient auto-fill re-picked modules on every solve, so a plan could rearrange itself behind the player's back: research unlocking a better tier silently re-tooled rows, and counts drifting across a whole-building boundary flapped fills while editing goals. Modules map to machines you physically filled — the plan shouldn't change them uninvited. Now the solve only ever uses the doc's stored picks. Each solve computes a per-row SUGGESTED fill (same direct algorithm: prod where allowed, else fewest speed modules to the smallest whole building count, rest efficiency, sized against the module-less baseline with beacon/TURD speed included) and returns it without applying: - rows whose stored fill differs show a sparkle hint — click applies it as ordinary stored picks (gate the ambient hint in Settings) - the modules dialog previews the suggestion with its own apply button - the block toolbar applies every suggestion at once, confirming when that would overwrite rows that already have modules - new rows start empty; the "A auto-managed" state is gone - the assistant's drafts adopt the suggestions as explicit picks BREAKING CHANGE: modules are no longer applied automatically. Rows that relied on ambient auto-fill show empty fills (with the hint) until applied — the block toolbar's sparkle button restores them in one click. The `autofill` setting now only controls hint visibility.
1 parent 37b47ba commit fdd43ca

13 files changed

Lines changed: 247 additions & 159 deletions

app/e2e/mut/module-autofill.e2e.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect, test } from "@playwright/test";
2+
import { addGoal, createBlock } from "./helpers";
3+
4+
/**
5+
* Module auto-fill as suggestion + explicit apply: the solve computes a
6+
* suggested fill per row (prod where allowed, else speed→floor→efficiency) but
7+
* NEVER applies it. A fresh row starts empty, shows the ✨ hint and the
8+
* toolbar's whole-block apply; clicking the hint bakes the suggestion into the
9+
* doc as ordinary stored picks, after which both affordances disappear.
10+
*
11+
* Uses "Coal gas from coal" (distilator, 1 slot, allow_productivity) — the
12+
* suggestion is a productivity fill as long as any prod module is unlocked in
13+
* the seeded project's research horizon.
14+
*/
15+
test("module auto-fill: fresh row suggests, hint click applies, affordances clear", async ({
16+
page,
17+
}) => {
18+
await createBlock(page);
19+
await addGoal(page, "coal gas", "Coal gas");
20+
await page.locator('button[title^="click to add a recipe that makes this goal"]').click();
21+
const picker = page.getByRole("dialog", { name: "Recipes that make Coal gas" });
22+
await picker.getByRole("button", { name: "Coal gas from coal" }).click();
23+
await expect(picker).toBeHidden();
24+
25+
// fresh row: EMPTY chip (nothing auto-applied) + the suggestion affordances
26+
const emptyChip = page.locator('button[title*="click to configure"]');
27+
const hint = page.locator('button[title^="better modules available"]');
28+
const blockFill = page.locator('button[title^="Auto-fill modules"]');
29+
await expect(emptyChip).toHaveCount(1);
30+
await expect(hint).toHaveCount(1);
31+
await expect(blockFill).toBeVisible();
32+
await expect(blockFill).toContainText("1"); // one row with a differing suggestion
33+
34+
// the modules dialog previews the same suggestion with its own apply button
35+
await emptyChip.click();
36+
const modal = page.getByRole("dialog", { name: /^Modules / });
37+
await expect(modal).toBeVisible();
38+
await expect(modal.getByText("suggested")).toBeVisible();
39+
await expect(modal.getByRole("button", { name: "apply" })).toBeVisible();
40+
await page.keyboard.press("Escape");
41+
await expect(modal).toBeHidden();
42+
43+
// clicking the hint applies the fill as stored picks…
44+
await hint.click();
45+
const loadoutChip = page.locator("button.bg-muted\\/50.text-success");
46+
await expect(loadoutChip).toHaveCount(1);
47+
// …and the row now matches its suggestion, so hint + toolbar apply vanish
48+
await expect(hint).toHaveCount(0);
49+
await expect(blockFill).toHaveCount(0);
50+
await expect(emptyChip).toHaveCount(0);
51+
});

app/e2e/mut/module-presets.e2e.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,6 @@ test("module template: save → set default → auto-applies to a new compatible
1616
}) => {
1717
const presetName = uniqueName("Prod everywhere");
1818

19-
// Module auto-fill (on by default) would give the fresh row a loadout of its
20-
// own — switch it off so this spec exercises the empty → hand-configured →
21-
// preset path. The scratch data dir is reseeded per run, so no restore needed.
22-
await page.goto("/settings");
23-
const autofill = page.getByRole("checkbox", { name: "auto-fill modules" });
24-
await expect(autofill).toBeVisible();
25-
if (await autofill.isChecked()) {
26-
await autofill.click();
27-
await expect(autofill).not.toBeChecked();
28-
}
29-
3019
// ── block 1: configure a loadout by hand and save it as a default template ──
3120
await createBlock(page);
3221
await addGoal(page, "coal gas", "Coal gas");
@@ -35,7 +24,7 @@ test("module template: save → set default → auto-applies to a new compatible
3524
await picker.getByRole("button", { name: "Coal gas from coal" }).click();
3625
await expect(picker).toBeHidden();
3726

38-
// the fresh row has no loadout (auto-fill is off) — open the modules dialog
27+
// the fresh row has no loadout (auto-fill only SUGGESTS) — open the modules dialog
3928
const emptyChip = page.locator(
4029
'button[title*="click to configure"], button[title*="click to override"]',
4130
);

app/src/components/block/block-toolbar.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Hammer,
99
History,
1010
Power,
11+
Sparkles,
1112
Star,
1213
} from "lucide-react";
1314
import { Badge } from "#/components/ui/badge.tsx";
@@ -43,6 +44,7 @@ export function BlockToolbar({
4344
blockEnabled,
4445
onToggleEnabled,
4546
onCopySetup,
47+
autoFill,
4648
onExport,
4749
onOpenHistory,
4850
showInGame,
@@ -60,6 +62,8 @@ export function BlockToolbar({
6062
blockEnabled: boolean;
6163
onToggleEnabled: () => void;
6264
onCopySetup: () => void;
65+
/** whole-block module auto-fill: rows with a differing suggested fill + apply */
66+
autoFill: { count: number; onApply: () => void };
6367
/** download this block as a shareable JSON file (#82) */
6468
onExport: () => void;
6569
/** open the snapshot-history drawer (#85) */
@@ -126,6 +130,17 @@ export function BlockToolbar({
126130
>
127131
<Copy className="size-4" />
128132
</Button>
133+
{autoFill.count > 0 && (
134+
<Button
135+
variant="outline"
136+
size="sm"
137+
onClick={autoFill.onApply}
138+
title={`Auto-fill modules — apply the suggested fill (prod where allowed, else speed to the whole-machine floor, rest efficiency) to ${autoFill.count} row${autoFill.count === 1 ? "" : "s"}`}
139+
className="text-info"
140+
>
141+
<Sparkles className="size-4" /> {autoFill.count}
142+
</Button>
143+
)}
129144
<Button
130145
variant="outline"
131146
size="icon-sm"

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,10 @@ export function createBlockDocStore() {
334334
modules: { ...s.modules, [recipe]: modules },
335335
beacons: { ...s.beacons, [recipe]: beacons },
336336
})),
337-
// "reset to auto" deletes the key — an explicit [] would mean "no modules"
338-
resetModules: (recipe: string) => edit((s) => ({ modules: withoutKey(s.modules, recipe) })),
337+
/** Apply auto-fill suggestions (hint click / whole-block button) as ordinary
338+
* stored picks — modules only; beacon configs stay as they are. */
339+
applyModuleFills: (fills: Record<string, string[]>) =>
340+
edit((s) => ({ modules: { ...s.modules, ...fills } })),
339341

340342
/* ── made marks & pins (#91) ── */
341343
/** Adopt the server-derived made set for a legacy doc — NOT a user edit

app/src/components/block/recipe-grid.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ export function RecipeGrid({
226226
producible={producible}
227227
logi={logi}
228228
open={open}
229+
moduleHints={!!res?.moduleHints}
229230
highlight={focusRecipe === name}
230231
tempWarnings={rowTempWarnings(
231232
res?.tempWarnings,

app/src/components/block/recipe-row.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Flame,
88
GripVertical,
99
Power,
10+
Sparkles,
1011
Timer,
1112
X,
1213
Zap,
@@ -40,6 +41,8 @@ export type RowOverlayOpeners = {
4041
machinePicker: (recipe: string) => void;
4142
fuelPicker: (recipe: string) => void;
4243
modulesPicker: (recipe: string) => void;
44+
/** apply the row's suggested module fill (the ✨ hint) as its stored picks */
45+
applyModuleFill: (recipe: string) => void;
4346
};
4447

4548
/** One live recipe row of the grid: name + solved rate, machine/fuel/module
@@ -62,6 +65,7 @@ export function RecipeRow({
6265
open,
6366
tempWarnings,
6467
highlight,
68+
moduleHints,
6569
}: {
6670
doc: BlockDocStore;
6771
name: string;
@@ -85,6 +89,8 @@ export function RecipeRow({
8589
tempWarnings: { ingredient: Map<string, ChipTempWarning>; product: Map<string, ChipTempWarning> };
8690
/** briefly ring + scroll this row into view — set when a flow node targets it (#101) */
8791
highlight?: boolean;
92+
/** show the ambient ✨ suggestion hint (Settings toggle; apply paths always work) */
93+
moduleHints?: boolean;
8894
}) {
8995
const rowPin = useStore(doc.store, (s) =>
9096
s.pins.find((p) => p.kind !== "share" && p.recipe === name),
@@ -285,9 +291,18 @@ export function RecipeRow({
285291
beacons={row.beacons}
286292
slots={row.machine.moduleSlots ?? 0}
287293
effects={row.effects}
288-
auto={row.autoModules}
289294
onClick={() => open.modulesPicker(name)}
290295
/>
296+
{/* auto-fill hint: a better fill exists — click applies it */}
297+
{moduleHints && row.suggestedModules && (
298+
<button
299+
onClick={() => open.applyModuleFill(name)}
300+
title="better modules available — click to apply the suggested fill (open the modules dialog to preview it)"
301+
className="flex items-center px-1 py-1 text-info hover:bg-accent"
302+
>
303+
<Sparkles className="size-3.5" />
304+
</button>
305+
)}
291306
{/* TURD: hidden modules the selected upgrades insert (no slot cost) */}
292307
{row.turdModules.length > 0 && (
293308
<Link

app/src/lib/modules-modal.tsx

Lines changed: 33 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useQuery, useQueryClient } from "@tanstack/react-query";
22
import { useMemo, useState } from "react";
3-
import { Ban, Minus, Plus, RotateCcw, SquarePlus, Star, X } from "lucide-react";
3+
import { Ban, Minus, Plus, Sparkles, SquarePlus, Star, X } from "lucide-react";
44
import {
55
deleteModulePresetFn,
66
moduleInfoFn,
@@ -68,14 +68,12 @@ export function ModulesChip({
6868
beacons,
6969
slots,
7070
effects,
71-
auto,
7271
onClick,
7372
}: {
7473
modules: string[];
7574
beacons: BeaconConfig[];
7675
slots: number;
7776
effects?: RowEffects;
78-
auto?: boolean;
7977
onClick: () => void;
8078
}) {
8179
if (slots <= 0 && !beacons.length && !modules.length) return null;
@@ -88,13 +86,7 @@ export function ModulesChip({
8886
onClick={onClick}
8987
// Empty state keeps a plain caption; a configured loadout shows the rich
9088
// ModuleLoadoutCard hover (below) instead of a flat "+X% speed" title.
91-
title={
92-
empty
93-
? auto
94-
? "auto-managed — no module helps here · click to override"
95-
: "no modules — click to configure"
96-
: undefined
97-
}
89+
title={empty ? "no modules — click to configure" : undefined}
9890
// Raw button on purpose: a compact icon-tile chip living inside a dense
9991
// grid cell — the Button primitive's h-8 box would break the row density.
10092
className={`flex items-center gap-1 px-1.5 py-1 text-sm hover:bg-accent ${
@@ -104,7 +96,6 @@ export function ModulesChip({
10496
}`}
10597
>
10698
{empty && <SquarePlus className="size-4" />}
107-
{auto && <span className="text-sm text-info">A</span>}
10899
{grouped.map(([n, c]) => (
109100
<span key={n} className="flex items-center">
110101
<Icon kind="item" name={n} size="sm" noHover />
@@ -124,7 +115,7 @@ export function ModulesChip({
124115
if (empty) return button;
125116
return (
126117
<CursorHover
127-
card={<ModuleLoadoutCard modules={modules} beacons={beacons} effects={effects} auto={auto} />}
118+
card={<ModuleLoadoutCard modules={modules} beacons={beacons} effects={effects} />}
128119
z={70}
129120
>
130121
{button}
@@ -152,12 +143,10 @@ function ModuleLoadoutCard({
152143
modules,
153144
beacons,
154145
effects,
155-
auto,
156146
}: {
157147
modules: string[];
158148
beacons: BeaconConfig[];
159149
effects?: RowEffects;
160-
auto?: boolean;
161150
}) {
162151
const names = useMemo(
163152
() => [...new Set([...modules, ...beacons.flatMap((b) => b.modules)])],
@@ -178,10 +167,7 @@ function ModuleLoadoutCard({
178167
if (effects?.consumption) total.push(`${pct(effects.consumption)} energy`);
179168
return (
180169
<div className="w-72 border border-border bg-popover p-3 text-sm text-popover-foreground shadow-xl">
181-
<div className="flex items-center gap-2 font-semibold">
182-
Module loadout
183-
{auto && <span className="bg-info/15 px-1 text-xs font-normal text-info">auto</span>}
184-
</div>
170+
<div className="flex items-center gap-2 font-semibold">Module loadout</div>
185171
{total.length > 0 && (
186172
<div className="mt-0.5 flex flex-wrap gap-x-3 text-muted-foreground">
187173
{total.map((t) => (
@@ -427,9 +413,8 @@ export function ModulesModal({
427413
modules,
428414
beacons,
429415
effects,
430-
auto,
416+
suggested,
431417
onChange,
432-
onReset,
433418
onClose,
434419
}: {
435420
recipe: string;
@@ -438,9 +423,9 @@ export function ModulesModal({
438423
modules: string[];
439424
beacons: BeaconConfig[];
440425
effects?: RowEffects;
441-
auto?: boolean;
426+
/** the auto-fill algorithm's pick, when it differs from the current fill */
427+
suggested?: string[];
442428
onChange: (modules: string[], beacons: BeaconConfig[]) => void;
443-
onReset?: () => void;
444429
onClose: () => void;
445430
}) {
446431
const qc = useQueryClient();
@@ -541,27 +526,33 @@ export function ModulesModal({
541526
)}
542527
</div>
543528

544-
{/* auto-fill state */}
545-
{(auto || onReset) && (
546-
<div className="flex items-center gap-2 text-sm">
547-
{auto ? (
548-
<span className="text-info" title="chosen by the module auto-fill">
549-
A auto-managed{modules.length === 0 ? " — no module helps here" : ""} — any
550-
edit takes manual control
529+
{/* auto-fill suggestion: what the algorithm would pick (prod where
530+
allowed, else speed to the whole-count floor, rest efficiency) */}
531+
{suggested && (
532+
<div className="flex flex-wrap items-center gap-2 text-sm">
533+
<span
534+
className="flex items-center gap-1 text-info"
535+
title="the auto-fill pick for this row's current building count and beacons"
536+
>
537+
<Sparkles className="size-3.5" /> suggested
538+
</span>
539+
{[
540+
...suggested
541+
.reduce((m, n) => m.set(n, (m.get(n) ?? 0) + 1), new Map<string, number>())
542+
.entries(),
543+
].map(([n, c]) => (
544+
<span key={n} className="flex items-center">
545+
<Icon kind="item" name={n} size="sm" />
546+
{c > 1 && <span className="text-sm">×{c}</span>}
551547
</span>
552-
) : (
553-
onReset && (
554-
<Button
555-
variant="outline"
556-
size="sm"
557-
onClick={onReset}
558-
className="border-dashed text-muted-foreground"
559-
title="drop the manual config and let auto-fill choose again"
560-
>
561-
<RotateCcw className="size-3.5" /> reset to auto
562-
</Button>
563-
)
564-
)}
548+
))}
549+
<Button
550+
variant="outline"
551+
size="sm"
552+
onClick={() => onChange(suggested.slice(0, slots), beacons)}
553+
>
554+
apply
555+
</Button>
565556
</div>
566557
)}
567558

0 commit comments

Comments
 (0)