|
|
@@ -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);
|
|
|
+ });
|
|
|
+});
|