فهرست منبع

implement simulator

JAQUIN_C 1 ماه پیش
والد
کامیت
2b0e30d1f5

+ 4 - 1
package.json

@@ -10,7 +10,9 @@
     "preview": "vite preview",
     "factorio-api": "ts-node -r tsconfig-paths/register scripts/factorio-dump/lua-api/index.ts",
     "postfactorio-api": "prettier --write \"scripts/factorio-dump/lua-api/models.ts\"",
-    "factorio-process": "ts-node -r tsconfig-paths/register scripts/factorio-dump/index.ts"
+    "factorio-process": "ts-node -r tsconfig-paths/register scripts/factorio-dump/index.ts",
+    "test": "vitest",
+    "test:run": "vitest run"
   },
   "dependencies": {
     "@emotion/react": "^11.14.0",
@@ -45,6 +47,7 @@
     "tsconfig-paths": "^4.2.0",
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.3",
+    "vitest": "^4.1.10",
     "vite": "^8.2.0"
   }
 }

+ 2 - 2
scripts/factorio-dump/helpers/recipes.helper.ts

@@ -3,7 +3,7 @@ import type { Item } from "../process-data.models.ts";
 
 export type Ingredient = {
   type: "fluid" | "item";
-  itemId: string;
+  name: string;
   amount: number;
 };
 
@@ -62,7 +62,7 @@ export function getMainProductName(recipe: RecipePrototype): string {
 
 export function parseRecipe(recipe: RecipePrototype, itemsMap: Record<string, Item>): Recipe {
   const rawIngredient = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
-  const ingredients = rawIngredient.map((i) => ({ itemId: i.name, amount: i.amount, type: i.type }));
+  const ingredients = rawIngredient.map((i) => ({ name: i.name, amount: i.amount, type: i.type }));
   const mainProduct = itemsMap[getMainProductName(recipe)];
   const r: Recipe = {
     name: recipe.name,

+ 5 - 9
src/assets/components/ClockWizard.tsx

@@ -4,16 +4,12 @@ import styles from "./ClockWizard.module.css";
 import { useClockStore } from "../../store/useClockStore";
 import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Machine, Module } from "../../../scripts/factorio-dump/process-data.models";
-import {
-  calculateOptimalBatch,
-  computeMachineStats,
-  generateAdvancedClock,
-  type ClockConfig,
-} from "../../engine/factorioEngine";
+
 import MachineSelector from "../MachineSelector";
 import BeaconConfigurator, { type BeaconGroup } from "./BeaconConfigurator";
 import ExpressionInput from "./ExpressionInpux";
 import InputConfigurator from "./InputConfigurator";
+import { calculateOptimalBatch, computeMachineStats, generateAdvancedClock, type ClockConfig } from "../../engine";
 
 export default function ClockWizard() {
   const loadState = useClockStore((s) => s.loadState);
@@ -77,7 +73,7 @@ export default function ClockWizard() {
     if (!recipe || !machine || !stats || !throughputData) return;
 
     // Math
-    const batch = calculateOptimalBatch(recipe, stats, 16);
+    const batch = calculateOptimalBatch(recipe, stats, {});
 
     // Build the strict Config Interface
     const clockConfig: ClockConfig = {
@@ -184,7 +180,7 @@ export default function ClockWizard() {
               <div style={{ fontSize: "18px", color: "#7fc98a", fontWeight: "bold" }}>
                 {Math.round(throughputData.baseItemsPerMin)}{" "}
                 <span style={{ fontSize: "12px", color: "#999", fontWeight: "normal" }}>
-                  / min ({Math.round(throughputData.baseItemsPerMin / 6) / 10} / sec)
+                  / min ({Math.round(throughputData.baseItemsPerMin / 6) / 10} / sec) 
                 </span>
               </div>
             </div>
@@ -205,7 +201,7 @@ export default function ClockWizard() {
             </div>
             <div>
               <div style={{ fontSize: "11px", color: "#999" }}>Overload Limit</div>
-              <div style={{ fontSize: "14px", color: "#ffe6c0" }}>{stats.overloadMultiplier}x</div>
+              <div style={{ fontSize: "14px", color: "#ffe6c0" }}>{stats.overloadMultiplier}x</div>{stats.singleCraftTicks}
             </div>
           </div>
         )}

+ 3 - 3
src/assets/components/InputConfigurator.tsx

@@ -26,10 +26,10 @@ export default function InputConfigurator({ recipe, configs, onChange }: InputCo
     const newConfigs = { ...configs };
 
     solidIngredients.forEach((ing, i) => {
-      if (!newConfigs[ing.itemId]) {
+      if (!newConfigs[ing.name]) {
         // By default, every ingredient gets its own dedicated inserter and chest
-        newConfigs[ing.itemId] = {
-          itemId: ing.itemId,
+        newConfigs[ing.name] = {
+          itemId: ing.name,
           inserterId: `in-${i + 1}`,
           source: "chest",
         };

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 143 - 143
src/assets/data/2.0/data.json


+ 303 - 0
src/engine/batch.test.ts

@@ -0,0 +1,303 @@
+import { describe, it, expect } from 'vitest';
+import { calculateOptimalBatch } from './batch';
+import type { Recipe } from '../../scripts/factorio-dump/helpers/recipes.helper';
+import type { CalculatedTimings } from './types';
+
+describe('Batch Calculator', () => {
+  it('calculates optimal batch for Copper Cable (1 plate -> 2 cables)', () => {
+    const mockRecipe: Partial<Recipe> = {
+      energy_required: 0.5,
+      ingredients: [{ type: 'item', name: 'copper-plate', amount: 1 }],
+      results: [{ type: 'item', name: 'copper-cable', amount: 2 }]
+    };
+    
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 1,
+      productivityBonus: 0,
+      singleCraftTicks: 30,
+      craftsPerSecond: 2,
+      overloadMultiplier: 8
+    };
+
+    // With a stack size of 16:
+    // Input takes 1 plate per craft. 16/gcd(1,16) = 16 crafts.
+    // Output yields 2 cables per craft. 16/gcd(2,16) = 8 crafts.
+    // LCM(16, 8) = 16 optimal crafts.
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings);
+
+    expect(batch.craftsPerCycle).toBe(16);
+    expect(batch.durationTicks).toBe(16 * 30); // 480 ticks
+    expect(batch.inputs['copper-plate'].totalAmount).toBe(16); // 1 full stack
+    expect(batch.outputs['copper-cable'].totalAmount).toBe(32); // 2 full stacks
+  });
+
+  it('factors in productivity yields correctly', () => {
+    const mockRecipe: Partial<Recipe> = {
+      ingredients: [{ type: 'item', name: 'iron-plate', amount: 2 }],
+      results: [{ type: 'item', name: 'iron-gear-wheel', amount: 1 }]
+    };
+    
+    // 50% productivity bonus means 1 craft yields 1.5 items
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 1,
+      productivityBonus: 0.5, 
+      singleCraftTicks: 30,
+      craftsPerSecond: 2,
+      overloadMultiplier: 8
+    };
+
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings);
+
+    expect(batch.craftsPerCycle).toBe(32);
+    expect(batch.inputs['iron-plate'].totalAmount).toBe(64); // 4 input stacks
+    expect(batch.outputs['iron-gear-wheel'].totalAmount).toBe(48); // 3 output stacks
+  });
+  it('ignores fluid ingredients and results when calculating LCM', () => {
+    // e.g. Battery recipe: 1 Iron Plate, 1 Copper Plate, 20 Sulfuric Acid (Fluid) -> 1 Battery
+    const mockRecipe: Partial<Recipe> = {
+      energy_required: 4,
+      ingredients: [
+        { type: 'item', name: 'iron-plate', amount: 1 },
+        { type: 'item', name: 'copper-plate', amount: 1 },
+        { type: 'fluid', name: 'sulfuric-acid', amount: 20 } // FLUID
+      ],
+      results: [
+        { type: 'item', name: 'battery', amount: 1 }
+      ]
+    };
+    
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 1, productivityBonus: 0, 
+      singleCraftTicks: 240, craftsPerSecond: 0.25, overloadMultiplier: 8
+    };
+
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings);
+
+    // If fluids were not ignored, GCD/LCM math would break trying to divide 20 fluid by 16 items.
+    // It should safely calculate LCM based ONLY on the item amounts (all are 1, so LCM = 16).
+    expect(batch.craftsPerCycle).toBe(16);
+    expect(batch.inputs['iron-plate']).toBeDefined();
+    expect(batch.inputs['sulfuric-acid']).toBeUndefined(); // Fluids should not be scheduled for inserters
+  });
+
+  it('calculates the output block limit correctly (bottlenecked by overload multiplier)', () => {
+    const mockRecipe: Partial<Recipe> = {
+      ingredients: [{ type: 'item', name: 'copper-plate', amount: 1 }],
+      results: [{ type: 'item', name: 'copper-cable', amount: 2 }]
+    };
+    
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 1, productivityBonus: 0, singleCraftTicks: 30, craftsPerSecond: 2,
+      overloadMultiplier: 4 // Very slow machine, low overload limit
+    };
+
+    // We pass a max stack size of 200 for the cable.
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'copper-cable': 200 });
+
+    // Output block limit is Math.min(maxStack, overloadMultiplier * resultAmount)
+    // Math.min(200, 4 * 2) = 8
+    expect(batch.outputs['copper-cable'].outputBlockLimit).toBe(8);
+  });
+
+  it('calculates the output block limit correctly (bottlenecked by max stack size)', () => {
+    const mockRecipe: Partial<Recipe> = {
+      ingredients: [{ type: 'item', name: 'iron-plate', amount: 1 }],
+      results: [{ type: 'item', name: 'iron-gear-wheel', amount: 1 }]
+    };
+    
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 10, productivityBonus: 0, singleCraftTicks: 3, craftsPerSecond: 20,
+      overloadMultiplier: 100 // Very fast machine, massive overload limit
+    };
+
+    // Iron gear wheels stack to 100 in Factorio
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'iron-gear-wheel': 100 });
+
+    // Output block limit is Math.min(maxStack, overloadMultiplier * resultAmount)
+    // Math.min(100, 100 * 1) = 100
+    expect(batch.outputs['iron-gear-wheel'].outputBlockLimit).toBe(100);
+    expect(batch.outputs['iron-gear-wheel'].totalAmount).toBe(96);
+  });
+  it('handles complex multi-input recipes (Advanced Circuit) with productivity', () => {
+    // Advanced Circuit: 2 Plastic, 4 Copper Cable, 2 Green Chips -> 1 Red Chip
+    const mockRecipe: Partial<Recipe> = {
+      ingredients: [
+        { type: 'item', name: 'plastic-bar', amount: 2 },
+        { type: 'item', name: 'copper-cable', amount: 4 },
+        { type: 'item', name: 'electronic-circuit', amount: 2 }
+      ],
+      results: [{ type: 'item', name: 'advanced-circuit', amount: 1 }]
+    };
+    
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 84,
+      productivityBonus: 1.75, 
+      singleCraftTicks: 4.2857142857142865, 
+      craftsPerSecond: 14,
+      overloadMultiplier: 17
+    };
+
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'advanced-circuit': 200 });
+
+    expect(batch.craftsPerCycle).toBe(64);
+    expect(batch.durationTicks).toBe(275);
+    // 64 crafts * 2 per craft = 128 (8 inserter swings of 16)
+    expect(batch.inputs['plastic-bar'].totalAmount).toBe(128); 
+    
+    // 64 crafts * 4 per craft = 256 (16 inserter swings of 16)
+    expect(batch.inputs['copper-cable'].totalAmount).toBe(256); 
+    
+    // 64 crafts * 2 per craft = 128 (8 inserter swings of 16)
+    expect(batch.inputs['electronic-circuit'].totalAmount).toBe(128); 
+    
+    // 64 crafts * 2.75 yield = 176 (11 inserter swings of 16)
+    expect(batch.outputs['advanced-circuit'].totalAmount).toBe(176); 
+    expect(batch.outputs['advanced-circuit'].outputBlockLimit).toBe(17);
+  });
+  it('handles prime-number fractions in extreme productivity scenarios (The Direct-Insertion Anchor)', () => {
+    // Productivity Module 1: 5 Advanced Circuit, 5 Electronic Circuit -> 1 Prod Module
+    const mockRecipe: Partial<Recipe> = {
+      ingredients: [
+        { type: 'item', name: 'advanced-circuit', amount: 5 },
+        { type: 'item', name: 'electronic-circuit', amount: 5 }
+      ],
+      results: [{ type: 'item', name: 'productivity-module', amount: 1 }]
+    };
+    
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 5,
+      productivityBonus: 1.15, // +115% yield (Yields 2.15 items per craft)
+      singleCraftTicks: 60, 
+      craftsPerSecond: 1,
+      overloadMultiplier: 4 
+    };
+
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'productivity-module': 50 });
+
+    // --- The Math Breakdown ---
+    // 1. Productivity Yield:
+    //    1.0 + 1.15 = 2.15.
+    //    Fraction: 2.15 = 215 / 100 = 43 / 20. (43 is prime!)
+    //
+    // 2. LCM for Inputs (Amount = 5, Base 16 Stack):
+    //    16 / gcd(5, 16) = 16. (Needs 16 crafts to consume a perfect 80 items)
+    //
+    // 3. LCM for Output (Amount = 1, Stack = 16):
+    //    Numerator = 1 * 43 = 43
+    //    Denominator = 16 * 20 = 320
+    //    Required Output Crafts = 320 / gcd(43, 320) = 320 crafts.
+    //
+    // 4. Final LCM:
+    //    LCM(16, 320) = 320 crafts per cycle.
+
+    expect(batch.craftsPerCycle).toBe(320);
+    
+    // Inputs: 320 crafts * 5 per craft = 1600.
+    // 1600 / 16 = Exactly 100 perfect inserter swings per cycle!
+    expect(batch.inputs['advanced-circuit'].totalAmount).toBe(1600); 
+    expect(batch.inputs['electronic-circuit'].totalAmount).toBe(1600); 
+    
+    // Outputs: 320 crafts * 2.15 yield = 688 items.
+    // 688 / 16 = Exactly 43 perfect inserter swings per cycle!
+    expect(batch.outputs['productivity-module'].totalAmount).toBe(688); 
+  });
+  it('safely caps batch scaling on high-volume output recipes to prevent buffer overflow', () => {
+    // Copper Cable: 1 Copper Plate -> 2 Copper Cable
+    const mockRecipe: Partial<Recipe> = {
+      ingredients: [{ type: 'item', name: 'copper-plate', amount: 1 }],
+      results: [{ type: 'item', name: 'copper-cable', amount: 2 }]
+    };
+    
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 10,
+      productivityBonus: 2.5, // +250% (Total yield = 2 * 3.5 = 7 cables per craft)
+      singleCraftTicks: 6, 
+      craftsPerSecond: 10,
+      overloadMultiplier: 50 // Machine can hold 50 * 1 plate = 50 plates buffer
+    };
+
+    // We restrict the cable output buffer to max stack (200)
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'copper-cable': 200 });
+
+    // --- The Math Breakdown ---
+    // Output Yield = 7 cables per craft.
+    // Base LCM: 
+    // Inputs (1): 16 / gcd(1, 16) = 16 crafts.
+    // Outputs (yield 7): Denominator (16) / gcd(7, 16) = 16 crafts.
+    // baseOptimalN = 16 crafts.
+    //
+    // Buffer Limits:
+    // Input safe crafts: 50 limit / 1 amount = 50 crafts.
+    // Output safe crafts: min(200, 50 * 2) = 100 limit. 100 / 7 yield = 14.28 crafts.
+    // maxSafeCrafts = Math.floor(14.28) = 14 crafts!
+    //
+    // WAIT! maxSafeCrafts (14) is LESS than baseOptimalN (16).
+    // The engine CANNOT safely scale down below the base LCM, or else we lose perfect 16-stack swings.
+    // It should fallback to baseOptimalN (16) and rely on the Timeline generator's 
+    // drip-feed logic to keep the machine from jamming.
+
+    expect(batch.craftsPerCycle).toBe(16); // Remains at the absolute mathematical minimum
+    expect(batch.inputs['copper-plate'].totalAmount).toBe(16);
+    expect(batch.outputs['copper-cable'].totalAmount).toBe(112); // 16 * 7
+  });
+  it('handles Legendary Max-Out (+100% Prod) with a highly restrictive overload limit', () => {
+    // Chemical Science Pack: 2 Engine, 3 Adv Circuit, 1 Sulfur -> 2 Chemical Science Packs
+    const mockRecipe: Partial<Recipe> = {
+      ingredients: [
+        { type: 'item', name: 'engine-unit', amount: 2 },
+        { type: 'item', name: 'advanced-circuit', amount: 3 },
+        { type: 'item', name: 'sulfur', amount: 1 }
+      ],
+      results: [{ type: 'item', name: 'chemical-science-pack', amount: 2 }]
+    };
+    
+    const timings: CalculatedTimings = {
+      actualCraftingSpeed: 10,
+      productivityBonus: 1.0, // +100% (4 Legendary Prod 3 Modules)
+      singleCraftTicks: 14.4, 
+      craftsPerSecond: 4.16,
+      overloadMultiplier: 3 // Extremely restrictive! Machine will stall almost instantly.
+    };
+
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'chemical-science-pack': 200 });
+
+    // --- The Math Breakdown ---
+    // 1. Productivity: Yield is 2 * 2.0 = 4 items per craft. (prod fraction = 2/1)
+    //
+    // 2. LCM for Inputs (Base 16):
+    //    Engine (2): 16 / gcd(2, 16) = 8
+    //    Circuit (3): 16 / gcd(3, 16) = 16
+    //    Sulfur (1): 16 / gcd(1, 16) = 16
+    //    Input Base LCM = 16 crafts.
+    //
+    // 3. LCM for Output:
+    //    Amount = 2. Numerator = 2 * 2 = 4. Denominator = 16 * 1 = 16.
+    //    Required N = 16 / gcd(4, 16) = 16 / 4 = 4 crafts.
+    //
+    // 4. Base LCM = LCM(16, 4) = 16 crafts.
+    //
+    // 5. Buffer Limits (maxSafeCrafts): 
+    //    Output limit = min(200, 3 (overload) * 2 (amount)) = 6 items max in output buffer!
+    //    Yield per craft is 4. 
+    //    Max safe crafts = Math.floor(6 / 4) = 1 craft!
+    //
+    
+    expect(batch.craftsPerCycle).toBe(16);
+    
+    // Inputs:
+    // Engine: 16 * 2 = 32 (Exactly 2 swings of 16)
+    expect(batch.inputs['engine-unit'].totalAmount).toBe(32);
+    
+    // Adv Circuit: 16 * 3 = 48 (Exactly 3 swings of 16)
+    expect(batch.inputs['advanced-circuit'].totalAmount).toBe(48);
+    
+    // Sulfur: 16 * 1 = 16 (Exactly 1 swing of 16)
+    expect(batch.inputs['sulfur'].totalAmount).toBe(16);
+    
+    // Output: 16 * 4 yield = 64 items (Exactly 4 swings of 16)
+    expect(batch.outputs['chemical-science-pack'].totalAmount).toBe(64); 
+    
+    // Validates that the engine properly identified the output bottleneck
+    expect(batch.outputs['chemical-science-pack'].outputBlockLimit).toBe(6); 
+  });
+});

