Skip to content

Commit 1964dfd

Browse files
committed
fix(data): import Factorio product probabilities
Use Factorio 2.1 independent and shared product probabilities when computing expected yields, and surface chances compactly in block rows and hover details.\n\nCloses #161
1 parent 4de0c03 commit 1964dfd

9 files changed

Lines changed: 207 additions & 11 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { DatabaseSync } from "node:sqlite";
2+
import { expect, test } from "@playwright/test";
3+
import { activeProjectDbFile, goto, uniqueName } from "./helpers";
4+
5+
test("probabilistic recipe products show their chance and solve from expected yield", async ({
6+
page,
7+
}) => {
8+
const data = {
9+
recipes: ["kicalk-mk04-breeder"],
10+
goals: [{ name: "kicalk-mk04", rate: 1 }],
11+
machines: { "kicalk-mk04-breeder": "kicalk-plantation-mk01" },
12+
};
13+
const db = new DatabaseSync(activeProjectDbFile());
14+
let id: number;
15+
try {
16+
const inserted = db
17+
.prepare("INSERT INTO blocks (name, data) VALUES (?, ?)")
18+
.run(uniqueName("Probabilistic kicalk"), JSON.stringify(data));
19+
id = Number(inserted.lastInsertRowid);
20+
} finally {
21+
db.close();
22+
}
23+
24+
await goto(page, `/block/${id}`);
25+
const row = page.locator('[data-recipe-row="kicalk-mk04-breeder"]');
26+
await expect(row).toContainText("0.16/s");
27+
28+
const optionalMk04 = row.getByRole("button", {
29+
name: /^Kicalk MK 04 0\.19\/s · 40% chance/,
30+
});
31+
await expect(optionalMk04).toBeVisible();
32+
await expect(optionalMk04.locator("[data-product-probability]")).toHaveText("40%");
33+
34+
await optionalMk04.hover();
35+
await expect(page.getByText("40% chance per craft", { exact: true })).toBeVisible();
36+
await expect(page.getByText("3 on success · 1.2 expected per craft", { exact: true })).toBeVisible();
37+
38+
await row.getByLabel("Variable recipe results").hover();
39+
const variableResult = page.getByText(
40+
/Kicalk MK 04: 3 on success · 40% chance · 1.2 expected\/craft/,
41+
);
42+
await expect(variableResult).toBeVisible();
43+
44+
await page.keyboard.press("Escape");
45+
await expect(variableResult).toBeHidden();
46+
await row.getByText("Grow kicalk gen 4", { exact: true }).hover();
47+
const recipeCard = page.getByText("kicalk-mk04-breeder", { exact: true }).locator("..");
48+
await expect(recipeCard.getByText("40% chance", { exact: false }).first()).toBeVisible();
49+
});

app/src/components/block/item-chip.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// @vitest-environment jsdom
22
import { cleanup, fireEvent, render } from "@testing-library/react";
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
34
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
45
import { ItemChip } from "./item-chip.tsx";
56
import { IncidentalSpoilageChip } from "./incidental-spoilage-chip.tsx";
@@ -10,7 +11,40 @@ vi.mock("#/server/factorio", () => ({
1011
itemDetailFn: () => Promise.resolve(null),
1112
}));
1213

