Skip to content

Commit 117264d

Browse files
committed
feat(mod): in-game sushi-loop tracer (ALT+B)
Hover a belt and press ALT+B: the mod flood-fills the loop (belts, undergrounds, splitters; balancers as graphs), prunes feed-on/feed-off spurs so only the circulating core counts, sets one "read entire belt (hold)" reader per game segment, clusters the readers at the junctions where segments meet, and stitches them with a minimum spanning tree of short player-legal wires (reach from the prototypes; unbridgeable gaps get GPS-linked power-pole suggestions instead of script-cheated spans). SHIFT+ALT+B removes exactly what the last trace added; a pyops-sushi remote interface exposes trace/untrace for tooling and future UI. Segment rules were derived empirically with isolated-reader probes, not guessed: segments end at splitters and sideload merges (positional — the input directly behind a belt continues its line; curves keep their segment), undergrounds CONTINUE the segment with the buried span readable, and tier-weaved undergrounds never cross lines because pairing comes from the engine. Verified against exact item censuses on a scripted torture loop (5 splitters, bypass arms rejoining by sideload and by splitter, mixed tiers, a T2 line weaved through a T3 span): reader counts at the theoretical minimum, one network, all wire spans player-legal, and wire-vs-physical reconciling up to the documented limits (splitter internals are circuit-invisible; reads bleed into adjacent feeder branches). The measurement (sushi.trace, protocol v8) lands in the app: the sushi planner shows a one-click "measured N tiles" chip for its loop length. Trace hotkeys register on the next full game restart (data-stage prototypes); the control-side registration is guarded so reload_mods on an older data stage doesn't crash.
1 parent 415c446 commit 117264d

10 files changed

Lines changed: 701 additions & 4 deletions

File tree

