|
@@ -1,159 +1,126 @@
|
|
|
-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', () => {
|
|
|
|
|
|
|
+import { describe, it, expect, vi } from "vitest";
|
|
|
|
|
+import * as statsModule from "./stats";
|
|
|
|
|
+import {
|
|
|
|
|
+ MachineSimulator,
|
|
|
|
|
+ InserterSimulator,
|
|
|
|
|
+ Chest,
|
|
|
|
|
+ InserterState,
|
|
|
|
|
+ FactorioEngineOrchestrator,
|
|
|
|
|
+ Belt,
|
|
|
|
|
+} 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 = {
|
|
const mockMachineSetup: MachineSetup = {
|
|
|
machine: { crafting_speed: 1 } as Machine,
|
|
machine: { crafting_speed: 1 } as Machine,
|
|
|
machineModules: [],
|
|
machineModules: [],
|
|
|
beacons: [],
|
|
beacons: [],
|
|
|
- machineQualityLevel: 0
|
|
|
|
|
|
|
+ machineQualityLevel: 0,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const basicRecipe: Recipe = {
|
|
const basicRecipe: Recipe = {
|
|
|
- name: 'iron-gear-wheel',
|
|
|
|
|
|
|
+ name: "iron-gear-wheel",
|
|
|
energy_required: 0.5, // 30 ticks
|
|
energy_required: 0.5, // 30 ticks
|
|
|
- ingredients: [{ type: 'item', name: 'iron-plate', amount: 2 }],
|
|
|
|
|
- results: [{ type: 'item', name: 'iron-gear-wheel', amount: 1 }]
|
|
|
|
|
|
|
+ ingredients: [{ type: "item", name: "iron-plate", amount: 2 }],
|
|
|
|
|
+ results: [{ type: "item", name: "iron-gear-wheel", amount: 1 }],
|
|
|
} as Recipe;
|
|
} as Recipe;
|
|
|
|
|
|
|
|
- it('Machine Simulator: Crafts correctly over time and consumes inputs instantly', () => {
|
|
|
|
|
- const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { 'iron-gear-wheel': 100 });
|
|
|
|
|
-
|
|
|
|
|
|
|
+ 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
|
|
// Feed it enough for 2 crafts
|
|
|
- machine.inputBuffer['iron-plate'] = 4;
|
|
|
|
|
-
|
|
|
|
|
|
|
+ machine.inputBuffer["iron-plate"] = 4;
|
|
|
|
|
+
|
|
|
machine.tick();
|
|
machine.tick();
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
// Tick 1: Inputs consumed instantly, craft starts
|
|
// Tick 1: Inputs consumed instantly, craft starts
|
|
|
expect(machine.isCrafting).toBe(true);
|
|
expect(machine.isCrafting).toBe(true);
|
|
|
- expect(machine.inputBuffer['iron-plate']).toBe(2);
|
|
|
|
|
- expect(machine.outputBuffer['iron-gear-wheel']).toBe(0);
|
|
|
|
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(2);
|
|
|
|
|
+ expect(machine.outputBuffer["iron-gear-wheel"]).toBe(0);
|
|
|
|
|
|
|
|
// Fast forward 28 ticks (29 total)
|
|
// Fast forward 28 ticks (29 total)
|
|
|
for (let i = 0; i < 28; i++) machine.tick();
|
|
for (let i = 0; i < 28; i++) machine.tick();
|
|
|
- expect(machine.outputBuffer['iron-gear-wheel']).toBe(0); // Not done yet
|
|
|
|
|
|
|
+ expect(machine.outputBuffer["iron-gear-wheel"]).toBe(0); // Not done yet
|
|
|
|
|
|
|
|
// Tick 30: Craft completes!
|
|
// Tick 30: Craft completes!
|
|
|
machine.tick();
|
|
machine.tick();
|
|
|
- expect(machine.outputBuffer['iron-gear-wheel']).toBe(1);
|
|
|
|
|
-
|
|
|
|
|
|
|
+ expect(machine.outputBuffer["iron-gear-wheel"]).toBe(1);
|
|
|
|
|
+
|
|
|
machine.tick();
|
|
machine.tick();
|
|
|
// Tick 31: It should immediately start the next craft in the same tick if buffer allows
|
|
// Tick 31: It should immediately start the next craft in the same tick if buffer allows
|
|
|
expect(machine.isCrafting).toBe(true);
|
|
expect(machine.isCrafting).toBe(true);
|
|
|
- expect(machine.inputBuffer['iron-plate']).toBe(0);
|
|
|
|
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(0);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- it('Machine Simulator: Processes multiple crafts in a single tick if speed is extreme', () => {
|
|
|
|
|
|
|
+ it("Machine Simulator: Processes multiple crafts in a single tick if speed is extreme", () => {
|
|
|
const fastSetup: MachineSetup = {
|
|
const fastSetup: MachineSetup = {
|
|
|
machine: { crafting_speed: 60 } as Machine, // 1 craft per tick!
|
|
machine: { crafting_speed: 60 } as Machine, // 1 craft per tick!
|
|
|
machineModules: [],
|
|
machineModules: [],
|
|
|
beacons: [],
|
|
beacons: [],
|
|
|
- machineQualityLevel: 0
|
|
|
|
|
|
|
+ machineQualityLevel: 0,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const machine = new MachineSimulator(fastSetup, basicRecipe, { 'iron-gear-wheel': 100 });
|
|
|
|
|
- machine.inputBuffer['iron-plate'] = 20; // 10 crafts worth
|
|
|
|
|
|
|
+ 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)
|
|
// Tick 1: Should complete 2 crafts instantly (60 speed / 30 ticks required = 2 per tick)
|
|
|
machine.tick();
|
|
machine.tick();
|
|
|
-
|
|
|
|
|
- expect(machine.inputBuffer['iron-plate']).toBe(16); // 20 - 4
|
|
|
|
|
- expect(machine.outputBuffer['iron-gear-wheel']).toBe(2);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ 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');
|
|
|
|
|
|
|
+ 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);
|
|
expect(inserter.state).toBe(InserterState.Idle);
|
|
|
|
|
|
|
|
- inserter.tick();
|
|
|
|
|
|
|
+ inserter.tick();
|
|
|
// Tick 1: Wakes up, extracts instantly, and transitions to SwingingForward
|
|
// Tick 1: Wakes up, extracts instantly, and transitions to SwingingForward
|
|
|
expect(inserter.state).toBe(InserterState.SwingingForward);
|
|
expect(inserter.state).toBe(InserterState.SwingingForward);
|
|
|
|
|
|
|
|
- inserter.tick();
|
|
|
|
|
- inserter.tick();
|
|
|
|
|
- inserter.tick();
|
|
|
|
|
|
|
+ inserter.tick();
|
|
|
|
|
+ inserter.tick();
|
|
|
|
|
+ inserter.tick();
|
|
|
// Ticks 2-4: Rotation (3 ticks) complete, transition to Dropping
|
|
// Ticks 2-4: Rotation (3 ticks) complete, transition to Dropping
|
|
|
expect(inserter.state).toBe(InserterState.Dropping);
|
|
expect(inserter.state).toBe(InserterState.Dropping);
|
|
|
|
|
|
|
|
- inserter.tick();
|
|
|
|
|
|
|
+ inserter.tick();
|
|
|
// Tick 5: Drop complete (1 tick machine), contents deposited!
|
|
// Tick 5: Drop complete (1 tick machine), contents deposited!
|
|
|
- expect(machine.inputBuffer['iron-plate']).toBe(16);
|
|
|
|
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(16);
|
|
|
expect(inserter.state).toBe(InserterState.SwingingBack);
|
|
expect(inserter.state).toBe(InserterState.SwingingBack);
|
|
|
|
|
|
|
|
inserter.tick();
|
|
inserter.tick();
|
|
|
inserter.tick();
|
|
inserter.tick();
|
|
|
- inserter.tick();
|
|
|
|
|
|
|
+ inserter.tick();
|
|
|
// Ticks 6-8: Rotation back (3 ticks)
|
|
// Ticks 6-8: Rotation back (3 ticks)
|
|
|
expect(inserter.state).toBe(InserterState.Idle);
|
|
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', () => {
|
|
|
|
|
|
|
+ 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
|
|
// 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.
|
|
// For this test, we assume computeMachineStats returned an overloadMultiplier of 3.
|
|
|
- const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { 'iron-gear-wheel': 100 });
|
|
|
|
|
|
|
+ const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { "iron-gear-wheel": 100 });
|
|
|
machine.timings.overloadMultiplier = 3; // Forcing it to 3 for the test assertion
|
|
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');
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const sourceChest = new Chest("iron-plate");
|
|
|
|
|
+ const inserter = new InserterSimulator(16, sourceChest, machine, "iron-plate");
|
|
|
|
|
|
|
|
expect(machine.timings.overloadMultiplier).toBe(3);
|
|
expect(machine.timings.overloadMultiplier).toBe(3);
|
|
|
// Overload limit = 2 (recipe amount) * 3 = 6 iron plates limit.
|
|
// 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)
|
|
// 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
|
|
for (let i = 0; i < 5; i++) inserter.tick(); // 5 ticks for Chest->Machine swing
|
|
|
-
|
|
|
|
|
- expect(machine.inputBuffer['iron-plate']).toBe(16); // Overstuffed!
|
|
|
|
|
|
|
+
|
|
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(16); // Overstuffed!
|
|
|
|
|
|
|
|
// The machine instantly consumes 2, leaving 14.
|
|
// The machine instantly consumes 2, leaving 14.
|
|
|
machine.tick();
|
|
machine.tick();
|
|
|
- expect(machine.inputBuffer['iron-plate']).toBe(14);
|
|
|
|
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(14);
|
|
|
|
|
|
|
|
// The inserter returns to idle
|
|
// The inserter returns to idle
|
|
|
for (let i = 0; i < 3; i++) inserter.tick(); // 3 ticks for rotation back
|
|
for (let i = 0; i < 3; i++) inserter.tick(); // 3 ticks for rotation back
|
|
@@ -163,5 +130,360 @@ describe('Simulator Engine', () => {
|
|
|
inserter.tick();
|
|
inserter.tick();
|
|
|
expect(inserter.state).toBe(InserterState.Idle);
|
|
expect(inserter.state).toBe(InserterState.Idle);
|
|
|
});
|
|
});
|
|
|
-
|
|
|
|
|
-});
|
|
|
|
|
|
|
+});
|
|
|
|
|
+describe("Factorio Strict Phase Orchestrator", () => {
|
|
|
|
|
+ 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("End-to-End: Proves the 1-tick delay caused by Inserters updating before Machines", () => {
|
|
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
|
|
+
|
|
|
|
|
+ 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");
|
|
|
|
|
+
|
|
|
|
|
+ orchestrator.registerInserter(inputInserter);
|
|
|
|
|
+ orchestrator.registerInserter(outputInserter);
|
|
|
|
|
+ orchestrator.registerMachine(machine);
|
|
|
|
|
+
|
|
|
|
|
+ orchestrator.tickUntil(() => (sinkChest.receivedCounts["iron-gear-wheel"] || 0) >= 1);
|
|
|
|
|
+
|
|
|
|
|
+ // EXACT TIMELINE TRACE:
|
|
|
|
|
+ // T1: Input Picks.
|
|
|
|
|
+ // T2-T4: Input Swings.
|
|
|
|
|
+ // T5 (Inserter Phase): Input Drops item into machine. Output is asleep.
|
|
|
|
|
+ // T5 (Machine Phase): Machine sees item, crafts (1/30).
|
|
|
|
|
+ // ...
|
|
|
|
|
+ // T34 (Machine Phase): Craft finishes (30/30). Item is output!
|
|
|
|
|
+ // T35 (Inserter Phase): Output finally sees item, wakes up, Picks. (1 TICK DELAY PROVEN!)
|
|
|
|
|
+ // T36-38: Output Swings.
|
|
|
|
|
+ // T39: Output Drops item into chest.
|
|
|
|
|
+
|
|
|
|
|
+ expect(orchestrator.currentTick).toBe(39);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("Round-Robin: Two inserters compete for same output and share perfectly", () => {
|
|
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
|
|
+ const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { "iron-gear-wheel": 100 });
|
|
|
|
|
+
|
|
|
|
|
+ const sourceChest = new Chest("iron-plate");
|
|
|
|
|
+ const sinkChestA = new Chest();
|
|
|
|
|
+ const sinkChestB = new Chest();
|
|
|
|
|
+
|
|
|
|
|
+ const input = new InserterSimulator(16, sourceChest, machine, "iron-plate");
|
|
|
|
|
+
|
|
|
|
|
+ // Both output inserters have a hand size of 1
|
|
|
|
|
+ const outA = new InserterSimulator(1, machine, sinkChestA, "iron-gear-wheel");
|
|
|
|
|
+ const outB = new InserterSimulator(1, machine, sinkChestB, "iron-gear-wheel");
|
|
|
|
|
+
|
|
|
|
|
+ orchestrator.registerInserter(input);
|
|
|
|
|
+ orchestrator.registerInserter(outA); // A registered first!
|
|
|
|
|
+ orchestrator.registerInserter(outB);
|
|
|
|
|
+ orchestrator.registerMachine(machine);
|
|
|
|
|
+
|
|
|
|
|
+ // Run until 4 items are produced and extracted
|
|
|
|
|
+ orchestrator.tickUntil(
|
|
|
|
|
+ () =>
|
|
|
|
|
+ (sinkChestA.receivedCounts["iron-gear-wheel"] || 0) + (sinkChestB.receivedCounts["iron-gear-wheel"] || 0) === 4,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // If build-order was absolute, A would have 4 and B would have 0.
|
|
|
|
|
+ // Because of the Round-Robin queue, they should have exactly 2 each!
|
|
|
|
|
+ expect(sinkChestA.receivedCounts["iron-gear-wheel"]).toBe(2);
|
|
|
|
|
+ expect(sinkChestB.receivedCounts["iron-gear-wheel"]).toBe(2);
|
|
|
|
|
+ });
|
|
|
|
|
+ it("Progressive Belt Pickup: Stack inserter extracts 4 items per tick until full", () => {
|
|
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
|
|
+
|
|
|
|
|
+ // Using the Belt helper from our previous setup
|
|
|
|
|
+ const sourceBelt = new Belt("iron-plate");
|
|
|
|
|
+ const sinkChest = new Chest();
|
|
|
|
|
+
|
|
|
|
|
+ // We set a weird hand size of 14.
|
|
|
|
|
+ // At 4 items per tick, this should take exactly 4 ticks (4, 4, 4, 2).
|
|
|
|
|
+ const inserter = new InserterSimulator(14, sourceBelt, sinkChest, "iron-plate");
|
|
|
|
|
+ orchestrator.registerInserter(inserter);
|
|
|
|
|
+
|
|
|
|
|
+ // Tick 1: Wakes up, picks 4.
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ expect(inserter.state).toBe(InserterState.Picking);
|
|
|
|
|
+ expect(inserter.heldItems).toBe(4);
|
|
|
|
|
+
|
|
|
|
|
+ // Tick 2: Picks 4.
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ expect(inserter.heldItems).toBe(8);
|
|
|
|
|
+
|
|
|
|
|
+ // Tick 3: Picks 4.
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ expect(inserter.heldItems).toBe(12);
|
|
|
|
|
+
|
|
|
|
|
+ // Tick 4: Picks remaining 2. Hand is full! Instantly transitions to Swinging.
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ expect(inserter.state).toBe(InserterState.SwingingForward);
|
|
|
|
|
+ expect(inserter.heldItems).toBe(14);
|
|
|
|
|
+ });
|
|
|
|
|
+ it("Round-Robin: Two inserters compete 4 stack for same output and share perfectly", () => {
|
|
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
|
|
+ const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { "iron-gear-wheel": 100 });
|
|
|
|
|
+
|
|
|
|
|
+ const sourceChest = new Chest("iron-plate");
|
|
|
|
|
+ const sinkChestA = new Chest();
|
|
|
|
|
+ const sinkChestB = new Chest();
|
|
|
|
|
+
|
|
|
|
|
+ const input = new InserterSimulator(16, sourceChest, machine, "iron-plate");
|
|
|
|
|
+
|
|
|
|
|
+ // Both output inserters have a hand size of 4
|
|
|
|
|
+ const outA = new InserterSimulator(4, machine, sinkChestA, "iron-gear-wheel");
|
|
|
|
|
+ const outB = new InserterSimulator(4, machine, sinkChestB, "iron-gear-wheel");
|
|
|
|
|
+
|
|
|
|
|
+ orchestrator.registerInserter(input);
|
|
|
|
|
+ orchestrator.registerInserter(outA); // A registered first!
|
|
|
|
|
+ orchestrator.registerInserter(outB);
|
|
|
|
|
+ orchestrator.registerMachine(machine);
|
|
|
|
|
+
|
|
|
|
|
+ // Run until 4 items are produced and extracted
|
|
|
|
|
+ orchestrator.tickUntil(() => false, 4 + 30 * 4 + 1);
|
|
|
|
|
+
|
|
|
|
|
+ // If build-order was absolute, A would have 4 and B would have 0.
|
|
|
|
|
+ // Because of the Round-Robin queue, they should have exactly 2 each!
|
|
|
|
|
+ expect(outA.heldItems).toBe(2);
|
|
|
|
|
+ expect(outB.heldItems).toBe(2);
|
|
|
|
|
+
|
|
|
|
|
+ orchestrator.tickUntil(() => sinkChestB.receivedCounts["iron-gear-wheel"] == 4);
|
|
|
|
|
+
|
|
|
|
|
+ expect(sinkChestB.receivedCounts["iron-gear-wheel"]).toBe(4);
|
|
|
|
|
+ expect(outA.heldItems).toBe(0);
|
|
|
|
|
+ expect(outB.heldItems).toBe(0);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("True Round-Robin: Stack inserters alternate picking from a machine while hovering", () => {
|
|
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
|
|
+
|
|
|
|
|
+ // Custom recipe: Produces exactly 2 gears per craft
|
|
|
|
|
+ const twoGearRecipe: Recipe = {
|
|
|
|
|
+ name: "iron-gear-wheel",
|
|
|
|
|
+ energy_required: 0.5,
|
|
|
|
|
+ ingredients: [{ type: "item", name: "iron-plate", amount: 2 }],
|
|
|
|
|
+ results: [{ type: "item", name: "iron-gear-wheel", amount: 2 }], // Yields 2!
|
|
|
|
|
+ } as Recipe;
|
|
|
|
|
+
|
|
|
|
|
+ // Use quick machine (instant craft, 1 craft per tick)
|
|
|
|
|
+ const machine = new MachineSimulator(
|
|
|
|
|
+ { machine: { crafting_speed: 30 } as Machine, machineModules: [], beacons: [] } as MachineSetup,
|
|
|
|
|
+ twoGearRecipe,
|
|
|
|
|
+ {
|
|
|
|
|
+ "iron-gear-wheel": 100,
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Infinite input so the machine never stops
|
|
|
|
|
+ const sourceChest = new Chest("iron-plate");
|
|
|
|
|
+ const sinkChest = new Chest();
|
|
|
|
|
+
|
|
|
|
|
+ // Input inserter to keep machine stuffed
|
|
|
|
|
+ const input = new InserterSimulator(16, sourceChest, machine, "iron-plate");
|
|
|
|
|
+
|
|
|
|
|
+ // The two competing output inserters
|
|
|
|
|
+ const outA = new InserterSimulator(16, machine, sinkChest, "iron-gear-wheel");
|
|
|
|
|
+ const outB = new InserterSimulator(16, machine, sinkChest, "iron-gear-wheel");
|
|
|
|
|
+
|
|
|
|
|
+ // Register: outA gets priority first
|
|
|
|
|
+ orchestrator.registerInserter(input);
|
|
|
|
|
+ orchestrator.registerInserter(outA);
|
|
|
|
|
+ orchestrator.registerInserter(outB);
|
|
|
|
|
+ orchestrator.registerMachine(machine);
|
|
|
|
|
+
|
|
|
|
|
+ // --- Tick 1 ---
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ // Input grabs 16 plates.
|
|
|
|
|
+ // --- Tick 6 ---
|
|
|
|
|
+ // Fast forward to when input drops items into the machine
|
|
|
|
|
+ orchestrator.tickUntil(() => machine.inputBuffer["iron-plate"] > 0);
|
|
|
|
|
+ // Machine now has plates, and will craft 2 gears during its phase!
|
|
|
|
|
+
|
|
|
|
|
+ // --- Tick 7: The Competition Begins ---
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ // Inserter phase: outA wakes up, grabs the 2 gears. outB wakes up, but machine is empty.
|
|
|
|
|
+ expect(outA.state).toBe(InserterState.Picking);
|
|
|
|
|
+ expect(outB.state).toBe(InserterState.Idle);
|
|
|
|
|
+ expect(outA.heldItems).toBe(2);
|
|
|
|
|
+ expect(outB.heldItems).toBe(0);
|
|
|
|
|
+ // Machine phase: Crafts 2 more gears.
|
|
|
|
|
+
|
|
|
|
|
+ // --- Tick 8: Alternation! ---
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ // Inserter phase: outB has priority now! B grabs 2 gears. A gets nothing.
|
|
|
|
|
+ expect(outA.heldItems).toBe(2);
|
|
|
|
|
+ expect(outB.heldItems).toBe(2);
|
|
|
|
|
+ // Machine phase: Crafts 2 more gears.
|
|
|
|
|
+
|
|
|
|
|
+ // --- Tick 9 ---
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ // Inserter phase: outA has priority! A grabs 2 gears (now holding 4).
|
|
|
|
|
+ expect(outA.heldItems).toBe(4);
|
|
|
|
|
+ expect(outB.heldItems).toBe(2);
|
|
|
|
|
+
|
|
|
|
|
+ // --- Tick 10 ---
|
|
|
|
|
+ orchestrator.tick();
|
|
|
|
|
+ // B's turn again.
|
|
|
|
|
+ expect(outA.heldItems).toBe(4);
|
|
|
|
|
+ expect(outB.heldItems).toBe(4);
|
|
|
|
|
+
|
|
|
|
|
+ // Run until outA fills its hand (16 items) and transitions to SwingingForward
|
|
|
|
|
+ orchestrator.tickUntil(() => outA.state === InserterState.SwingingForward);
|
|
|
|
|
+
|
|
|
|
|
+ // Because they alternate 2 items at a time, when A hits 16 (and swings),
|
|
|
|
|
+ // B should be right behind it holding exactly 14!
|
|
|
|
|
+ expect(outA.heldItems).toBe(16);
|
|
|
|
|
+ expect(outB.heldItems).toBe(14);
|
|
|
|
|
+ expect(outB.state).toBe(InserterState.Picking);
|
|
|
|
|
+ });
|
|
|
|
|
+ it("Complex Recipe: Advanced Circuits with Productivity and multiple inputs", () => {
|
|
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
|
|
+
|
|
|
|
|
+ const advCircuitRecipe: Recipe = {
|
|
|
|
|
+ name: "advanced-circuit",
|
|
|
|
|
+ energy_required: 6, // 360 ticks
|
|
|
|
|
+ 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 }],
|
|
|
|
|
+ } as Recipe;
|
|
|
|
|
+
|
|
|
|
|
+ // A fast machine with +40% productivity
|
|
|
|
|
+ const advSetup: MachineSetup = {
|
|
|
|
|
+ machine: { crafting_speed: 6 } as Machine, // 60 ticks per craft
|
|
|
|
|
+ machineModules: [],
|
|
|
|
|
+ beacons: [],
|
|
|
|
|
+ machineQualityLevel: 0,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const machine = new MachineSimulator(advSetup, advCircuitRecipe, { "advanced-circuit": 200 });
|
|
|
|
|
+ // Manually inject productivity for test
|
|
|
|
|
+ machine.timings.productivityBonus = 0.4;
|
|
|
|
|
+ machine.timings.overloadMultiplier = 10;
|
|
|
|
|
+
|
|
|
|
|
+ const sourcePlastic = new Chest("plastic-bar");
|
|
|
|
|
+ const sourceCable = new Chest("copper-cable");
|
|
|
|
|
+ const sourceGreen = new Chest("electronic-circuit");
|
|
|
|
|
+ const sinkRed = new Chest();
|
|
|
|
|
+
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(4, sourcePlastic, machine, "plastic-bar"));
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(8, sourceCable, machine, "copper-cable"));
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(4, sourceGreen, machine, "electronic-circuit"));
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(16, machine, sinkRed, "advanced-circuit"));
|
|
|
|
|
+ orchestrator.registerMachine(machine);
|
|
|
|
|
+ // Run until 14 Red Chips are produced (10 base + 4 prod bonus)
|
|
|
|
|
+ const success = orchestrator.tickUntil(() => (sinkRed.receivedCounts["advanced-circuit"] || 0) >= 14);
|
|
|
|
|
+
|
|
|
|
|
+ expect(success).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it("Performance Stress Test: 1,000 machines for 10,000 ticks", () => {
|
|
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
|
|
+
|
|
|
|
|
+ const source = new Chest("iron-plate");
|
|
|
|
|
+ const sink = new Chest();
|
|
|
|
|
+
|
|
|
|
|
+ // Create a massive factory block
|
|
|
|
|
+ for (let i = 0; i < 1000; i++) {
|
|
|
|
|
+ const machine = new MachineSimulator(mockMachineSetup, basicRecipe, { "iron-gear-wheel": 100 });
|
|
|
|
|
+ orchestrator.registerMachine(machine);
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(16, source, machine, "iron-plate"));
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(16, machine, sink, "iron-gear-wheel"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const start = performance.now();
|
|
|
|
|
+
|
|
|
|
|
+ // Run for 10,000 ticks (about 2.7 minutes of real Factorio game time)
|
|
|
|
|
+ orchestrator.tickUntil(() => false, 10000);
|
|
|
|
|
+
|
|
|
|
|
+ const end = performance.now();
|
|
|
|
|
+ const durationMs = end - start;
|
|
|
|
|
+
|
|
|
|
|
+ // Ensure the O(1) optimizations keep it fast!
|
|
|
|
|
+ // 1000 machines * 2000 inserters * 10,000 ticks = 30 million entity ticks.
|
|
|
|
|
+ // Should easily run in under 500ms in Node.js/Vitest.
|
|
|
|
|
+ expect(durationMs).toBeLessThan(1000);
|
|
|
|
|
+
|
|
|
|
|
+ // Verify they actually produced items
|
|
|
|
|
+ expect(sink.receivedCounts["iron-gear-wheel"]).toBeGreaterThan(100000);
|
|
|
|
|
+ });
|
|
|
|
|
+ it("Endgame Setup: Legendary EMP with Beacons perfectly matches batch metrics", () => {
|
|
|
|
|
+ // ISOLATED MOCK: Intercept computeMachineStats only for this test
|
|
|
|
|
+ const statsSpy = vi.spyOn(statsModule, "computeMachineStats").mockReturnValue({
|
|
|
|
|
+ actualCraftingSpeed: 84,
|
|
|
|
|
+ productivityBonus: 1.75,
|
|
|
|
|
+ singleCraftTicks: 4.2857142857142865,
|
|
|
|
|
+ craftsPerSecond: 14,
|
|
|
|
|
+ overloadMultiplier: 17,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
|
|
+
|
|
|
|
|
+ const advCircuitRecipe: Recipe = {
|
|
|
|
|
+ name: "advanced-circuit",
|
|
|
|
|
+ energy_required: 6,
|
|
|
|
|
+ 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 }],
|
|
|
|
|
+ } as Recipe;
|
|
|
|
|
+
|
|
|
|
|
+ const empSetup: MachineSetup = {
|
|
|
|
|
+ machine: { name: "electromagnetic-plant" } as Machine,
|
|
|
|
|
+ machineModules: [],
|
|
|
|
|
+ beacons: [],
|
|
|
|
|
+ machineQualityLevel: 5,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // The MachineSimulator will call the spy during its constructor and get our exact timings
|
|
|
|
|
+ const machine = new MachineSimulator(empSetup, advCircuitRecipe, { "advanced-circuit": 200 });
|
|
|
|
|
+
|
|
|
|
|
+ expect((machine as any).outputBlockLimits["advanced-circuit"]).toBe(17);
|
|
|
|
|
+
|
|
|
|
|
+ const sourcePlastic = new Chest("plastic-bar");
|
|
|
|
|
+ const sourceCable = new Chest("copper-cable");
|
|
|
|
|
+ const sourceGreen = new Chest("electronic-circuit");
|
|
|
|
|
+ const sinkRed = new Chest();
|
|
|
|
|
+
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(16, sourcePlastic, machine, "plastic-bar"));
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(16, sourceCable, machine, "copper-cable"));
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(16, sourceGreen, machine, "electronic-circuit"));
|
|
|
|
|
+ orchestrator.registerInserter(new InserterSimulator(16, machine, sinkRed, "advanced-circuit"));
|
|
|
|
|
+ orchestrator.registerMachine(machine);
|
|
|
|
|
+
|
|
|
|
|
+ // Run until chest receives the batch size of 176
|
|
|
|
|
+ const success = orchestrator.tickUntil(() => (sinkRed.receivedCounts["advanced-circuit"] || 0) >= 176);
|
|
|
|
|
+ expect(success).toBe(true);
|
|
|
|
|
+
|
|
|
|
|
+ expect(sinkRed.receivedCounts["advanced-circuit"]).toBe(176);
|
|
|
|
|
+ expect(sourcePlastic.extractedCounts["plastic-bar"]).toBeGreaterThanOrEqual(128);
|
|
|
|
|
+ expect(sourceCable.extractedCounts["copper-cable"]).toBeGreaterThanOrEqual(256);
|
|
|
|
|
+ expect(sourceGreen.extractedCounts["electronic-circuit"]).toBeGreaterThanOrEqual(128);
|
|
|
|
|
+
|
|
|
|
|
+ // Timeline Duration: Base 275 + 5 + 5
|
|
|
|
|
+ expect(orchestrator.currentTick).toBe(285);
|
|
|
|
|
+
|
|
|
|
|
+ // CLEANUP: Restore the original function so other tests aren't affected
|
|
|
|
|
+ statsSpy.mockRestore();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|