+ 88 - 0
src/engine/batch.ts

@@ -0,0 +1,88 @@
+import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
+import type { CalculatedTimings, BatchPlan } from "./types";
+import { gcd, lcm, floatToFraction } from "./math";
+
+
+function getMinimumCraftsForOutput(amount: number, prodRatio: { num: number; den: number }, stackSize: number): number {
+  const numerator = amount * prodRatio.num;
+  const denominator = stackSize * prodRatio.den;
+  return denominator / gcd(numerator, denominator);
+}
+/**
+ * Calculates the optimal number of crafts (N) to group into one clock cycle.
+ * Ensures that all inputs and outputs move in exact multiples of the inserter stack size.
+ * 
+ * @param recipe The recipe to process.
+ * @param timings Calculated timings from computeMachineStats.
+ * @param itemStackSizes A map of the maximum inventory stack size for specific items.
+ */
+export function calculateOptimalBatch(
+  recipe: Recipe,
+  timings: CalculatedTimings,
+  itemStackSizes: Record<string, number> = {},
+): BatchPlan {
+  const prodMultiplier = floatToFraction(1 + timings.productivityBonus);
+  let optimalN = 1;
+  const baselineStack = 16; // Standard fully researched stack size
+  // Process Solid Ingredients
+  const solidIngredients = (recipe.ingredients || []).filter((ing) => ing.type === "item");
+  const hasIngredients = solidIngredients.length>0
+  for (const ing of solidIngredients) {
+    const requiredN = baselineStack / gcd(ing.amount, baselineStack);
+    optimalN = lcm(optimalN, requiredN);
+  }
+  let maxSafeCrafts = hasIngredients? timings.overloadMultiplier : Infinity;
+  
+  // Process Solid Products
+  const solidResults = (recipe.results || []).filter((res) => res.type === "item");
+  for (const res of solidResults) {
+    const amount = res.amount ?? res.amount_min ?? 1
+    const requiredN = getMinimumCraftsForOutput(amount,prodMultiplier,baselineStack)
+    optimalN = lcm(optimalN, requiredN);
+
+    const maxStack = itemStackSizes[res.name] ?? 50;
+    const outputBlockQuantity = hasIngredients
+      ? Math.min(maxStack, timings.overloadMultiplier * amount) 
+      : maxStack;
+      
+    const yieldPerCraft = (amount * prodMultiplier.num) / prodMultiplier.den;
+    maxSafeCrafts = Math.min(maxSafeCrafts, Math.floor(outputBlockQuantity / yieldPerCraft));
+  }
+
+  if (maxSafeCrafts !== Infinity && maxSafeCrafts > optimalN) {
+    const scaleFactor = Math.floor(maxSafeCrafts / optimalN);
+    optimalN *= scaleFactor; 
+  }
+  // Compile final totals
+  const inputs: BatchPlan["inputs"] = {};
+  solidIngredients.forEach((ing) => {
+    inputs[ing.name] = { totalAmount: ing.amount * optimalN, baseAmount: ing.amount };
+  });
+
+  const outputs: BatchPlan["outputs"] = {};
+  solidResults.forEach((res) => {
+    const amount = res.amount ?? res.amount_min ?? 1;
+    const yieldPerCraft = (amount * prodMultiplier.num) / prodMultiplier.den;
+
+    const maxItemStack = itemStackSizes[res.name] ?? 50;
+    // Factorio Output Block Logic: machine stalls if buffer exceeds min(StackSize, OverloadMultiplier * ResultAmount)
+    const outputBlockQuantity = recipe.ingredients && recipe.ingredients.length > 0
+        ? Math.min(maxItemStack, timings.overloadMultiplier * amount)
+        : maxItemStack;
+
+    outputs[res.name] = {
+      totalAmount: yieldPerCraft * optimalN,
+      baseAmount: amount,
+      yieldPerCraft,
+      outputBlockLimit: outputBlockQuantity,
+    };
+  });
+
+  return {
+    craftsPerCycle: optimalN,
+    durationTicks: Math.ceil(timings.singleCraftTicks * optimalN),
+    timings,
+    inputs,
+    outputs,
+  };
+}