14+
window.ResizeObserver ??= class {
15+
observe() {}
16+
unobserve() {}
17+
disconnect() {}
18+
} as unknown as typeof ResizeObserver;
19+
1320
describe("ItemChip spoil time", () => {
21+
it("shows a probabilistic product chance compactly and in its hover context", () => {
22+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
23+
const { getByRole, getByText } = render(
24+
<QueryClientProvider client={client}>
25+
<ItemChip
26+
name="kicalk-mk04"
27+
kind="item"
28+
display="Kicalk MK 04"
29+
rate={0.19}
30+
probability={0.4}
31+
amountExpected={1.2}
32+
amountMin={3}
33+
amountMax={3}
34+
link="export"
35+
onClick={() => {}}
36+
/>
37+
</QueryClientProvider>,
38+
);
39+
40+
expect(getByText("40%").getAttribute("data-product-probability")).not.toBeNull();
41+
const chip = getByRole("button");
42+
expect(chip.getAttribute("aria-label")).toContain("40% chance");
43+
fireEvent.mouseEnter(chip);
44+
expect(getByText("40% chance per craft")).toBeTruthy();
45+
expect(getByText("3 on success · 1.2 expected per craft")).toBeTruthy();
46+
});
47+
1448
it("makes a finite campaign total the primary chip value", () => {
1549
const { getByRole, getByText } = render(
1650
<ItemChip

app/src/components/block/item-chip.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Flame, Timer } from "lucide-react";
22
import type { ReactNode } from "react";
33
import { ItemHover } from "../../lib/recipe-card";
44
import { fmtSpoilTime, Icon } from "../../lib/icons";
5-
import { quantityLabel, rateLabel } from "./format.ts";
5+
import { num, quantityLabel, rateLabel } from "./format.ts";
66

77
/** A block item's role under the current solve — drives the chip colour so it's
88
* obvious which flows are linked internally vs. need a recipe vs. spill out. */
@@ -27,6 +27,10 @@ export function ItemChip({
2727
display,
2828
rate,
2929
total,
30+
probability,
31+
amountExpected,
32+
amountMin,
33+
amountMax,
3034
rateMin,
3135
rateMax,
3236
temp,
@@ -48,6 +52,11 @@ export function ItemChip({
4852
/** Finite campaign amount. Replaces the throughput rate as the chip's
4953
* primary value; the caller may show the derived average separately. */
5054
total?: number;
55+
/** Per-craft chance and yield context for a recipe product. */
56+
probability?: number;
57+
amountExpected?: number;
58+
amountMin?: number;
59+
amountMax?: number;
5160
/** Variable energy production range; `rate` is the planner average. */
5261
rateMin?: number;
5362
rateMax?: number;
@@ -96,11 +105,28 @@ export function ItemChip({
96105
rate == null ? "" : variableRate ? displayedRate : rateLabel(name, rate, { perSec: true });
97106
const displayedTotal = total == null ? "" : `${quantityLabel(name, total)} total`;
98107
const totalRate = total != null && rate != null ? rateLabel(name, rate, { perSec: true }) : "";
108+
const chance = probability != null && probability < 1 ? `${num(probability * 100)}%` : "";
109+
const chanceContext = chance ? (
110+
<div>
111+
<div className="text-warning">{chance} chance per craft</div>
112+
{amountExpected != null && (
113+
<div className="text-muted-foreground">
114+
{amountMin != null && amountMax != null
115+
? amountMin === amountMax
116+
? `${num(amountMin)} on success · `
117+
: `${num(amountMin)}${num(amountMax)} on success · `
118+
: ""}
119+
{num(amountExpected)} expected per craft
120+
</div>
121+
)}
122+
</div>
123+
) : undefined;
99124
return (
100125
<span className={`inline-flex items-center gap-1 ${cls}`}>
101126
<ItemHover
102127
name={name}
103128
kind={kind as "item" | "fluid"}
129+
extraText={chanceContext}
104130
className="inline-flex"
105131
// the rich card (cost, produced-by / used-in) replaces the old native title;
106132
// role is the chip colour, rate is shown on the chip, actions are on right-click
@@ -112,7 +138,7 @@ export function ItemChip({
112138
e.preventDefault();
113139
onContext(e);
114140
}}
115-
aria-label={`${display ?? name}${displayedTotal ? ` ${displayedTotal}${totalRate ? ` · ${totalRate} average` : ""}` : accessibleRate ? ` ${accessibleRate}` : ""}${spoilTime ? ` · spoils in ${spoilTime}` : ""}${incidental ? " · includes estimated incidental spoilage" : ""}${indicatorLabel ? ` · ${indicatorLabel}` : ""} · ${why}`}
141+
aria-label={`${display ?? name}${displayedTotal ? ` ${displayedTotal}${totalRate ? ` · ${totalRate} average` : ""}` : accessibleRate ? ` ${accessibleRate}` : ""}${chance ? ` · ${chance} chance` : ""}${spoilTime ? ` · spoils in ${spoilTime}` : ""}${incidental ? " · includes estimated incidental spoilage" : ""}${indicatorLabel ? ` · ${indicatorLabel}` : ""} · ${why}`}
116142
className="flex items-center gap-1 px-1.5 py-1 text-sm hover:brightness-95"
117143
>
118144
<span className="relative flex">
@@ -137,7 +163,16 @@ export function ItemChip({
137163
)}
138164
</span>
139165
) : rate != null ? (
140-
<span data-rate-range={variableRate ? "variable" : undefined}>{displayedRate}</span>
166+
chance ? (
167+
<span className="flex flex-col items-start leading-tight tabular-nums">
168+
<span data-rate-range={variableRate ? "variable" : undefined}>{displayedRate}</span>
169+
<span data-product-probability className="text-xs text-warning">
170+
{chance}
171+
</span>
172+
</span>
173+
) : (
174+
<span data-rate-range={variableRate ? "variable" : undefined}>{displayedRate}</span>
175+
)
141176
) : null}
142177
{incidental && (
143178
<span

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,17 +221,20 @@ export function RecipeRow({
221221
<RecipeSpoilageIndicator products={row.products} spoilables={spoilables} />
222222
</span>
223223
) : null}
224-
{campaign && uncertainProducts.length > 0 && (
224+
{uncertainProducts.length > 0 && (
225225
<Tooltip
226226
content={uncertainProducts
227227
.map(
228228
(product) =>
229-
`${product.display ?? product.name}: ${num(product.amountMin)}${num(product.amountMax)} each${product.probability < 1 ? ` · ${num(product.probability * 100)}% chance` : ""} · ${num(product.amountExpected * (row?.rate ?? 0) * campaign.duration)} expected total`,
229+
`${product.display ?? product.name}: ${product.amountMin === product.amountMax ? num(product.amountMin) : `${num(product.amountMin)}${num(product.amountMax)}`} on success${product.probability < 1 ? ` · ${num(product.probability * 100)}% chance` : ""} · ${num(product.amountExpected)} expected/craft${campaign ? ` · ${num(product.amountExpected * (row?.rate ?? 0) * campaign.duration)} expected total` : ""}`,
230230
)
231231
.join("\n")}
232232
>
233-
<span className="flex items-center gap-1 text-sm text-warning">
234-
<FlaskConical className="size-3" /> Variable result · hover for range and chance
233+
<span
234+
aria-label="Variable recipe results"
235+
className="flex cursor-help text-warning"
236+
>
237+
<FlaskConical className="size-3.5" />
235238
</span>
236239
</Tooltip>
237240
)}
@@ -544,10 +547,13 @@ export function RecipeRow({
544547
</div>
545548
<div className="flex flex-wrap gap-x-4 gap-y-3">
546549
<FieldLabel className="w-full md:hidden">Products ↑</FieldLabel>
547-
{row?.products.map((c) => {
550+
{row?.products.map((c, productIndex) => {
548551
const incidental = incidentalSpoilage.filter((s) => s.source === c.name);
549552
return (
550-
<div key={c.name} className="flex flex-col items-start gap-1.5">
553+
<div
554+
key={`${c.name}:${productIndex}`}
555+
className="flex flex-col items-start gap-1.5"
556+
>
551557
<div className="flex flex-wrap items-center gap-1.5">
552558
<ItemChip
553559
name={c.name}
@@ -556,6 +562,10 @@ export function RecipeRow({
556562
rate={c.rate}
557563
rateMin={c.rateMin}
558564
rateMax={c.rateMax}
565+
probability={c.probability}
566+
amountExpected={c.amountExpected}
567+
amountMin={c.amountMin}
568+
amountMax={c.amountMax}
559569
temp={c.temp}
560570
spoilTicks={spoilables[c.name]}
561571
link={linkOf(c.name)}

app/src/db/import-factorio.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,47 @@ describe("importFactorioDump — recipe categories", () => {
4949
});
5050
});
5151

52+
describe("importFactorioDump — Factorio 2.1 product probability", () => {
53+
it("combines independent and shared rolls with legacy and default fallbacks", () => {
54+
const db = runImport({
55+
recipe: {
56+
chance: {
57+
results: [
58+
{ type: "item", name: "default", amount: 1 },
59+
{ type: "item", name: "legacy", amount: 1, probability: 0.25 },
60+
{ type: "item", name: "independent", amount: 1, independent_probability: 0.4 },
61+
{
62+
type: "item",
63+
name: "shared",
64+
amount: 1,
65+
shared_probability: { min: 0.2, max: 0.7 },
66+
},
67+
{
68+
type: "item",
69+
name: "both",
70+
amount: 1,
71+
independent_probability: 0.5,
72+
shared_probability: { min: 0.6, max: 0.8 },
73+
},
74+
],
75+
},
76+
},
77+
});
78+
const rows = db.prepare(`SELECT name, probability FROM recipe_products ORDER BY idx`).all() as {
79+
name: string;
80+
probability: number;
81+
}[];
82+
expect(rows[0]).toEqual({ name: "default", probability: 1 });
83+
expect(rows[1]).toEqual({ name: "legacy", probability: 0.25 });
84+
expect(rows[2]).toEqual({ name: "independent", probability: 0.4 });
85+
expect(rows[3].name).toBe("shared");
86+
expect(rows[3].probability).toBeCloseTo(0.5);
87+
expect(rows[4].name).toBe("both");
88+
expect(rows[4].probability).toBeCloseTo(0.1);
89+
db.close();
90+
});
91+
});
92+
5293
describe("importFactorioDump — machine footprints", () => {
5394
it("derives placed tile dimensions from array and named selection boxes", () => {
5495
const db = runImport({

app/src/db/import-factorio.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ function normComponent(c: unknown): Component {
7676
if (Array.isArray(c)) return { type: "item", name: c[0], amount: c[1] };
7777
return c as Component;
7878
}
79+
80+
/** Effective chance that Factorio gives one product entry. Factorio 2.1 split
81+
* the old `probability` into an independent roll and a shared-roll interval;
82+
* both tests must pass. Keep the legacy field as the independent fallback so
83+
* older dumps remain importable. */
84+
function productProbability(c: Component): number {
85+
const independent =
86+
typeof c.independent_probability === "number"
87+
? c.independent_probability
88+
: typeof c.probability === "number"
89+
? c.probability
90+
: 1;
91+
const shared =
92+
c.shared_probability && typeof c.shared_probability === "object"
93+
? (c.shared_probability as { min?: unknown; max?: unknown })
94+
: null;
95+
const min = typeof shared?.min === "number" ? shared.min : 0;
96+
const max = typeof shared?.max === "number" ? shared.max : 1;
97+
return independent * Math.max(0, max - min);
98+
}
7999
/** Lua serializes an empty table as `{}` (object), not `[]`. Coerce to array. */
80100
const arr = <T = any>(x: unknown): T[] => (Array.isArray(x) ? (x as T[]) : []);
81101
/** Non-empty string list → JSON text; empty/missing → NULL (= "no restriction"). */
@@ -320,7 +340,7 @@ export function importFactorioDump(
320340
amount: c.amount ?? null,
321341
amount_min: (c.amount_min as number) ?? null,
322342
amount_max: (c.amount_max as number) ?? null,
323-
probability: (c.probability as number) ?? 1,
343+
probability: productProbability(c),
324344
temperature: (c.temperature as number) ?? null,
325345
// Factorio 2.0: an AMOUNT (the catalytic part of the product that
326346
// productivity never multiplies), not a flag. Kovarex: u-235 out 41

app/src/db/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export const recipeProducts = sqliteTable(
9494
amount: real(), // null when amount_min/max used
9595
amountMin: real("amount_min"),
9696
amountMax: real("amount_max"),
97+
// Effective product chance: independent_probability × shared range width
98+
// in Factorio 2.1, with legacy probability support during import.
9799
probability: real().notNull().default(1),
98100
temperature: real(), // exact produced temperature (fluids)
99101
// AMOUNT of this product not scaled by productivity (Factorio 2.0 semantics:

docs/development/data-pipeline.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ The importer converts Factorio's flexible prototype shapes into stable relationa
9898
Recipe products with ranges are represented by their expected amount for planning. Product
9999
probability, temperature, productivity exclusions, and the original range remain available
100100
where the solver or UI needs them. The parser accepts the prototype variants Factorio may
101-
emit, including recipe category arrays and singular categories.
101+
emit, including recipe category arrays and singular categories. For Factorio 2.1 products it
102+
stores the effective chance: the independent probability multiplied by the width of the shared
103+
probability range. Legacy dumps with a single probability field remain supported.
102104

103105
Localized display names come from the locale dumps. When a recipe or machine inherits its
104106
name from a product or placeable item, the importer uses that localized prototype name as a

docs/guide/blocks.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ row:
9595
keep walking the graph.
9696
- Hover recipe, technology, and item details when you need the precise inputs, outputs, or
9797
unlock status.
98+
- A probabilistic product shows its chance as a small percentage beneath its expected rate.
99+
Hover the product for its amount on success and expected amount per craft. The flask icon by
100+
the recipe rate summarizes every variable result in one tooltip.
98101

99102
The picker puts choices already unlocked in the synced save first, then recipes available
100103
later in the current planning horizon, and finally locked choices. It sorts each group by

0 commit comments

Comments
 (0)