Skip to content

Commit 138c27e

Browse files
committed
fix(solver): byproduct consumers no longer let the plan import-and-restructure
The block-27 failure: adding a consumer for an exported byproduct could REWRITE the plan. grade-2-crush (a reprocessor) let the LP import 15.4/s grade-2-iron and shut the mine off entirely — cheaper by machine-seconds, absurd by intent — because the byproduct was a free good (importable). And a pyvoid did nothing at all: it produces nothing the objective wants, so it idled at 0 with the export intact. Three-part fix: 1. The consume-gesture (adding a consumer via a byproduct's chip) now marks the good MADE — imports forbidden, so a reprocessor can only eat the in-block surplus. On the real block 27: mining trims 58→39 (recycling legitimately reduces ore demand) instead of dying. 2. New DRAIN pin (net = 0 on the item): the byproduct-disposal primitive. Set automatically when the added consumer is a pure sink (no products, or only returning less of the same good — the pyvoid shape, classified from data). Forces the sink to absorb exactly the surplus; coexisting demand-driven consumers are unaffected. Flows through temp expansion, diagnosis provenance (with a one-click 'allow export instead'), snapshot diffs, rename migrations, the pin dialog, and the row badge. 3. importedProducible: the solve reports imports of goods an enabled in-block recipe produces — the tell-tale of the trap arising any other way (search-adds, manual unmarks) — and the import chip offers one click to claim the good in-block. Also hardens e2e: the deps spec scoped its clicks to the sidebar (its page-wide button match could hit the nav's Undo button, whose accessible name embeds the last-edited block's name — a mutating control on the user's real DB; luckily the click never fired) and waits for hydration before typing. Refs #91
1 parent 1815b48 commit 138c27e

14 files changed

Lines changed: 326 additions & 34 deletions

File tree