+ 0 - 3
src/engine/error.txt

@@ -1,3 +0,0 @@
-Uncaught InternalError: too much recursion
-    gcd factorioEngine.ts:7
-    gcd factorioEngine.ts:7

+ 0 - 315
src/engine/factorioEngine.ts

@@ -1,315 +0,0 @@
-import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
-import type { ItemProductPrototype } from "../../scripts/factorio-dump/lua-api/models";
-import type { Beacon, Machine, Module } from "../../scripts/factorio-dump/process-data.models";
-import { defaultClockSignal } from "../store/useClockStore";
-
-// --- Math Helpers ---
-const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
-const lcm = (a: number, b: number): number => (a * b) / gcd(a, b);
-
-function floatToFraction(val: number) {
-  const precision = 10000;
-  const num = Math.round(val * precision);
-  const den = precision;
-  const divisor = gcd(num, den);
-  return { num: num / divisor, den: den / divisor };
-}
-
-// --- Interfaces ---
-export interface InserterConfig {
-  inserterId?: string; // Used to group mixed belts (e.g., "in-1")
-  presetId: string; // e.g., "chest_to_belt"
-  swingTicks: number; // e.g., 12
-  stackSize: number; // e.g., 16
-}
-
-export interface ClockConfig {
-  /* Keyed by itemId */
-  inputs: Record<string, InserterConfig>;
-  /* Keyed by itemId */
-  outputs: Record<string, InserterConfig>;
-  machineCount?: number;
-}
-
-export interface MachineSetup {
-  machine: Machine;
-  machineQualityLevel?: number;
-  machineModules: Array<{ module: Module; qualityLevel: number }>;
-  beacons: Array<{
-    beacon: Beacon;
-    beaconQualityLevel?: number;
-    count: number;
-    modules: Array<{ module: Module; qualityLevel: number }>;
-  }>;
-}
-
-export interface CalculatedTimings {
-  actualCraftingSpeed: number;
-  productivityBonus: number;
-  singleCraftTicks: number;
-  craftsPerSecond: number;
-  overloadMultiplier: number;
-}
-
-export interface BatchPlan {
-  craftsPerCycle: number;
-  durationTicks: number;
-  timings: CalculatedTimings;
-  inputs: Record<string, { totalAmount: number; baseAmount: number }>;
-  outputs: Record<string, { totalAmount: number; baseAmount: number; yieldPerCraft: number; outputBlockLimit: number }>;
-}
-
-function getQualityMultiplier(level: number): number {
-  return 1 + level * 0.3;
-}
-function getBeaconOverlapPenalty(beacon: Beacon, overlapCount: number): number {
-  if (beacon.profile && beacon.profile.length < overlapCount) {
-    return beacon.profile[overlapCount];
-  }
-  // Default to fomula
-  return 1 / Math.sqrt(overlapCount);
-}
-function getTransmissionStrength(b: Beacon, overlapCount: number, qualityLevel: number = 0): number {
-  const qualityBonus = (b.distribution_effectivity_bonus_per_quality_level ?? 0) * qualityLevel;
-  return (b.distribution_effectivity + qualityBonus) * getBeaconOverlapPenalty(b, overlapCount);
-}
-
-// ---  Machine Stats Calculator ---
-export function computeMachineStats(setup: MachineSetup, recipe: Recipe): CalculatedTimings {
-  // Including your base_effect additions!
-  let speedBonus = setup.machine.effect_receiver?.base_effect?.speed ?? 0;
-  let productivityBonus = setup.machine.effect_receiver?.base_effect?.productivity ?? 0;
-
-  const machineQualityMultiplier = getQualityMultiplier(setup.machineQualityLevel ?? 0);
-  const baseSpeed = setup.machine.crafting_speed * machineQualityMultiplier;
-
-  setup.machineModules.forEach(({ module, qualityLevel }) => {
-    const modQualityMultiplier = getQualityMultiplier(qualityLevel);
-    if (module.effect?.speed) speedBonus += module.effect.speed * modQualityMultiplier;
-    if (module.effect?.productivity) productivityBonus += module.effect.productivity * modQualityMultiplier;
-  });
-
-  let beaconCount = setup.beacons.reduce((acc, b) => acc + b.count, 0);
-  setup.beacons.forEach((b) => {
-    let beaconSpeed = 0;
-    let beaconProd = 0;
-
-    b.modules.forEach(({ module, qualityLevel }) => {
-      const modQualityMultiplier = getQualityMultiplier(qualityLevel);
-      if (module.effect?.speed) beaconSpeed += module.effect.speed * modQualityMultiplier;
-      if (module.effect?.productivity) beaconProd += module.effect.productivity * modQualityMultiplier;
-    });
-
-    const transmissionStrength = getTransmissionStrength(b.beacon, beaconCount, b.beaconQualityLevel);
-    speedBonus += beaconSpeed * transmissionStrength * b.count;
-    productivityBonus += beaconProd * transmissionStrength * b.count;
-  });
-
-  const effectiveSpeedMultiplier = Math.max(0.2, 1 + speedBonus);
-  const actualCraftingSpeed = baseSpeed * effectiveSpeedMultiplier;
-
-  const energyRequired = recipe.energy_required ?? 0.5;
-  const craftTimeSeconds = energyRequired / actualCraftingSpeed;
-  const singleCraftTicks = craftTimeSeconds * 60;
-
-  let overloadMultiplier = recipe.overload_multiplier;
-  if (!overloadMultiplier || overloadMultiplier === 0) {
-    overloadMultiplier = Math.max(2, Math.min(100, Math.ceil(1.166 / craftTimeSeconds)));
-  }
-
-  return {
-    actualCraftingSpeed,
-    productivityBonus,
-    singleCraftTicks,
-    craftsPerSecond: 1 / craftTimeSeconds,
-    overloadMultiplier,
-  };
-}
-
-// ---  Batch Calculator (Buffer Aware) ---
-export function calculateOptimalBatch(
-  recipe: Recipe,
-  timings: CalculatedTimings,
-  itemStackSizes: Record<string, number> = {},
-): BatchPlan {
-  const prodMultiplier = floatToFraction(1 + timings.productivityBonus);
-  let optimalN = 1;
-
-  // We assume a baseline stack size of 16 for batch math, but execution can vary
-  const baselineStack = 16;
-
-  const solidIngredients = (recipe.ingredients || []).filter((ing) => ing.type === "item");
-  for (const ing of solidIngredients) {
-    const requiredN = baselineStack / gcd(ing.amount, baselineStack);
-    optimalN = lcm(optimalN, requiredN);
-  }
-
-  const solidResults = (recipe.results || []).filter((res): res is ItemProductPrototype => res.type === "item");
-  for (const res of solidResults) {
-    const amount = res.amount ?? res.amount_min ?? 1;
-    const numerator = amount * prodMultiplier.num;
-    const denominator = baselineStack * prodMultiplier.den;
-    const requiredN = denominator / gcd(numerator, denominator);
-    optimalN = lcm(optimalN, requiredN);
-  }
-
-  const inputs: BatchPlan["inputs"] = {};
-  solidIngredients.forEach((ing) => {
-    inputs[ing.itemId] = { totalAmount: ing.amount * optimalN, baseAmount: ing.amount };
-  });
-
-  const outputs: BatchPlan["outputs"] = {};
-  solidResults.forEach((res) => {
-    const amount = res.amount ?? res.amount_min ?? 1;
-    const yieldPerCraft = (amount * prodMultiplier.num) / prodMultiplier.den;
-
-    const maxItemStack = itemStackSizes[res.name] ?? 50;
-    const outputBlockQuantity =
-      recipe.ingredients && recipe.ingredients.length > 0
-        ? Math.min(maxItemStack, timings.overloadMultiplier * amount)
-        : maxItemStack;
-
-    outputs[res.name] = {
-      totalAmount: yieldPerCraft * optimalN,
-      baseAmount: amount,
-      yieldPerCraft,
-      outputBlockLimit: outputBlockQuantity,
-    };
-  });
-
-  return {
-    craftsPerCycle: optimalN,
-    durationTicks: Math.ceil(timings.singleCraftTicks * optimalN),
-    timings,
-    inputs,
-    outputs,
-  };
-}
-
-// ---  Timeline Generator (Row-Configured & Mixed Belt Scheduler) ---
-export function generateAdvancedClock(batch: BatchPlan, config: ClockConfig) {
-  const rowMap: Record<string, any> = {};
-  const blocks: any[] = [];
-
-  // Tracks when an inserter is free so mixed belts don't overlap swings
-  const inserterBusyUntil: Record<string, number> = {};
-
-  // Helper to get or create a row based on grouped inserterId
-  const getOrCreateRow = (inserterId: string, itemId: string, isInput: boolean, stackSize: number) => {
-    if (!rowMap[inserterId]) {
-      rowMap[inserterId] = {
-        id: `row-${inserterId}`,
-        name: isInput ? `Input ${inserterId}` : `Output ${inserterId}`,
-        signals: [],
-        stackSize,
-        inserterCount: config.machineCount || 1,
-      };
-    }
-    // Add the signal if it's not already on the row (for mixed belts)
-    if (!rowMap[inserterId].signals.find((s: any) => s.name === itemId)) {
-      rowMap[inserterId].signals.push({ type: "item", name: itemId, subgroup: "intermediate-product" });
-    }
-    return rowMap[inserterId].id;
-  };
-
-  // --- Process Inputs ---
-  Object.entries(batch.inputs).forEach(([itemId, data]) => {
-    const cfg = config.inputs[itemId] || { presetId: "chest_to_chest", swingTicks: 8, stackSize: 16 };
-    const inserterId = cfg.inserterId || `in-${itemId}`;
-    const rowId = getOrCreateRow(inserterId, itemId, true, cfg.stackSize);
-
-    const totalSwings = data.totalAmount / cfg.stackSize;
-    const consumptionPerTick = data.baseAmount / batch.timings.singleCraftTicks;
-    const bufferLimit = data.baseAmount * batch.timings.overloadMultiplier;
-
-    let maxBurst = 0;
-    for (let k = 0; k < totalSwings; k++) {
-      const invAtStartOfSwing = k * cfg.stackSize - (k - 1) * cfg.swingTicks * consumptionPerTick;
-      if (invAtStartOfSwing < bufferLimit) maxBurst = k + 1;
-      else break;
-    }
-    if (maxBurst === 0) maxBurst = 1;
-
-    let swingsLeft = totalSwings;
-    let currentArrivalTick = 0;
-    let blockIndex = 0;
-
-    while (swingsLeft > 0) {
-      const burst = Math.min(swingsLeft, maxBurst);
-      let startTick = Math.round(currentArrivalTick - cfg.swingTicks);
-      startTick = ((startTick % batch.durationTicks) + batch.durationTicks) % batch.durationTicks;
-
-      // Ensure the inserter isn't busy with another item on this mixed belt
-      const busyUntil = inserterBusyUntil[inserterId] || 0;
-      if (startTick < busyUntil) {
-        startTick = busyUntil; // Delay swing until the inserter drops the other item
-      }
-
-      const duration = cfg.swingTicks + 1;
-      inserterBusyUntil[inserterId] = startTick + burst * duration; // Mark inserter as busy
-
-      blocks.push({
-        id: `block-in-${itemId}-${blockIndex++}`,
-        rowId,
-        presetId: cfg.presetId,
-        start: startTick,
-        duration,
-        count: cfg.stackSize,
-        repeat: burst,
-      });
-
-      const spanTicks = (burst * cfg.stackSize) / consumptionPerTick;
-      currentArrivalTick += spanTicks;
-      swingsLeft -= burst;
-    }
-  });
-
-  // --- Process Outputs ---
-  Object.entries(batch.outputs).forEach(([itemId, data]) => {
-    const cfg = config.outputs[itemId] || { presetId: "chest_to_chest", swingTicks: 8, stackSize: 16 };
-    const inserterId = cfg.inserterId || `out-${itemId}`;
-
-    const extractionTrigger = Math.min(cfg.stackSize, data.outputBlockLimit);
-    const rowId = getOrCreateRow(inserterId, itemId, false, extractionTrigger);
-
-    const productionPerTick = data.yieldPerCraft / batch.timings.singleCraftTicks;
-    let amountLeft = data.totalAmount;
-    let currentReadyTick = 0;
-    let blockIndex = 0;
-
-    while (amountLeft > 0) {
-      const amountToExtract = Math.min(amountLeft, extractionTrigger);
-      const spanTicks = amountToExtract / productionPerTick;
-      currentReadyTick += spanTicks;
-
-      let startTick = Math.round(currentReadyTick - cfg.swingTicks);
-      startTick = ((startTick % batch.durationTicks) + batch.durationTicks) % batch.durationTicks;
-
-      // Output inserter scheduling for mixed belts
-      const busyUntil = inserterBusyUntil[inserterId] || 0;
-      if (startTick < busyUntil) startTick = busyUntil;
-
-      const duration = cfg.swingTicks + 1;
-      inserterBusyUntil[inserterId] = startTick + duration;
-
-      blocks.push({
-        id: `block-out-${itemId}-${blockIndex++}`,
-        rowId,
-        presetId: cfg.presetId,
-        start: startTick,
-        duration,
-        count: amountToExtract,
-        repeat: 1,
-      });
-
-      amountLeft -= amountToExtract;
-    }
-  });
-
-  return {
-    duration: batch.durationTicks,
-    clockSignal: defaultClockSignal,
-    rows: Object.values(rowMap),
-    blocks,
-  };
-}

