Skip to content

Commit 51cf62f

Browse files
committed
fix(solver): a made mark with no producer imports silently, not a nag
Two connected block-page nitpicks: 1. Remove the redundant 'made in this block' strip from the balance card — the recipe rows already show what's produced in-block. The made set still drives the solve; toggle a good's made state from its right-click menu, and the IIS cards still offer 'import it instead'. 2. A made mark whose good nothing in-block produces now degrades SILENTLY to an import instead of being reported as unmade. A made mark is a soft 'cover this here'; when it can't be honored the graceful answer is to import, not to nag in two places at once (the case in the screenshot: enriched-water shown both in the 'no recipe makes this' strip AND in imports). Only a produce GOAL with no producer still earns the 'add a recipe' flag — the user asked for that output. The sidebar/editor health tints drop the made-no-producer condition to match. Bonus: this un-deadlocks blocks that migrated a v1 'balance' on an imported feed into a made mark — block 26 (the fusion chain) went from artificially infeasible to solved, importing its feeds while the real temperature mismatch stays flagged by the per-producer temp warnings. Refs #91
1 parent d6429fc commit 51cf62f

10 files changed

Lines changed: 83 additions & 94 deletions

File tree

app/e2e/mut/made-links.e2e.ts

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,51 @@ import { expect, test } from "@playwright/test";
22
import { createBlock } from "./helpers";
33

44
/**
5-
* The v2 link model (#91): adding a producer through an item's chip marks the
6-
* item "made in this block" (production covers consumption, imports
7-
* forbidden); the mark is visible on the balance card's made strip and one
8-
* click unmarks it — the item flips back to an import and the producer row
9-
* honestly idles at 0.
5+
* The v2 link model (#91): a good's made state is toggled from its right-click
6+
* menu (the made set drives the solve but is not shown in a strip — the recipe
7+
* rows show what's produced). A made mark with no in-block producer degrades
8+
* silently to an import — no warning strip (the #91 nitpick). This drives the
9+
* import chip's menu and asserts the good stays an import either way.
1010
*/
11-
test("chip-adding a producer marks the item made; unmarking flips it back to an import", async ({
12-
page,
13-
}) => {
11+
test("marking an import made without a producer keeps it a silent import", async ({ page }) => {
1412
await createBlock(page);
1513

16-
// goal: iron plate (any solid with a real chain works)
14+
// goal: iron plate (a real Py chain); its recipe consumes iron ore, an import
1715
await page.locator('button[title="add a goal product"]').click();
1816
const goalDialog = page.getByRole("dialog", { name: "Add a goal product" });
1917
await goalDialog.getByPlaceholder("search an item or fluid…").fill("iron plate");
2018
await goalDialog.getByRole("button", { name: "Iron plate", exact: true }).first().click();
2119
await expect(goalDialog).toBeHidden();
2220

23-
// producer for the goal itself (goals self-link — no made mark expected)
2421
await page.locator('button[title^="click to add a recipe that makes this goal"]').click();
2522
const platePicker = page.getByRole("dialog", { name: /Recipes that make/ });
2623
await platePicker.getByRole("button", { name: /Iron plate/ }).first().click();
2724
await expect(platePicker).toBeHidden();
28-
await expect(page.getByText("made in this block:")).toBeHidden();
2925

30-
// the plate recipe consumes iron ore (py.db: iron-plate ← 8 iron-ore),
31-
// showing as an import; adding a producer for it is the linking gesture —
32-
// the made strip appears with exactly that item
26+
const oreImport = page.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ }).first();
27+
await expect(oreImport).toBeVisible();
28+
29+
// right-click the import → the good menu offers the made gesture
30+
await oreImport.click({ button: "right" });
31+
const markItem = page.getByRole("menuitem", {
32+
name: /Require in-block production|Make in this block/,
33+
});
34+
await expect(markItem).toBeVisible();
35+
await markItem.click();
36+
37+
// no producer exists for it, so marking made is a non-event: NO "no recipe
38+
// makes this" strip, and the good still shows as an import
39+
await expect(page.getByText(/No recipe makes/)).toBeHidden();
40+
await expect(
41+
page.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ }).first(),
42+
).toBeVisible();
43+
44+
// the menu now reads "made" — unmarking it back is available and harmless
3345
await page
3446
.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ })
3547
.first()
36-
.click();
37-
const ingPicker = page.getByRole("dialog", { name: /Recipes that make/ });
38-
await ingPicker.getByRole("button", { name: /Iron ore/ }).first().click();
39-
await expect(ingPicker).toBeHidden();
40-
await expect(page.getByText("made in this block:")).toBeVisible();
41-
42-
// unmark via the strip: the mark disappears and the item re-imports (its
43-
// producer row stays in the block but idles at 0)
44-
await page
45-
.getByRole("button", { name: /^Iron ore/, exact: false })
46-
.and(page.locator('[title*="Click to unmark"]'))
47-
.click();
48-
await expect(page.getByText("made in this block:")).toBeHidden();
48+
.click({ button: "right" });
4949
await expect(
50-
page.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ }).first(),
50+
page.getByRole("menuitem", { name: /click to import instead/ }),
5151
).toBeVisible();
5252
});

