|
|
@@ -1,943 +1,525 @@
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
import * as statsModule from "./stats";
|
|
|
-import {
|
|
|
- MachineSimulator,
|
|
|
- InserterSimulator,
|
|
|
- Chest,
|
|
|
- InserterState,
|
|
|
- FactorioEngineOrchestrator,
|
|
|
- Belt,
|
|
|
- FilterableInserterSimulator,
|
|
|
-} from "./simulator";
|
|
|
-import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
|
|
|
-import type { Machine } from "../../scripts/factorio-dump/process-data.models";
|
|
|
+import { InserterState } from "./simulator";
|
|
|
import type { ClockBlock, ClockRow } from "../assets/ClockTimeline/model";
|
|
|
-import type { MachineSetup } from "./model";
|
|
|
+import { Recipes, tickTimes } from "./test-factories";
|
|
|
+import { SimulationBuilder, StandardSetups } from "./SimulationBuilder";
|
|
|
|
|
|
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 < 29; 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);
|
|
|
- expect(machine.inputBuffer["iron-plate"]).toBe(0);
|
|
|
-
|
|
|
- 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);
|
|
|
-
|
|
|
- for (let i = 0; i < 29; i++) machine.tick();
|
|
|
- expect(machine.outputBuffer["iron-gear-wheel"]).toBe(2);
|
|
|
- });
|
|
|
+ describe("Basic Mechanics", () => {
|
|
|
+ it("Machine Simulator: Crafts correctly over time and consumes inputs instantly", () => {
|
|
|
+ const { machines } = new SimulationBuilder()
|
|
|
+ .addMachine("machine", Recipes.ironGear)
|
|
|
+ .build();
|
|
|
|
|
|
- it("Machine Simulator: Processes multiple crafts in a single tick if speed is extreme", () => {
|
|
|
- const fastSetup: MachineSetup = {
|
|
|
- machine: { crafting_speed: 60 } as Machine,
|
|
|
- machineModules: [],
|
|
|
- beacons: [],
|
|
|
- machineQualityLevel: 0,
|
|
|
- };
|
|
|
+ const machine = machines.machine;
|
|
|
+ machine.inputBuffer["iron-plate"] = 4;
|
|
|
|
|
|
- const machine = new MachineSimulator(fastSetup, basicRecipe, { "iron-gear-wheel": 100 });
|
|
|
- machine.inputBuffer["iron-plate"] = 20; // 10 crafts worth
|
|
|
+ machine.tick();
|
|
|
+ expect(machine.isCrafting).toBe(true);
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(2);
|
|
|
+ expect(machine.outputBuffer["iron-gear-wheel"]).toBe(0);
|
|
|
|
|
|
- // Tick 1: Should complete 2 crafts instantly (60 speed / 30 ticks required = 2 per tick)
|
|
|
- machine.tick();
|
|
|
+ tickTimes(machine, 29);
|
|
|
+ expect(machine.outputBuffer["iron-gear-wheel"]).toBe(0);
|
|
|
|
|
|
- expect(machine.inputBuffer["iron-plate"]).toBe(14); // 20 - 6 (2 first craft plus next tick craft)
|
|
|
- expect(machine.outputBuffer["iron-gear-wheel"]).toBe(2);
|
|
|
- });
|
|
|
+ machine.tick();
|
|
|
+ expect(machine.outputBuffer["iron-gear-wheel"]).toBe(1);
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(0);
|
|
|
|
|
|
- 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);
|
|
|
- });
|
|
|
+ machine.tick();
|
|
|
+ expect(machine.isCrafting).toBe(true);
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(0);
|
|
|
|
|
|
- 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
|
|
|
+ tickTimes(machine, 29);
|
|
|
+ expect(machine.outputBuffer["iron-gear-wheel"]).toBe(2);
|
|
|
+ });
|
|
|
|
|
|
- const sourceChest = new Chest("iron-plate");
|
|
|
- const inserter = new InserterSimulator(16, sourceChest, machine, "iron-plate");
|
|
|
+ it("Machine Simulator: Processes multiple crafts in a single tick if speed is extreme", () => {
|
|
|
+ const { machines } = new SimulationBuilder()
|
|
|
+ .addMachine("machine", Recipes.ironGear, StandardSetups.fastAssembler)
|
|
|
+ .build();
|
|
|
|
|
|
- expect(machine.timings.overloadMultiplier).toBe(3);
|
|
|
- // Overload limit = 2 (recipe amount) * 3 = 6 iron plates limit.
|
|
|
+ const machine = machines.machine;
|
|
|
+ machine.inputBuffer["iron-plate"] = 20;
|
|
|
|
|
|
- // 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
|
|
|
+ machine.tick();
|
|
|
|
|
|
- expect(machine.inputBuffer["iron-plate"]).toBe(16); // Overstuffed!
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(14);
|
|
|
+ expect(machine.outputBuffer["iron-gear-wheel"]).toBe(2);
|
|
|
+ });
|
|
|
|
|
|
- // The machine instantly consumes 2, leaving 14.
|
|
|
- machine.tick();
|
|
|
- expect(machine.inputBuffer["iron-plate"]).toBe(14);
|
|
|
+ it("Inserter Simulator: Executes exact tick phases for a Chest -> Machine transfer", () => {
|
|
|
+ const { machines, inserters } = new SimulationBuilder()
|
|
|
+ .addChest("source", "iron-plate")
|
|
|
+ .addMachine("machine", Recipes.ironGear)
|
|
|
+ .addInserter("inserter", 16, "source", "machine", "iron-plate")
|
|
|
+ .build();
|
|
|
|
|
|
- // 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);
|
|
|
+ const inserter = inserters.inserter;
|
|
|
+ const machine = machines.machine;
|
|
|
|
|
|
- // Tick the inserter again. It should NOT wake up, because 14 is NOT < 6.
|
|
|
- inserter.tick();
|
|
|
- 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);
|
|
|
-
|
|
|
- expect(orchestrator.currentTick).toBe(0);
|
|
|
-
|
|
|
- orchestrator.tickUntil(() => inputInserter.state == InserterState.Dropping);
|
|
|
- expect(orchestrator.currentTick).toBe(4);
|
|
|
-
|
|
|
- orchestrator.tickUntil(() => (machine.outputBuffer["iron-gear-wheel"] || 0) >= 1);
|
|
|
- expect(orchestrator.currentTick).toBe(35);
|
|
|
-
|
|
|
- orchestrator.tickUntil(() => outputInserter.heldItems >= 1);
|
|
|
- expect(orchestrator.currentTick).toBe(36);
|
|
|
-
|
|
|
- orchestrator.tickUntil(() => (sinkChest.receivedCounts["iron-gear-wheel"] || 0) >= 1);
|
|
|
-
|
|
|
- // EXACT TIMELINE TRACE:
|
|
|
- // T0: Input Picks.
|
|
|
- // T1-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!
|
|
|
- // T36 (Inserter Phase): Output finally sees item, wakes up, Picks.
|
|
|
- // T37-39: Output Swings.
|
|
|
- // T40: Output Drops item into chest.
|
|
|
-
|
|
|
- expect(orchestrator.currentTick).toBe(40);
|
|
|
- });
|
|
|
+ expect(inserter.state).toBe(InserterState.Idle);
|
|
|
|
|
|
- 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 });
|
|
|
+ inserter.tick();
|
|
|
+ expect(inserter.state).toBe(InserterState.SwingingForward);
|
|
|
|
|
|
- const sourceChest = new Chest("iron-plate");
|
|
|
- const sinkChestA = new Chest();
|
|
|
- const sinkChestB = new Chest();
|
|
|
+ tickTimes(inserter, 3);
|
|
|
+ expect(inserter.state).toBe(InserterState.Dropping);
|
|
|
|
|
|
- const input = new InserterSimulator(16, sourceChest, machine, "iron-plate");
|
|
|
+ inserter.tick();
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(16);
|
|
|
+ expect(inserter.state).toBe(InserterState.SwingingBack);
|
|
|
|
|
|
- // 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");
|
|
|
+ tickTimes(inserter, 3);
|
|
|
+ expect(inserter.state).toBe(InserterState.Idle);
|
|
|
+ });
|
|
|
|
|
|
- orchestrator.registerInserter(input);
|
|
|
- orchestrator.registerInserter(outA); // A registered first!
|
|
|
- orchestrator.registerInserter(outB);
|
|
|
- orchestrator.registerMachine(machine);
|
|
|
+ it("Overload Limit validation: Inserter ignores target size if buffer is under limit", () => {
|
|
|
+ const { machines, inserters } = new SimulationBuilder()
|
|
|
+ .addChest("source", "iron-plate")
|
|
|
+ .addMachine("machine", Recipes.ironGear)
|
|
|
+ .addInserter("inserter", 16, "source", "machine", "iron-plate")
|
|
|
+ .build();
|
|
|
|
|
|
- // Run until 4 items are produced and extracted
|
|
|
- orchestrator.tickUntil(
|
|
|
- () =>
|
|
|
- (sinkChestA.receivedCounts["iron-gear-wheel"] || 0) + (sinkChestB.receivedCounts["iron-gear-wheel"] || 0) === 4,
|
|
|
- );
|
|
|
+ const machine = machines.machine;
|
|
|
+ const inserter = inserters.inserter;
|
|
|
+
|
|
|
+ machine.timings.overloadMultiplier = 3;
|
|
|
|
|
|
- // 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.heldItems).toBe(14);
|
|
|
- expect(inserter.state).toBe(InserterState.SwingingForward);
|
|
|
- });
|
|
|
- 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 });
|
|
|
+ tickTimes(inserter, 5);
|
|
|
+
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(16);
|
|
|
+
|
|
|
+ machine.tick();
|
|
|
+ expect(machine.inputBuffer["iron-plate"]).toBe(14);
|
|
|
|
|
|
- const sourceChest = new Chest("iron-plate");
|
|
|
- const sinkChestA = new Chest();
|
|
|
- const sinkChestB = new Chest();
|
|
|
+ tickTimes(inserter, 3);
|
|
|
+ expect(inserter.state).toBe(InserterState.Idle);
|
|
|
|
|
|
- const input = new InserterSimulator(16, sourceChest, machine, "iron-plate");
|
|
|
+ inserter.tick();
|
|
|
+ expect(inserter.state).toBe(InserterState.Idle);
|
|
|
+ });
|
|
|
+ });
|
|
|
|
|
|
- // 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");
|
|
|
+ describe("Tick/Phase Mechanics", () => {
|
|
|
+ it("End-to-End: Proves the 1-tick delay caused by Inserters updating before Machines", () => {
|
|
|
+ const { orchestrator, machines, chests, inserters } = new SimulationBuilder()
|
|
|
+ .addChest("source", "iron-plate")
|
|
|
+ .addChest("sink")
|
|
|
+ .addMachine("machine", Recipes.ironGear)
|
|
|
+ .addInserter("input", 2, "source", "machine", "iron-plate")
|
|
|
+ .addInserter("output", 1, "machine", "sink", "iron-gear-wheel")
|
|
|
+ .build();
|
|
|
|
|
|
- orchestrator.registerInserter(input);
|
|
|
- orchestrator.registerInserter(outA); // A registered first!
|
|
|
- orchestrator.registerInserter(outB);
|
|
|
- orchestrator.registerMachine(machine);
|
|
|
+ expect(orchestrator.currentTick).toBe(0);
|
|
|
|
|
|
- // Run until 4 items are produced and extracted
|
|
|
- orchestrator.tickUntil(() => false, 5 + 30 * 4 + 1);
|
|
|
+ orchestrator.tickUntil(() => inserters.input.state == InserterState.Dropping);
|
|
|
+ expect(orchestrator.currentTick).toBe(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(outA.heldItems).toBe(2);
|
|
|
- expect(outB.heldItems).toBe(2);
|
|
|
+ orchestrator.tickUntil(() => (machines.machine.outputBuffer["iron-gear-wheel"] || 0) >= 1);
|
|
|
+ expect(orchestrator.currentTick).toBe(35);
|
|
|
|
|
|
- orchestrator.tickUntil(() => sinkChestB.receivedCounts["iron-gear-wheel"] == 4);
|
|
|
+ orchestrator.tickUntil(() => inserters.output.heldItems >= 1);
|
|
|
+ expect(orchestrator.currentTick).toBe(36);
|
|
|
|
|
|
- expect(sinkChestB.receivedCounts["iron-gear-wheel"]).toBe(4);
|
|
|
- expect(outA.heldItems).toBe(0);
|
|
|
- expect(outB.heldItems).toBe(0);
|
|
|
+ orchestrator.tickUntil(() => (chests.sink.receivedCounts["iron-gear-wheel"] || 0) >= 1);
|
|
|
+ expect(orchestrator.currentTick).toBe(40);
|
|
|
+ });
|
|
|
});
|
|
|
|
|
|
- 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);
|
|
|
- });
|
|
|
+ describe("Queue & Round-Robin Behaviors", () => {
|
|
|
+ it("Round-Robin: Two inserters compete for same output and share perfectly", () => {
|
|
|
+ const { orchestrator, chests } = new SimulationBuilder()
|
|
|
+ .addChest("source", "iron-plate")
|
|
|
+ .addChest("sinkA")
|
|
|
+ .addChest("sinkB")
|
|
|
+ .addMachine("machine", Recipes.ironGear)
|
|
|
+ .addInserter("input", 16, "source", "machine", "iron-plate")
|
|
|
+ .addInserter("outA", 1, "machine", "sinkA", "iron-gear-wheel")
|
|
|
+ .addInserter("outB", 1, "machine", "sinkB", "iron-gear-wheel")
|
|
|
+ .build();
|
|
|
+
|
|
|
+ orchestrator.tickUntil(
|
|
|
+ () => (chests.sinkA.receivedCounts["iron-gear-wheel"] || 0) + (chests.sinkB.receivedCounts["iron-gear-wheel"] || 0) === 4
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(chests.sinkA.receivedCounts["iron-gear-wheel"]).toBe(2);
|
|
|
+ expect(chests.sinkB.receivedCounts["iron-gear-wheel"]).toBe(2);
|
|
|
+ });
|
|
|
|
|
|
- it("Performance Stress Test: 1,000 machines for 10,000 ticks", () => {
|
|
|
- const orchestrator = new FactorioEngineOrchestrator();
|
|
|
+ it("Progressive Belt Pickup: Stack inserter extracts 4 items per tick until full", () => {
|
|
|
+ const { orchestrator, inserters } = new SimulationBuilder()
|
|
|
+ .addBelt("belt", "iron-plate")
|
|
|
+ .addChest("sink")
|
|
|
+ .addInserter("inserter", 14, "belt", "sink", "iron-plate")
|
|
|
+ .build();
|
|
|
|
|
|
- const source = new Chest("iron-plate");
|
|
|
- const sink = new Chest();
|
|
|
+ const inserter = inserters.inserter;
|
|
|
|
|
|
- // 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"));
|
|
|
- }
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(inserter.state).toBe(InserterState.Picking);
|
|
|
+ expect(inserter.heldItems).toBe(4);
|
|
|
|
|
|
- const start = performance.now();
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(inserter.heldItems).toBe(8);
|
|
|
|
|
|
- // Run for 10,000 ticks (about 2.7 minutes of real Factorio game time)
|
|
|
- orchestrator.tickUntil(() => false, 10000);
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(inserter.heldItems).toBe(12);
|
|
|
|
|
|
- const end = performance.now();
|
|
|
- const durationMs = end - start;
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(inserter.heldItems).toBe(14);
|
|
|
+ expect(inserter.state).toBe(InserterState.SwingingForward);
|
|
|
+ });
|
|
|
|
|
|
- // 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);
|
|
|
+ it("Round-Robin: Two inserters compete 4 stack for same output and share perfectly", () => {
|
|
|
+ const { orchestrator, chests, inserters } = new SimulationBuilder()
|
|
|
+ .addChest("source", "iron-plate")
|
|
|
+ .addChest("sinkA")
|
|
|
+ .addChest("sinkB")
|
|
|
+ .addMachine("machine", Recipes.ironGear)
|
|
|
+ .addInserter("input", 16, "source", "machine", "iron-plate")
|
|
|
+ .addInserter("outA", 4, "machine", "sinkA", "iron-gear-wheel")
|
|
|
+ .addInserter("outB", 4, "machine", "sinkB", "iron-gear-wheel")
|
|
|
+ .build();
|
|
|
|
|
|
- // Verify they actually produced items
|
|
|
- expect(sink.receivedCounts["iron-gear-wheel"]).toBeGreaterThan(100000);
|
|
|
- });
|
|
|
- it("Endgame Setup: Legendary EMP with Beacons perfectly matches batch metrics", () => {
|
|
|
- const statsSpy = vi.spyOn(statsModule, "computeMachineStats").mockReturnValue({
|
|
|
- actualCraftingSpeed: 84,
|
|
|
- productivityBonus: 1.75,
|
|
|
- singleCraftTicks: 4.2857142857142865,
|
|
|
- craftsPerSecond: 14,
|
|
|
- overloadMultiplier: 17,
|
|
|
+ orchestrator.tickUntil(() => false, 5 + 30 * 4 + 1);
|
|
|
+
|
|
|
+ expect(inserters.outA.heldItems).toBe(2);
|
|
|
+ expect(inserters.outB.heldItems).toBe(2);
|
|
|
+
|
|
|
+ orchestrator.tickUntil(() => chests.sinkB.receivedCounts["iron-gear-wheel"] == 4);
|
|
|
+
|
|
|
+ expect(chests.sinkB.receivedCounts["iron-gear-wheel"]).toBe(4);
|
|
|
+ expect(inserters.outA.heldItems).toBe(0);
|
|
|
+ expect(inserters.outB.heldItems).toBe(0);
|
|
|
});
|
|
|
|
|
|
- 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 + 4
|
|
|
- expect(orchestrator.currentTick).toBe(284);
|
|
|
-
|
|
|
- statsSpy.mockRestore();
|
|
|
- });
|
|
|
- it("Optimized Clock: Pub/Sub row mapping controls inserter activation with zero scan overhead", () => {
|
|
|
- const orchestrator = new FactorioEngineOrchestrator();
|
|
|
-
|
|
|
- const machine = new MachineSimulator(
|
|
|
- {
|
|
|
- machine: { crafting_speed: 1 } as Machine,
|
|
|
- machineQualityLevel: 0,
|
|
|
- machineModules: [],
|
|
|
- beacons: [],
|
|
|
- } as MachineSetup,
|
|
|
- basicRecipe,
|
|
|
- {
|
|
|
- "iron-gear-wheel": 100,
|
|
|
- },
|
|
|
- );
|
|
|
- const source = new Chest("iron-plate");
|
|
|
- const sink = new Chest();
|
|
|
-
|
|
|
- // Register Clock Rows (43-tick cycle)
|
|
|
-
|
|
|
- orchestrator.registerClockRow({ id: "row_input" } as ClockRow, [{ start: 0, duration: 8 } as ClockBlock], 43);
|
|
|
- orchestrator.registerClockRow({ id: "row_output" } as ClockRow, [{ start: 35, duration: 8 } as ClockBlock], 43);
|
|
|
-
|
|
|
- // Create Inserters
|
|
|
- const inputInserter = new InserterSimulator(2, source, machine, "iron-plate");
|
|
|
- const outputInserter = new InserterSimulator(1, machine, sink, "iron-gear-wheel");
|
|
|
-
|
|
|
- // Bind Inserters to Rows via Pub/Sub
|
|
|
- orchestrator.bindInserterToRow(inputInserter, "row_input");
|
|
|
- orchestrator.bindInserterToRow(outputInserter, "row_output");
|
|
|
-
|
|
|
- orchestrator.registerInserter(inputInserter);
|
|
|
- orchestrator.registerInserter(outputInserter);
|
|
|
- orchestrator.registerMachine(machine);
|
|
|
-
|
|
|
- orchestrator.tickUntil(() => false, 43);
|
|
|
-
|
|
|
- // 5. Validate execution
|
|
|
- expect(sink.receivedCounts["iron-gear-wheel"]).toBe(1);
|
|
|
- expect(inputInserter.swingCount).toBe(1);
|
|
|
- expect(outputInserter.swingCount).toBe(1);
|
|
|
+ it("True Round-Robin: Stack inserters alternate picking from a machine while hovering", () => {
|
|
|
+ const { orchestrator, machines, inserters } = new SimulationBuilder()
|
|
|
+ .addChest("source", "iron-plate")
|
|
|
+ .addChest("sink")
|
|
|
+ .addMachine("machine", Recipes.twoGear, StandardSetups.instantAssembler)
|
|
|
+ .addInserter("input", 16, "source", "machine", "iron-plate")
|
|
|
+ .addInserter("outA", 16, "machine", "sink", "iron-gear-wheel")
|
|
|
+ .addInserter("outB", 16, "machine", "sink", "iron-gear-wheel")
|
|
|
+ .build();
|
|
|
+
|
|
|
+ orchestrator.tick();
|
|
|
+ orchestrator.tickUntil(() => machines.machine.inputBuffer["iron-plate"] > 0);
|
|
|
+
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(inserters.outA.state).toBe(InserterState.Picking);
|
|
|
+ expect(inserters.outB.state).toBe(InserterState.Idle);
|
|
|
+ expect(inserters.outA.heldItems).toBe(2);
|
|
|
+ expect(inserters.outB.heldItems).toBe(0);
|
|
|
+
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(inserters.outA.heldItems).toBe(2);
|
|
|
+ expect(inserters.outB.heldItems).toBe(2);
|
|
|
+
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(inserters.outA.heldItems).toBe(4);
|
|
|
+ expect(inserters.outB.heldItems).toBe(2);
|
|
|
+
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(inserters.outA.heldItems).toBe(4);
|
|
|
+ expect(inserters.outB.heldItems).toBe(4);
|
|
|
+
|
|
|
+ orchestrator.tickUntil(() => inserters.outA.state === InserterState.SwingingForward);
|
|
|
+
|
|
|
+ expect(inserters.outA.heldItems).toBe(16);
|
|
|
+ expect(inserters.outB.heldItems).toBe(14);
|
|
|
+ expect(inserters.outB.state).toBe(InserterState.Picking);
|
|
|
+ });
|
|
|
});
|
|
|
|
|
|
- it("Unclocked Baseline: 3:2 Copper to GC stabilizes and executes perfect swing ratios", () => {
|
|
|
- const orchestrator = new FactorioEngineOrchestrator();
|
|
|
-
|
|
|
- const copperCableRecipe: Recipe = {
|
|
|
- name: "copper-cable",
|
|
|
- energy_required: 0.5,
|
|
|
- ingredients: [{ type: "item", name: "copper-plate", amount: 1 }],
|
|
|
- results: [{ type: "item", name: "copper-cable", amount: 2 }],
|
|
|
- } as Recipe;
|
|
|
-
|
|
|
- const greenCircuitRecipe: Recipe = {
|
|
|
- name: "electronic-circuit",
|
|
|
- energy_required: 0.5,
|
|
|
- ingredients: [
|
|
|
- { type: "item", name: "iron-plate", amount: 1 },
|
|
|
- { type: "item", name: "copper-cable", amount: 3 },
|
|
|
- ],
|
|
|
- results: [{ type: "item", name: "electronic-circuit", amount: 1 }],
|
|
|
- } as Recipe;
|
|
|
-
|
|
|
- const assemblerSetup: MachineSetup = {
|
|
|
- machine: { name: "assembling-machine-2", crafting_speed: 1 } as Machine,
|
|
|
- machineModules: [],
|
|
|
- beacons: [],
|
|
|
- machineQualityLevel: 0,
|
|
|
- };
|
|
|
-
|
|
|
- const cop1 = new MachineSimulator(assemblerSetup, copperCableRecipe, { "copper-cable": 200 });
|
|
|
- const cop2 = new MachineSimulator(assemblerSetup, copperCableRecipe, { "copper-cable": 200 });
|
|
|
- const cop3 = new MachineSimulator(assemblerSetup, copperCableRecipe, { "copper-cable": 200 });
|
|
|
-
|
|
|
- const circ1 = new MachineSimulator(assemblerSetup, greenCircuitRecipe, { "electronic-circuit": 200 });
|
|
|
- const circ2 = new MachineSimulator(assemblerSetup, greenCircuitRecipe, { "electronic-circuit": 200 });
|
|
|
-
|
|
|
- const sourceCopper = new Chest("copper-plate");
|
|
|
- const sourceIron = new Chest("iron-plate");
|
|
|
- const sinkGreenChips = new Chest();
|
|
|
-
|
|
|
- // ALL inserters set to Stack Size 16
|
|
|
- const inCop1 = new InserterSimulator(16, sourceCopper, cop1, "copper-plate");
|
|
|
- const inCop2 = new InserterSimulator(16, sourceCopper, cop2, "copper-plate");
|
|
|
- const inCop3 = new InserterSimulator(16, sourceCopper, cop3, "copper-plate");
|
|
|
-
|
|
|
- const inIron1 = new InserterSimulator(16, sourceIron, circ1, "iron-plate");
|
|
|
- const inIron2 = new InserterSimulator(16, sourceIron, circ2, "iron-plate");
|
|
|
-
|
|
|
- // Direct insertions (Stack size 16)
|
|
|
- const mid1 = new InserterSimulator(16, cop1, circ1, "copper-cable");
|
|
|
- const mid2a = new InserterSimulator(16, cop2, circ1, "copper-cable");
|
|
|
- const mid2b = new InserterSimulator(16, cop2, circ2, "copper-cable");
|
|
|
- const mid3 = new InserterSimulator(16, cop3, circ2, "copper-cable");
|
|
|
-
|
|
|
- const outCirc1 = new InserterSimulator(16, circ1, sinkGreenChips, "electronic-circuit");
|
|
|
- const outCirc2 = new InserterSimulator(16, circ2, sinkGreenChips, "electronic-circuit");
|
|
|
-
|
|
|
- const allInserters = [inCop1, inCop2, inCop3, inIron1, inIron2, mid1, mid2a, mid2b, mid3, outCirc1, outCirc2];
|
|
|
-
|
|
|
- allInserters.forEach((ins) => orchestrator.registerInserter(ins));
|
|
|
- [cop1, cop2, cop3, circ1, circ2].forEach((m) => orchestrator.registerMachine(m));
|
|
|
-
|
|
|
- // --- PHASE 1: STABILIZATION ---
|
|
|
- // 16 items * 30 ticks = 488 ticks per complete buffer cycle
|
|
|
- orchestrator.tickUntil(() => false, 5000);
|
|
|
-
|
|
|
- // Reset all swing counters to 0 to prepare for the measurement window
|
|
|
- allInserters.forEach((ins) => (ins.swingCount = 0));
|
|
|
-
|
|
|
- // --- PHASE 2: MEASUREMENT WINDOW ---
|
|
|
- // Run for exactly 1440 ticks.
|
|
|
- // Speed 1 machine = 30 ticks per craft. 1440 / 30 = 48 crafts perfectly.
|
|
|
- // 48 crafts * 1 plate = 48 plates. 48 / 16 stack size = exactly 3 swings!
|
|
|
- const targetTick = orchestrator.currentTick + 1440;
|
|
|
- orchestrator.tickUntil(() => false, targetTick);
|
|
|
-
|
|
|
- // Validate Outputs (3 swings each = 48 chips per machine)
|
|
|
- expect(outCirc1.swingCount).toBe(3);
|
|
|
- expect(outCirc2.swingCount).toBe(3);
|
|
|
-
|
|
|
- // Validate Iron Inputs (3 swings each = 48 plates per machine)
|
|
|
- expect(inIron1.swingCount).toBe(3);
|
|
|
- expect(inIron2.swingCount).toBe(3);
|
|
|
-
|
|
|
- // Validate Copper Inputs (3 swings each = 48 plates per machine)
|
|
|
- expect([inCop1.swingCount, inCop2.swingCount, inCop3.swingCount]).toStrictEqual([3, 3, 3]);
|
|
|
- });
|
|
|
+ describe("Complex Layouts & Clocking", () => {
|
|
|
+ it("Complex Recipe: Advanced Circuits with Productivity and multiple inputs", () => {
|
|
|
+ const { orchestrator, machines, chests } = new SimulationBuilder()
|
|
|
+ .addChest("srcPlastic", "plastic-bar")
|
|
|
+ .addChest("srcCable", "copper-cable")
|
|
|
+ .addChest("srcGreen", "electronic-circuit")
|
|
|
+ .addChest("sinkRed")
|
|
|
+ .addMachine("machine", Recipes.advancedCircuit, StandardSetups.empSpeed)
|
|
|
+ .addInserter("inPlastic", 4, "srcPlastic", "machine", "plastic-bar")
|
|
|
+ .addInserter("inCable", 8, "srcCable", "machine", "copper-cable")
|
|
|
+ .addInserter("inGreen", 4, "srcGreen", "machine", "electronic-circuit")
|
|
|
+ .addInserter("outRed", 16, "machine", "sinkRed", "advanced-circuit")
|
|
|
+ .build();
|
|
|
+
|
|
|
+ machines.machine.timings.productivityBonus = 0.4;
|
|
|
+ machines.machine.timings.overloadMultiplier = 10;
|
|
|
+
|
|
|
+ const success = orchestrator.tickUntil(() => (chests.sinkRed.receivedCounts["advanced-circuit"] || 0) >= 14);
|
|
|
+ expect(success).toBe(true);
|
|
|
+ });
|
|
|
|
|
|
- it("Clocked Baseline: 3:2 setup with staggered shared-inserter rows and exact 8-tick windows", () => {
|
|
|
- const orchestrator = new FactorioEngineOrchestrator();
|
|
|
- const stackSizes = {
|
|
|
- "copper-cable": 200,
|
|
|
- "electronic-circuit": 200,
|
|
|
- };
|
|
|
-
|
|
|
- const copperCableRecipe: Recipe = {
|
|
|
- name: "copper-cable",
|
|
|
- energy_required: 0.5,
|
|
|
- ingredients: [{ type: "item", name: "copper-plate", amount: 1 }],
|
|
|
- results: [{ type: "item", name: "copper-cable", amount: 2 }],
|
|
|
- } as Recipe;
|
|
|
-
|
|
|
- const greenCircuitRecipe: Recipe = {
|
|
|
- name: "electronic-circuit",
|
|
|
- energy_required: 0.5,
|
|
|
- ingredients: [
|
|
|
- { type: "item", name: "iron-plate", amount: 1 },
|
|
|
- { type: "item", name: "copper-cable", amount: 3 },
|
|
|
- ],
|
|
|
- results: [{ type: "item", name: "electronic-circuit", amount: 1 }],
|
|
|
- } as Recipe;
|
|
|
-
|
|
|
- const assemblerSetup: MachineSetup = {
|
|
|
- machine: { name: "assembling-machine-2", crafting_speed: 10 } as Machine,
|
|
|
- machineModules: [],
|
|
|
- beacons: [],
|
|
|
- machineQualityLevel: 0,
|
|
|
- };
|
|
|
-
|
|
|
- const cop1 = new MachineSimulator(assemblerSetup, copperCableRecipe, stackSizes);
|
|
|
- const cop2 = new MachineSimulator(assemblerSetup, copperCableRecipe, stackSizes);
|
|
|
- const cop3 = new MachineSimulator(assemblerSetup, copperCableRecipe, stackSizes);
|
|
|
-
|
|
|
- const circ1 = new MachineSimulator(assemblerSetup, greenCircuitRecipe, stackSizes);
|
|
|
- const circ2 = new MachineSimulator(assemblerSetup, greenCircuitRecipe, stackSizes);
|
|
|
-
|
|
|
- const sourceCopper = new Chest("copper-plate");
|
|
|
- const sourceIron = new Chest("iron-plate");
|
|
|
- const sinkGreenChips = new Chest();
|
|
|
-
|
|
|
- // --- MATHEMATICAL CLOCK SETUP ---
|
|
|
- // Cycle: 480 ticks. 16 crafts per cycle.
|
|
|
-
|
|
|
- // Inputs (Copper/Iron): 16 items needed = 1 swing per cycle.
|
|
|
- const rowInputs: ClockRow = { id: "row_inputs", name: "Inputs", signals: [], stackSize: 16, inserterCount: 5 };
|
|
|
- const rowMidOuter: ClockRow = {
|
|
|
- id: "row_mid_outer",
|
|
|
- name: "Mid Outer",
|
|
|
- signals: [],
|
|
|
- stackSize: 16,
|
|
|
- inserterCount: 2,
|
|
|
- };
|
|
|
- const rowMidInner: ClockRow = {
|
|
|
- id: "row_mid_inner",
|
|
|
- name: "Mid Inner",
|
|
|
- signals: [],
|
|
|
- stackSize: 16,
|
|
|
- inserterCount: 2,
|
|
|
- };
|
|
|
- const rowOutputs: ClockRow = { id: "row_outputs", name: "Outputs", signals: [], stackSize: 16, inserterCount: 2 };
|
|
|
-
|
|
|
- orchestrator.registerClockRow(
|
|
|
- rowInputs,
|
|
|
- [{ id: "b1", rowId: "row_inputs", presetId: "custom", start: 1, duration: 8, count: 16 }],
|
|
|
- 480,
|
|
|
- );
|
|
|
-
|
|
|
- // Outer Mid Inserters (cop1 -> circ1, cop3 -> circ2): 32 cables generated = 2 swings per cycle.
|
|
|
- orchestrator.registerClockRow(
|
|
|
- rowMidOuter,
|
|
|
- [
|
|
|
- { id: "b2", rowId: "row_mid_outer", presetId: "custom", start: 1, duration: 8, count: 16 },
|
|
|
- { id: "b3", rowId: "row_mid_outer", presetId: "custom", start: 321, duration: 8, count: 16 },
|
|
|
- ],
|
|
|
- 480,
|
|
|
- );
|
|
|
- orchestrator.registerClockRow(
|
|
|
- rowMidInner,
|
|
|
- [{ id: "b4", rowId: "row_mid_inner", presetId: "custom", start: 161, duration: 8, count: 16 }],
|
|
|
- 480,
|
|
|
- );
|
|
|
-
|
|
|
- orchestrator.registerClockRow(
|
|
|
- rowOutputs,
|
|
|
- [{ id: "b5", rowId: "row_outputs", presetId: "custom", start: 0, duration: 16, count: 16 }],
|
|
|
- 480,
|
|
|
- );
|
|
|
-
|
|
|
- // Instantiate Inserters (Stack Size 16)
|
|
|
- const inCop1 = new InserterSimulator(16, sourceCopper, cop1, "copper-plate");
|
|
|
- const inCop2 = new InserterSimulator(16, sourceCopper, cop2, "copper-plate");
|
|
|
- const inCop3 = new InserterSimulator(16, sourceCopper, cop3, "copper-plate");
|
|
|
- const inIron1 = new InserterSimulator(16, sourceIron, circ1, "iron-plate");
|
|
|
- const inIron2 = new InserterSimulator(16, sourceIron, circ2, "iron-plate");
|
|
|
-
|
|
|
- const mid1 = new InserterSimulator(16, cop1, circ1, "copper-cable");
|
|
|
- const mid2a = new InserterSimulator(16, cop2, circ1, "copper-cable"); // Inner A
|
|
|
- const mid2b = new InserterSimulator(16, cop2, circ2, "copper-cable"); // Inner B
|
|
|
- const mid3 = new InserterSimulator(16, cop3, circ2, "copper-cable");
|
|
|
-
|
|
|
- const outCirc1 = new InserterSimulator(16, circ1, sinkGreenChips, "electronic-circuit");
|
|
|
- const outCirc2 = new InserterSimulator(16, circ2, sinkGreenChips, "electronic-circuit");
|
|
|
-
|
|
|
- [inCop1, inCop2, inCop3, inIron1, inIron2].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_inputs"));
|
|
|
- [mid1, mid3].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_mid_outer"));
|
|
|
- orchestrator.bindInserterToRow(mid2a, "row_mid_inner");
|
|
|
- orchestrator.bindInserterToRow(mid2b, "row_mid_inner");
|
|
|
- [outCirc1, outCirc2].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_outputs"));
|
|
|
-
|
|
|
- const allInserters = [inCop1, inCop2, inCop3, inIron1, inIron2, mid1, mid2a, mid2b, mid3, outCirc1, outCirc2];
|
|
|
- allInserters.forEach((ins) => orchestrator.registerInserter(ins));
|
|
|
- [cop1, cop2, cop3, circ1, circ2].forEach((m) => orchestrator.registerMachine(m));
|
|
|
-
|
|
|
- // --- PHASE 1: STABILIZATION ---
|
|
|
- orchestrator.tickUntil(() => false, 5000);
|
|
|
- allInserters.forEach((ins) => (ins.swingCount = 0));
|
|
|
- const startTarget = orchestrator.currentTick;
|
|
|
-
|
|
|
- // --- PHASE 2: MEASUREMENT WINDOW ---
|
|
|
- orchestrator.tickUntil(() => false, startTarget + 1440);
|
|
|
-
|
|
|
- // --- VALIDATION ---
|
|
|
- expect(inCop1.swingCount).toBe(3);
|
|
|
- expect(inCop2.swingCount).toBe(3);
|
|
|
- expect(inIron1.swingCount).toBe(3);
|
|
|
- expect(mid1.swingCount).toBe(6);
|
|
|
- expect(mid3.swingCount).toBe(6);
|
|
|
- expect(mid2a.swingCount).toBe(3);
|
|
|
- expect(mid2b.swingCount).toBe(3);
|
|
|
- expect(outCirc1.swingCount).toBe(3);
|
|
|
- expect(outCirc2.swingCount).toBe(3);
|
|
|
-
|
|
|
- // Output yield matches maximum physical theoretical limit
|
|
|
- // 3 swings * 16 items * 2 machines = 96
|
|
|
- const outputAfterPhase1 = sinkGreenChips.receivedCounts["electronic-circuit"];
|
|
|
- expect(outputAfterPhase1 - (sinkGreenChips.extractedCounts["electronic-circuit"] || 0)).toBeGreaterThanOrEqual(96);
|
|
|
- });
|
|
|
+ it("Performance Stress Test: 1,000 machines for 10,000 ticks", () => {
|
|
|
+ const builder = new SimulationBuilder()
|
|
|
+ .addChest("source", "iron-plate")
|
|
|
+ .addChest("sink");
|
|
|
+
|
|
|
+ for (let i = 0; i < 1000; i++) {
|
|
|
+ builder
|
|
|
+ .addMachine(`machine${i}`, Recipes.ironGear)
|
|
|
+ .addInserter(`in${i}`, 16, "source", `machine${i}`, "iron-plate")
|
|
|
+ .addInserter(`out${i}`, 16, `machine${i}`, "sink", "iron-gear-wheel");
|
|
|
+ }
|
|
|
+
|
|
|
+ const { orchestrator, chests } = builder.build();
|
|
|
+
|
|
|
+ const start = performance.now();
|
|
|
+ orchestrator.tickUntil(() => false, 10000);
|
|
|
+ const end = performance.now();
|
|
|
+
|
|
|
+ expect(end - start).toBeLessThan(1000);
|
|
|
+ expect(chests.sink.receivedCounts["iron-gear-wheel"]).toBeGreaterThan(100000);
|
|
|
+ });
|
|
|
|
|
|
- // src/engine/simulator.test.ts
|
|
|
+ it("Endgame Setup: Legendary EMP with Beacons perfectly matches batch metrics", () => {
|
|
|
+ const statsSpy = vi.spyOn(statsModule, "computeMachineStats").mockReturnValue({
|
|
|
+ actualCraftingSpeed: 84,
|
|
|
+ productivityBonus: 1.75,
|
|
|
+ singleCraftTicks: 4.2857142857142865,
|
|
|
+ craftsPerSecond: 14,
|
|
|
+ overloadMultiplier: 17,
|
|
|
+ });
|
|
|
+
|
|
|
+ const { orchestrator, machines, chests } = new SimulationBuilder()
|
|
|
+ .addChest("srcPlastic", "plastic-bar")
|
|
|
+ .addChest("srcCable", "copper-cable")
|
|
|
+ .addChest("srcGreen", "electronic-circuit")
|
|
|
+ .addChest("sinkRed")
|
|
|
+ .addMachine("machine", Recipes.advancedCircuit, StandardSetups.empSpeed)
|
|
|
+ .addInserter("inPlastic", 16, "srcPlastic", "machine", "plastic-bar")
|
|
|
+ .addInserter("inCable", 16, "srcCable", "machine", "copper-cable")
|
|
|
+ .addInserter("inGreen", 16, "srcGreen", "machine", "electronic-circuit")
|
|
|
+ .addInserter("outRed", 16, "machine", "sinkRed", "advanced-circuit")
|
|
|
+ .build();
|
|
|
+
|
|
|
+ expect((machines.machine as any).outputBlockLimits["advanced-circuit"]).toBe(17);
|
|
|
+
|
|
|
+ const success = orchestrator.tickUntil(() => (chests.sinkRed.receivedCounts["advanced-circuit"] || 0) >= 176);
|
|
|
+ expect(success).toBe(true);
|
|
|
+
|
|
|
+ expect(chests.sinkRed.receivedCounts["advanced-circuit"]).toBe(176);
|
|
|
+ expect(chests.srcPlastic.extractedCounts["plastic-bar"]).toBeGreaterThanOrEqual(128);
|
|
|
+ expect(chests.srcCable.extractedCounts["copper-cable"]).toBeGreaterThanOrEqual(256);
|
|
|
+ expect(chests.srcGreen.extractedCounts["electronic-circuit"]).toBeGreaterThanOrEqual(128);
|
|
|
+
|
|
|
+ expect(orchestrator.currentTick).toBe(284);
|
|
|
+ statsSpy.mockRestore();
|
|
|
+ });
|
|
|
|
|
|
- it("Advanced Filtering: Filter Inserter dynamically changes target on a mixed belt", () => {
|
|
|
- const orchestrator = new FactorioEngineOrchestrator();
|
|
|
+ it("Optimized Clock: Pub/Sub row mapping controls inserter activation with zero scan overhead", () => {
|
|
|
+ const builder = new SimulationBuilder()
|
|
|
+ .addChest("source", "iron-plate")
|
|
|
+ .addChest("sink")
|
|
|
+ .addMachine("machine", Recipes.ironGear)
|
|
|
+ .addInserter("in", 2, "source", "machine", "iron-plate")
|
|
|
+ .addInserter("out", 1, "machine", "sink", "iron-gear-wheel");
|
|
|
|
|
|
- const mixedBelt = new Belt("iron-plate", "copper-plate");
|
|
|
- const sortingChest = new Chest();
|
|
|
- const filterInserter = new FilterableInserterSimulator(16, mixedBelt, sortingChest);
|
|
|
- filterInserter.useDynamicFilters = true;
|
|
|
+ const { orchestrator, chests, inserters } = builder.build();
|
|
|
|
|
|
- filterInserter.updateDynamicFilters(["copper-plate"]);
|
|
|
+ orchestrator.registerClockRow({ id: "row_input" } as ClockRow, [{ start: 0, duration: 8 } as ClockBlock], 43);
|
|
|
+ orchestrator.registerClockRow({ id: "row_output" } as ClockRow, [{ start: 35, duration: 8 } as ClockBlock], 43);
|
|
|
|
|
|
- orchestrator.registerInserter(filterInserter);
|
|
|
- orchestrator.tick();
|
|
|
+ builder.bindToClock("in", "row_input").bindToClock("out", "row_output");
|
|
|
|
|
|
- expect(filterInserter.currentTargetItem).toBe("copper-plate");
|
|
|
- expect(filterInserter.state).toBe(1);
|
|
|
- expect(filterInserter.heldItems).toBe(4);
|
|
|
- orchestrator.tickUntil(() => false, 100);
|
|
|
- expect(mixedBelt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
|
|
|
- expect(mixedBelt.extractedCounts["iron-plate"]).toBeUndefined();
|
|
|
- expect(sortingChest.receivedCounts["copper-plate"]).toBeGreaterThan(0);
|
|
|
- expect(sortingChest.receivedCounts["iron-plate"]).toBeUndefined();
|
|
|
+ orchestrator.tickUntil(() => false, 43);
|
|
|
|
|
|
- // The Combinator Flips! ---
|
|
|
- filterInserter.updateDynamicFilters(["iron-plate"]);
|
|
|
+ expect(chests.sink.receivedCounts["iron-gear-wheel"]).toBe(1);
|
|
|
+ expect(inserters.in.swingCount).toBe(1);
|
|
|
+ expect(inserters.out.swingCount).toBe(1);
|
|
|
+ });
|
|
|
|
|
|
- // Run for another 100 ticks
|
|
|
- orchestrator.tickUntil(() => false, 200);
|
|
|
+ it("Unclocked Baseline: 3:2 Copper to GC stabilizes and executes perfect swing ratios", () => {
|
|
|
+ const { orchestrator, inserters } = new SimulationBuilder()
|
|
|
+ .addChest("cu_src", "copper-plate").addChest("fe_src", "iron-plate").addChest("gc_sink")
|
|
|
+ .addMachine("cop1", Recipes.copperCable).addMachine("cop2", Recipes.copperCable).addMachine("cop3", Recipes.copperCable)
|
|
|
+ .addMachine("circ1", Recipes.greenCircuit).addMachine("circ2", Recipes.greenCircuit)
|
|
|
+ .addInserter("inCop1", 16, "cu_src", "cop1", "copper-plate")
|
|
|
+ .addInserter("inCop2", 16, "cu_src", "cop2", "copper-plate")
|
|
|
+ .addInserter("inCop3", 16, "cu_src", "cop3", "copper-plate")
|
|
|
+ .addInserter("inIron1", 16, "fe_src", "circ1", "iron-plate")
|
|
|
+ .addInserter("inIron2", 16, "fe_src", "circ2", "iron-plate")
|
|
|
+ .addInserter("mid1", 16, "cop1", "circ1", "copper-cable")
|
|
|
+ .addInserter("mid2a", 16, "cop2", "circ1", "copper-cable")
|
|
|
+ .addInserter("mid2b", 16, "cop2", "circ2", "copper-cable")
|
|
|
+ .addInserter("mid3", 16, "cop3", "circ2", "copper-cable")
|
|
|
+ .addInserter("outCirc1", 16, "circ1", "gc_sink", "electronic-circuit")
|
|
|
+ .addInserter("outCirc2", 16, "circ2", "gc_sink", "electronic-circuit")
|
|
|
+ .build();
|
|
|
+
|
|
|
+ orchestrator.tickUntil(() => false, 5000);
|
|
|
+ Object.values(inserters).forEach((ins) => (ins.swingCount = 0));
|
|
|
+
|
|
|
+ const targetTick = orchestrator.currentTick + 1440;
|
|
|
+ orchestrator.tickUntil(() => false, targetTick);
|
|
|
+
|
|
|
+ expect(inserters.outCirc1.swingCount).toBe(3);
|
|
|
+ expect(inserters.outCirc2.swingCount).toBe(3);
|
|
|
+ expect(inserters.inIron1.swingCount).toBe(3);
|
|
|
+ expect(inserters.inIron2.swingCount).toBe(3);
|
|
|
+ expect([inserters.inCop1.swingCount, inserters.inCop2.swingCount, inserters.inCop3.swingCount]).toStrictEqual([3, 3, 3]);
|
|
|
+ });
|
|
|
|
|
|
- // It should now have extracted Iron as well!
|
|
|
- expect(mixedBelt.extractedCounts["iron-plate"]).toBeGreaterThan(0);
|
|
|
- expect(sortingChest.receivedCounts["iron-plate"]).toBeGreaterThan(0);
|
|
|
+ it("Clocked Baseline: 3:2 setup with staggered shared-inserter rows and exact 8-tick windows", () => {
|
|
|
+ const builder = new SimulationBuilder()
|
|
|
+ .addChest("cu_src", "copper-plate").addChest("fe_src", "iron-plate").addChest("gc_sink")
|
|
|
+ .addMachine("cop1", Recipes.copperCable, StandardSetups.assembler2)
|
|
|
+ .addMachine("cop2", Recipes.copperCable, StandardSetups.assembler2)
|
|
|
+ .addMachine("cop3", Recipes.copperCable, StandardSetups.assembler2)
|
|
|
+ .addMachine("circ1", Recipes.greenCircuit, StandardSetups.assembler2)
|
|
|
+ .addMachine("circ2", Recipes.greenCircuit, StandardSetups.assembler2)
|
|
|
+ .addInserter("inCop1", 16, "cu_src", "cop1", "copper-plate")
|
|
|
+ .addInserter("inCop2", 16, "cu_src", "cop2", "copper-plate")
|
|
|
+ .addInserter("inCop3", 16, "cu_src", "cop3", "copper-plate")
|
|
|
+ .addInserter("inIron1", 16, "fe_src", "circ1", "iron-plate")
|
|
|
+ .addInserter("inIron2", 16, "fe_src", "circ2", "iron-plate")
|
|
|
+ .addInserter("mid1", 16, "cop1", "circ1", "copper-cable")
|
|
|
+ .addInserter("mid2a", 16, "cop2", "circ1", "copper-cable")
|
|
|
+ .addInserter("mid2b", 16, "cop2", "circ2", "copper-cable")
|
|
|
+ .addInserter("mid3", 16, "cop3", "circ2", "copper-cable")
|
|
|
+ .addInserter("outCirc1", 16, "circ1", "gc_sink", "electronic-circuit")
|
|
|
+ .addInserter("outCirc2", 16, "circ2", "gc_sink", "electronic-circuit");
|
|
|
+
|
|
|
+ const { orchestrator, chests, inserters } = builder.build();
|
|
|
+
|
|
|
+ const rowInputs: ClockRow = { id: "row_inputs", name: "Inputs", signals: [], stackSize: 16, inserterCount: 5 };
|
|
|
+ const rowMidOuter: ClockRow = { id: "row_mid_outer", name: "Mid Outer", signals: [], stackSize: 16, inserterCount: 2 };
|
|
|
+ const rowMidInner: ClockRow = { id: "row_mid_inner", name: "Mid Inner", signals: [], stackSize: 16, inserterCount: 2 };
|
|
|
+ const rowOutputs: ClockRow = { id: "row_outputs", name: "Outputs", signals: [], stackSize: 16, inserterCount: 2 };
|
|
|
+
|
|
|
+ orchestrator.registerClockRow(rowInputs, [{ id: "b1", rowId: "row_inputs", presetId: "custom", start: 1, duration: 8, count: 16 }], 480);
|
|
|
+ orchestrator.registerClockRow(rowMidOuter, [
|
|
|
+ { id: "b2", rowId: "row_mid_outer", presetId: "custom", start: 1, duration: 8, count: 16 },
|
|
|
+ { id: "b3", rowId: "row_mid_outer", presetId: "custom", start: 321, duration: 8, count: 16 }
|
|
|
+ ], 480);
|
|
|
+ orchestrator.registerClockRow(rowMidInner, [{ id: "b4", rowId: "row_mid_inner", presetId: "custom", start: 161, duration: 8, count: 16 }], 480);
|
|
|
+ orchestrator.registerClockRow(rowOutputs, [{ id: "b5", rowId: "row_outputs", presetId: "custom", start: 0, duration: 16, count: 16 }], 480);
|
|
|
+
|
|
|
+ ["inCop1", "inCop2", "inCop3", "inIron1", "inIron2"].forEach(id => builder.bindToClock(id, "row_inputs"));
|
|
|
+ ["mid1", "mid3"].forEach(id => builder.bindToClock(id, "row_mid_outer"));
|
|
|
+ ["mid2a", "mid2b"].forEach(id => builder.bindToClock(id, "row_mid_inner"));
|
|
|
+ ["outCirc1", "outCirc2"].forEach(id => builder.bindToClock(id, "row_outputs"));
|
|
|
+
|
|
|
+ orchestrator.tickUntil(() => false, 5000);
|
|
|
+ Object.values(inserters).forEach((ins) => (ins.swingCount = 0));
|
|
|
+ const startTarget = orchestrator.currentTick;
|
|
|
+
|
|
|
+ orchestrator.tickUntil(() => false, startTarget + 1440);
|
|
|
+
|
|
|
+ expect(inserters.inCop1.swingCount).toBe(3);
|
|
|
+ expect(inserters.inCop2.swingCount).toBe(3);
|
|
|
+ expect(inserters.inIron1.swingCount).toBe(3);
|
|
|
+ expect(inserters.mid1.swingCount).toBe(6);
|
|
|
+ expect(inserters.mid3.swingCount).toBe(6);
|
|
|
+ expect(inserters.mid2a.swingCount).toBe(3);
|
|
|
+ expect(inserters.mid2b.swingCount).toBe(3);
|
|
|
+ expect(inserters.outCirc1.swingCount).toBe(3);
|
|
|
+ expect(inserters.outCirc2.swingCount).toBe(3);
|
|
|
+
|
|
|
+ const outputAfterPhase1 = chests.gc_sink.receivedCounts["electronic-circuit"];
|
|
|
+ expect(outputAfterPhase1 - (chests.gc_sink.extractedCounts["electronic-circuit"] || 0)).toBeGreaterThanOrEqual(96);
|
|
|
+ });
|
|
|
});
|
|
|
|
|
|
- it("Advanced Filtering: Static multi-item filtering respects item locking", () => {
|
|
|
- const orchestrator = new FactorioEngineOrchestrator();
|
|
|
- const mixedBelt = new Belt("iron-plate", "copper-plate");
|
|
|
- const sortingChest = new Chest();
|
|
|
+ describe("Filtering Logic", () => {
|
|
|
+ it("Advanced Filtering: Filter Inserter dynamically changes target on a mixed belt", () => {
|
|
|
+ const { orchestrator, belts, chests, inserters } = new SimulationBuilder()
|
|
|
+ .addBelt("belt", "iron-plate", "copper-plate")
|
|
|
+ .addChest("sink")
|
|
|
+ .addInserter("filterIns", 16, "belt", "sink", undefined, true)
|
|
|
+ .build();
|
|
|
+
|
|
|
+ const filterInserter = inserters.filterIns as any;
|
|
|
+ filterInserter.updateDynamicFilters(["copper-plate"]);
|
|
|
+
|
|
|
+ orchestrator.tick();
|
|
|
+
|
|
|
+ expect(filterInserter.currentTargetItem).toBe("copper-plate");
|
|
|
+ expect(filterInserter.state).toBe(1);
|
|
|
+ expect(filterInserter.heldItems).toBe(4);
|
|
|
+
|
|
|
+ orchestrator.tickUntil(() => false, 100);
|
|
|
+
|
|
|
+ expect(belts.belt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
|
|
|
+ expect(belts.belt.extractedCounts["iron-plate"]).toBeUndefined();
|
|
|
+ expect(chests.sink.receivedCounts["copper-plate"]).toBeGreaterThan(0);
|
|
|
+ expect(chests.sink.receivedCounts["iron-plate"]).toBeUndefined();
|
|
|
+
|
|
|
+ filterInserter.updateDynamicFilters(["iron-plate"]);
|
|
|
+ orchestrator.tickUntil(() => false, 200);
|
|
|
+
|
|
|
+ expect(belts.belt.extractedCounts["iron-plate"]).toBeGreaterThan(0);
|
|
|
+ expect(chests.sink.receivedCounts["iron-plate"]).toBeGreaterThan(0);
|
|
|
+ });
|
|
|
|
|
|
- // Configure static filters for BOTH Iron and Copper
|
|
|
- const filterInserter = new FilterableInserterSimulator(16, mixedBelt, sortingChest, ["iron-plate", "copper-plate"]);
|
|
|
- orchestrator.registerInserter(filterInserter);
|
|
|
+ it("Advanced Filtering: Static multi-item filtering respects item locking", () => {
|
|
|
+ const { orchestrator, inserters } = new SimulationBuilder()
|
|
|
+ .addBelt("belt", "iron-plate", "copper-plate")
|
|
|
+ .addChest("sink")
|
|
|
+ .addInserter("filterIns", 16, "belt", "sink", ["iron-plate", "copper-plate"])
|
|
|
+ .build();
|
|
|
|
|
|
- // Tick 1: It evaluates canWakeUp(). Iron is first in the list, so it locks onto Iron!
|
|
|
- orchestrator.tick();
|
|
|
+ const filterInserter = inserters.filterIns as any;
|
|
|
|
|
|
- expect(filterInserter.state).toBe(InserterState.Picking);
|
|
|
- expect(filterInserter.currentTargetItem).toBe("iron-plate"); // Locked!
|
|
|
- expect(filterInserter.heldItems).toBe(4); // Belt pickup rate is 4
|
|
|
+ orchestrator.tick();
|
|
|
|
|
|
- // Tick 2: It continues picking Iron. It does NOT mix Copper into the hand!
|
|
|
- orchestrator.tick();
|
|
|
- expect(filterInserter.heldItems).toBe(8);
|
|
|
- expect(filterInserter.currentTargetItem).toBe("iron-plate");
|
|
|
- });
|
|
|
+ expect(filterInserter.state).toBe(InserterState.Picking);
|
|
|
+ expect(filterInserter.currentTargetItem).toBe("iron-plate");
|
|
|
+ expect(filterInserter.heldItems).toBe(4);
|
|
|
|
|
|
- it("Advanced Filtering: Single multi-filter inserter autonomously feeds a Red Science machine", () => {
|
|
|
- const orchestrator = new FactorioEngineOrchestrator();
|
|
|
-
|
|
|
- // The Red Science Recipe
|
|
|
- const redScienceRecipe: Recipe = {
|
|
|
- name: "automation-science-pack",
|
|
|
- energy_required: 5, // 300 ticks per craft at Speed 1
|
|
|
- ingredients: [
|
|
|
- { name: "copper-plate", amount: 1, type: "item" },
|
|
|
- { name: "iron-gear-wheel", amount: 1, type: "item" },
|
|
|
- ],
|
|
|
- results: [{ type: "item", name: "automation-science-pack", amount: 1 }],
|
|
|
- } as Recipe;
|
|
|
-
|
|
|
- const assemblerSetup: MachineSetup = {
|
|
|
- machine: { name: "assembling-machine-2", crafting_speed: 1 } as Machine,
|
|
|
- machineModules: [],
|
|
|
- beacons: [],
|
|
|
- machineQualityLevel: 0,
|
|
|
- };
|
|
|
-
|
|
|
- // Setup the Machine and the Mixed Input Belt
|
|
|
- const machine = new MachineSimulator(assemblerSetup, redScienceRecipe, { "automation-science-pack": 100 });
|
|
|
- const mixedBelt = new Belt("copper-plate", "iron-gear-wheel");
|
|
|
- const sinkChest = new Chest();
|
|
|
-
|
|
|
- // A single Filter Inserter configured to grab BOTH ingredients!
|
|
|
- // We use a small hand size (4) to force it to make multiple trips.
|
|
|
- const smartInput = new FilterableInserterSimulator(4, mixedBelt, machine, ["copper-plate", "iron-gear-wheel"]);
|
|
|
- const output = new InserterSimulator(4, machine, sinkChest, "automation-science-pack");
|
|
|
-
|
|
|
- orchestrator.registerInserter(smartInput);
|
|
|
- orchestrator.registerMachine(machine);
|
|
|
- orchestrator.registerInserter(output);
|
|
|
-
|
|
|
- // Run the simulation
|
|
|
- // It takes 300 ticks for a single craft.
|
|
|
- const targetTick = orchestrator.currentTick + 300 * 5;
|
|
|
- orchestrator.tickUntil(() => false, targetTick);
|
|
|
-
|
|
|
- // --- VALIDATION ---
|
|
|
-
|
|
|
- // The inserter MUST have extracted both items from the mixed belt.
|
|
|
- // If it had locked up trying to stuff infinite copper, the gear count would be undefined/0.
|
|
|
- expect(mixedBelt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
|
|
|
- expect(mixedBelt.extractedCounts["iron-gear-wheel"]).toBeGreaterThan(0);
|
|
|
-
|
|
|
- expect(sinkChest.receivedCounts["automation-science-pack"]).toBeGreaterThanOrEqual(2);
|
|
|
-
|
|
|
- // The extracted copper should not wildly exceed the extracted gears,
|
|
|
- // proving the machine's buffer choked the copper input and forced the inserter to switch.
|
|
|
- // They should be reasonably close to each other (within 1 hand size margin)
|
|
|
- const copperPulled = mixedBelt.extractedCounts["copper-plate"];
|
|
|
- const gearsPulled = mixedBelt.extractedCounts["iron-gear-wheel"];
|
|
|
- expect(Math.abs(copperPulled - gearsPulled)).toBeLessThanOrEqual(4);
|
|
|
- });
|
|
|
- it("Advanced dynamic Filtering: Single multi-filter inserter autonomously feeds a Red Science machine", () => {
|
|
|
- const orchestrator = new FactorioEngineOrchestrator();
|
|
|
-
|
|
|
- // The Red Science Recipe
|
|
|
- const redScienceRecipe: Recipe = {
|
|
|
- name: "automation-science-pack",
|
|
|
- energy_required: 5, // 300 ticks per craft at Speed 1
|
|
|
- ingredients: [
|
|
|
- { name: "copper-plate", amount: 1, type: "item" },
|
|
|
- { name: "iron-gear-wheel", amount: 1, type: "item" },
|
|
|
- ],
|
|
|
- results: [{ type: "item", name: "automation-science-pack", amount: 1 }],
|
|
|
- } as Recipe;
|
|
|
-
|
|
|
- const assemblerSetup: MachineSetup = {
|
|
|
- machine: { name: "assembling-machine-2", crafting_speed: 1 } as Machine,
|
|
|
- machineModules: [],
|
|
|
- beacons: [],
|
|
|
- machineQualityLevel: 0,
|
|
|
- };
|
|
|
-
|
|
|
- // Setup the Machine and the Mixed Input Belt
|
|
|
- const machine = new MachineSimulator(assemblerSetup, redScienceRecipe, { "automation-science-pack": 100 });
|
|
|
- const mixedBelt = new Belt("copper-plate", "iron-gear-wheel");
|
|
|
- const sinkChest = new Chest();
|
|
|
-
|
|
|
- // A single Filter Inserter configured to grab BOTH ingredients!
|
|
|
- // We use a small hand size (4) to force it to make multiple trips.
|
|
|
- const smartInput = new FilterableInserterSimulator(4, mixedBelt, machine);
|
|
|
- const output = new InserterSimulator(4, machine, sinkChest, "automation-science-pack");
|
|
|
-
|
|
|
- orchestrator.registerInserter(smartInput);
|
|
|
- orchestrator.registerMachine(machine);
|
|
|
- orchestrator.registerInserter(output);
|
|
|
- smartInput.updateDynamicFilters(["copper-plate", "iron-gear-wheel"]);
|
|
|
- smartInput.useDynamicFilters = true;
|
|
|
- // Run the simulation
|
|
|
- // It takes 300 ticks for a single craft.
|
|
|
- const targetTick = orchestrator.currentTick + 300 * 5;
|
|
|
- orchestrator.tickUntil(() => false, targetTick);
|
|
|
-
|
|
|
- // --- VALIDATION ---
|
|
|
-
|
|
|
- // The inserter MUST have extracted both items from the mixed belt.
|
|
|
- // If it had locked up trying to stuff infinite copper, the gear count would be undefined/0.
|
|
|
- expect(mixedBelt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
|
|
|
- expect(mixedBelt.extractedCounts["iron-gear-wheel"]).toBeGreaterThan(0);
|
|
|
-
|
|
|
- expect(sinkChest.receivedCounts["automation-science-pack"]).toBeGreaterThanOrEqual(2);
|
|
|
-
|
|
|
- // The extracted copper should not wildly exceed the extracted gears,
|
|
|
- // proving the machine's buffer choked the copper input and forced the inserter to switch.
|
|
|
- // They should be reasonably close to each other (within 1 hand size margin)
|
|
|
- const copperPulled = mixedBelt.extractedCounts["copper-plate"];
|
|
|
- const gearsPulled = mixedBelt.extractedCounts["iron-gear-wheel"];
|
|
|
- expect(Math.abs(copperPulled - gearsPulled)).toBeLessThanOrEqual(4);
|
|
|
+ orchestrator.tick();
|
|
|
+ expect(filterInserter.heldItems).toBe(8);
|
|
|
+ expect(filterInserter.currentTargetItem).toBe("iron-plate");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("Advanced Filtering: Single multi-filter inserter autonomously feeds a Red Science machine", () => {
|
|
|
+ const { orchestrator, belts, chests } = new SimulationBuilder()
|
|
|
+ .addBelt("belt", "copper-plate", "iron-gear-wheel")
|
|
|
+ .addChest("sink")
|
|
|
+ .addMachine("machine", Recipes.redScience)
|
|
|
+ .addInserter("input", 4, "belt", "machine", ["copper-plate", "iron-gear-wheel"])
|
|
|
+ .addInserter("output", 4, "machine", "sink", "automation-science-pack")
|
|
|
+ .build();
|
|
|
+
|
|
|
+ const targetTick = orchestrator.currentTick + 300 * 5;
|
|
|
+ orchestrator.tickUntil(() => false, targetTick);
|
|
|
+
|
|
|
+ expect(belts.belt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
|
|
|
+ expect(belts.belt.extractedCounts["iron-gear-wheel"]).toBeGreaterThan(0);
|
|
|
+ expect(chests.sink.receivedCounts["automation-science-pack"]).toBeGreaterThanOrEqual(2);
|
|
|
+
|
|
|
+ const copperPulled = belts.belt.extractedCounts["copper-plate"];
|
|
|
+ const gearsPulled = belts.belt.extractedCounts["iron-gear-wheel"];
|
|
|
+ expect(Math.abs(copperPulled - gearsPulled)).toBeLessThanOrEqual(4);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("Advanced dynamic Filtering: Single multi-filter inserter autonomously feeds a Red Science machine", () => {
|
|
|
+ const { orchestrator, belts, chests, inserters } = new SimulationBuilder()
|
|
|
+ .addBelt("belt", "copper-plate", "iron-gear-wheel")
|
|
|
+ .addChest("sink")
|
|
|
+ .addMachine("machine", Recipes.redScience)
|
|
|
+ .addInserter("input", 4, "belt", "machine", undefined, true)
|
|
|
+ .addInserter("output", 4, "machine", "sink", "automation-science-pack")
|
|
|
+ .build();
|
|
|
+
|
|
|
+ const smartInput = inserters.input as any;
|
|
|
+ smartInput.updateDynamicFilters(["copper-plate", "iron-gear-wheel"]);
|
|
|
+
|
|
|
+ const targetTick = orchestrator.currentTick + 300 * 5;
|
|
|
+ orchestrator.tickUntil(() => false, targetTick);
|
|
|
+
|
|
|
+ expect(belts.belt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
|
|
|
+ expect(belts.belt.extractedCounts["iron-gear-wheel"]).toBeGreaterThan(0);
|
|
|
+ expect(chests.sink.receivedCounts["automation-science-pack"]).toBeGreaterThanOrEqual(2);
|
|
|
+
|
|
|
+ const copperPulled = belts.belt.extractedCounts["copper-plate"];
|
|
|
+ const gearsPulled = belts.belt.extractedCounts["iron-gear-wheel"];
|
|
|
+ expect(Math.abs(copperPulled - gearsPulled)).toBeLessThanOrEqual(4);
|
|
|
+ });
|
|
|
});
|
|
|
-});
|
|
|
+});
|