+ 5 - 0
src/engine/index.ts

@@ -0,0 +1,5 @@
+export * from "./types";
+export { gcd, lcm, floatToFraction } from "./math";
+export { computeMachineStats, getQualityMultiplier, getBeaconOverlapPenalty, getTransmissionStrength } from "./stats";
+export { calculateOptimalBatch } from "./batch";
+export { generateAdvancedClock } from "./timeline";

+ 24 - 0
src/engine/math.test.ts

@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest';
+import { gcd, lcm, floatToFraction } from './math';
+
+describe('Math Helpers', () => {
+  it('calculates Greatest Common Divisor (gcd)', () => {
+    expect(gcd(12, 8)).toBe(4);
+    expect(gcd(17, 5)).toBe(1); // Prime numbers
+    expect(gcd(100, 10)).toBe(10);
+  });
+
+  it('calculates Least Common Multiple (lcm)', () => {
+    expect(lcm(4, 6)).toBe(12);
+    expect(lcm(3, 5)).toBe(15);
+    expect(lcm(16, 12)).toBe(48);
+  });
+
+  it('converts floats to exact fractions', () => {
+    expect(floatToFraction(1.75)).toEqual({ num: 7, den: 4 });
+    expect(floatToFraction(1.5)).toEqual({ num: 3, den: 2 });
+    expect(floatToFraction(1.0)).toEqual({ num: 1, den: 1 });
+    // Factorio often uses productivity bonuses like +30% (1.3)
+    expect(floatToFraction(1.3)).toEqual({ num: 13, den: 10 });
+  });
+});

+ 23 - 0
src/engine/math.ts

@@ -0,0 +1,23 @@
+/**
+ * Calculates the Greatest Common Divisor of two numbers.
+ */
+export const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
+
+/**
+ * Calculates the Least Common Multiple of two numbers.
+ */
+export const lcm = (a: number, b: number): number => (a * b) / gcd(a, b);
+
+/**
+ * Converts a floating point number (like 1.75) into an exact fraction {num: 7, den: 4}.
+ * Essential for Factorio calculations to prevent floating-point drift over thousands of ticks.
+ * 
+ * @param val The float value to convert.
+ * @param precision The multiplier used to find the fraction (default 10000).
+ */
+export function floatToFraction(val: number, precision: number = 10000) {
+  const num = Math.round(val * precision);
+  const den = precision;
+  const divisor = gcd(num, den);
+  return { num: num / divisor, den: den / divisor };
+}

+ 167 - 0
src/engine/simulator.test.ts