app/e2e/mut/temp-warnings.e2e.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@ test("fluid-temp mismatch flags the 3000° producer feeding a 4000° generator",
4949

5050
// add b-h (neutron @4000°, satisfies the generator): the dt-he3 mismatch
5151
// must STAY flagged — one matching producer used to mask it entirely.
52-
// Target the generator row's linked ingredient chip: the balance card's
53-
// "made in this block" strip also has a Neutron button (it unmarks — #91).
5452
await page
5553
.getByRole("button", { name: /^Neutron.*linked/ })
5654
.first()

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

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useStore } from "@tanstack/react-store";
2-
import { AlertTriangle, Cloud, Flame, Lock, Plus, Timer, X, Zap } from "lucide-react";
2+
import { AlertTriangle, Cloud, Flame, Lock, Plus, Timer, Zap } from "lucide-react";
33
import { Button } from "#/components/ui/button.tsx";
44
import { Callout } from "#/components/ui/callout.tsx";
55
import { Card, CardHeader, CardTitle } from "#/components/ui/card.tsx";
@@ -12,7 +12,7 @@ import type { BlockDocStore } from "./doc-store.ts";
1212
import type { LogiView, SolveResult } from "./solve-view.ts";
1313
import { fmtW, num } from "./format.ts";
1414

