Skip to content

Commit 278edb1

Browse files
committed
fix(planner): drain a byproduct when its consumer is a terminal sink
Adding a consumer through a byproduct's surplus chip marked the good made but only drained it (net = 0, forcing the sink to run) for the literal pure-void shape — no products, or only returning less of the same good. A Py hard-mode disposal recipe like coal-gas-void (50 coal-gas -> 1 ash) makes ash, so it failed that test: the good stayed merely made, its surplus vented as a free export, and the void idled at 0 next to it — the gesture looked like it did nothing. Generalize the test to TERMINAL sinks: net-consumes the good AND none of its other products feeds anything else in the block (everything it makes leaves). That is the actual line between 'consume my surplus' and 'restructure production' — coal-gas -> ash (nothing here uses ash) drains and runs; block 27's grade-2 -> grade-3, whose output re-enters the chain, is still only made, never drained, so forcing it can't cascade. Extracted to lib/sink-classify.ts (drainsOnConsume) with unit tests for both shapes; the block's consumed-goods set comes from the current solve. Verified on the live carbolic-oil block: coal-gas-void goes 0 -> 2.34 machines and the 117/s coal-gas export drops to zero, chain unchanged. Refs #91
1 parent ecc5292 commit 278edb1

4 files changed

Lines changed: 162 additions & 26 deletions

File tree

app/src/lib/sink-classify.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { drainsOnConsume } from "./sink-classify.ts";
3+
4+
const good = (name: string, amount: number) => ({ name, amount });
5+
6+
describe("drainsOnConsume", () => {
7+
it("drains a pure void whose output leaves the block (coal-gas → ash)", () => {
8+
// coal-gas-void: 50 coal-gas in, 1 ash out; nothing in the block uses ash
9+
expect(
10+
drainsOnConsume({
11+
good: "coal-gas",
12+
ingredients: [good("coal-gas", 50)],
13+
products: [good("ash", 1)],
14+
consumedInBlock: new Set(["coal", "raw-coal", "tar", "steam", "water"]),
15+
}),
16+
).toBe(true);
17+
});
18+
19+
it("does NOT drain a reprocessor whose output re-enters the chain (block 27)", () => {
20+
// grade-2-crush: consumes grade-2-iron, makes grade-3-iron which the chain uses
21+
expect(
22+
drainsOnConsume({
23+
good: "grade-2-iron",
24+
ingredients: [good("grade-2-iron", 4)],
25+
products: [good("grade-3-iron", 2), good("iron-slime", 1)],
26+
consumedInBlock: new Set(["grade-2-iron", "grade-3-iron", "iron-slime"]),
27+
}),
28+
).toBe(false);
29+
});
30+
31+
it("drains a product-less void", () => {
32+
expect(
33+
drainsOnConsume({
34+
good: "pollution",
35+
ingredients: [good("pollution", 10)],
36+
products: [],
37+
consumedInBlock: new Set(["iron-plate"]),
38+
}),
39+
).toBe(true);
40+
});
41+
42+
it("drains when it returns LESS of the same good (net reducer)", () => {
43+
expect(
44+
drainsOnConsume({
45+
good: "sludge",
46+
ingredients: [good("sludge", 10)],
47+
products: [good("sludge", 3)],
48+
consumedInBlock: new Set(),
49+
}),
50+
).toBe(true);
51+
});
52+
53+
it("does NOT drain a net PRODUCER of the good", () => {
54+
// consumes 10, makes 20 of the same good — not a sink
55+
expect(
56+
drainsOnConsume({
57+
good: "steam",
58+
ingredients: [good("steam", 10)],
59+
products: [good("steam", 20)],
60+
consumedInBlock: new Set(),
61+
}),
62+
).toBe(false);
63+
});
64+
65+
it("drains a multi-output void when every other product leaves the block", () => {
66+
expect(
67+
drainsOnConsume({
68+
good: "waste-water",
69+
ingredients: [good("waste-water", 100)],
70+
products: [good("mineral-sludge", 1), good("stone", 1)],
71+
consumedInBlock: new Set(["waste-water"]), // neither product used elsewhere
72+
}),
73+
).toBe(true);
74+
});
75+
76+
it("does NOT drain when ANY other product feeds the block", () => {
77+
expect(
78+
drainsOnConsume({
79+
good: "waste-water",
80+
ingredients: [good("waste-water", 100)],
81+
products: [good("stone", 1), good("iron-ore", 1)],
82+
consumedInBlock: new Set(["iron-ore"]), // iron-ore re-enters the chain
83+
}),
84+
).toBe(false);
85+
});
86+
});