@@ -0,0 +1,167 @@
+import { describe, it, expect } from 'vitest';
+import { 
+  MachineSimulator, 
+  InserterSimulator, 
+  Chest, 
+  SimulationOrchestrator, 
+  InserterState 
+} from './simulator';
+import type { MachineSetup } from './types';
+import type { Recipe } from '../../scripts/factorio-dump/helpers/recipes.helper';
+import type { Machine } from '../../scripts/factorio-dump/process-data.models';
+
+describe('Simulator Engine', () => {
+
+  const mockMachineSetup: MachineSetup = {
+    machine: { crafting_speed: 1 } as Machine,
+    machineModules: [],
+    beacons: [],
+    machineQualityLevel: 0
+  };
+
+  const basicRecipe: Recipe = {
+    name: 'iron-gear-wheel',
+    energy_required: 0.5, // 30 ticks
+    ingredients: [{ type: 'item', name: 'iron-plate', amount: 2 }],
+    results: [{ type: 'item', name: 'iron-gear-wheel', amount: 1 }]
+  } as Recipe;
+
+  it('Machine Simulator: Crafts correctly over time and consumes inputs instantly', () => {
+    const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { 'iron-gear-wheel': 100 });
+    
+    // Feed it enough for 2 crafts
+    machine.inputBuffer['iron-plate'] = 4;
+    
+    machine.tick();
+    
+    // Tick 1: Inputs consumed instantly, craft starts
+    expect(machine.isCrafting).toBe(true);
+    expect(machine.inputBuffer['iron-plate']).toBe(2); 
+    expect(machine.outputBuffer['iron-gear-wheel']).toBe(0);
+
+    // Fast forward 28 ticks (29 total)
+    for (let i = 0; i < 28; i++) machine.tick();
+    expect(machine.outputBuffer['iron-gear-wheel']).toBe(0); // Not done yet
+
+    // Tick 30: Craft completes!
+    machine.tick();
+    expect(machine.outputBuffer['iron-gear-wheel']).toBe(1);
+    
+    machine.tick();
+    // Tick 31: It should immediately start the next craft in the same tick if buffer allows
+    expect(machine.isCrafting).toBe(true);
+    expect(machine.inputBuffer['iron-plate']).toBe(0);
+  });
+
+  it('Machine Simulator: Processes multiple crafts in a single tick if speed is extreme', () => {
+    const fastSetup: MachineSetup = {
+      machine: { crafting_speed: 60 } as Machine, // 1 craft per tick!
+      machineModules: [],
+      beacons: [],
+      machineQualityLevel: 0
+    };
+
+    const machine = new MachineSimulator(fastSetup, basicRecipe, { 'iron-gear-wheel': 100 });
+    machine.inputBuffer['iron-plate'] = 20; // 10 crafts worth
+
+    // Tick 1: Should complete 2 crafts instantly (60 speed / 30 ticks required = 2 per tick)
+    machine.tick();
+    
+    expect(machine.inputBuffer['iron-plate']).toBe(16); // 20 - 4
+    expect(machine.outputBuffer['iron-gear-wheel']).toBe(2);
+  });
+
+  it('Inserter Simulator: Executes exact tick phases for a Chest -> Machine transfer', () => {
+    const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { 'iron-gear-wheel': 100 });
+    const sourceChest = new Chest('iron-plate');
+    const inserter = new InserterSimulator(16, sourceChest, machine, 'iron-plate');
+
+    expect(inserter.state).toBe(InserterState.Idle);
+
+    inserter.tick(); 
+    // Tick 1: Wakes up, extracts instantly, and transitions to SwingingForward
+    expect(inserter.state).toBe(InserterState.SwingingForward);
+
+    inserter.tick(); 
+    inserter.tick(); 
+    inserter.tick(); 
+    // Ticks 2-4: Rotation (3 ticks) complete, transition to Dropping
+    expect(inserter.state).toBe(InserterState.Dropping);
+
+    inserter.tick(); 
+    // Tick 5: Drop complete (1 tick machine), contents deposited!
+    expect(machine.inputBuffer['iron-plate']).toBe(16);
+    expect(inserter.state).toBe(InserterState.SwingingBack);
+
+    inserter.tick();
+    inserter.tick();
+    inserter.tick(); 
+    // Ticks 6-8: Rotation back (3 ticks)
+    expect(inserter.state).toBe(InserterState.Idle);
+  });
+
+  it('End-to-End Simulation: Input -> Machine -> Output perfectly coordinated', () => {
+    const orchestrator = new SimulationOrchestrator();
+
+    const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { 'iron-gear-wheel': 100 });
+    const sourceChest = new Chest('iron-plate');
+    const sinkChest = new Chest();
+
+    const inputInserter = new InserterSimulator(2, sourceChest, machine, 'iron-plate');
+    const outputInserter = new InserterSimulator(1, machine, sinkChest, 'iron-gear-wheel');
+
+    // Register in strict topological order mimicking Factorio's downstream update priority
+    orchestrator.register(inputInserter);
+    orchestrator.register(machine);
+    orchestrator.register(outputInserter);
+
+    // Run until the sink chest receives the final item
+    const success = orchestrator.tickUntil(() => (sinkChest.receivedCounts['iron-gear-wheel'] || 0) >= 1);
+    expect(success).toBe(true);
+
+    // Breakdown:
+    // T1: Input Inserter Picks.
+    // T2-4: Input Inserter Swings.
+    // T5: Input Inserter Drops. Machine gets input, starts craft (Progress=1).
+    // T6-33: Machine crafts...
+    // T34: Machine finishes 30th tick of craft. Item generated. Output Inserter wakes up, Picks.
+    // T35-37: Output Inserter Swings.
+    // T38: Output Inserter Drops into chest!
+    expect(orchestrator.currentTick).toBe(38);
+    
+    // Safely extracted from machine
+    expect(machine.outputBuffer['iron-gear-wheel']).toBe(0); 
+    expect(sinkChest.receivedCounts['iron-gear-wheel']).toBe(1);
+  });
+
+  it('Overload Limit validation: Inserter ignores target size if buffer is under limit', () => {
+    // We override the stats module or set up the config so overloadMultiplier is 3
+    // For this test, we assume computeMachineStats returned an overloadMultiplier of 3.
+    const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { 'iron-gear-wheel': 100 });
+    machine.timings.overloadMultiplier = 3; // Forcing it to 3 for the test assertion
+    
+    const sourceChest = new Chest('iron-plate');
+    const inserter = new InserterSimulator(16, sourceChest, machine, 'iron-plate');
+
+    expect(machine.timings.overloadMultiplier).toBe(3);
+    // Overload limit = 2 (recipe amount) * 3 = 6 iron plates limit.
+
+    // It should wake up and insert all 16 because the buffer is currently 0 (< 6)
+    for (let i = 0; i < 5; i++) inserter.tick(); // 5 ticks for Chest->Machine swing
+    
+    expect(machine.inputBuffer['iron-plate']).toBe(16); // Overstuffed!
+
+    // The machine instantly consumes 2, leaving 14.
+    machine.tick();
+    expect(machine.inputBuffer['iron-plate']).toBe(14);
+
+    // The inserter returns to idle
+    for (let i = 0; i < 3; i++) inserter.tick(); // 3 ticks for rotation back
+    expect(inserter.state).toBe(InserterState.Idle);
+
+    // Tick the inserter again. It should NOT wake up, because 14 is NOT < 6.
+    inserter.tick();
+    expect(inserter.state).toBe(InserterState.Idle);
+  });
+  
+});

+ 352 - 0
src/engine/simulator.ts

@@ -0,0 +1,352 @@
+import { computeMachineStats } from "./stats";
+import type { MachineSetup, CalculatedTimings } from "./types";
+import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
+
+export enum ContainerType {
+  Chest = "Chest",
+  Belt = "Belt",
+  Splitter = "Splitter",
+  Machine = "Machine",
+}
+export interface IContainer {
+  readonly type: ContainerType;
+
+  // For Inserter checking if it should wake up
+  canAccept(itemId: string, amount: number): boolean;
+  getAvailable(itemId: string): number;
+
+  // For Inserter executing the transfer
+  insert(itemId: string, amount: number): void;
+  extract(itemId: string, maxAmount: number): number;
+
+  // Optional tick method for active containers (Machines)
+  tick?(): void;
+}
+
+export const INSERTER_TIMINGS = {
+  ROTATION: 3,
+  PICKUP: {
+    [ContainerType.Chest]: 1,
+    [ContainerType.Machine]: 1,
+    [ContainerType.Belt]: 4,
+    [ContainerType.Splitter]: 4,
+  },
+  DROP: {
+    [ContainerType.Chest]: 1,
+    [ContainerType.Machine]: 1,
+    [ContainerType.Splitter]: 4,
+    [ContainerType.Belt]: 5,
+  },
+};
+
+export enum InserterState {
+  Idle,
+  Picking,
+  SwingingForward,
+  Dropping,
+  SwingingBack,
+}
+
+export class MachineSimulator implements IContainer {
+  public readonly type = ContainerType.Machine;
+  public inputBuffer: Record<string, number> = {};
+  public outputBuffer: Record<string, number> = {};
+
+  public craftProgress = 0;
+  public prodProgress = 0;
+  public isCrafting = false;
+
+  public timings: CalculatedTimings;
+  private progressPerTick: number;
+
+  private solidIngredients: { name: string; amount: number }[] = [];
+  private solidResults: { name: string; amount: number }[] = [];
+
+  private overloadLimits: Record<string, number> = {};
+  private outputBlockLimits: Record<string, number> = {};
+
+  constructor(
+    public setup: MachineSetup,
+    public recipe: Recipe,
+    itemStackSizes: Record<string, number> = {},
+  ) {
+    this.timings = computeMachineStats(setup, recipe);
+    this.progressPerTick = 1 / this.timings.singleCraftTicks;
+
+    const ingredients = recipe.ingredients || [];
+    for (const ing of ingredients) {
+      if (ing.type === "item") {
+        this.solidIngredients.push({ name: ing.name, amount: ing.amount });
+        this.overloadLimits[ing.name] =
+          ing.amount * this.timings.overloadMultiplier;
+        this.inputBuffer[ing.name] = 0;
+      }
+    }
+
+    const results = recipe.results || [];
+    const hasIngredients = this.solidIngredients.length > 0;
+    for (const res of results) {
+      if (res.type === "item") {
+        const amount = (res as any).amount ?? (res as any).amount_min ?? 1;
+        this.solidResults.push({ name: res.name, amount });
+        this.outputBuffer[res.name] = 0;
+
+        const maxStack = itemStackSizes[res.name] ?? 50;
+        this.outputBlockLimits[res.name] = hasIngredients
+          ? Math.min(maxStack, this.timings.overloadMultiplier * amount)
+          : maxStack;
+      }
+    }
+  }
+
+  public tick() {
+    let progressRemaining = this.progressPerTick;
+
+    while (progressRemaining > 0) {
+      if (!this.isCrafting) {
+        if (this.hasEnoughInputs() && !this.isOutputBlocked()) {
+          this.consumeInputs();
+          this.isCrafting = true;
+        } else {
+          break;
+        }
+      }
+
+      if (this.isCrafting) {
+        const progressToNextFinish = 1.0 - this.craftProgress;
+        const step = Math.min(progressRemaining, progressToNextFinish);
+
+        this.craftProgress += step;
+        progressRemaining -= step;
+        this.prodProgress += step * this.timings.productivityBonus;
+
+        if (this.craftProgress >= 0.99999) {
+          this.addResults(1);
+          this.craftProgress = 0;
+          this.isCrafting = false;
+        }
+
+        while (this.prodProgress >= 0.99999) {
+          this.addResults(1);
+          this.prodProgress -= 1.0;
+        }
+      }
+    }
+  }
+
+  private hasEnoughInputs(): boolean {
+    for (const ing of this.solidIngredients) {
+      if (this.inputBuffer[ing.name] < ing.amount) return false;
+    }
+    return true;
+  }
+
+  private isOutputBlocked(): boolean {
+    for (const res of this.solidResults) {
+      if (this.outputBuffer[res.name] >= this.outputBlockLimits[res.name])
+        return true;
+    }
+    return false;
+  }
+
+  private consumeInputs() {
+    for (const ing of this.solidIngredients) {
+      this.inputBuffer[ing.name] -= ing.amount;
+    }
+  }
+
+  private addResults(multiplier: number) {
+    for (const res of this.solidResults) {
+      this.outputBuffer[res.name] += res.amount * multiplier;
+    }
+  }
+
+  public canAccept(itemId: string): boolean {
+    const limit = this.overloadLimits[itemId];
+    if (limit === undefined) return false; // Doesn't accept this item
+
+    return this.inputBuffer[itemId] < limit && !this.isOutputBlocked();
+  }
+  public insert(itemId: string, amount: number): void {
+    this.inputBuffer[itemId] = (this.inputBuffer[itemId] || 0) + amount;
+  }
+
+  public getAvailable(itemId: string): number {
+    return this.outputBuffer[itemId] || 0;
+  }
+  public extract(itemId: string, maxAmount: number): number {
+    const available = this.getAvailable(itemId);
+    const toPick = Math.min(maxAmount, available);
+    this.outputBuffer[itemId] -= toPick;
+    return toPick;
+  }
+}
+export class Chest implements IContainer {
+  public readonly type = ContainerType.Chest;
+  public receivedCounts: Record<string, number> = {}; // Tracks inserted items for testing
+  private readonly isSink: boolean; // Precomputed!
+  constructor(public providedItem?: string) {
+    this.isSink = this.providedItem === undefined;
+  }
+
+  public canAccept(itemId: string, amount: number): boolean {
+    // Only accepts items if it wasn't configured as a source
+    return this.isSink;
+  }
+
+  public getAvailable(itemId: string): number {
+    return this.providedItem === itemId ? Infinity : 0;
+  }
+
+  public extract(itemId: string, maxAmount: number): number {
+    return this.getAvailable(itemId) > 0 ? maxAmount : 0;
+  }
+
+  public insert(itemId: string, amount: number): void {
+    if (this.canAccept(itemId, amount)) {
+      this.receivedCounts[itemId] = (this.receivedCounts[itemId] || 0) + amount;
+    }
+  }
+}
+
+export class Belt implements IContainer {
+  public readonly type = ContainerType.Belt;
+  public receivedCounts: Record<string, number> = {};
+
+  private readonly isSink: boolean; // Precomputed!
+  private readonly providedItems = new Set<string>();
+
+  constructor(
+    providedItem1?: string,
+    providedItem2?: string,
+  ) {
+    this.isSink = providedItem1 === undefined && providedItem2 === undefined;
+
+    if (providedItem1) this.providedItems.add(providedItem1);
+    if (providedItem2) this.providedItems.add(providedItem2);
+  }
+
+  public canAccept(itemId: string, amount: number): boolean {
+    return this.isSink;
+  }
+
+  public getAvailable(itemId: string): number {
+    return this.providedItems.has(itemId) ? Infinity : 0;
+  }
+
+  public extract(itemId: string, maxAmount: number): number {
+    return this.getAvailable(itemId) > 0 ? maxAmount : 0;
+  }
+
+  public insert(itemId: string, amount: number): void {
+    if (this.canAccept(itemId, amount)) {
+      this.receivedCounts[itemId] = (this.receivedCounts[itemId] || 0) + amount;
+    }
+  }
+}
+export class InserterSimulator {
+  public state: InserterState = InserterState.Idle;
+  public ticksInState = 0;
+  public heldItems = 0;
+  private readonly pickupTicks: number;
+  private readonly dropTicks: number;
+
+  constructor(
+    public handSize: number,
+    public source: IContainer,
+    public destination: IContainer,
+    public targetItemId: string,
+  ) {
+    this.pickupTicks = INSERTER_TIMINGS.PICKUP[source.type];
+    this.dropTicks = INSERTER_TIMINGS.DROP[destination.type];
+  }
+
+  public tick() {
+    if (this.state === InserterState.Idle) {
+      if (this.canWakeUp()) {
+        this.state = InserterState.Picking;
+        this.ticksInState = 0;
+      } else {
+        return;
+      }
+    }
+
+    this.ticksInState++;
+
+    switch (this.state) {
+      case InserterState.Picking:
+        // Reads the timing based on the INTERFACE type!
+        if (this.ticksInState >= this.pickupTicks) {
+          this.heldItems = this.source.extract(
+            this.targetItemId,
+            this.handSize,
+          );
+          this.state = InserterState.SwingingForward;
+          this.ticksInState = 0;
+        }
+        break;
+
+      case InserterState.SwingingForward:
+        if (this.ticksInState >= INSERTER_TIMINGS.ROTATION) {
+          this.state = InserterState.Dropping;
+          this.ticksInState = 0;
+        }
+        break;
+
+      case InserterState.Dropping:
+        if (!this.destination.canAccept(this.targetItemId, this.heldItems)) {
+          this.ticksInState--; // Hover if blocked
+          break;
+        }
+
+        if (this.ticksInState >= this.dropTicks) {
+          this.destination.insert(this.targetItemId, this.heldItems);
+          this.heldItems = 0;
+          this.state = InserterState.SwingingBack;
+          this.ticksInState = 0;
+        }
+        break;
+
+      case InserterState.SwingingBack:
+        if (this.ticksInState >= INSERTER_TIMINGS.ROTATION) {
+          this.state = InserterState.Idle;
+          this.ticksInState = 0;
+        }
+        break;
+    }
+  }
+
+  private canWakeUp(): boolean {
+    return (
+      this.source.getAvailable(this.targetItemId) > 0 &&
+      this.destination.canAccept(this.targetItemId, this.handSize)
+    );
+  }
+}
+
+export class SimulationOrchestrator {
+  private tickables: { tick?(): void }[] = [];
+  public currentTick = 0;
+
+  /**
+   * Add entities in strict topological order to mimic perfect Factorio build order.
+   * e.g., Sources -> Input Inserters -> Machines -> Output Inserters -> Sinks
+   */
+  public register(entity: { tick?(): void }) {
+    this.tickables.push(entity);
+  }
+
+  public tick() {
+    for (const entity of this.tickables) {
+      if (entity.tick) entity.tick();
+    }
+    this.currentTick++;
+  }
+
+  public tickUntil(condition: () => boolean, maxTicks = 10000): boolean {
+    while (!condition() && this.currentTick < maxTicks) {
+      this.tick();
+    }
+    return this.currentTick < maxTicks; // Returns true if condition met, false if timed out
+  }
+}

