Skip to content

Commit eda5146

Browse files
committed
feat(app): sushi planner for block belt loops
Sizes one mixed belt loop carrying everything a block moves — imports, exports, and row-to-row intermediates (a good's belt rate is max(production, consumption) across the rows). Opens from the Block balance header: feasibility verdict against the selected belt tier + stacking (comfortable / tight / fragile / over capacity / loop too small), per-item on-belt set-points (stock = rate x lap time, floored so trace items keep cycling), pass frequency with sparse flags, and spoil-dwell warnings via Little's law. Loop length in tiles drives lap time and slot capacity; items untick to stay off the loop. Delivery: "to cursor in game" sends a constant combinator over the bridge; "copy blueprint" yields the same as an importable string (lib/blueprint.ts — 2.0 blueprint JSON + zlib string encoding, built client-side). The combinator holds one logistic section per role with only block inputs enabled — imports are the injected flows that need gating; outputs and intermediates free-flow, their set-points parked in disabled sections as flippable reference.
1 parent 2ba8559 commit eda5146

8 files changed

Lines changed: 862 additions & 0 deletions

File tree

app/e2e/mut/sushi.e2e.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { expect, test } from "@playwright/test";
2+
import { addGoal, createBlock } from "./helpers";
3+
4+
/**
5+
* Sushi planner (block balance header): with a plate + ore chain the block has
6+
* an internal flow (ore) and boundary flows, so the planner opens with per-item
7+
* rows, a verdict, and set-points that react to the loop-length input.
8+
*/
9+
test("sushi planner sizes one mixed loop for the block's flows", async ({ page }) => {
10+
await createBlock(page);
11+
await addGoal(page, "iron plate", "Iron plate");
12+
13+
// plate recipe, then a producer for its ore ingredient → ore becomes internal
14+
await page.locator('button[title^="click to add a recipe that makes this goal"]').click();
15+
const platePicker = page.getByRole("dialog", { name: /Recipes that make/ });
16+
await platePicker.getByRole("button", { name: /Iron plate/ }).first().click();
17+
await expect(platePicker).toBeHidden();
18+
await page.getByRole("button", { name: /^Iron ore.*(raw input|craftable)/ }).first().click();
19+
const orePicker = page.getByRole("dialog", { name: /Recipes that make/ });
20+
await orePicker.getByRole("button", { name: /Iron ore/ }).first().click();
21+
await expect(orePicker).toBeHidden();
22+
23+
await page.getByRole("button", { name: "sushi" }).click();
24+
const dialog = page.getByRole("dialog", { name: "Sushi planner" });
25+
await expect(dialog).toBeVisible();
26+
27+
// a verdict callout and the ore row (internal flow) with a set-point
28+
await expect(dialog.getByText(/comfortable|workable|fragile|over capacity|loop too small/)).toBeVisible();
29+
const oreRow = dialog.locator("div").filter({ hasText: /^.*Iron ore/ }).last();
30+
await expect(oreRow.getByText("int")).toBeVisible();
31+
32+
// loop length drives the lap/slot readout
33+
const tiles = dialog.getByLabel("loop length in belt tiles");
34+
await tiles.fill("200");
35+
await expect(dialog.getByText(/lap .* s · \d+\/\d+ slots/)).toBeVisible();
36+
37+
// unticking a flow keeps the planner live (recomputes without it)
38+
await dialog.getByRole("checkbox").first().click();
39+
await expect(dialog.getByText(/lap /)).toBeVisible();
40+
41+
// delivery: bridge send is gated on a connected mod (none here), the
42+
// blueprint-string copy works without one
43+
await expect(dialog.getByRole("button", { name: "to cursor in game" })).toBeDisabled();
44+
await expect(dialog.getByRole("button", { name: "copy blueprint" })).toBeEnabled();
45+
});

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { fmtSpoilTime, Icon, useSpoilables } from "../../lib/icons";
99
import { fmtTemp } from "../../lib/format";
1010
import { ItemChip, type Link as ItemLink } from "./item-chip.tsx";
1111
import { LogiTag } from "./logi-tag.tsx";
12+
import { SushiPlanner, type SushiPlannerFlow } from "./sushi-planner.tsx";
1213
import type { BlockDocStore } from "./doc-store.ts";
1314
import type { LogiView, SolveResult } from "./solve-view.ts";
1415
import { num } from "./format.ts";
@@ -56,12 +57,65 @@ export function BalanceCard({
5657
onOpenSpoilDialog: (name: string) => void;
5758
}) {
5859
const spoilRates = useStore(doc.store, (s) => s.spoilRates);
60+
const blockName = useStore(doc.store, (s) => s.blockName);
5961
const spoilables = useSpoilables();
62+
// One mixed loop candidate: every solid item touching a belt in this block —
63+
// imports, exports, AND internal row-to-row flows all ride the same loop in
64+
// the "everything on one belt" pattern. A good's belt rate is
65+
// max(production, consumption) across the rows (that identity covers all
66+
// three roles); boundary-only goods (e.g. a stock goal with no row) are
67+
// topped up from the import/export lists.
68+
const sushiFlows: SushiPlannerFlow[] = (() => {
69+
const solid = (f: { name: string; kind: string }) =>
70+
f.kind === "item" && !f.name.startsWith("pyops-");
71+
const prod = new Map<string, number>();
72+
const cons = new Map<string, number>();
73+
const disp = new Map<string, string | null>();
74+
for (const row of res?.rows ?? []) {
75+
for (const c of row.ingredients) {
76+
if (!solid(c)) continue;
77+
cons.set(c.name, (cons.get(c.name) ?? 0) + c.rate);
78+
disp.set(c.name, c.display ?? res?.display?.[c.name] ?? null);
79+
}
80+
for (const c of row.products) {
81+
if (!solid(c)) continue;
82+
prod.set(c.name, (prod.get(c.name) ?? 0) + c.rate);
83+
disp.set(c.name, c.display ?? res?.display?.[c.name] ?? null);
84+
}
85+
}
86+
for (const f of res?.imports ?? []) {
87+
if (!solid(f) || cons.has(f.name) || prod.has(f.name)) continue;
88+
cons.set(f.name, f.rate);
89+
disp.set(f.name, res?.display?.[f.name] ?? null);
90+
}
91+
for (const f of res?.exports ?? []) {
92+
if (!solid(f) || cons.has(f.name) || prod.has(f.name)) continue;
93+
prod.set(f.name, f.rate);
94+
disp.set(f.name, res?.display?.[f.name] ?? null);
95+
}
96+
return [...new Set([...prod.keys(), ...cons.keys()])]
97+
.map((name) => {
98+
const p = prod.get(name) ?? 0;
99+
const c = cons.get(name) ?? 0;
100+
return {
101+
name,
102+
display: disp.get(name) ?? null,
103+
rate: Math.max(p, c),
104+
role:
105+
p > 1e-9 && c > 1e-9 ? ("int" as const) : c > 1e-9 ? ("in" as const) : ("out" as const),
106+
};
107+
})
108+
.filter((f) => f.rate > 1e-9)
109+
.sort((a, b) => b.rate - a.rate);
110+
})();
60111
return (
61112
<Card className="lg:col-span-2">
62113
<CardHeader className="justify-between">
63114
<CardTitle>Block balance</CardTitle>
64115
<div className="flex items-center gap-4 text-sm">
116+
{logi.resolved && sushiFlows.length >= 2 && (
117+
<SushiPlanner flows={sushiFlows} resolved={logi.resolved} blockName={blockName} />
118+
)}
65119
{/* Pollution rides in the header (#23) to save a whole body row —
66120
electricity/heat aren't shown here at all: both surface as their
67121
own import chips below (pyops-electricity / pyops-heat). */}

0 commit comments

Comments
 (0)