Kaynağa Gözat

implent topology and check

clovis 1 ay önce
ebeveyn
işleme
ec5667d392

+ 9 - 12
src/assets/Selector/BeaconConfigurator.tsx

@@ -1,25 +1,22 @@
 import data from "../../assets/data/2.0/data.json"; // Adjust path if needed
 import ModuleSlots from "./ModuleSlots";
-import type { Beacon, Module } from "../../../scripts/factorio-dump/process-data.models";
+import type { Beacon } from "../../../scripts/factorio-dump/process-data.models";
 import { qualityShortcut, useQualityScroller } from "../../hooks/useQualityScroller";
 import Icon from "../icon";
 import NumberInput from "../components/NumberInput";
 import styles from "./BeaconConfigurator.module.css";
+import type { BeaconGroup } from "../../engine";
 
 const beaconsData = data.beacons as Beacon[];
 
-export type BeaconGroup = {
+export type BeaconGroupConf = BeaconGroup & {
   id: string;
-  beacon: Beacon;
-  qualityLevel: number;
-  count: number;
-  modules: { module: Module; qualityLevel: number }[];
 };
 
 type BeaconConfiguratorProps = {
-  groups: BeaconGroup[];
-  onChange: (g: BeaconGroup[]) => void;
-  direction?: "horizontal" | "vertical"; // NEW: control stacking direction
+  groups: BeaconGroupConf[];
+  onChange: (g: BeaconGroupConf[]) => void;
+  direction?: "horizontal" | "vertical";
 };
 
 function BeaconIconWrapper({
@@ -50,7 +47,7 @@ export default function BeaconConfigurator({ groups, onChange, direction = "vert
       {
         id: crypto.randomUUID(), // Use modern UUID instead of Date.now()
         beacon: defaultBeacon,
-        qualityLevel: 0,
+        beaconQualityLevel: 0,
         count: 1,
         modules: [],
       },
@@ -74,8 +71,8 @@ export default function BeaconConfigurator({ groups, onChange, direction = "vert
           {/* Beacon Type & Quality */}
           <BeaconIconWrapper
             beacon={g.beacon}
-            qualityLevel={g.qualityLevel} // Pass the controlled state down!
-            onChangeQuality={(q) => updateGroup(g.id, { qualityLevel: q })}
+            qualityLevel={g.beaconQualityLevel ?? 0} // Pass the controlled state down!
+            onChangeQuality={(q) => updateGroup(g.id, { beaconQualityLevel: q })}
           />
 
           {/* Beacon Modules */}

+ 1 - 1
src/engine/Topology/EdgeInspector.tsx

@@ -95,7 +95,7 @@ export default function EdgeInspector({ edge, nodes, onChange }: EdgeInspectorPr
         <label>Target Item</label>
         <Select
           options={itemOptions}
-          value={edge.itemId}
+          value={edge.itemId ?? ""}
           onChange={(itemId) => onChange({ ...edge, itemId })}
           placeholder={itemOptions.length === 0 ? "Connect machines to see items" : "Select item..."}
           renderOption={renderItemWithIcon}

+ 33 - 9
src/engine/Topology/InserterEdge.tsx

@@ -1,5 +1,8 @@
 import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from "@xyflow/react";
 import Icon from "../../assets/icon";
+import type { TopologyEdge } from "./model";
+
+type FlowEdgeData = TopologyEdge & Record<string, unknown> & { onEdgeSelect?: (id: string) => void };
 
 export default function InserterEdge({
   id,
@@ -12,7 +15,7 @@ export default function InserterEdge({
   style,
   markerEnd,
   data,
-}: EdgeProps) {
+}: EdgeProps<FlowEdgeData>) {
   const [edgePath, labelX, labelY] = getBezierPath({
     sourceX,
     sourceY,
@@ -22,20 +25,23 @@ export default function InserterEdge({
     targetPosition,
   });
 
-  // Default to stack inserter, but you can dynamically set this in EdgeInspector later!
   const inserterIcon = (data?.inserterName as string) || "stack-inserter";
+  const inserterQuality = (data?.inserterQuality as number) ?? 5;
+
+  // Backwards compatibility with itemId, preferring the new filters array
+  const filters = data?.filters?.length ? data.filters : data?.itemId ? [data.itemId] : [];
 
   return (
     <>
       <BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
-
       <EdgeLabelRenderer>
         <div
           style={{
             position: "absolute",
             transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
-            pointerEvents: "all", // Critical: allows this HTML element to receive clicks
+            pointerEvents: "all",
             display: "flex",
+            flexDirection: "column",
             alignItems: "center",
             justifyContent: "center",
             background: "rgba(30, 30, 30, 0.8)",
@@ -47,13 +53,31 @@ export default function InserterEdge({
           className="nodrag nopan"
           onClick={(e) => {
             e.stopPropagation();
-            // Call the function we will inject into the data payload
-            if (data?.onEdgeSelect) {
-              (data.onEdgeSelect as (id: string) => void)(id);
-            }
+            if (data?.onEdgeSelect) data.onEdgeSelect(id);
           }}
         >
-          <Icon iconName={`item/${inserterIcon}.png`} size={32} qualityLevel={4} />
+          {/* Filters List overlay (Up to 5) */}
+          {filters.length > 0 && (
+            <div
+              style={{
+                display: "flex",
+                gap: "2px",
+                position: "absolute",
+                bottom: -10,
+                right: -5,
+                background: "#1a1a1a",
+                padding: "2px 4px",
+                borderRadius: "4px",
+                border: "1px solid #3a3a3a",
+              }}
+            >
+              {filters.slice(0, 5).map((f) => (
+                <Icon key={f} iconName={`item/${f}.png`} size={16} />
+              ))}
+            </div>
+          )}
+
+          <Icon iconName={`item/${inserterIcon}.png`} size={32} qualityLevel={inserterQuality} />
         </div>
       </EdgeLabelRenderer>
     </>

+ 112 - 2
src/engine/Topology/NodeInspector.tsx

@@ -3,8 +3,9 @@ import styles from "./TopologyBuilder.module.css";
 import type { TopologyNode } from "./model";
 import MachineSelector from "../../assets/Selector/MachineSelector";
 import ModuleSlots from "../../assets/Selector/ModuleSlots";
-import BeaconConfigurator, { type BeaconGroup } from "../../assets/Selector/BeaconConfigurator";
+import BeaconConfigurator from "../../assets/Selector/BeaconConfigurator";
 import { ContainerType } from "../simulator";
+import Icon from "../../assets/icon";
 
 export default function NodeInspector({ node, onChange }: { node: TopologyNode; onChange: (n: TopologyNode) => void }) {
   const updateName = (e: React.ChangeEvent<HTMLInputElement>) => onChange({ ...node, name: e.target.value });
@@ -77,7 +78,7 @@ export default function NodeInspector({ node, onChange }: { node: TopologyNode;
           <div className={styles.formGroup}>
             <label>Beacons</label>
             <BeaconConfigurator
-              groups={node.machineConfig.setup.beacons as BeaconGroup[]}
+              groups={node.machineConfig.setup.beacons}
               onChange={(beacons) =>
                 updateMachineConfig({
                   setup: { ...node.machineConfig!.setup, beacons },
@@ -87,6 +88,115 @@ export default function NodeInspector({ node, onChange }: { node: TopologyNode;
           </div>
         </div>
       )}
+      {node.validationReport && (
+        <div
+          style={{
+            marginTop: "16px",
+            background: node.isBottleneck ? "rgba(217, 97, 79, 0.1)" : "rgba(76, 175, 80, 0.1)",
+            border: `1px solid ${node.isBottleneck ? "#d9614f" : "#4CAF50"}`,
+            borderRadius: "6px",
+            padding: "12px",
+          }}
+        >
+          <h4 style={{ margin: "0 0 8px 0", display: "flex", alignItems: "center", gap: "8px" }}>
+            Performance Report
+            {node.isBottleneck ? (
+              <span title="Bottlenecked" style={{ cursor: "help" }}>
+                ⚠️
+              </span>
+            ) : (
+              <span title="Running smoothly" style={{ cursor: "help" }}>
+                ✅
+              </span>
+            )}
+          </h4>
+
+          {node.type === ContainerType.Machine && (
+            <div style={{ marginBottom: "12px", fontSize: "13px" }}>
+              <div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
+                <span style={{ color: "#aaa" }}>Demanded:</span>
+                <strong style={{ color: node.isBottleneck ? "#d9614f" : "#fff" }}>
+                  {node.validationReport.demandedCrafts.toFixed(2)} crafts/s
+                </strong>
+              </div>
+              <div style={{ display: "flex", justifyContent: "space-between" }}>
+                <span style={{ color: "#aaa" }}>Max Capacity:</span>
+                <strong>{node.validationReport.maxCrafts.toFixed(2)} crafts/s</strong>
+              </div>
+
+              {/* Progress Bar */}
+              <div
+                style={{
+                  height: "6px",
+                  background: "#1a1a1a",
+                  borderRadius: "3px",
+                  marginTop: "8px",
+                  overflow: "hidden",
+                }}
+              >
+                <div
+                  style={{
+                    height: "100%",
+                    background: node.isBottleneck ? "#d9614f" : "#4CAF50",
+                    width: `${Math.min(100, (node.validationReport.demandedCrafts / Math.max(0.01, node.validationReport.maxCrafts)) * 100)}%`,
+                  }}
+                />
+              </div>
+            </div>
+          )}
+
+          {/* Inputs & Outputs */}
+          <div style={{ display: "flex", gap: "16px", fontSize: "12px" }}>
+            {Object.keys(node.validationReport.inputs).length > 0 && (
+              <div style={{ flex: 1 }}>
+                <span
+                  style={{
+                    color: "#aaa",
+                    borderBottom: "1px solid #4a4a4a",
+                    display: "block",
+                    paddingBottom: "2px",
+                    marginBottom: "4px",
+                  }}
+                >
+                  Pulling (Inputs)
+                </span>
+                {Object.entries(node.validationReport.inputs).map(([item, rate]) => (
+                  <div key={item} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
+                    <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
+                      <Icon iconName={`item/${item}.png`} size={14} /> {item}
+                    </div>
+                    <span>{Number(rate).toFixed(1)}/s</span>
+                  </div>
+                ))}
+              </div>
+            )}
+
+            {Object.keys(node.validationReport.outputs).length > 0 && (
+              <div style={{ flex: 1 }}>
+                <span
+                  style={{
+                    color: "#aaa",
+                    borderBottom: "1px solid #4a4a4a",
+                    display: "block",
+                    paddingBottom: "2px",
+                    marginBottom: "4px",
+                  }}
+                >
+                  Pushing (Outputs)
+                </span>
+                {Object.entries(node.validationReport.outputs).map(([item, rate]) => (
+                  <div key={item} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
+                    <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
+                      <Icon iconName={`item/${item}.png`} size={14} /> {item}
+                    </div>
+                    <span>{Number(rate).toFixed(1)}/s</span>
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 5 - 1
src/engine/Topology/TopologyBuilder.module.css

@@ -123,10 +123,14 @@
 /* ========================================== */
 
 .inspector {
-  flex-grow: 1;
+  width: 400px;
   background-color: #1a1a1a;
   overflow-y: auto;
   position: relative;
+  flex-shrink: 0;
+  border-left: 1px solid #3a3a3a;
+  background: #1a1a1a;
+  overflow-y: auto;
 }
 
 .emptyState {

+ 318 - 40
src/engine/Topology/TopologyBuilder.tsx

@@ -15,23 +15,22 @@ import {
 import "@xyflow/react/dist/style.css";
 
 import styles from "./TopologyBuilder.module.css";
-import type { TopologyEdge, TopologyNode } from "./model";
+import type { TopologyEdge, TopologyNode, ValidationReport } from "./model";
 import { ContainerType } from "../simulator";
 import NodeInspector from "./NodeInspector";
 import EdgeInspector from "./EdgeInspector";
 import TopologyNodeUI from "./TopologyNodeUI";
 import InserterEdge from "./InserterEdge";
 import { downloadJSON } from "../../utils";
+import { computeMachineStats } from "../stats";
 
 const nodeTypes = { containerNode: TopologyNodeUI };
 const edgeTypes = { inserterEdge: InserterEdge };
 
-// FIX: Satisfy React Flow's strict generic constraints
 type FlowNodeData = TopologyNode & Record<string, unknown>;
 type FlowEdgeData = TopologyEdge & Record<string, unknown> & { onEdgeSelect?: (id: string) => void };
 
 export default function TopologyBuilder() {
-  // FIX: Properly typed as Arrays
   const [nodes, setNodes] = useState<Node<FlowNodeData>[]>([]);
   const [edges, setEdges] = useState<Edge<FlowEdgeData>[]>([]);
 
@@ -39,13 +38,14 @@ export default function TopologyBuilder() {
   const [selectionType, setSelectionType] = useState<"node" | "edge" | null>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
-  // Reusable callback for Edge clicks (passed into Edge payload)
   const handleEdgeSelect = useCallback((id: string) => {
     setSelectedId(id);
     setSelectionType("edge");
+
+    setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
+    setNodes((nds) => nds.map((n) => ({ ...n, selected: false })));
   }, []);
 
-  // --- React Flow Interaction Handlers ---
   const onNodesChange = useCallback(
     (changes: NodeChange<Node<FlowNodeData>>[]) =>
       setNodes((nds) => applyNodeChanges(changes, nds) as Node<FlowNodeData>[]),
@@ -61,24 +61,47 @@ export default function TopologyBuilder() {
   const onConnect = useCallback(
     (connection: Connection) => {
       const id = `edge_${crypto.randomUUID().slice(0, 8)}`;
+
+      // Auto-detect filter from the target handle ID!
+      // Example: "in-iron-plate" -> extracts "iron-plate"
+      let autoFilters: string[] = [];
+      let autoItemId = "";
+
+      if (
+        connection.targetHandle &&
+        connection.targetHandle.startsWith("in-") &&
+        connection.targetHandle !== "in-any"
+      ) {
+        const extractedItem = connection.targetHandle.replace("in-", "");
+        autoFilters = [extractedItem];
+        autoItemId = extractedItem; // Set both just in case other parts of your app still rely on itemId
+      }
+
+      // Save to Domain Model
       const newDomainEdge: TopologyEdge = {
         id,
         sourceId: connection.source,
         destinationId: connection.target,
-        itemId: "",
+        sourceHandle: connection.sourceHandle,
+        targetHandle: connection.targetHandle,
+        itemId: autoItemId,
+        filters: autoFilters, // <-- Automatically applied!
         stackSize: 16,
       };
 
+      // Save to React Flow
       const newFlowEdge: Edge<FlowEdgeData> = {
         id,
         source: connection.source,
         target: connection.target,
+        sourceHandle: connection.sourceHandle,
+        targetHandle: connection.targetHandle,
         type: "inserterEdge",
         data: { ...newDomainEdge, onEdgeSelect: handleEdgeSelect },
         animated: true,
       };
 
-      setEdges((eds) => connectEdges(newFlowEdge, eds));
+      setEdges((eds) => connectEdges(newFlowEdge, eds) as Edge<FlowEdgeData>[]);
       setSelectedId(id);
       setSelectionType("edge");
     },
@@ -86,29 +109,32 @@ export default function TopologyBuilder() {
   );
 
   const addNode = (type: ContainerType) => {
+    const shortHash = crypto.randomUUID().slice(0, 4).toUpperCase();
     const id = `node_${crypto.randomUUID().slice(0, 8)}`;
-    const domainNode: TopologyNode = {
-      id,
-      name: `New ${type}`,
-      type,
-      ...(type === ContainerType.Machine
-        ? {
-            machineConfig: {
-              setup: { machine: null as any, machineModules: [], beacons: [], machineQualityLevel: 0 },
-              recipe: null,
-              multiplier: 1,
-            },
-          }
-        : {}),
-    };
+    let defaultName = `Node ${shortHash}`;
+    if (type === ContainerType.Machine) defaultName = `Assembler ${shortHash}`;
+    if (type === ContainerType.Chest) defaultName = `Chest ${shortHash}`;
+    if (type === ContainerType.Belt) defaultName = `Belt ${shortHash}`;
 
     const flowNode: Node<FlowNodeData> = {
       id,
       type: "containerNode",
       position: { x: Math.random() * 200 + 100, y: Math.random() * 200 + 100 },
-      data: domainNode,
+      data: {
+        id,
+        name: defaultName,
+        type,
+        ...(type === ContainerType.Machine
+          ? {
+              machineConfig: {
+                setup: { machine: null as any, machineModules: [], beacons: [], machineQualityLevel: 0 },
+                recipe: null,
+                multiplier: 1,
+              },
+            }
+          : {}),
+      },
     };
-
     setNodes((nds) => [...nds, flowNode]);
     setSelectedId(id);
     setSelectionType("node");
@@ -119,7 +145,7 @@ export default function TopologyBuilder() {
   }, [nodes]);
 
   const updateDomainNode = (updated: TopologyNode) => {
-    setNodes((nds) => nds.map((n) => (n.id === updated.id ? { ...n, data: updated } : n)));
+    setNodes((nds) => nds.map((n) => (n.id === updated.id ? { ...n, data: { ...n.data, ...updated } } : n)));
   };
 
   const updateDomainEdge = (updated: TopologyEdge) => {
@@ -130,8 +156,9 @@ export default function TopologyBuilder() {
               ...e,
               source: updated.sourceId,
               target: updated.destinationId,
-              // Ensure we preserve the callback when replacing the data object!
-              data: { ...updated, onEdgeSelect: handleEdgeSelect },
+              sourceHandle: updated.sourceHandle,
+              targetHandle: updated.targetHandle,
+              data: { ...e.data, ...updated, onEdgeSelect: handleEdgeSelect },
             }
           : e,
       ),
@@ -139,6 +166,7 @@ export default function TopologyBuilder() {
   };
 
   const handleExport = () => downloadJSON({ nodes, edges }, `topology-${Date.now()}.json`);
+
   const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
     const file = event.target.files?.[0];
     if (!file) return;
@@ -146,7 +174,6 @@ export default function TopologyBuilder() {
     reader.onload = (e) => {
       try {
         const payload = JSON.parse(e.target?.result as string);
-        // Re-inject the edge selection callback which is lost during JSON stringification
         const restoredEdges = (payload.edges || []).map((edge: any) => ({
           ...edge,
           data: { ...edge.data, onEdgeSelect: handleEdgeSelect },
@@ -160,19 +187,270 @@ export default function TopologyBuilder() {
     reader.readAsText(file);
     if (fileInputRef.current) fileInputRef.current.value = "";
   };
+  const onNodesDelete = useCallback(
+    (deleted: Node<FlowNodeData>[]) => {
+      if (deleted.some((n) => n.id === selectedId)) {
+        setSelectedId(null);
+        setSelectionType(null);
+      }
+    },
+    [selectedId],
+  );
+
+  const onEdgesDelete = useCallback(
+    (deleted: Edge<FlowEdgeData>[]) => {
+      if (deleted.some((e) => e.id === selectedId)) {
+        setSelectedId(null);
+        setSelectionType(null);
+      }
+    },
+    [selectedId],
+  );
+  const handleValidateTopology = () => {
+    const outEdges: Record<string, typeof edges> = {};
+    const inEdges: Record<string, typeof edges> = {};
+    nodes.forEach((n) => {
+      outEdges[n.id] = [];
+      inEdges[n.id] = [];
+    });
+    edges.forEach((e) => {
+      outEdges[e.source]?.push(e);
+      inEdges[e.target]?.push(e);
+    });
+
+    const machineStats: Record<string, { maxCraftsPerSec: number; prodMultiplier: number; recipe: any }> = {};
+
+    nodes.forEach((n) => {
+      if (
+        n.data.type === ContainerType.Machine &&
+        n.data.machineConfig?.recipe &&
+        n.data.machineConfig?.setup?.machine
+      ) {
+        const timings = computeMachineStats(n.data.machineConfig.setup, n.data.machineConfig.recipe);
+        console.log(n.data.machineConfig.recipe.name, timings.craftsPerSecond, timings.actualCraftingSpeed);
+        machineStats[n.id] = {
+          maxCraftsPerSec: timings.craftsPerSecond,
+          // `productivityBonus` is e.g. 0.4. Multiplier becomes 1.4
+          prodMultiplier: 1 + timings.productivityBonus,
+          recipe: n.data.machineConfig.recipe,
+        };
+      }
+    });
+
+    const edgeFlows: Record<string, number> = {};
+    const edgeLimits: Record<string, number> = {};
+    const nodeRequiredInputs: Record<string, Record<string, number>> = {};
+    const nodeBottlenecks: Record<string, boolean> = {};
+    const nodeReports: Record<string, ValidationReport> = {};
+
+    nodes.forEach((n) => {
+      nodeRequiredInputs[n.id] = {};
+      nodeBottlenecks[n.id] = false;
+      nodeReports[n.id] = { demandedCrafts: 0, maxCrafts: 0, inputs: {}, outputs: {} }; // <-- ADD THIS
+    });
+
+    const queue = nodes.filter((n) => outEdges[n.id].length === 0 || n.data.role === "void").map((n) => n.id);
+    const inQueue = new Set(queue);
+    const visitCount: Record<string, number> = {};
+
+    while (queue.length > 0) {
+      const nodeId = queue.shift()!;
+      inQueue.delete(nodeId);
+
+      if ((visitCount[nodeId] || 0) > nodes.length + 2) continue;
+      visitCount[nodeId] = (visitCount[nodeId] || 0) + 1;
+
+      const node = nodes.find((n) => n.id === nodeId)!;
+      const isEndNode = outEdges[nodeId].length === 0 || node.data.role === "void";
 
+      // Calculate Cumulated Downstream Demand
+      const requiredOutputs: Record<string, number> = {};
+      outEdges[nodeId].forEach((e) => {
+        const flow = edgeFlows[e.id] || 0;
+        let item = (e.data?.computedItem as string) || e.data?.filters?.[0] || e.data?.itemId;
+        if (!item && e.targetHandle?.startsWith("in-") && e.targetHandle !== "in-any")
+          item = e.targetHandle.replace("in-", "");
+
+        if (item && flow > 0) {
+          requiredOutputs[item] = (requiredOutputs[item] || 0) + flow;
+        }
+      });
+
+      if (node.data.type === ContainerType.Machine && machineStats[nodeId]) {
+        const stats = machineStats[nodeId];
+        let demandedCrafts = 0;
+
+        // PATHFINDER: Does this machine eventually dump into a Sink without hitting another machine?
+        const goesToSink = (() => {
+          if (isEndNode) return true;
+          const stack = outEdges[nodeId].map((e) => e.target);
+          const visited = new Set<string>();
+          while (stack.length > 0) {
+            const curr = stack.pop()!;
+            if (visited.has(curr)) continue;
+            visited.add(curr);
+            const n = nodes.find((nx) => nx.id === curr);
+            if (!n) continue;
+            if (outEdges[curr].length === 0 || n.data.role === "void") return true;
+            if (n.data.type === ContainerType.Machine) continue; // Blocked by downstream machine demand
+            outEdges[curr].forEach((e) => stack.push(e.target));
+          }
+          return false;
+        })();
+
+        if (goesToSink) {
+          // If it dumps into a chest, it wants to run at 100% capacity!
+          demandedCrafts = stats.maxCraftsPerSec;
+        } else {
+          Object.keys(requiredOutputs).forEach((item) => {
+            const res = stats.recipe.results?.find((r: any) => r.name === item);
+            const yieldPerCraft = (res?.amount || res?.amount_min || 1) * stats.prodMultiplier;
+            const craftsForThis = requiredOutputs[item] / yieldPerCraft;
+            if (craftsForThis > demandedCrafts) demandedCrafts = craftsForThis;
+          });
+        }
+
+        nodeBottlenecks[nodeId] = demandedCrafts > stats.maxCraftsPerSec;
+        const actualCraftsToDraw = Math.min(demandedCrafts, stats.maxCraftsPerSec);
+
+        // Demand ingredients upstream
+        stats.recipe.ingredients?.forEach((ing: any) => {
+          nodeRequiredInputs[nodeId][ing.name] = actualCraftsToDraw * ing.amount;
+        });
+        const producedOutputs: Record<string, number> = {};
+        stats.recipe.results?.forEach((res: any) => {
+          const yieldPerCraft = (res.amount || res.amount_min || 1) * stats.prodMultiplier;
+          producedOutputs[res.name] = actualCraftsToDraw * yieldPerCraft;
+        });
+        nodeReports[nodeId] = {
+          demandedCrafts: demandedCrafts,
+          maxCrafts: stats.maxCraftsPerSec,
+          inputs: { ...nodeRequiredInputs[nodeId] },
+          outputs: producedOutputs,
+        };
+        outEdges[nodeId].forEach((e) => {
+          let item = e.data?.computedItem || e.data?.filters?.[0] || e.data?.itemId;
+          if (!item) item = stats.recipe.results?.[0]?.name;
+
+          if (item) {
+            const res = stats.recipe.results?.find((r: any) => r.name === item);
+            const yieldPerCraft = (res?.amount || res?.amount_min || 1) * stats.prodMultiplier;
+
+            const outEdgesForItem = outEdges[nodeId].filter((oe) => {
+              let oeItem = oe.data?.computedItem || oe.data?.filters?.[0] || oe.data?.itemId;
+              if (!oeItem) oeItem = stats.recipe.results?.[0]?.name;
+              return oeItem === item;
+            });
+
+            const flowPerEdge = (actualCraftsToDraw * yieldPerCraft) / (outEdgesForItem.length || 1);
+            edgeFlows[e.id] = flowPerEdge;
+            e.data.computedItem = item;
+          }
+        });
+      } else {
+        // Belts/Chests pass demand straight through
+        nodeRequiredInputs[nodeId] = { ...requiredOutputs };
+      }
+
+      // Propagate Required Inputs Upstream (Set edge flows for incoming edges)
+      const inGroups: Record<string, typeof edges> = {};
+      inEdges[nodeId].forEach((e) => {
+        let item = e.data?.filters?.[0] || e.data?.itemId;
+        if (!item && e.targetHandle?.startsWith("in-") && e.targetHandle !== "in-any")
+          item = e.targetHandle.replace("in-", "");
+
+        if (!item && nodes.find((n) => n.id === e.source)?.data.type === ContainerType.Machine) {
+          const srcStats = machineStats[e.source];
+          if (srcStats) item = srcStats.recipe.results?.[0]?.name;
+        }
+
+        if (item) {
+          if (!inGroups[item]) inGroups[item] = [];
+          inGroups[item].push(e);
+          e.data.computedItem = item;
+        }
+      });
+
+      Object.keys(inGroups).forEach((item) => {
+        const edgesForItem = inGroups[item];
+        const totalReq = nodeRequiredInputs[nodeId][item] || 0;
+        const flowPerEdge = totalReq / edgesForItem.length;
+
+        edgesForItem.forEach((e) => {
+          // Only set the flow if it wasn't already forcefully set by a Machine pushing into it
+          if (edgeFlows[e.id] === undefined) {
+            edgeFlows[e.id] = flowPerEdge;
+          }
+
+          const srcNode = nodes.find((n) => n.id === e.source)!.data;
+          const tgtNode = node.data;
+          const isSrcInv = srcNode.type === ContainerType.Chest || srcNode.type === ContainerType.Machine;
+          const isTgtInv = tgtNode.type === ContainerType.Chest || tgtNode.type === ContainerType.Machine;
+
+          let limit = 80;
+          if (isSrcInv && isTgtInv) limit = 120;
+          else if (isSrcInv && tgtNode.type === ContainerType.Belt) limit = 80;
+          else if (srcNode.type === ContainerType.Belt && isTgtInv) limit = 80;
+
+          edgeLimits[e.id] = limit;
+
+          if (!inQueue.has(e.source)) {
+            queue.push(e.source);
+            inQueue.add(e.source);
+          }
+        });
+      });
+    }
+
+    setNodes((nds) =>
+      nds.map((n) => ({
+        ...n,
+        data: { ...n.data, isBottleneck: nodeBottlenecks[n.id] || false, validationReport: nodeReports[n.id] },
+      })),
+    );
+
+    setEdges((eds) =>
+      eds.map((e) => {
+        const flow = edgeFlows[e.id] || 0;
+        const max = edgeLimits[e.id] || 80;
+        return {
+          ...e,
+          data: {
+            ...e.data,
+            computedThroughput: Number(flow.toFixed(2)),
+            maxThroughput: max,
+            isBottleneck: flow > max,
+          },
+        };
+      }),
+    );
+  };
   return (
     <div className={styles.container} style={{ display: "flex", height: "100vh", width: "100%" }}>
       <input type="file" accept=".json" ref={fileInputRef} style={{ display: "none" }} onChange={handleImport} />
 
-      {/* LEFT PANE: React Flow Canvas */}
-      <div style={{ flexGrow: 1, position: "relative" }}>
+      {/* LEFT PANE: React Flow Canvas (Flex Grow ensures it takes most of the screen) */}
+      <div style={{ flexGrow: 1, minWidth: 0, position: "relative" }}>
         <div style={{ position: "absolute", top: 16, left: 16, zIndex: 10, display: "flex", gap: "8px" }}>
-          <button onClick={() => addNode(ContainerType.Machine)}>+ Machine</button>
-          <button onClick={() => addNode(ContainerType.Chest)}>+ Chest</button>
-          <button onClick={() => addNode(ContainerType.Belt)}>+ Belt</button>
-          <button onClick={() => fileInputRef.current?.click()}>📂 Import</button>
-          <button onClick={handleExport}>💾 Export</button>
+          <button className="button small" onClick={() => addNode(ContainerType.Machine)}>
+            + Machine
+          </button>
+          <button className="button small" onClick={() => addNode(ContainerType.Chest)}>
+            + Chest
+          </button>
+          <button className="button small" onClick={() => addNode(ContainerType.Belt)}>
+            + Belt
+          </button>
+          <div style={{ width: "1px", background: "#4a4a4a", margin: "0 4px" }} /> {/* Divider */}
+          <button className="button small" onClick={handleValidateTopology}>
+            Check topology
+          </button>
+          <button className="button small" onClick={() => fileInputRef.current?.click()}>
+            📂 Import
+          </button>
+          <button className="button small" onClick={handleExport}>
+            💾 Export
+          </button>
         </div>
 
         <ReactFlow
@@ -180,6 +458,8 @@ export default function TopologyBuilder() {
           edges={edges}
           onNodesChange={onNodesChange}
           onEdgesChange={onEdgesChange}
+          onNodesDelete={onNodesDelete}
+          onEdgesDelete={onEdgesDelete}
           onConnect={onConnect}
           nodeTypes={nodeTypes}
           edgeTypes={edgeTypes}
@@ -187,24 +467,22 @@ export default function TopologyBuilder() {
             setSelectedId(node.id);
             setSelectionType("node");
           }}
-          // Standard SVG line clicks:
           onEdgeClick={(_, edge) => handleEdgeSelect(edge.id)}
           onPaneClick={() => {
             setSelectedId(null);
             setSelectionType(null);
           }}
+          deleteKeyCode={["Backspace", "Delete"]}
           fitView
+          theme="dark"
         >
           <Background color="#3a3a3a" gap={20} />
           <Controls />
         </ReactFlow>
       </div>
 
-      {/* RIGHT PANE: Inspectors */}
-      <div
-        className={styles.inspector}
-        style={{ width: 400, minWidth: 400, borderLeft: "1px solid #3a3a3a", background: "#1a1a1a" }}
-      >
+      {/* RIGHT PANE: Inspectors (Fixed Width) */}
+      <div className={styles.inspector}>
         {selectionType === "node" && selectedId && domainNodesMap[selectedId] && (
           <NodeInspector node={domainNodesMap[selectedId]} onChange={updateDomainNode} />
         )}

+ 156 - 16
src/engine/Topology/TopologyNodeUI.tsx

@@ -3,34 +3,174 @@ import { ContainerType } from "../simulator";
 import type { TopologyNode } from "./model";
 import Icon from "../../assets/icon";
 
-export default function TopologyNodeUI({ data, selected }: { data: TopologyNode; selected: boolean }) {
-  // Determine icon based on node type
-  let iconName = "item/wooden-chest.png";
-  if (data.type === ContainerType.Belt) iconName = "item/transport-belt.png";
-  if (data.type === ContainerType.Machine && data.machineConfig?.setup.machine?.icon) {
-    iconName = data.machineConfig.setup.machine.icon;
+type FlowNodeData = TopologyNode & Record<string, unknown>;
+
+export default function TopologyNodeUI({ data, selected }: { data: FlowNodeData; selected: boolean }) {
+  // End-game defaults
+  let iconName = "item/steel-chest.png";
+  if (data.type === ContainerType.Belt) iconName = "item/turbo-transport-belt.png";
+  if (data.type === ContainerType.Machine) {
+    iconName = data.machineConfig?.setup.machine?.icon || "item/assembling-machine-1.png";
   }
 
+  // Source/Sink logic (Computed state if passed down, otherwise visual fallbacks)
+  const isVoid = data.role === "void";
+  const isSource = data.role === "source";
+  const isBottleneck = data.isBottleneck;
+  const providedItemIcon = data.providedItemId ? `item/${data.providedItemId}.png` : null;
+  const recipeIcon = data.machineConfig?.recipe?.icon;
+
+  // Machine Input Handles (Any + Specific Ingredients)
+  const ingredients = data.machineConfig?.recipe?.ingredients || [];
+  const handleCount = data.type === ContainerType.Machine ? 1 + ingredients.length : 1;
+
+  const getHandleTop = (index: number, total: number) => `${((index + 1) / (total + 1)) * 100}%`;
+
   return (
     <div
       style={{
-        background: "#242324",
-        border: `2px solid ${selected ? "#e39827" : "#3a3a3a"}`,
-        padding: "10px",
+        background: isVoid ? "#2a1a1a" : isSource ? "#1a2a1a" : "#242324",
+        border: `2px solid ${selected ? "#e39827" : isBottleneck ? "#ff3333" : isVoid ? "#d9614f" : isSource ? "#4CAF50" : "#3a3a3a"}`,
+        padding: "12px 10px",
         borderRadius: "8px",
-        minWidth: "120px",
+        minWidth: "130px",
         textAlign: "center",
         color: "#e3e3e3",
+        boxShadow: isBottleneck
+          ? "0 0 15px rgba(255, 51, 51, 0.5)"
+          : selected
+            ? "0 0 10px rgba(227, 152, 39, 0.2)"
+            : "none",
+        transition: "all 0.2s ease-in-out",
       }}
     >
-      {/* Input Handle (Left) */}
-      <Handle type="target" position={Position.Left} style={{ width: 12, height: 12, background: "#4CAF50" }} />
+      {/* INPUT HANDLES */}
+      {data.type === ContainerType.Machine ? (
+        <>
+          {/* Generic "Any" Input */}
+          <Handle
+            type="target"
+            position={Position.Left}
+            id="in-any"
+            style={{
+              top: getHandleTop(0, handleCount),
+              background: "#999",
+              border: "2px solid #242324",
+              width: 12,
+              height: 12,
+            }}
+            title="Any Input"
+          />
+
+          {/* Specific Ingredient Inputs */}
+          {ingredients.map((ing: any, i: number) => (
+            <Handle
+              key={ing.name}
+              type="target"
+              position={Position.Left}
+              id={`in-${ing.name}`}
+              style={{
+                top: getHandleTop(i + 1, handleCount),
+                background: "transparent",
+                border: "none",
+                left: -0, // Push the icon slightly outside the box
+                width: 24,
+                height: 24,
+                display: "flex",
+                alignItems: "center",
+                justifyContent: "center",
+              }}
+              title={ing.name}
+            >
+              {/* pointerEvents: 'none' ensures the Handle itself catches the drag event, not the image */}
+              <div
+                style={{
+                  pointerEvents: "none",
+                  background: "#1a1a1a",
+                  borderRadius: "50%",
+                  padding: "2px",
+                  display: "flex",
+                }}
+              >
+                <Icon iconName={`item/${ing.name}.png`} size={16} />
+              </div>
+            </Handle>
+          ))}
+        </>
+      ) : (
+        // Standard single input for chests/belts
+        <Handle
+          type="target"
+          position={Position.Left}
+          id="in-any"
+          style={{ width: 12, height: 12, background: "#4CAF50", border: "2px solid #242324" }}
+        />
+      )}
+
+      {/* NODE CONTENT */}
+      <div style={{ position: "relative", display: "inline-block" }}>
+        <Icon iconName={iconName} size={48} qualityLevel={data.machineConfig?.setup.machineQualityLevel || 0} />
+
+        {isSource && providedItemIcon && (
+          <div
+            style={{
+              position: "absolute",
+              top: -10,
+              right: -10,
+              background: "#1a1a1a",
+              borderRadius: "50%",
+              border: "2px solid #4CAF50",
+            }}
+          >
+            <Icon iconName={providedItemIcon} size={24} />
+          </div>
+        )}
+
+        {isVoid && (
+          <div
+            style={{
+              position: "absolute",
+              top: -10,
+              right: -10,
+              background: "#d9614f",
+              color: "#fff",
+              borderRadius: "50%",
+              width: 24,
+              height: 24,
+              fontSize: 16,
+              display: "flex",
+              alignItems: "center",
+              justifyContent: "center",
+            }}
+            title="Void (Destroys Items)"
+          >
+            🗑️
+          </div>
+        )}
+
+        {/* Recipe Badge */}
+        {recipeIcon && (
+          <div
+            style={{
+              position: "absolute",
+              bottom: -10,
+              right: -10,
+            }}
+          >
+            <Icon iconName={recipeIcon} size={24} />
+          </div>
+        )}
+      </div>
 
-      <Icon iconName={iconName} size={48} qualityLevel={data.machineConfig?.setup.machineQualityLevel || 0} />
-      <div style={{ fontSize: "12px", marginTop: "8px" }}>{data.name}</div>
+      <div style={{ fontSize: "12px", marginTop: "14px", fontWeight: 500 }}>{data.name}</div>
 
-      {/* Output Handle (Right) */}
-      <Handle type="source" position={Position.Right} style={{ width: 12, height: 12, background: "#F44336" }} />
+      {/* OUTPUT HANDLE */}
+      <Handle
+        type="source"
+        position={Position.Right}
+        id="out-any"
+        style={{ width: 12, height: 12, background: "#F44336", border: "2px solid #242324" }}
+      />
     </div>
   );
 }

+ 18 - 4
src/engine/Topology/model.ts

@@ -1,25 +1,39 @@
 import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
-import type { MachineSetup, CalculatedTimings, BatchPlan } from "../model";
+import type { MachineSetup, CalculatedTimings, BatchPlan, BeaconGroup } from "../model";
 import type { ContainerType } from "../simulator";
 
+export interface ValidationReport {
+  demandedCrafts: number;
+  maxCrafts: number;
+  inputs: Record<string, number>;
+  outputs: Record<string, number>;
+}
 export interface TopologyNode {
   id: string;
   name: string; // User-friendly name (e.g., "Copper Smelter A")
   type: ContainerType;
   machineConfig?: {
-    setup: MachineSetup;
+    setup: Omit<MachineSetup, "beacons"> & { beacons: Array<BeaconGroup & { id: string }> };
     recipe: Recipe | null;
     multiplier: number;
-    // Timings and Batch will be computed dynamically when the setup/recipe changes
     timings?: CalculatedTimings;
     batch?: BatchPlan;
   };
+  isBottleneck?: boolean;
+  validationReport?: ValidationReport;
 }
 
 export interface TopologyEdge {
   id: string;
   sourceId: string;
   destinationId: string;
-  itemId: string;
+  sourceHandle?: string | null;
+  targetHandle?: string | null;
+  itemId?: string;
+  filters?: string[];
   stackSize: number;
+  inserterName?: string;
+  computedThroughput?: number; // What the machine actually demands/produces
+  maxThroughput?: number; // The physical limit (120 or 80)
+  isBottleneck?: boolean;
 }

+ 14 - 13
src/engine/model.ts

@@ -2,10 +2,10 @@ import type { Beacon, Machine, Module } from "../../scripts/factorio-dump/proces
 
 export interface InserterConfig {
   /** Used to group items onto mixed belts (e.g., assigning "in-1" to both Iron and Copper) */
-  inserterId?: string; 
-  presetId: string; 
-  swingTicks: number; 
-  stackSize: number; 
+  inserterId?: string;
+  presetId: string;
+  swingTicks: number;
+  stackSize: number;
 }
 
 export interface ClockConfig {
@@ -16,17 +16,18 @@ export interface ClockConfig {
   /** Target number of machines to scale the final inserter blueprint */
   machineCount?: number;
 }
-
+export interface BeaconGroup {
+  id?: string;
+  beacon: Beacon;
+  beaconQualityLevel?: number;
+  count: number;
+  modules: Array<{ module: Module; qualityLevel: number }>;
+}
 export interface MachineSetup {
   machine: Machine;
-  machineQualityLevel?: number; // 0 = Normal, 1 = Uncommon, 2 = Rare, 3 = Epic, 4 = Legendary
+  machineQualityLevel?: number; // 0 = Normal, 1 = Uncommon, 2 = Rare, 3 = Epic, 5 = Legendary
   machineModules: Array<{ module: Module; qualityLevel: number }>;
-  beacons: Array<{
-    beacon: Beacon;
-    beaconQualityLevel?: number;
-    count: number;
-    modules: Array<{ module: Module; qualityLevel: number }>;
-  }>;
+  beacons: Array<BeaconGroup>;
 }
 
 export interface CalculatedTimings {
@@ -44,4 +45,4 @@ export interface BatchPlan {
   timings: CalculatedTimings;
   inputs: Record<string, { totalAmount: number; baseAmount: number }>;
   outputs: Record<string, { totalAmount: number; baseAmount: number; yieldPerCraft: number; outputBlockLimit: number }>;
-}
+}

+ 1 - 1
src/engine/stats.ts

@@ -46,7 +46,7 @@ export function computeMachineStats(setup: MachineSetup, recipe: Recipe): Calcul
   // Machine Internal Modules
   setup.machineModules.forEach(({ module, qualityLevel }) => {
     const modQualityMultiplier = getQualityMultiplier(qualityLevel);
-    if (module.effect?.speed) speedBonus += module.effect.speed * modQualityMultiplier;
+    if (module.effect?.speed) speedBonus += module.effect.speed * (module.effect.speed > 0 ? modQualityMultiplier : 1);
     if (module.effect?.productivity) productivityBonus += module.effect.productivity * modQualityMultiplier;
   });
 

+ 6 - 1
src/index.css

@@ -73,7 +73,7 @@ input:focus {
     inset 0px -9px 2px -8px #000,
     0px 0px 4px 0px #000;
   position: relative;
-  margin-right: 14px;
+  margin-right: 8px;
   cursor: pointer;
   -webkit-user-select: none;
   -moz-user-select: none;
@@ -149,6 +149,11 @@ input:focus {
     0px 0px 4px 0px #000;
   filter: none;
 }
+.button.small {
+  padding: 3px 8px;
+  min-width: 80px;
+  height: 30px;
+}
 .button-green {
   background-color: #5eb663;
   padding: 10px 12px 10px 12px;

+ 1 - 1
src/main.tsx

@@ -8,7 +8,7 @@ import Layout from "./Layout.tsx";
 import ClockBuilder from "./ClockBuilder.tsx";
 import ClockWizard from "./assets/components/ClockWizard.tsx";
 import { Simulator } from "./assets/Simulator.tsx";
-import TopologyBuilder from "./engine/Topology/TopologyBuilderV0.tsx";
+import TopologyBuilder from "./engine/Topology/TopologyBuilder.tsx";
 
 const root = document.getElementById("root");