app/src/components/block/sushi-planner.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { planSushi, type ResolvedLogistics, type SushiFlow } from "../../lib/log
55
import { constantCombinatorBlueprint, encodeBlueprint } from "../../lib/blueprint";
66
import { fmtSpoilTime, Icon, useSpoilables } from "../../lib/icons";
77
import { toast } from "../../lib/toast-store";
8-
import { bridgeBlueprintFn, bridgeStatusFn } from "../../server/bridge/fns";
8+
import { bridgeBlueprintFn, bridgeStatusFn, sushiTraceInfoFn } from "../../server/bridge/fns";
99
import { fmtCount } from "./format.ts";
1010
import { Button } from "#/components/ui/button.tsx";
1111
import { Callout } from "#/components/ui/callout.tsx";
@@ -135,6 +135,14 @@ export function SushiPlanner({
135135
});
136136
const peer = bridge.data?.lastPeer ?? null;
137137
const connected = peer != null && Date.now() - peer.lastSeenMs < FRESH_MS;
138+
// the mod's ALT+B loop tracer pushes its measurement here — offer, don't overwrite
139+
const measured = useQuery({
140+
queryKey: ["sushiTrace"],
141+
queryFn: () => sushiTraceInfoFn(),
142+
enabled: open,
143+
refetchInterval: 3000,
144+
});
145+
const trace = measured.data ?? null;
138146
const buildBlueprint = () => {
139147
if (!plan) return null;
140148
const section = (role: SushiPlannerFlow["role"], active: boolean) => ({
@@ -208,13 +216,23 @@ export function SushiPlanner({
208216
<b className="text-foreground">Loop length</b> — sets lap time, and with it the pass
209217
frequency ("seen every") and how long items dwell on the loop. Longer loops buffer
210218
more but cycle slower: trace items get sparse and spoilables rot in transit (flagged
211-
when riding the loop eats over a quarter of an item's spoil time).
219+
when riding the loop eats over a quarter of an item's spoil time). With the game
220+
linked, hover any belt of the built loop and press ALT+B — the mod traces it, wires
221+
every segment onto one red network for reading, and the measured length appears here
222+
as a one-click "measured" chip (SHIFT+ALT+B undoes the last trace).
212223
</p>
213224
<p>
214225
The planner covers capacity and composition only — it can't verify the control side.
215226
Without filtered or circuit-limited insertion a single item will eventually flood any
216227
sushi loop.
217228
</p>
229+
<p>
230+
<b className="text-foreground">Read accuracy</b> — the wire runs a little under the
231+
true stock: items inside splitters are invisible to circuits (nothing any wiring can
232+
fix), and reads can include items on feeder branches next to the loop. On a healthy
233+
circulating loop the error is a handful of items; treat set-points as self-correcting
234+
approximations, not exact counts.
235+
</p>
218236
<p>
219237
<b className="text-foreground">Getting the constants in game</b> — "to cursor in game"
220238
drops a ready-made constant combinator on your cursor via the live bridge (each item
@@ -249,6 +267,19 @@ export function SushiPlanner({
249267
className="w-24"
250268
/>
251269
<span className="text-sm text-muted-foreground">tiles</span>
270+
{trace && trace.tiles !== tiles && (
271+
<Tooltip
272+
content={`traced in-game (ALT+B): ${trace.tiles} tiles, ${trace.segments} segment(s)${trace.closed ? "" : " — not a closed loop"} — click to use`}
273+
>
274+
<Button
275+
variant="outline"
276+
size="xs"
277+
onClick={() => setTilesPersisted(trace.tiles)}
278+
>
279+
<Gamepad2 className="size-3" /> measured {trace.tiles}
280+
</Button>
281+
</Tooltip>
282+
)}
252283
</div>
253284
</div>
254285
{plan && (

app/src/server/bridge/fns.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createServerFn } from "@tanstack/react-start";
88
import * as b from "./server.ts";
99
import { requestFromMod } from "./inspect.ts";
1010
import { factorioLaunchInfo, launchFactorio } from "../factorio-launch.server.ts";
11+
import { lastSushiTrace } from "./handlers/sushi.ts";
1112

1213
/** Ensure the bridge is listening and return its status. Calling this from the
1314
* UI (polled) is what starts the socket — idempotent and HMR-safe. */
@@ -35,6 +36,13 @@ export const bridgeLocateFn = createServerFn({ method: "POST" })
3536
};
3637
});
3738

39+
/** The latest in-game sushi-loop trace (tiles measured by the mod's ALT+B
40+
* tracer), if any arrived this app session — the planner offers it as the loop
41+
* length. */
42+
export const sushiTraceInfoFn = createServerFn({ method: "GET" }).handler(async () => {
43+
return lastSushiTrace();
44+
});
45+
3846
/** Put a blueprint string on the connected player's cursor (cmd.blueprint) —
3947
* e.g. the sushi planner's set-point combinator. Fire-and-forget; returns
4048
* whether a peer was reachable. */

app/src/server/bridge/handlers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { handleResearch } from "./handlers/research.ts";
99
import { handleTurd } from "./handlers/turd.ts";
1010
import { handleBuilt } from "./handlers/built.ts";
1111
import { handleStats } from "./handlers/stats.ts";
12+
import { handleSushiTrace } from "./handlers/sushi.ts";
1213
import { handleTaskCapture, handleTaskList } from "./handlers/tasks.ts";
1314
import { handleModResult } from "./inspect.ts";
1415

@@ -32,6 +33,8 @@ const handlers: Record<string, BridgeHandler> = {
3233
"state.built": handleBuilt,
3334
// Live force state: production/consumption rates → production_stats (planned-vs-actual).
3435
"state.stats": handleStats,
36+
// The in-game sushi tracer measured (and wired) a belt loop — hold the reading.
37+
"sushi.trace": handleSushiTrace,
3538
// The in-game New-task dialog filed a task (title/description + best-effort anchors).
3639
"task.capture": handleTaskCapture,
3740
// The in-game panel pulls the project's tasks to render (list + detail).
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* sushi.trace — the mod's in-game loop tracer just measured (and wired) a belt
3+
* loop; hold the latest measurement so the sushi planner can offer it as the
4+
* loop length. In-memory only: a measurement is a point-in-time reading of the
5+
* save, not project data.
6+
*/
7+
import type { BridgeRequest, BridgeResponse } from "../protocol.ts";
8+
9+
export type SushiTrace = {
10+
tiles: number;
11+
belts: number;
12+
segments: number;
13+
readers: number;
14+
skipped: number;
15+
closed: boolean;
16+
receivedAt: number;
17+
};
18+
19+
let last: SushiTrace | null = null;
20+
21+
export function handleSushiTrace(req: BridgeRequest): BridgeResponse | null {
22+
const p = (req.payload ?? {}) as Partial<SushiTrace>;
23+
if (typeof p.tiles !== "number" || !(p.tiles > 0)) return null;
24+
last = {
25+
tiles: Math.round(p.tiles),
26+
belts: typeof p.belts === "number" ? p.belts : 0,
27+
segments: typeof p.segments === "number" ? p.segments : 0,
28+
readers: typeof p.readers === "number" ? p.readers : 0,
29+
skipped: typeof p.skipped === "number" ? p.skipped : 0,
30+
closed: p.closed === true,
31+
receivedAt: Date.now(),
32+
};
33+
return null;
34+
}
35+
36+
export function lastSushiTrace(): SushiTrace | null {
37+
return last;
38+
}

app/src/server/bridge/protocol.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// The bridge wire contract. Bump on BOTH sides (here and the mod's PROTOCOL_VERSION
1111
// in control.lua) whenever the message shapes change — each side warns when the
1212
// other reports a different version.
13-
export const PROTOCOL_VERSION = 7;
13+
export const PROTOCOL_VERSION = 8;
1414

1515
/** A request from the mod. `type` selects the handler; `payload` is type-specific. */
1616
export type BridgeRequest = {

docs/bridge.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ on any page. The same tab hosts the companion-mod installer (see below).
3232
interface), and put an app-built blueprint string on the player's cursor
3333
(`cmd.blueprint` — e.g. the sushi planner's set-point combinator; the mod
3434
refuses politely if the cursor is holding something).
35+
- **Sushi-loop tracer (`mod/sushi.lua`):** hover a belt and press ALT+B — the mod
36+
flood-fills the belt loop (belts, undergrounds, splitters; lane balancers are
37+
handled as graphs), prunes feed-on/feed-off spurs so only the circulating core
38+
counts, sets one "read entire belt (hold)" reader per game segment (segments
39+
end at splitters and sideload merges; undergrounds CONTINUE the segment,
40+
buried span included — probed empirically with isolated readers), places the
41+
readers at the junction cluster where segments meet, and connects them with a
42+
minimum spanning tree of short player-legal wires (prototype reach; if a gap
43+
can't be bridged belt-to-belt it prints GPS-linked power-pole suggestions
44+
instead). Skip-and-warn on belts you already wired. The measured loop
45+
(`sushi.trace`: tiles/segments/closed) lands in the app, where the sushi
46+
planner offers it as a one-click loop length. SHIFT+ALT+B removes exactly what
47+
the last trace added. A `pyops-sushi` remote interface exposes trace/untrace
48+
for tooling. Read accuracy: splitter internals aren't circuit-readable (the
49+
read undercounts by the in-splitter transit population) and "entire belt"
50+
zones bleed into adjacent feeder branches — on a circulating loop the error
51+
is a handful of items either way. Verified against exact item censuses on a
52+
scripted torture loop (5 splitters, bypass arms, tier-weaved undergrounds).
3553
- **Task panel:** the in-game panel's **Tasks** tab pulls the project's
3654
tasks with `task.list` (the app replies with the full set — title, status,
3755
priority, body, steps, and links resolved to Factorio sprite paths) and renders

mod/control.lua

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ local mod_gui = require("__core__.lualib.mod-gui")
1212
local Summary = require("summary")
1313
local Combinator = require("combinator")
1414
local Tasks = require("tasks")
15+
local Sushi = require("sushi")
1516

1617
local PANEL_NAME = "pyops_panel"
1718
local BUTTON_NAME = "pyops_button"
1819
local SHORTCUT_NAME = "pyops-toggle-panel"
1920
-- Bridge wire contract. Keep in lockstep with PROTOCOL_VERSION in the app's
2021
-- src/server/bridge/protocol.ts — each side warns if the other reports a different one.
21-
local PROTOCOL_VERSION = 7
22+
local PROTOCOL_VERSION = 8
2223

2324
local function get_player(event)
2425
if not event.player_index then
@@ -148,6 +149,11 @@ Tasks.send = function(player, request_type, payload)
148149
send_request(player, request_type, payload)
149150
end
150151

152+
-- Same hook for the sushi-loop tools (sushi.trace measurements → the planner).
153+
Sushi.send = function(player, request_type, payload)
154+
send_request(player, request_type, payload)
155+
end
156+
151157
-- Reply to an app→mod request (a `cmd.*` the app pushed with a request_id). We
152158
-- echo that request_id in a `bridge.result` so the app correlates it back to the
153159
-- awaiting caller (see server/bridge/inspect.ts).
@@ -869,6 +875,45 @@ script.on_event("pyops-toggle-panel", function(event)
869875
toggle_panel(get_player(event))
870876
end)
871877

878+
-- pcall'd: after a control-only reload (game.reload_mods) the data stage hasn't
879+
-- run, so a freshly added custom-input prototype may not exist yet — skip the
880+
-- hotkeys then (the remote interface still works) instead of crashing the load.
881+
pcall(function()
882+
script.on_event("pyops-trace-sushi", function(event)
883+
local player = get_player(event)
884+
if player then
885+
Sushi.trace(player)
886+
end
887+
end)
888+
script.on_event("pyops-untrace-sushi", function(event)
889+
local player = get_player(event)
890+
if player then
891+
Sushi.untrace(player)
892+
end
893+
end)
894+
end)
895+
896+
-- Tooling entry (dev/MCP + future UI surfaces): trace/untrace without a hotkey.
897+
remote.add_interface("pyops-sushi", {
898+
trace_at = function(player_index, position)
899+
local player = game.get_player(player_index)
900+
if not player then
901+
return false
902+
end
903+
local seed = player.surface.find_entities_filtered({
904+
position = position,
905+
radius = 2,
906+
type = { "transport-belt", "underground-belt", "splitter" },
907+
limit = 1,
908+
})[1]
909+
return Sushi.trace(player, seed)
910+
end,
911+
untrace = function(player_index)
912+
local player = game.get_player(player_index)
913+
return player and Sushi.untrace(player) or false
914+
end,
915+
})
916+
872917
script.on_event(defines.events.on_gui_click, function(event)
873918
local player = get_player(event)
874919
if not player or not event.element or not event.element.valid then

mod/data.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ data:extend({
1313
consuming = "none",
1414
action = "lua"
1515
},
16+
-- Sushi-loop tools: trace + wire the hovered belt's loop; undo the last trace.
17+
{
18+
type = "custom-input",
19+
name = "pyops-trace-sushi",
20+
key_sequence = "ALT + B",
21+
consuming = "none",
22+
action = "lua"
23+
},
24+
{
25+
type = "custom-input",
26+
name = "pyops-untrace-sushi",
27+
key_sequence = "SHIFT + ALT + B",
28+
consuming = "none",
29+
action = "lua"
30+
},
1631
-- Mirrors the smart-pipette (Q) key so the summary panel can pipette the good /
1732
-- building under the cursor. Linked (no own key) — it fires alongside the normal
1833
-- pipette, which still works everywhere else.

mod/locale/en/pyops.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
[controls]
22
pyops-toggle-panel=Toggle PyOps panel
3+
pyops-trace-sushi=PyOps: trace sushi loop (hovered belt)
4+
pyops-untrace-sushi=PyOps: undo last sushi trace
35

46
[shortcut-name]
57
pyops-toggle-panel=PyOps

0 commit comments

Comments
 (0)