+ 176 - 0
src/engine/stats.test.ts

@@ -0,0 +1,176 @@
+import { describe, it, expect } from "vitest";
+import {
+  getQualityMultiplier,
+  getBeaconOverlapPenalty,
+  computeMachineStats,
+} from "./stats";
+import type { MachineSetup } from "./types";
+import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
+import type {
+  Beacon,
+  Machine,
+} from "../../scripts/factorio-dump/process-data.models";
+
+describe("getQualityMultiplier", () => {
+  it("returns correct quality multipliers", () => {
+    expect(getQualityMultiplier(0)).toBe(1.0); // Normal
+    expect(getQualityMultiplier(1)).toBe(1.3); // Uncommon
+    expect(getQualityMultiplier(3)).toBe(1.9); // Epic
+    expect(getQualityMultiplier(5)).toBe(2.5); // Legendary
+  });
+});
+
+describe("getBeaconOverlapPenalty", () => {
+  const mockBeacon: Partial<Beacon> = { profile: [1, 0.5, 0.33] };
+  it("With profile array", () => {
+    // Uses profile array if within bounds
+    expect(getBeaconOverlapPenalty(mockBeacon as Beacon, 2)).toBe(0.5);
+    expect(getBeaconOverlapPenalty(mockBeacon as Beacon, 3)).toBe(0.33);
+  });
+  it("Out of bound profile array", () => {
+    // Falls back to formula 1 / sqrt(N) if out of bounds
+    expect(getBeaconOverlapPenalty(mockBeacon as Beacon, 16)).toBe(0.25); // 1 / sqrt(16) = 0.25
+  });
+  it("Without profile array", () => {
+    // A beacon with no profile uses directly the fallback formula
+    expect(getBeaconOverlapPenalty({} as Beacon, 4)).toBe(0.5); // 1 / sqrt(4) = 0.5
+  });
+});
+
+describe("computeMachineStats", () => {
+  it("computes basic machine stats without modules", () => {
+    const mockMachine: Partial<Machine> = { crafting_speed: 1 };
+    const mockRecipe: Partial<Recipe> = {
+      energy_required: 1,
+      overload_multiplier: 0,
+    }; // 0 forces formula
+
+    const setup: MachineSetup = {
+      machine: mockMachine as Machine,
+      machineQualityLevel: 0,
+      machineModules: [],
+      beacons: [],
+    };
+
+    const stats = computeMachineStats(setup, mockRecipe as Recipe);
+
+    expect(stats.actualCraftingSpeed).toBe(1);
+    expect(stats.productivityBonus).toBe(0);
+    expect(stats.singleCraftTicks).toBe(60);
+    expect(stats.overloadMultiplier).toBe(2);
+  });
+
+  it("applies module and machine quality bonuses correctly", () => {
+    const mockMachine: Partial<Machine> = { crafting_speed: 1.0 };
+    const mockRecipe: Partial<Recipe> = { energy_required: 1.0 };
+
+    const setup: MachineSetup = {
+      machine: mockMachine as Machine,
+      machineQualityLevel: 5, // Legendary machine (2.5x base speed = 2.5)
+      machineModules: [
+        {
+          module: { effect: { speed: 0.2, productivity: 0.1 } } as any,
+          qualityLevel: 1, // Uncommon module (1.3x effect -> speed 0.26, prod 0.13)
+        },
+      ],
+      beacons: [],
+    };
+
+    const stats = computeMachineStats(setup, mockRecipe as Recipe);
+
+    expect(stats.actualCraftingSpeed).toBe(2.5 * (1 + 0.26)); // 3.15
+    expect(stats.productivityBonus).toBeCloseTo(0.13);
+  });
+  it("applies inherent machine base effects (e.g. Electromagnetic Plant)", () => {
+    const mockMachine: Partial<Machine> = {
+      crafting_speed: 2,
+      effect_receiver: {
+        base_effect: { speed: 0.5, productivity: 0.5 },
+      } as any,
+    };
+    const mockRecipe: Partial<Recipe> = { energy_required: 1.0 };
+
+    const setup: MachineSetup = {
+      machine: mockMachine as Machine,
+      machineQualityLevel: 0,
+      machineModules: [],
+      beacons: [],
+    };
+
+    const stats = computeMachineStats(setup, mockRecipe as Recipe);
+
+    // Speed multiplier = 1 + 0.5 (base effect) = 1.5. Actual speed = 2 * 1.5 = 3
+    expect(stats.actualCraftingSpeed).toBe(3);
+    expect(stats.productivityBonus).toBe(0.5);
+  });
+
+  it("clamps severe speed penalties to the Factorio minimum of 20%", () => {
+    const mockMachine: Partial<Machine> = { crafting_speed: 1.0 };
+    const mockRecipe: Partial<Recipe> = { energy_required: 1.0 };
+
+    const setup: MachineSetup = {
+      machine: mockMachine as Machine,
+      machineModules: [
+        // A module that applies -90% speed
+        { module: { effect: { speed: -0.9 } } as any, qualityLevel: 0 },
+      ],
+      beacons: [],
+    };
+
+    const stats = computeMachineStats(setup, mockRecipe as Recipe);
+
+    // 1.0 - 0.9 = 0.1, but Factorio clamps this to 0.2!
+    expect(stats.actualCraftingSpeed).toBe(0.2);
+  });
+
+  it("clamps the overload multiplier to a maximum of 100 for extremely fast crafts", () => {
+    const mockMachine: Partial<Machine> = { crafting_speed: 10.0 };
+    const mockRecipe: Partial<Recipe> = {
+      energy_required: 0.01,
+      overload_multiplier: 0,
+    };
+
+    const setup: MachineSetup = {
+      machine: mockMachine as Machine,
+      machineModules: [],
+      beacons: [],
+    };
+    const stats = computeMachineStats(setup, mockRecipe as Recipe);
+
+    // Formula would result in ~1166, but clamp ensures it caps at 100
+    expect(stats.overloadMultiplier).toBe(100);
+  });
+
+  it("correctly calculates beacon effects with distribution effectivity and overlap penalties", () => {
+    const mockMachine: Partial<Machine> = { crafting_speed: 1.0 };
+    const mockRecipe: Partial<Recipe> = { energy_required: 1.0 };
+
+    const mockBeacon: Partial<Beacon> = {
+      distribution_effectivity: 0.5, // Standard Factorio 2.0 beacon transmits 50%
+      profile: [1, 0.5], // Penalty: 2 beacons = 50% transmission strength each
+    };
+
+    const setup: MachineSetup = {
+      machine: mockMachine as Machine,
+      machineModules: [],
+      beacons: [
+        {
+          beacon: mockBeacon as Beacon,
+          beaconQualityLevel: 0,
+          count: 2,
+          modules: [
+            { module: { effect: { speed: 1.0 } } as any, qualityLevel: 0 }, // +100% speed module inside beacon
+          ],
+        },
+      ],
+    };
+
+    const stats = computeMachineStats(setup, mockRecipe as Recipe);
+
+    // Math:
+    // Transmission Strength = 0.5 (effectivity) * 0.5 (penalty from profile for 2 beacons) = 0.25
+    // Total beacon speed = 1.0 (module) * 0.25 (transmission) * 2 (count) = +0.5 (50% bonus)
+    // Final Speed = 1.0 * (1 + 0.5) = 1.5
+    expect(stats.actualCraftingSpeed).toBe(1.5);
+  });
+});