15-
/** The Block balance card: solve status, made-here and planned-spoil strips,
15+
/** The Block balance card: solve status, the planned-spoil strip,
1616
* the root-cause (IIS) cards on an infeasible solve, the unmade/temperature
1717
* warnings, the power line, and the imports/exports chip lists with the
1818
* sizing-lock controls. */
@@ -53,7 +53,6 @@ export function BalanceCard({
5353
) => void;
5454
onOpenSpoilDialog: (name: string) => void;
5555
}) {
56-
const made = useStore(doc.store, (s) => s.made);
5756
const spoilRates = useStore(doc.store, (s) => s.spoilRates);
5857
const spoilables = useSpoilables();
5958
return (
@@ -67,38 +66,10 @@ export function BalanceCard({
6766
</span>
6867
)}
6968
</CardHeader>
70-
{/* Made-here marks (#91) — the block's claimed in-block production. Always
71-
visible when any exist (even on an infeasible solve) so a mark can
72-
never soft-lock the block: click × to unmark (the item goes free —
73-
imports its shortfall, exports its surplus). */}
74-
{!!made?.size && (
75-
<Callout tone="info" icon={null} className="mx-3 mt-2 px-2 py-1.5">
76-
<div className="flex flex-wrap items-center gap-2">
77-
<span>made in this block:</span>
78-
{[...made]
79-
.sort((a, b) => a.localeCompare(b))
80-
.map((name) => (
81-
<button
82-
key={name}
83-
onClick={() => {
84-
doc.unmark(name);
85-
doc.note(`Unmark "${res?.display?.[name] ?? name}" (import instead)`);
86-
}}
87-
title="production here covers consumption (surplus exports). Click to unmark — the item imports instead."
88-
className="inline-flex items-center gap-1 bg-success/15 px-1.5 py-0.5 text-success ring-1 ring-success/30 hover:brightness-110"
89-
>
90-
<Icon
91-
kind={kindOf(name)}
92-
name={name}
93-
size="sm"
94-
title={res?.display?.[name] ?? name}
95-
/>
96-
{res?.display?.[name] ?? name} <X className="size-3" />
97-
</button>
98-
))}
99-
</div>
100-
</Callout>
101-
)}
69+
{/* The made set (#91) drives the solve but isn't listed here — the recipe
70+
rows already show what's produced in-block (linked green chips). Toggle
71+
a good's made state from its right-click menu; the IIS cards below
72+
offer "import it instead" per made item when a block is infeasible. */}
10273
{/* Planned spoil losses (#20) — always visible when set: the pinned
10374
surplus never reaches the boundary flows (it rots), so without this
10475
strip a planned loss would be invisible. */}

app/src/db/queries.server.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,14 +1163,12 @@ export function listBlocks() {
11631163
for (const r of blockRecipes)
11641164
for (const p of productsByRecipe.get(r) ?? []) makesInBlock.add(p);
11651165
const unmadeGoals = goalNames(d).filter((g) => goodNames.has(g) && !makesInBlock.has(g));
1166-
// v2 (#91): made marks whose producer vanished (or was never added) — the
1167-
// same "unmade" condition the solve reports, derivable here without solving.
1168-
const unmadeMade = (d.made ?? []).filter((m) => !makesInBlock.has(m));
1166+
// NB: a made mark with no producer is NOT a health problem (#91 nitpick) — it
1167+
// degrades silently to an import in the solve, so it doesn't tint the block.
11691168
const health: BlockHealth =
11701169
broken || solveStatus === "infeasible" || solveStatus === "error"
11711170
? "error"
11721171
: unmadeGoals.length > 0 ||
1173-
unmadeMade.length > 0 ||
11741172
// stale pre-v2 statuses: the block re-solves (and re-caches) on open
11751173
solveStatus === "relaxed" ||
11761174
solveStatus === "underdetermined"
@@ -1181,7 +1179,6 @@ export function listBlocks() {
11811179
broken,
11821180
health,
11831181
unmadeGoals,
1184-
unmadeMade,
11851182
// for the delete-block confirm (#83): what the deletion would destroy
11861183
recipeCount: blockRecipes.length,
11871184
goalCount: goalNames(d).length,

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,8 +514,9 @@ function Block({ blockId }: { blockId: number }) {
514514
: "text-warning";
515515

516516
// Block health for the title tint (mirrors the sidebar verdict): red for broken
517-
// refs / infeasible / solver error, amber for unmade goals or made marks /
518-
// temperature mismatches, none when clean.
517+
// refs / infeasible / solver error, amber for goals with no recipe or
518+
// temperature mismatches, none when clean. (A made mark with no producer is
519+
// not a warning — it just imports.)
519520
const editorHealth: "error" | "warn" | null = !res
520521
? null
521522
: res.broken || res.status === "infeasible" || res.status === "error"

app/src/routes/block.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,9 +462,7 @@ function Shell() {
462462
? "this block has no exact solution — open to fix"
463463
: b.unmadeGoals.length
464464
? `${b.unmadeGoals.length} goal${b.unmadeGoals.length === 1 ? "" : "s"} with no recipe yet — open to add one`
465-
: b.unmadeMade.length
466-
? `${b.unmadeMade.length} made-here item${b.unmadeMade.length === 1 ? "" : "s"} with no producer — open to fix`
467-
: "this block needs attention — open to see";
465+
: "this block needs attention — open to see";
468466
// worst health among every block in a folder subtree (errors dominate warnings),
469467
// so a problem nested anywhere bubbles up to the folder header.
470468
const groupHealth = (groupId: number): Health => {

app/src/solver/lp.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,16 @@ test("unmade produce goal is reported, not enforced — the rest still solves",
216216
expect(rate(res).gear).toBeCloseTo(1);
217217
});
218218

219-
test("made mark with no producer is reported and dropped, consumers keep running", async () => {
219+
test("made mark with no producer degrades silently to an import (no unmade nag)", async () => {
220220
const res = await solveBlockLp({
221221
goals: [{ name: "gear", rate: 1 }],
222222
recipes: [gear],
223223
made: ["plate"], // no plate producer in the block
224224
});
225225
expect(res.status).toBe("solved");
226-
expect(res.unmade).toEqual(["plate"]);
226+
// a made mark that can't be honored just imports — NOT reported as unmade
227+
// (only a produce goal with no producer earns that flag)
228+
expect(res.unmade).toBeUndefined();
227229
expect(flow(res.imports, "plate")).toBeCloseTo(2);
228230
});
229231

app/src/solver/lp.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ export type LpBlockResult = {
8888
recipes: { recipe: string; rate: number; machines1x: number }[];
8989
imports: Flow[];
9090
exports: Flow[];
91-
/** produce goals nothing in-block makes, and `made` items with no producer.
92-
* Their constraints are dropped (reported, not enforced) so the rest of the
93-
* block still solves — the UI flags exactly these with "add a producer". */
91+
/** produce goals nothing in-block makes — the constraint is dropped (reported,
92+
* not enforced) so the rest of the block still solves and the UI flags exactly
93+
* these with "add a recipe". A `made` mark with no producer is NOT listed here:
94+
* it degrades silently to an import (a mark that can't be honored is a
95+
* non-event, not a warning). */
9496
unmade?: string[];
9597
/** whole-machine mode (#98): recipe → integer building count (the ceiling
9698
* the solve committed to; the recipe's rate may leave it partly idle) */
@@ -187,7 +189,11 @@ export function buildModel(input: LpBlockInput): BlockModel {
187189
const c = coeff[item];
188190
if (!c) continue; // stale mark on an item nothing touches — inert
189191
if (!produced[item]) {
190-
unmade.push(item); // marked made but no producer: report, don't zero consumers
192+
// marked made but nothing in-block produces it: degrade silently to an
193+
// import (net < 0). A made mark is a soft "cover this here" — when it
194+
// can't be honored the graceful answer is to import, NOT to nag. Only a
195+
// GOAL with no producer earns the "add a recipe" flag (the user asked
196+
// for that output); a made mark that can't be met is a non-event.
191197
continue;
192198
}
193199
constraints.push({

app/src/solver/temps.test.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,22 @@ test("fluids without ranged consumers pass through untouched", () => {
4141
expect(fold.isSynthetic("anything")).toBe(false);
4242
});
4343

44-
test("an out-of-range producer cannot feed a ranged consumer — the pool reads unmade", async () => {
45-
// only dt-he3 (3000°) present, generator needs 4000°: with neutron made, the
46-
// 4000° pool has no producer → reported unmade; the block imports the pool
47-
// (temperature is then the player's problem, stated honestly)
44+
test("an out-of-range producer cannot feed a ranged consumer — the pool imports", async () => {
45+
// only dt-he3 (3000°) present, generator needs 4000°: the 4000° pool has no
46+
// in-block producer, so it degrades to an IMPORT (a made mark that can't be
47+
// honored is a silent import, #91). The temperature mismatch is surfaced
48+
// separately by the per-producer temp warnings, not by a double nag here.
4849
const { res, fold } = solve(
4950
[dtHe3, mdh4000],
5051
[{ name: "pyops-electricity", rate: 9600000 }],
5152
["neutron"],
5253
);
5354
const r = await res;
5455
expect(r.status).toBe("solved");
55-
const unmade = (r.unmade ?? []).map((u) => `${fold.bare(u)} ${fold.tempOf(u) ?? ""}`.trim());
56-
expect(unmade).toContain("neutron 4000°");
57-
// nothing pulls dt-he3 (its 3000° output can't feed the generator): it
58-
// honestly idles instead of running for an exportable byproduct
56+
expect(r.unmade ?? []).toEqual([]); // no unmade nag
57+
const imports = r.imports.map((f) => `${fold.bare(f.name)} ${fold.tempOf(f.name) ?? ""}`.trim());
58+
expect(imports).toContain("neutron 4000°"); // the generator imports the temp it needs
59+
// nothing pulls dt-he3 (its 3000° output can't feed the generator): it idles
5960
const rates = Object.fromEntries(r.recipes.map((x) => [x.recipe, x.rate]));
6061
expect(rates["dt-he3"]).toBeCloseTo(0);
6162
});
@@ -114,14 +115,25 @@ test("a goal on an expanded fluid is satisfied by any temperature, variants stay
114115
ingredients: [{ kind: "fluid", name: "water", amount: 10, maxTemp: 101 }],
115116
products: [{ kind: "item", name: "gel", amount: 1 }],
116117
};
117-
const { res, fold } = solve([boil, cold], [{ name: "water", rate: 175 }], ["water"]);
118+
// gel is a goal too, so the cold consumer actually runs (needs ≤101° water)
119+
const { res, fold } = solve(
120+
[boil, cold],
121+
[
122+
{ name: "water", rate: 175 },
123+
{ name: "gel", rate: 1 },
124+
],
125+
["water"],
126+
);
118127
const r = await res;
119128
expect(r.status).toBe("solved");
120129
const rates = Object.fromEntries(r.recipes.map((x) => [x.recipe, x.rate]));
121-
expect(rates["boil"]).toBeCloseTo(1); // goal met by the 125° variant
122-
// the ≤101° pool has no in-range producer → unmade, its consumer imports
123-
const unmade = (r.unmade ?? []).map((u) => `${fold.bare(u)} ${fold.tempOf(u) ?? ""}`.trim());
124-
expect(unmade).toContain("water ≤101°");
130+
expect(rates["boil"]).toBeCloseTo(1); // water goal met by the 125° variant
131+
expect(rates["cold-process"]).toBeCloseTo(1); // gel goal drives the ≤101° draw
132+
// the ≤101° pool has no in-range producer → it imports silently (no nag);
133+
// the cold consumer draws its ≤101° water from the boundary
134+
expect(r.unmade ?? []).toEqual([]);
135+
const imports = r.imports.map((f) => `${fold.bare(f.name)} ${fold.tempOf(f.name) ?? ""}`.trim());
136+
expect(imports).toContain("water ≤101°");
125137
});
126138

127139
test("share pins follow the consumer onto its pool", async () => {

app/test-results/.last-run.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"status": "failed",
3+
"failedTests": []
4+
}

0 commit comments

Comments
 (0)