app/src/lib/sink-classify.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/** When you add a recipe to consume a byproduct (clicking its surplus chip),
2+
* should the block DRAIN that good — pin it to net = 0 so the surplus MUST be
3+
* consumed in-block rather than vented — or only mark it made?
4+
*
5+
* Marking made alone forbids importing the good (the block-27 import-and-
6+
* restructure trap) but still lets any surplus leave as an export, so a pure
7+
* disposal recipe idles at 0 next to an untouched export. Draining forces it to
8+
* run. The catch: draining a consumer whose output RE-ENTERS the chain
9+
* restructures production (block 27's grade-2 → grade-3, which the chain then
10+
* consumes), which is not what "deal with my surplus" means.
11+
*
12+
* The line is TERMINALITY: drain only when the consumer net-consumes the good
13+
* AND none of its other products feeds anything else in the block — everything
14+
* it makes leaves. A void (coal-gas → ash, nothing here uses ash) qualifies; a
15+
* reprocessor does not. `consumedInBlock` is the set of goods the block's other
16+
* recipes consume, read from the current solve (the block before this add), so
17+
* a terminal product that only starts leaving after the add still reads right. */
18+
export function drainsOnConsume(opts: {
19+
good: string;
20+
ingredients: readonly { name: string; amount?: number | null }[];
21+
products: readonly { name: string; amount?: number | null }[];
22+
consumedInBlock: ReadonlySet<string>;
23+
}): boolean {
24+
const { good, ingredients, products, consumedInBlock } = opts;
25+
const sum = (arr: readonly { name: string; amount?: number | null }[], name: string) =>
26+
arr.filter((c) => c.name === name).reduce((s, c) => s + (c.amount ?? 0), 0);
27+
// a net producer of the good isn't a sink for it
28+
const netConsumes = sum(products, good) < sum(ingredients, good);
29+
// every other product leaves the block (nothing else consumes it)
30+
const terminal = products.every((c) => c.name === good || !consumedInBlock.has(c.name));
31+
return netConsumes && terminal;
32+
}

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

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "../server/factorio";
1919
import { exportBlockFn } from "../server/export-fns";
2020
import { registerBlockEditor } from "../lib/block-editors";
21+
import { drainsOnConsume } from "../lib/sink-classify";
2122
import { downloadJson } from "../lib/download";
2223
import { exportFileName } from "../lib/plan-export";
2324
import { toast } from "../lib/toast-store";
@@ -466,26 +467,36 @@ function Block({ blockId }: { blockId: number }) {
466467
// Adding a CONSUMER via a byproduct's chip means "deal with MY surplus":
467468
// mark the good made — without this, a reprocessing recipe lets the plan
468469
// IMPORT the byproduct and shut down the real producers (the block-27
469-
// failure). A reprocessor then absorbs surplus on its own (recycling is
470-
// cheaper than making more). A pure SINK (void: no products, or only
471-
// returning less of the same good) makes nothing the objective wants, so
472-
// it also gets a drain pin — "this good's surplus must be consumed here"
473-
// (net = 0) — or it would idle at 0 with the export untouched.
470+
// failure). Then, when the consumer is a TERMINAL sink, also DRAIN the good
471+
// (net = 0 → the surplus must be consumed in-block, not vented) so the sink
472+
// actually runs instead of idling at 0 next to an untouched export.
473+
//
474+
// Terminal = the consumer net-consumes the good AND none of its OTHER
475+
// products feeds anything else in this block (they all leave). That's the
476+
// line between "consume the surplus" and "restructure production": a pure
477+
// void (coal-gas → ash, and nothing here uses ash) drains cleanly; a
478+
// reprocessor whose output re-enters the chain (block 27's grade-2 →
479+
// grade-3, which the chain consumes) is only marked made, never drained,
480+
// so forcing it can't cascade. Read from the CURRENT solve (the block
481+
// before this add), so newly-added terminal products still read as leaving.
474482
if (pickFor?.mode === "consume") {
475483
const good = pickFor.name;
476484
if (!goals.some((g) => g.name === good)) doc.markMade(good);
477485
const cand = picker.data?.find((c) => c.name === name);
478-
const intake = cand?.ingredients
479-
.filter((c) => c.name === good)
480-
.reduce((s, c) => s + (c.amount ?? 0), 0);
481-
const sameGoodOut = cand?.products
482-
.filter((c) => c.name === good)
483-
.reduce((s, c) => s + (c.amount ?? 0), 0);
484-
const isSink =
485-
cand != null &&
486-
(cand.products.length === 0 ||
487-
(cand.products.every((c) => c.name === good) && (sameGoodOut ?? 0) < (intake ?? 0)));
488-
if (isSink) doc.setPin({ kind: "drain", recipe: name, item: good });
486+
if (cand) {
487+
const consumedInBlock = new Set(
488+
(res?.rows ?? []).flatMap((row) => row.ingredients.map((i) => i.name)),
489+
);
490+
if (
491+
drainsOnConsume({
492+
good,
493+
ingredients: cand.ingredients,
494+
products: cand.products,
495+
consumedInBlock,
496+
})
497+
)
498+
doc.setPin({ kind: "drain", recipe: name, item: good });
499+
}
489500
}
490501
// label the save for the undo stack — the picker rows carry the display name
491502
const display = picker.data?.find((c) => c.name === name)?.display;

docs/solver.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,23 @@ single-`target` shape. The item rules:
4040
incidental byproduct just offsets the import — a 0.02/s side-product of
4141
something else is never scaled up to cover a 10/s demand.
4242
- **Draining a byproduct**: adding a consumer through a byproduct's chip marks
43-
the good made AND — when the chosen recipe is a pure sink (a void: no
44-
products, or only returning less of the same good) — records a **drain**
45-
(`net = 0`): the surplus must be consumed in-block, which is what forces a
46-
void to run at all (it produces nothing the objective wants). A reprocessing
47-
consumer needs no drain — once the good is made (import forbidden), recycling
48-
the surplus is cheaper than making more, so the optimizer uses it; without
49-
the made mark it would instead IMPORT the byproduct and idle the real
50-
producers. The solve also reports `importedProducible` — imports of goods an
51-
enabled in-block recipe produces, the tell-tale of that trap — and the import
52-
chip offers one click to claim the good in-block.
43+
the good made AND — when the chosen recipe is a **terminal sink** — records a
44+
**drain** (`net = 0`): the surplus must be consumed in-block, which is what
45+
forces a void to run at all (it produces nothing the objective wants).
46+
Terminal means the recipe net-consumes the good and none of its *other*
47+
products feeds anything else in the block — everything it makes leaves
48+
(`lib/sink-classify.ts`, `drainsOnConsume`, tested). That single test is the
49+
line between "consume my surplus" and "restructure production": a void like
50+
coal-gas → ash (nothing here uses ash) drains and runs; a reprocessor whose
51+
output re-enters the chain (block 27's grade-2 → grade-3, which the chain
52+
consumes) is only marked made, never drained, so forcing it can't cascade.
53+
A reprocessing consumer needs no drain — once the good is made (import
54+
forbidden), recycling the surplus is cheaper than making more, so the
55+
optimizer uses it; without the made mark it would instead IMPORT the
56+
byproduct and idle the real producers. The solve also reports
57+
`importedProducible` — imports of goods an enabled in-block recipe produces,
58+
the tell-tale of that trap — and the import chip offers one click to claim
59+
the good in-block.
5360
- **Pins** (`pins` in the doc, in building counts) constrain single rows:
5461
`count` = always run exactly N buildings (supply-push — this is how byproducts
5562
route into in-block consumers), `cap` = at most N (a built-capacity ceiling;

0 commit comments

Comments
 (0)