+ 91 - 0
src/engine/stats.ts

@@ -0,0 +1,91 @@
+import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
+import type { Beacon } from "../../scripts/factorio-dump/process-data.models";
+import type { MachineSetup, CalculatedTimings } from "./types";
+
+/**
+ * Returns the Factorio 2.0 stat multiplier for a given quality level.
+ * @param level 0=Normal (1x), 1=Uncommon (1.3x), 2=Rare (1.6x), 3=Epic (1.9x), 5=Legendary (2.5x)
+ */
+export function getQualityMultiplier(level: number): number {
+  return 1 + level * 0.3;
+}
+
+/**
+ * Calculates the diminishing returns penalty for overlapping beacons in Factorio 2.0.
+ * @param beacon The beacon prototype.
+ * @param overlapCount Total number of beacons of this type affecting the machine.
+ */
+export function getBeaconOverlapPenalty(beacon: Beacon, overlapCount: number): number {
+  if (beacon.profile && (beacon.profile.length >= overlapCount)) {
+    return beacon.profile[overlapCount - 1];
+  }
+  // Default formula if profile array is exceeded or missing
+  return 1 / Math.sqrt(overlapCount);
+}
+
+/**
+ * Calculates the effective transmission strength of a beacon factoring in quality and overlap.
+ */
+export function getTransmissionStrength(b: Beacon, overlapCount: number, qualityLevel: number = 0): number {
+  const qualityBonus = (b.distribution_effectivity_bonus_per_quality_level ?? 0) * qualityLevel;
+  return (b.distribution_effectivity + qualityBonus) * getBeaconOverlapPenalty(b, overlapCount);
+}
+
+/**
+ * Computes the final speed, productivity, and buffer limits for a fully configured machine.
+ */
+export function computeMachineStats(setup: MachineSetup, recipe: Recipe): CalculatedTimings {
+  // Base inherent effects (e.g., Electromagnetic plant built-in +50% speed)
+  let speedBonus = setup.machine.effect_receiver?.base_effect?.speed ?? 0;
+  let productivityBonus = setup.machine.effect_receiver?.base_effect?.productivity ?? 0;
+
+  // Machine Base Speed & Quality
+  const machineQualityMultiplier = getQualityMultiplier(setup.machineQualityLevel ?? 0);
+  const baseSpeed = setup.machine.crafting_speed * machineQualityMultiplier;
+
+  // Machine Internal Modules
+  setup.machineModules.forEach(({ module, qualityLevel }) => {
+    const modQualityMultiplier = getQualityMultiplier(qualityLevel);
+    if (module.effect?.speed) speedBonus += module.effect.speed * modQualityMultiplier;
+    if (module.effect?.productivity) productivityBonus += module.effect.productivity * modQualityMultiplier;
+  });
+
+  // Beacon Transmissions
+  let beaconCount = setup.beacons.reduce((acc, b) => acc + b.count, 0);
+  setup.beacons.forEach((b) => {
+    let beaconSpeed = 0;
+    let beaconProd = 0;
+
+    b.modules.forEach(({ module, qualityLevel }) => {
+      const modQualityMultiplier = getQualityMultiplier(qualityLevel);
+      if (module.effect?.speed) beaconSpeed += module.effect.speed * modQualityMultiplier;
+      if (module.effect?.productivity) beaconProd += module.effect.productivity * modQualityMultiplier;
+    });
+
+    const transmissionStrength = getTransmissionStrength(b.beacon, beaconCount, b.beaconQualityLevel);
+    speedBonus += beaconSpeed * transmissionStrength * b.count;
+    productivityBonus += beaconProd * transmissionStrength * b.count;
+  });
+
+  // Minimum speed multiplier in Factorio is 0.2 (-80%)
+  const effectiveSpeedMultiplier = Math.max(0.2, 1 + speedBonus);
+  const actualCraftingSpeed = baseSpeed * effectiveSpeedMultiplier;
+
+  // Timings & Insertion Limits
+  const energyRequired = recipe.energy_required ?? 0.5;
+  const craftTimeSeconds = energyRequired / actualCraftingSpeed;
+  const singleCraftTicks = craftTimeSeconds * 60;
+
+  let overloadMultiplier = recipe.overload_multiplier;
+  if (!overloadMultiplier || overloadMultiplier === 0) {
+    overloadMultiplier = Math.max(2, Math.min(100, Math.ceil(1.166 / craftTimeSeconds)));
+  }
+
+  return {
+    actualCraftingSpeed,
+    productivityBonus,
+    singleCraftTicks,
+    craftsPerSecond: 1 / craftTimeSeconds,
+    overloadMultiplier,
+  };
+}

+ 164 - 0
src/engine/timeline.test.ts

@@ -0,0 +1,164 @@
+import { describe, it, expect } from 'vitest';
+import { generateAdvancedClock } from './timeline';
+import type { BatchPlan, ClockConfig } from './types';
+
+function assertBlocksWithinBounds(timeline: ReturnType<typeof generateAdvancedClock>) {
+  timeline.blocks.forEach(block => {
+    expect(block.start).toBeGreaterThanOrEqual(0);
+    expect(block.start).toBeLessThan(timeline.duration);
+  });
+}
+
+describe('Timeline Generator', () => {
+  it('schedules simple 1:1 recipes perfectly (Input anticipates, Output extracts after craft)', () => {
+    // A slow machine doing a 1-to-1 craft. 16 crafts = 960 ticks.
+    const mockBatch: BatchPlan = {
+      craftsPerCycle: 16,
+      durationTicks: 960, // 16 crafts * 60 ticks
+      timings: { actualCraftingSpeed: 1, productivityBonus: 0, singleCraftTicks: 60, craftsPerSecond: 1, overloadMultiplier: 2 },
+      inputs: { 'iron-plate': { totalAmount: 16, baseAmount: 1 } },
+      outputs: { 'iron-gear-wheel': { totalAmount: 16, baseAmount: 1, yieldPerCraft: 1, outputBlockLimit: 2 } }
+    };
+
+    const mockConfig: ClockConfig = {
+      inputs: { 'iron-plate': { presetId: 'chest', stackSize: 16, swingTicks: 8, inserterId: 'in' } },
+      outputs: { 'iron-gear-wheel': { presetId: 'chest', stackSize: 16, swingTicks: 8, inserterId: 'out' } },
+      machineCount: 1
+    };
+
+    const timeline = generateAdvancedClock(mockBatch, mockConfig);
+
+    expect(timeline.duration).toBe(960);
+    
+    // Should generate exactly 1 input block and 1 output block (1 swing of 16 each)
+    expect(timeline.blocks.length).toBe(2);
+    const inBlock = timeline.blocks.find(b => b.rowId === 'row-in');
+    const outBlock = timeline.blocks.find(b => b.rowId === 'row-out');
+
+    expect(inBlock).toBeDefined();
+    expect(outBlock).toBeDefined();
+
+    // Input math: currentArrivalTick = 0. startTick = 0 - 8(swing) = -8. 
+    // Wrapped to cycle: 960 - 8 = 952.
+    // This is perfect: the inserter starts swinging 8 ticks BEFORE the cycle technically starts, 
+    // so the items hit the machine exactly at Tick 0.
+    expect(inBlock!.start).toBe(952);
+    expect(inBlock!.repeat).toBe(1);
+
+    // Output math: currentReadyTick = 16 items / (1/60 items per tick) = 960.
+    // startTick = 960 - 8(swing) = 952.
+    // It extracts the finished stack at the exact moment the final craft finishes!
+    expect(outBlock!.start).toBe(952);
+    expect(outBlock!.repeat).toBe(1);
+
+    assertBlocksWithinBounds(timeline);
+  });
+  it('schedules mixed belts on the same row without overlapping', () => {
+    const mockBatch: BatchPlan = {
+      craftsPerCycle: 16,
+      durationTicks: 400,
+      timings: { actualCraftingSpeed: 1, productivityBonus: 0, singleCraftTicks: 25, craftsPerSecond: 2.4, overloadMultiplier: 100 },
+      inputs: {
+        'iron-plate': { totalAmount: 16, baseAmount: 1 },
+        'copper-plate': { totalAmount: 16, baseAmount: 1 }
+      },
+      outputs: {}
+    };
+
+    // Both inputs assigned to "in-1" (Mixed Belt)
+    const mockConfig: ClockConfig = {
+      inputs: {
+        'iron-plate': { presetId: 'belt', stackSize: 16, swingTicks: 12, inserterId: 'in-1' },
+        'copper-plate': { presetId: 'belt', stackSize: 16, swingTicks: 12, inserterId: 'in-1' }
+      },
+      outputs: {}
+    };
+
+    const timeline = generateAdvancedClock(mockBatch, mockConfig);
+
+    // Should only create 1 input row since they share 'in-1'
+    expect(timeline.rows.length).toBe(1);
+    expect(timeline.rows[0].id).toBe('row-in-1');
+    expect(timeline.rows[0].signals).toHaveLength(2); // Row has both iron and copper signals
+
+    // Should create 2 blocks (one for iron, one for copper)
+    expect(timeline.blocks.length).toBe(2);
+
+    // Verify they do not overlap
+    const block1 = timeline.blocks[0];
+    const block2 = timeline.blocks[1];
+    
+    const block1End = block1.start + block1.duration;
+    // Block 2 must start AFTER Block 1 finishes its swing
+    expect(block2.start).toBeGreaterThanOrEqual(block1End);
+    assertBlocksWithinBounds(timeline);
+  });
+  it('groups swings into massive bursts when the overload limit is huge', () => {
+    // Fast machine, massive buffer (100). Batch cycle is 4 full swings of 16 (64 total).
+    const mockBatch: BatchPlan = {
+      craftsPerCycle: 64,
+      durationTicks: 640, // 64 crafts * 10 ticks
+      timings: { actualCraftingSpeed: 6, productivityBonus: 0, singleCraftTicks: 10, craftsPerSecond: 6, overloadMultiplier: 100 },
+      inputs: { 'iron-plate': { totalAmount: 64, baseAmount: 1 } },
+      outputs: { 'iron-gear-wheel': { totalAmount: 64, baseAmount: 1, yieldPerCraft: 1, outputBlockLimit: 100 } }
+    };
+
+    const mockConfig: ClockConfig = {
+      inputs: { 'iron-plate': { presetId: 'chest', stackSize: 16, swingTicks: 8, inserterId: 'in' } },
+      outputs: { 'iron-gear-wheel': { presetId: 'chest', stackSize: 16, swingTicks: 8, inserterId: 'out' } },
+      machineCount: 1
+    };
+
+    const timeline = generateAdvancedClock(mockBatch, mockConfig);
+
+    const inBlock = timeline.blocks.find(b => b.rowId === 'row-in');
+    
+    // Because the overload multiplier is 100, the machine can hold all 64 items at once.
+    // The generator should condense all 4 swings into a single Block with repeat = 4!
+    expect(timeline.blocks.filter(b => b.rowId === 'row-in').length).toBe(1);
+    expect(inBlock!.repeat).toBe(4);
+    
+    assertBlocksWithinBounds(timeline);
+  });
+  it('schedules mixed belts safely without overlapping swings', () => {
+    const mockBatch: BatchPlan = {
+      craftsPerCycle: 16,
+      durationTicks: 400,
+      timings: { actualCraftingSpeed: 1, productivityBonus: 0, singleCraftTicks: 25, craftsPerSecond: 2.4, overloadMultiplier: 100 },
+      inputs: {
+        'iron-plate': { totalAmount: 16, baseAmount: 1 },
+        'copper-plate': { totalAmount: 16, baseAmount: 1 }
+      },
+      outputs: {}
+    };
+
+    // Both inputs assigned to "in-1" (Mixed Belt)
+    const mockConfig: ClockConfig = {
+      inputs: {
+        'iron-plate': { presetId: 'belt', stackSize: 16, swingTicks: 12, inserterId: 'in-1' },
+        'copper-plate': { presetId: 'belt', stackSize: 16, swingTicks: 12, inserterId: 'in-1' }
+      },
+      outputs: {}
+    };
+
+    const timeline = generateAdvancedClock(mockBatch, mockConfig);
+
+    // Should only create 1 input row since they share 'in-1'
+    expect(timeline.rows.length).toBe(1);
+    expect(timeline.rows[0].id).toBe('row-in-1');
+    expect(timeline.rows[0].signals).toHaveLength(2); // Row has both iron and copper signals
+
+    // Should create 2 blocks (one for iron, one for copper)
+    expect(timeline.blocks.length).toBe(2);
+
+    // Verify they do not overlap
+    const block1 = timeline.blocks[0];
+    const block2 = timeline.blocks[1];
+    
+    const block1End = block1.start + (block1.duration * block1.repeat);
+    // Block 2 must start AFTER Block 1 finishes its swing, proving the busy-wait tracker works!
+    expect(block2.start).toBeGreaterThanOrEqual(block1End);
+
+    assertBlocksWithinBounds(timeline);
+  });
+});