app/e2e/deps.e2e.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,25 @@ const NONSENSE = "zzz-no-such-thing-e2e";
1010
test("deps explorer walks requires / required-by for a good", async ({ page }) => {
1111
await page.goto("/deps");
1212
await expect(page.getByText("Nothing selected")).toBeVisible();
13+
// wait for hydration before typing — a fill that lands on SSR'd markup is
14+
// wiped when React mounts, leaving the search silently empty
15+
await page.waitForFunction(() => {
16+
const nav = document.querySelector("nav");
17+
return !!nav && Object.keys(nav).some((k) => k.startsWith("__reactFiber$"));
18+
});
1319

1420
// pick a root from the sidebar search (iron plate exists in vanilla and Py alike)
1521
const search = page.getByPlaceholder("search goods & recipes…");
1622
await search.fill("iron plate");
17-
const first = page.getByRole("button", { name: /iron plate/i }).first();
23+
// Scope the click to the SIDEBAR results. A page-wide button match is a
24+
// booby trap on the read-only suite: the nav's Undo button embeds the last
25+
// edited block's NAME in its accessible name ("Undo: Edit block \"Iron
26+
// plate\"") — a loose .first() can click a mutating control on the user's
27+
// real database. Never match buttons globally by data-derived names here.
28+
const sidebar = page.locator("aside");
29+
const first = sidebar.getByRole("button", { name: /iron plate/i }).first();
1830
try {
19-
await first.waitFor({ state: "visible", timeout: 5000 });
31+
await first.waitFor({ state: "visible", timeout: 15000 }); // first search builds the whole deps graph server-side
2032
} catch {
2133
test.skip(true, "no 'iron plate' in the active project DB");
2234
}

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,27 @@ export function BalanceCard({
147147
)}
148148
</div>
149149
);
150+
if (prov.type === "drain")
151+
return (
152+
<div key={mi} className="flex flex-wrap items-center gap-1.5">
153+
<span>
154+
surplus of {res.display?.[prov.item] ?? prov.item} must be consumed
155+
here (drain)
156+
{short}
157+
</span>
158+
<button
159+
onClick={() => {
160+
doc.clearDrains(prov.item);
161+
doc.note(
162+
`Stop draining "${res.display?.[prov.item] ?? prov.item}"`,
163+
);
164+
}}
165+
className="bg-warning/25 px-1.5 py-0.5 text-sm text-warning hover:brightness-110"
166+
>
167+
allow export instead
168+
</button>
169+
</div>
170+
);
150171
// pin members: name the recipe + offer one-click removal
151172
const label =
152173
prov.type === "pin-share"
@@ -290,6 +311,21 @@ export function BalanceCard({
290311
{/* Locked-as-block-driver state (set via right-click → "Size block by this
291312
input"): edit its rate inline + an unlock control. The toggle itself
292313
lives in the context menu, so non-locked rows stay uncluttered. */}
314+
{/* the block imports this while an in-block recipe
315+
produces it — usually the import-instead-of-make
316+
trap (block 27); one click claims it in-block */}
317+
{res.importedProducible?.includes(f.name) && (
318+
<button
319+
title="an enabled recipe in this block produces this good, but the plan imports it. Click to mark it made in-block (production must cover consumption)."
320+
onClick={() => {
321+
doc.markMade(f.name);
322+
doc.note(`Mark "${res.display?.[f.name] ?? f.name}" made in-block`);
323+
}}
324+
className="bg-warning/25 px-1.5 py-0.5 text-sm text-warning hover:brightness-110"
325+
>
326+
made here? · make in-block
327+
</button>
328+
)}
293329
{lockedInput === f.name && (
294330
<>
295331
<Input

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

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -357,27 +357,36 @@ export function createBlockDocStore() {
357357
next.delete(name);
358358
return { made: next, dispositions: {} };
359359
}),
360-
/** Set/replace a row pin: one count-or-cap pin per recipe, one share pin
361-
* per (recipe, item) edge. */
360+
/** Set/replace a row pin: one count-or-cap pin per recipe, one edge pin
361+
* (share OR drain) per (recipe, item). */
362362
setPin: (pin: DocPin) =>
363-
edit((s) => ({
364-
pins: [
365-
...s.pins.filter((p) =>
366-
pin.kind === "share"
367-
? !(p.kind === "share" && p.recipe === pin.recipe && p.item === pin.item)
368-
: !(p.kind !== "share" && p.recipe === pin.recipe),
363+
edit((s) => {
364+
const isEdge = (p: DocPin) => p.kind === "share" || p.kind === "drain";
365+
return {
366+
pins: [
367+
...s.pins.filter((p) =>
368+
isEdge(pin)
369+
? !(isEdge(p) && p.recipe === pin.recipe && p.item === pin.item)
370+
: !(!isEdge(p) && p.recipe === pin.recipe),
371+
),
372+
pin,
373+
],
374+
};
375+
}),
376+
/** Remove every drain pin on a good (from the IIS card's one-click fix). */
377+
clearDrains: (item: string) =>
378+
edit((s) => ({ pins: s.pins.filter((p) => !(p.kind === "drain" && p.item === item)) })),
379+
clearPin: (recipe: string, edge?: { item: string }) =>
380+
edit((s) => {
381+
const isEdge = (p: DocPin) => p.kind === "share" || p.kind === "drain";
382+
return {
383+
pins: s.pins.filter((p) =>
384+
edge
385+
? !(isEdge(p) && p.recipe === recipe && p.item === edge.item)
386+
: !(!isEdge(p) && p.recipe === recipe),
369387
),
370-
pin,
371-
],
372-
})),
373-
clearPin: (recipe: string, share?: { item: string }) =>
374-
edit((s) => ({
375-
pins: s.pins.filter((p) =>
376-
share
377-
? !(p.kind === "share" && p.recipe === recipe && p.item === share.item)
378-
: !(p.kind !== "share" && p.recipe === recipe),
379-
),
380-
})),
388+
};
389+
}),
381390
// Planned spoil loss (#20): rate == null (or <= 0) clears the plan.
382391
setSpoilRate: (name: string, rate: number | null) =>
383392
edit((s) => ({

app/src/components/block/pin-dialog.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export function PinDialog({
3939
(p): p is { kind: "share"; recipe: string; item: string; share: number } =>
4040
p.kind === "share" && p.recipe === recipe,
4141
);
42+
const drainPins = pins.filter(
43+
(p): p is { kind: "drain"; recipe: string; item: string } =>
44+
p.kind === "drain" && p.recipe === recipe,
45+
);
4246
const display = res?.recipeDisplay?.[recipe] ?? recipe;
4347
const row = res?.rows.find((r) => r.recipe === recipe);
4448
const [kind, setKind] = useState<"count" | "cap">(rowPin?.kind ?? "count");
@@ -174,6 +178,25 @@ export function PinDialog({
174178
route
175179
</Button>
176180
</div>
181+
{drainPins.length > 0 && (
182+
<div className="mt-1 flex flex-col gap-0.5 text-muted-foreground">
183+
{drainPins.map((p) => (
184+
<span key={p.item} className="flex items-center gap-1.5">
185+
drains all surplus {label(p.item)} (nothing exports)
186+
<button
187+
onClick={() => {
188+
doc.clearPin(recipe, { item: p.item });
189+
doc.note(`Stop draining "${label(p.item)}" into "${display}"`);
190+
onClose();
191+
}}
192+
className="text-muted-foreground underline hover:text-foreground"
193+
>
194+
clear
195+
</button>
196+
</span>
197+
))}
198+
</div>
199+
)}
177200
{sharePins.length > 0 && (
178201
<div className="mt-1 flex flex-col gap-0.5 text-muted-foreground">
179202
{sharePins.map((p) => (

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ export function RecipeRow({
9191
) as { kind: "count" | "cap"; count: number } | undefined;
9292
const shareCount = useStore(
9393
doc.store,
94-
(s) => s.pins.filter((p) => p.kind === "share" && p.recipe === name).length,
94+
(s) =>
95+
s.pins.filter((p) => (p.kind === "share" || p.kind === "drain") && p.recipe === name).length,
9596
);
9697
// v2 solver (#91): rates are ≥ 0 by construction. A row at exactly 0 is
9798
// idle — nothing in the block pulls it (not an error; often a parked option).

app/src/db/schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,9 @@ export type BlockData = {
450450
pins?: (
451451
| { kind: "count" | "cap"; recipe: string; count: number }
452452
| { kind: "share"; recipe: string; item: string; share: number; base?: "total" | "remaining" }
453+
// drain: this recipe absorbs the item's surplus (net = 0 on the item) —
454+
// the byproduct-disposal gesture; the recipe names the designated sink
455+
| { kind: "drain"; recipe: string; item: string }
453456
)[];
454457
machines?: Record<string, string>; // recipe → chosen machine
455458
fuels?: Record<string, string>; // recipe → chosen fuel

app/src/lib/block-diff.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,13 @@ export function diffBlockDocs(from: BlockData, to: BlockData): BlockDiff {
129129
}
130130
// pins (#91): keyed per recipe(+item for shares); value compared structurally
131131
const pinKey = (p: NonNullable<BlockData["pins"]>[number]) =>
132-
p.kind === "share" ? `${p.recipe} « ${p.item}` : p.recipe;
132+
p.kind === "share" || p.kind === "drain" ? `${p.recipe} « ${p.item}` : p.recipe;
133133
const pinVal = (p: NonNullable<BlockData["pins"]>[number]) =>
134134
p.kind === "share"
135135
? `${Math.round(p.share * 100)}%${p.base === "total" ? " of total" : ""}`
136-
: `${p.kind} ${p.count}`;
136+
: p.kind === "drain"
137+
? "drains surplus"
138+
: `${p.kind} ${p.count}`;
137139
const pinsA = Object.fromEntries((from.pins ?? []).map((p) => [pinKey(p), pinVal(p)]));
138140
const pinsB = Object.fromEntries((to.pins ?? []).map((p) => [pinKey(p), pinVal(p)]));
139141
const pins = diffMap(pinsA, pinsB);

app/src/routes/block.$id.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,10 +455,33 @@ function Block({ blockId }: { blockId: number }) {
455455
doc.addRecipe(name);
456456
// Adding a producer via an item's chip is the linking gesture (#91): the
457457
// block now claims in-block production for that item. Goal items skip the
458-
// mark (a goal already links itself); consume-mode adds (routing a
459-
// byproduct) and search-adds never link anything implicitly.
458+
// mark (a goal already links itself); search-adds never link implicitly.
460459
if (pickFor?.mode === "produce" && !goals.some((g) => g.name === pickFor.name))
461460
doc.markMade(pickFor.name);
461+
// Adding a CONSUMER via a byproduct's chip means "deal with MY surplus":
462+
// mark the good made — without this, a reprocessing recipe lets the plan
463+
// IMPORT the byproduct and shut down the real producers (the block-27
464+
// failure). A reprocessor then absorbs surplus on its own (recycling is
465+
// cheaper than making more). A pure SINK (void: no products, or only
466+
// returning less of the same good) makes nothing the objective wants, so
467+
// it also gets a drain pin — "this good's surplus must be consumed here"
468+
// (net = 0) — or it would idle at 0 with the export untouched.
469+
if (pickFor?.mode === "consume") {
470+
const good = pickFor.name;
471+
if (!goals.some((g) => g.name === good)) doc.markMade(good);
472+
const cand = picker.data?.find((c) => c.name === name);
473+
const intake = cand?.ingredients
474+
.filter((c) => c.name === good)
475+
.reduce((s, c) => s + (c.amount ?? 0), 0);
476+
const sameGoodOut = cand?.products
477+
.filter((c) => c.name === good)
478+
.reduce((s, c) => s + (c.amount ?? 0), 0);
479+
const isSink =
480+
cand != null &&
481+
(cand.products.length === 0 ||
482+
(cand.products.every((c) => c.name === good) && (sameGoodOut ?? 0) < (intake ?? 0)));
483+
if (isSink) doc.setPin({ kind: "drain", recipe: name, item: good });
484+
}
462485
// label the save for the undo stack — the picker rows carry the display name
463486
const display = picker.data?.find((c) => c.name === name)?.display;
464487
doc.note(`Add recipe "${display ?? name}"`);

app/src/server/block-compute.server.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import { migrateToLpInput } from "../solver/migrate";
2424
* per-building craft rate, so module/beacon changes re-derive the rate. */
2525
export type DocPin =
2626
| { kind: "count" | "cap"; recipe: string; count: number }
27-
| { kind: "share"; recipe: string; item: string; share: number; base?: "total" | "remaining" };
27+
| { kind: "share"; recipe: string; item: string; share: number; base?: "total" | "remaining" }
28+
/** this recipe absorbs the item's surplus (net = 0) — byproduct disposal */
29+
| { kind: "drain"; recipe: string; item: string };
2830
import { computeEffects, type BeaconConfig } from "./effects";
2931
import { resolveLogistics, rowLogistics } from "../lib/logistics";
3032
import { prodScaledAmount } from "../lib/productivity";
@@ -609,7 +611,13 @@ export async function computeBlock(rawData: SolveInput) {
609611
return speed / Math.max(1e-9, def.energyRequired ?? 0.5);
610612
};
611613
const pins: Pin[] = [];
614+
// drain pins constrain the ITEM (net = 0), not a recipe rate — collected
615+
// separately; the recipe on the pin is provenance for the UI badge
616+
const drains = [
617+
...new Set((data.pins ?? []).flatMap((p) => (p.kind === "drain" ? [p.item] : []))),
618+
];
612619
for (const p of data.pins ?? []) {
620+
if (p.kind === "drain") continue;
613621
if (p.kind === "share") {
614622
pins.push({ kind: "share", item: p.item, recipe: p.recipe, share: p.share, base: p.base });
615623
continue;
@@ -660,7 +668,7 @@ export async function computeBlock(rawData: SolveInput) {
660668
// goods with selector pseudo-recipes — a pure input transformation; the LP
661669
// core is untouched. `fold` maps synthetic goods/recipes back for display.
662670
const { input: expandedInput, fold } = expandTemps(
663-
{ goals, recipes: compose.parentDefs, made: parentMade, pins: compose.parentPins },
671+
{ goals, recipes: compose.parentDefs, made: parentMade, pins: compose.parentPins, drains },
664672
defaultTemp,
665673
);
666674
const lpInput: LpBlockInput = expandedInput;
@@ -1033,6 +1041,19 @@ export async function computeBlock(rawData: SolveInput) {
10331041
const fuelItems = [...fuelTotals.keys()]; // for the 🔥 tag in the UI
10341042
const burntItems = [...burntTotals.keys()]; // ash / depleted cells from burning
10351043

1044+
// Imports of a good some enabled recipe IN THIS BLOCK produces — the
1045+
// tell-tale of a plan importing instead of making (a free byproduct + a
1046+
// reprocessing recipe lets the LP import the byproduct and idle the real
1047+
// producers). The chip offers one click to mark the good made.
1048+
const inBlockProducerGoods = new Set(
1049+
compose.parentDefs.flatMap((d) =>
1050+
d.products.flatMap((p) => (p.amount > 0 ? [fold.bare(p.name)] : [])),
1051+
),
1052+
);
1053+
const importedProducible = imports
1054+
.map((f) => f.name)
1055+
.filter((n) => inBlockProducerGoods.has(n) && !n.startsWith("pyops-"));
1056+
10361057
// Which imports are craftable in-block (a recipe exists to make them) vs. true
10371058
// raws (nothing produces them — you must supply them). Drives the import tint.
10381059
const producible = imports
@@ -1164,6 +1185,8 @@ export async function computeBlock(rawData: SolveInput) {
11641185
display,
11651186
recipeDisplay,
11661187
producible,
1188+
// imports the block could be making itself (see above) — chip warning + fix
1189+
importedProducible,
11671190
// the block's effective made set (explicit or migrated) — the editor
11681191
// hydrates this into legacy docs so the next save persists it
11691192
made,

app/src/server/migrations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export function applyRenames(
106106
// item for shares) — a prototype rename must follow them all
107107
made: d.made?.map(good),
108108
pins: d.pins?.map((p) =>
109-
p.kind === "share"
109+
p.kind === "share" || p.kind === "drain"
110110
? { ...p, recipe: recipe(p.recipe), item: good(p.item) }
111111
: { ...p, recipe: recipe(p.recipe) },
112112
),

0 commit comments

Comments
 (0)