+ 140 - 0
src/engine/timeline.ts

@@ -0,0 +1,140 @@
+import { defaultClockSignal } from "../store/useClockStore";
+import type { BatchPlan, ClockConfig } from "./types";
+
+/**
+ * Translates an optimized batch plan into a precise sequence of inserter swings (ClockBlocks).
+ * It simulates the machine's virtual inventory to group swings into bursts safely, 
+ * and uses a busy-wait tracker to schedule multiple ingredients safely onto mixed belts.
+ */
+export function generateAdvancedClock(batch: BatchPlan, config: ClockConfig) {
+  const rowMap: Record<string, any> = {};
+  const blocks: any[] = [];
+
+  // Tracks the tick when an inserter finishes swinging, preventing mixed-belt collisions
+  const inserterBusyUntil: Record<string, number> = {};
+
+  const getOrCreateRow = (inserterId: string, itemId: string, isInput: boolean, stackSize: number) => {
+    if (!rowMap[inserterId]) {
+      rowMap[inserterId] = {
+        id: `row-${inserterId}`,
+        name: isInput ? `Input ${inserterId}` : `Output ${inserterId}`,
+        signals: [],
+        stackSize,
+        inserterCount: config.machineCount || 1,
+      };
+    }
+    // Add signal to the combinator if it's not already there (Mixed Belt handling)
+    if (!rowMap[inserterId].signals.find((s: any) => s.name === itemId)) {
+      rowMap[inserterId].signals.push({ type: "item", name: itemId, subgroup: "intermediate-product" });
+    }
+    return rowMap[inserterId].id;
+  };
+
+  // --- Process Inputs ---
+  Object.entries(batch.inputs).forEach(([itemId, data]) => {
+    const cfg = config.inputs[itemId] || { presetId: "chest_to_chest", swingTicks: 8, stackSize: 16 };
+    const inserterId = cfg.inserterId || `in-${itemId}`;
+    const rowId = getOrCreateRow(inserterId, itemId, true, cfg.stackSize);
+
+    const totalSwings = data.totalAmount / cfg.stackSize;
+    const consumptionPerTick = data.baseAmount / batch.timings.singleCraftTicks;
+    const bufferLimit = data.baseAmount * batch.timings.overloadMultiplier;
+
+    let maxBurst = 0;
+    for (let k = 0; k < totalSwings; k++) {
+      const invAtStartOfSwing = k * cfg.stackSize - (k - 1) * cfg.swingTicks * consumptionPerTick;
+      if (invAtStartOfSwing < bufferLimit) maxBurst = k + 1;
+      else break;
+    }
+    if (maxBurst === 0) maxBurst = 1;
+
+    let swingsLeft = totalSwings;
+    let currentArrivalTick = 0;
+    let blockIndex = 0;
+
+    while (swingsLeft > 0) {
+      const burst = Math.min(swingsLeft, maxBurst);
+      let startTick = Math.round(currentArrivalTick - cfg.swingTicks);
+      
+      // Ensure positive tick space before tracking busy state
+      startTick = ((startTick % batch.durationTicks) + batch.durationTicks) % batch.durationTicks;
+
+      // Mixed Belt Scheduling: Wait if the inserter is busy
+      const busyUntil = inserterBusyUntil[inserterId] || 0;
+      if (startTick < busyUntil) {
+        startTick = busyUntil; 
+      }
+
+      const duration = cfg.swingTicks; // FIXED: No more +1
+      
+      // Track chronological busy time (can exceed durationTicks chronologically)
+      inserterBusyUntil[inserterId] = startTick + (burst * duration); 
+
+      const safeStartTick = startTick % batch.durationTicks;
+
+      blocks.push({
+        id: `block-in-${itemId}-${blockIndex++}`,
+        rowId,
+        presetId: cfg.presetId,
+        start: safeStartTick, 
+        duration,
+        count: cfg.stackSize,
+        repeat: burst,
+      });
+
+      const spanTicks = (burst * cfg.stackSize) / consumptionPerTick;
+      currentArrivalTick += spanTicks;
+      swingsLeft -= burst;
+    }
+  });
+
+  // --- Process Outputs ---
+  Object.entries(batch.outputs).forEach(([itemId, data]) => {
+    const cfg = config.outputs[itemId] || { presetId: "chest_to_chest", swingTicks: 8, stackSize: 16 };
+    const inserterId = cfg.inserterId || `out-${itemId}`;
+
+    // Force partial swings if the machine's Output Block limit is smaller than a full stack
+    const extractionTrigger = Math.min(cfg.stackSize, data.outputBlockLimit);
+    const rowId = getOrCreateRow(inserterId, itemId, false, extractionTrigger);
+
+    const productionPerTick = data.yieldPerCraft / batch.timings.singleCraftTicks;
+    let amountLeft = data.totalAmount;
+    let currentReadyTick = 0;
+    let blockIndex = 0;
+
+    while (amountLeft > 0) {
+      const amountToExtract = Math.min(amountLeft, extractionTrigger);
+      const spanTicks = amountToExtract / productionPerTick;
+      currentReadyTick += spanTicks;
+
+      let startTick = Math.round(currentReadyTick - cfg.swingTicks);
+      startTick = ((startTick % batch.durationTicks) + batch.durationTicks) % batch.durationTicks;
+
+      // Mixed Belt Scheduling for Outputs
+      const busyUntil = inserterBusyUntil[inserterId] || 0;
+      if (startTick < busyUntil) startTick = busyUntil;
+
+      const duration = cfg.swingTicks + 1;
+      inserterBusyUntil[inserterId] = startTick + duration;
+
+      blocks.push({
+        id: `block-out-${itemId}-${blockIndex++}`,
+        rowId,
+        presetId: cfg.presetId,
+        start: startTick,
+        duration,
+        count: amountToExtract,
+        repeat: 1,
+      });
+
+      amountLeft -= amountToExtract;
+    }
+  });
+
+  return {
+    duration: batch.durationTicks,
+    clockSignal: defaultClockSignal,
+    rows: Object.values(rowMap),
+    blocks,
+  };
+}

+ 47 - 0
src/engine/types.ts

@@ -0,0 +1,47 @@
+import type { Beacon, Machine, Module } from "../../scripts/factorio-dump/process-data.models";
+
+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; 
+}
+
+export interface ClockConfig {
+  /** Configurations for inputs, keyed by the item's name */
+  inputs: Record<string, InserterConfig>;
+  /** Configurations for outputs, keyed by the item's name */
+  outputs: Record<string, InserterConfig>;
+  /** Target number of machines to scale the final inserter blueprint */
+  machineCount?: number;
+}
+
+export interface MachineSetup {
+  machine: Machine;
+  machineQualityLevel?: number; // 0 = Normal, 1 = Uncommon, 2 = Rare, 3 = Epic, 4 = Legendary
+  machineModules: Array<{ module: Module; qualityLevel: number }>;
+  beacons: Array<{
+    beacon: Beacon;
+    beaconQualityLevel?: number;
+    count: number;
+    modules: Array<{ module: Module; qualityLevel: number }>;
+  }>;
+}
+
+export interface CalculatedTimings {
+  actualCraftingSpeed: number;
+  productivityBonus: number;
+  singleCraftTicks: number;
+  craftsPerSecond: number;
+  /** The maximum multiplier of a recipe's base ingredients an assembler will buffer before stalling input inserters */
+  overloadMultiplier: number;
+}
+
+export interface BatchPlan {
+  craftsPerCycle: number;
+  durationTicks: number;
+  timings: CalculatedTimings;
+  inputs: Record<string, { totalAmount: number; baseAmount: number }>;
+  outputs: Record<string, { totalAmount: number; baseAmount: number; yieldPerCraft: number; outputBlockLimit: number }>;
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است