|
|
@@ -0,0 +1,665 @@
|
|
|
+// src/engine/simulator.ts
|
|
|
+
|
|
|
+export class InserterSimulator {
|
|
|
+ public state: InserterState = InserterState.Idle;
|
|
|
+ public ticksInState = 0;
|
|
|
+ public heldItems = 0;
|
|
|
+
|
|
|
+ // NEW: Tracks how many full drops it has completed
|
|
|
+ public swingCount = 0;
|
|
|
+
|
|
|
+ // NEW: Controlled by the Circuit Network (Combinators)
|
|
|
+ public isActive = true;
|
|
|
+
|
|
|
+// NEW: Mutable target so filter inserters can switch items!
|
|
|
+ public currentTargetItem: string;
|
|
|
+
|
|
|
+ private readonly pickupRate: number;
|
|
|
+ private readonly dropTicks: number;
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ public handSize: number,
|
|
|
+ public source: IContainer,
|
|
|
+ public destination: IContainer,
|
|
|
+ public targetItemId: string,
|
|
|
+ ) {
|
|
|
+ this.currentTargetItem = targetItemId;
|
|
|
+ this.pickupRate = INSERTER_TIMINGS.PICKUP_RATE[source.type];
|
|
|
+ this.dropTicks = INSERTER_TIMINGS.DROP_DELAY[destination.type];
|
|
|
+ }
|
|
|
+
|
|
|
+ public tick() {
|
|
|
+ if (this.state === InserterState.Idle) {
|
|
|
+ if (this.canWakeUp()) {
|
|
|
+ this.state = InserterState.Picking;
|
|
|
+ this.ticksInState = 0;
|
|
|
+
|
|
|
+ // Initialize caches
|
|
|
+ this.needed = this.handSize;
|
|
|
+ this.toPick = Math.min(this.needed, this.pickupRate);
|
|
|
+ } else {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.ticksInState++;
|
|
|
+
|
|
|
+ switch (this.state) {
|
|
|
+ case InserterState.Picking:
|
|
|
+ // FASTEST PATH: Only extract if active and we know we need items
|
|
|
+ if (this.isActive && this.toPick > 0) {
|
|
|
+ const picked = this.source.extract(this.targetItemId, this.toPick);
|
|
|
+
|
|
|
+ if (picked > 0) {
|
|
|
+ this.heldItems += picked;
|
|
|
+ this.needed -= picked;
|
|
|
+ // Update toPick cache ONLY when needed changes
|
|
|
+ this.toPick = Math.min(this.needed, this.pickupRate);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // FAST EVALUATION: 0 is falsy, instantly transitions if full
|
|
|
+ if (!this.needed) {
|
|
|
+ this.state = InserterState.SwingingForward;
|
|
|
+ this.ticksInState = 0;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case InserterState.SwingingForward:
|
|
|
+ if (this.ticksInState >= INSERTER_TIMINGS.ROTATION) {
|
|
|
+ this.state = InserterState.Dropping;
|
|
|
+ this.ticksInState = 0;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case InserterState.Dropping:
|
|
|
+ if (!this.destination.canAccept(this.targetItemId)) {
|
|
|
+ this.ticksInState--;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.ticksInState >= this.dropTicks) {
|
|
|
+ this.destination.insert(this.targetItemId, this.heldItems);
|
|
|
+
|
|
|
+ this.heldItems = 0;
|
|
|
+ this.swingCount++;
|
|
|
+ this.state = InserterState.SwingingBack;
|
|
|
+ this.ticksInState = 0;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case InserterState.SwingingBack:
|
|
|
+ if (this.ticksInState >= INSERTER_TIMINGS.ROTATION) {
|
|
|
+ this.state = InserterState.Idle;
|
|
|
+ this.ticksInState = 0;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ protected canWakeUp(): boolean {
|
|
|
+ if (!this.isActive) return false;
|
|
|
+ const hasEnough = this.source.getAvailable(this.currentTargetItem) > 0;
|
|
|
+ return hasEnough && this.destination.canAccept(this.currentTargetItem);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ... (keep canWakeUp unchanged)
|
|
|
+}
|
|
|
+export class FilterableInserterSimulator extends InserterSimulator {
|
|
|
+ public staticFilters: string[] = [];
|
|
|
+
|
|
|
+ public useDynamicFilters = false;
|
|
|
+ public dynamicFilters: string[] = [];
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ handSize: number,
|
|
|
+ source: IContainer,
|
|
|
+ destination: IContainer,
|
|
|
+ filters: string[] = [],
|
|
|
+ ) {
|
|
|
+ // Pass the first filter as a dummy fallback to super()
|
|
|
+ super(handSize, source, destination, filters[0] || '');
|
|
|
+ this.staticFilters = filters;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Called by the Circuit Network (Pub/Sub)
|
|
|
+ public updateDynamicFilters(filters: string[]) {
|
|
|
+ this.dynamicFilters = filters;
|
|
|
+
|
|
|
+ // FACTORIO RULE: Partial Hand Eviction on Filter Change
|
|
|
+ if (this.state === InserterState.Picking && this.useDynamicFilters) {
|
|
|
+ if (!this.dynamicFilters.includes(this.currentTargetItem)) {
|
|
|
+ if (this.heldItems > 0) {
|
|
|
+ // Force it to swing forward with the partial hand
|
|
|
+ this.needed = 0;
|
|
|
+ this.toPick = 0;
|
|
|
+ } else {
|
|
|
+ // If empty, abort the pick and return to Idle
|
|
|
+ this.state = InserterState.Idle;
|
|
|
+ this.ticksInState = 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ protected override canWakeUp(): boolean {
|
|
|
+ if (!this.isActive) return false;
|
|
|
+
|
|
|
+ const activeFilters = this.useDynamicFilters ? this.dynamicFilters : this.staticFilters;
|
|
|
+
|
|
|
+ // Scan the filters in order of priority (left to right in Factorio UI)
|
|
|
+ for (const itemId of activeFilters) {
|
|
|
+ if (this.source.getAvailable(itemId) > 0 && this.destination.canAccept(itemId)) {
|
|
|
+
|
|
|
+ // LOCK ON: We found a valid item! Bind the hand to this item for the entire swing.
|
|
|
+ this.currentTargetItem = itemId;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+// src/engine/simulator.ts
|
|
|
+
|
|
|
+export class OptimizedClockRow {
|
|
|
+ public rowId: string;
|
|
|
+ public isActive = false;
|
|
|
+
|
|
|
+ // Maps exact local tick -> target active state (true/false)
|
|
|
+ private transitionMap = new Map<number, boolean>();
|
|
|
+ private subscribers: ((isActive: boolean) => void)[] = [];
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ rowId: string,
|
|
|
+ blocks: { start: number; end: number }[],
|
|
|
+ public cycleDuration: number
|
|
|
+ ) {
|
|
|
+ this.rowId = rowId;
|
|
|
+
|
|
|
+ // 1. Compile blocks into a temporary bitmap of length cycleDuration
|
|
|
+ const bitmap = new Uint8Array(cycleDuration);
|
|
|
+ for (const b of blocks) {
|
|
|
+ let t = b.start;
|
|
|
+ const end = b.end;
|
|
|
+ while (t < end) {
|
|
|
+ bitmap[t % cycleDuration] = 1;
|
|
|
+ t++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. Extract exact transition points where state changes
|
|
|
+ for (let t = 0; t < cycleDuration; t++) {
|
|
|
+ const prev = bitmap[(t - 1 + cycleDuration) % cycleDuration];
|
|
|
+ const curr = bitmap[t];
|
|
|
+ if (curr !== prev) {
|
|
|
+ this.transitionMap.set(t, curr === 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Set initial state based on tick 0
|
|
|
+ this.isActive = bitmap[0] === 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Inserters subscribe to receive direct state change callbacks.
|
|
|
+ */
|
|
|
+ public subscribe(callback: (isActive: boolean) => void) {
|
|
|
+ this.subscribers.push(callback);
|
|
|
+ // Push initial state immediately upon subscription
|
|
|
+ callback(this.isActive);
|
|
|
+ }
|
|
|
+
|
|
|
+ public tick(globalTick: number) {
|
|
|
+ const localTick = globalTick % this.cycleDuration;
|
|
|
+
|
|
|
+ // O(1) Check: Is this specific tick a transition boundary?
|
|
|
+ if (this.transitionMap.has(localTick)) {
|
|
|
+ const newState = this.transitionMap.get(localTick)!;
|
|
|
+
|
|
|
+ // STATE CHANGE: Only trigger mutations when the boolean actually flips!
|
|
|
+ if (newState !== this.isActive) {
|
|
|
+ this.isActive = newState;
|
|
|
+
|
|
|
+ for (const callback of this.subscribers) {
|
|
|
+ callback(this.isActive);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class FactorioEngineOrchestrator {
|
|
|
+ public currentTick = 0;
|
|
|
+
|
|
|
+ private rows = new Map<string, OptimizedClockRow>();
|
|
|
+ private belts: IContainer[] = [];
|
|
|
+ private inserters: InserterSimulator[] = [];
|
|
|
+ private machines: MachineSimulator[] = [];
|
|
|
+
|
|
|
+ public registerRow(rowId: string, blocks: { start: number; end: number }[], cycleDuration: number) {
|
|
|
+ const row = new OptimizedClockRow(rowId, blocks, cycleDuration);
|
|
|
+ this.rows.set(rowId, row);
|
|
|
+ }
|
|
|
+
|
|
|
+ public bindInserterToRow(inserter: InserterSimulator, rowId: string) {
|
|
|
+ const row = this.rows.get(rowId);
|
|
|
+ if (!row) throw new Error(`Row ${rowId} not found in orchestrator`);
|
|
|
+
|
|
|
+ // Pub/Sub binding: Inserter's isActive updates *only* when the row signals a state change
|
|
|
+ row.subscribe((active) => {
|
|
|
+ inserter.isActive = active;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ... (keep belts, machines registers)
|
|
|
+
|
|
|
+ public registerInserter(inserter: InserterSimulator) {
|
|
|
+ this.inserters.push(inserter);
|
|
|
+ }
|
|
|
+
|
|
|
+ public tick() {
|
|
|
+ // --- PHASE 0: CLOCK & NETWORK STATE UPDATES ---
|
|
|
+ for (const row of this.rows.values()) {
|
|
|
+ row.tick(this.currentTick);
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- PHASE 1: BELTS ---
|
|
|
+ for (const belt of this.belts) {
|
|
|
+ if (belt.tick) belt.tick(this.currentTick);
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- PHASE 2: INSERTERS ---
|
|
|
+ const sleepingOrHoveringInserters: InserterSimulator[] = [];
|
|
|
+ const successfullyPickedInserters: InserterSimulator[] = [];
|
|
|
+
|
|
|
+ for (const inserter of this.inserters) {
|
|
|
+ const heldBefore = inserter.heldItems;
|
|
|
+ inserter.tick();
|
|
|
+ const pickedItemsThisTick = inserter.heldItems > heldBefore;
|
|
|
+
|
|
|
+ if (pickedItemsThisTick) {
|
|
|
+ successfullyPickedInserters.push(inserter);
|
|
|
+ } else {
|
|
|
+ sleepingOrHoveringInserters.push(inserter);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.inserters = [...sleepingOrHoveringInserters, ...successfullyPickedInserters];
|
|
|
+
|
|
|
+ // --- PHASE 3: MACHINES ---
|
|
|
+ for (const machine of this.machines) {
|
|
|
+ machine.tick(this.currentTick);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.currentTick++;
|
|
|
+ }
|
|
|
+
|
|
|
+ public tickUntil(condition: () => boolean, maxTicks = 100000): boolean {
|
|
|
+ while (!condition() && this.currentTick < maxTicks) {
|
|
|
+ this.tick();
|
|
|
+ }
|
|
|
+ return this.currentTick < maxTicks;
|
|
|
+ }
|
|
|
+}
|
|
|
+// src/engine/simulator.test.ts
|
|
|
+
|
|
|
+ it('Optimized Clock: Pub/Sub row mapping controls inserter activation with zero scan overhead', () => {
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
+
|
|
|
+ const machine = new MachineSimulator({ machineQualityLevel: 0 } as MachineSetup, basicRecipe, { 'iron-gear-wheel': 100 });
|
|
|
+ const source = new Chest('iron-plate');
|
|
|
+ const sink = new Chest();
|
|
|
+
|
|
|
+ // 1. Register Clock Rows (43-tick cycle)
|
|
|
+ orchestrator.registerRow('row_input', [{ start: 0, end: 8 }], 43);
|
|
|
+ orchestrator.registerRow('row_output', [{ start: 35, end: 43 }], 43);
|
|
|
+
|
|
|
+ // 2. Create Inserters
|
|
|
+ const inputInserter = new InserterSimulator(2, source, machine, 'iron-plate');
|
|
|
+ const outputInserter = new InserterSimulator(1, machine, sink, 'iron-gear-wheel');
|
|
|
+
|
|
|
+ // 3. 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);
|
|
|
+
|
|
|
+ // 4. Run exactly 1 cycle
|
|
|
+ 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);
|
|
|
+ });
|
|
|
+// src/engine/simulator.test.ts
|
|
|
+
|
|
|
+ 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 ---
|
|
|
+ // Run for 600 ticks (~10 seconds) to fill all buffers and start the cascades
|
|
|
+ orchestrator.tickUntil(() => false, 600);
|
|
|
+
|
|
|
+ // 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 Copper Inputs (3 swings each = 48 plates per machine)
|
|
|
+ expect(inCop1.swingCount).toBe(3);
|
|
|
+ expect(inCop2.swingCount).toBe(3);
|
|
|
+ expect(inCop3.swingCount).toBe(3);
|
|
|
+
|
|
|
+ // Validate Iron Inputs (3 swings each = 48 plates per machine)
|
|
|
+ expect(inIron1.swingCount).toBe(3);
|
|
|
+ expect(inIron2.swingCount).toBe(3);
|
|
|
+
|
|
|
+ // Validate Outputs (3 swings each = 48 chips per machine)
|
|
|
+ expect(outCirc1.swingCount).toBe(3);
|
|
|
+ expect(outCirc2.swingCount).toBe(3);
|
|
|
+ });
|
|
|
+
|
|
|
+ // src/engine/simulator.test.ts
|
|
|
+
|
|
|
+ it('Clocked Baseline: 3:2 setup with staggered shared-inserter rows and exact 8-tick windows', () => {
|
|
|
+ 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();
|
|
|
+
|
|
|
+ // --- MATHEMATICAL CLOCK SETUP ---
|
|
|
+ // Cycle: 480 ticks. 16 crafts per cycle.
|
|
|
+
|
|
|
+ // Inputs (Copper/Iron): 16 items needed = 1 swing per cycle.
|
|
|
+ orchestrator.registerRow('row_inputs', [{ start: 0, end: 8 }], 480);
|
|
|
+
|
|
|
+ // Outer Mid Inserters (cop1 -> circ1, cop3 -> circ2): 32 cables generated = 2 swings per cycle.
|
|
|
+ orchestrator.registerRow('row_mid_outer', [{ start: 200, end: 208 }, { start: 400, end: 408 }], 480);
|
|
|
+
|
|
|
+ // Inner Mid Inserter A (cop2 -> circ1): 16 cables = 1 swing per cycle. Staggered to tick 120.
|
|
|
+ orchestrator.registerRow('row_mid_inner_A', [{ start: 120, end: 128 }], 480);
|
|
|
+
|
|
|
+ // Inner Mid Inserter B (cop2 -> circ2): 16 cables = 1 swing per cycle. Staggered to tick 360.
|
|
|
+ // Because A and B are perfectly staggered, they will never fight over cop2's buffer!
|
|
|
+ orchestrator.registerRow('row_mid_inner_B', [{ start: 360, end: 368 }], 480);
|
|
|
+
|
|
|
+ // Outputs (Green Chips): 16 items generated = 1 swing per cycle.
|
|
|
+ orchestrator.registerRow('row_outputs', [{ start: 460, end: 468 }], 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');
|
|
|
+
|
|
|
+ // Bind to Pub/Sub Rows
|
|
|
+ [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_A');
|
|
|
+ orchestrator.bindInserterToRow(mid2b, 'row_mid_inner_B');
|
|
|
+ [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 ---
|
|
|
+ // Let the factory run for 3 full cycles (1440 ticks) so all machine buffers fill,
|
|
|
+ // the pipeline finishes, and steady-state clocked rhythm is established.
|
|
|
+ orchestrator.tickUntil(() => false, 1440);
|
|
|
+
|
|
|
+ // Reset counts for the true measurement
|
|
|
+ allInserters.forEach(ins => ins.swingCount = 0);
|
|
|
+ const startTarget = orchestrator.currentTick;
|
|
|
+
|
|
|
+ // --- PHASE 2: MEASUREMENT WINDOW ---
|
|
|
+ // Measure exactly 3 cycles (1440 ticks).
|
|
|
+ orchestrator.tickUntil(() => false, startTarget + 1440);
|
|
|
+
|
|
|
+ // --- VALIDATION ---
|
|
|
+ // Inputs: exactly 3 swings (1 per cycle)
|
|
|
+ expect(inCop1.swingCount).toBe(3);
|
|
|
+ expect(inCop2.swingCount).toBe(3);
|
|
|
+ expect(inIron1.swingCount).toBe(3);
|
|
|
+
|
|
|
+ // Mid Outer (cop1, cop3): exactly 6 swings (2 per cycle)
|
|
|
+ expect(mid1.swingCount).toBe(6);
|
|
|
+ expect(mid3.swingCount).toBe(6);
|
|
|
+
|
|
|
+ // Mid Inner (cop2): exactly 3 swings EACH (1 per cycle).
|
|
|
+ // They did not fight because they were staggered by the clock!
|
|
|
+ expect(mid2a.swingCount).toBe(3);
|
|
|
+ expect(mid2b.swingCount).toBe(3);
|
|
|
+
|
|
|
+ // Outputs: exactly 3 swings (1 per cycle)
|
|
|
+ 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);
|
|
|
+ });
|
|
|
+
|
|
|
+ // src/engine/simulator.test.ts
|
|
|
+
|
|
|
+ it('Advanced Filtering: Filter Inserter dynamically changes target on a mixed belt', () => {
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
+
|
|
|
+ // 1. Setup a Mixed Belt (simulated using our Belt class holding both)
|
|
|
+ const mixedBelt = new Belt('iron-plate', 'copper-plate');
|
|
|
+ const sortingChest = new Chest();
|
|
|
+
|
|
|
+ // 2. Setup the Filter Inserter
|
|
|
+ const filterInserter = new FilterableInserterSimulator(16, mixedBelt, sortingChest);
|
|
|
+
|
|
|
+ // Configure it to use Circuit Network filters
|
|
|
+ filterInserter.useDynamicFilters = true;
|
|
|
+
|
|
|
+ // 3. Fake the Circuit Network Pub/Sub (Forcing Copper initially)
|
|
|
+ filterInserter.updateDynamicFilters(['copper-plate']);
|
|
|
+
|
|
|
+ orchestrator.registerInserter(filterInserter);
|
|
|
+
|
|
|
+ // --- PHASE 1: Pull Copper ---
|
|
|
+ orchestrator.tickUntil(() => false, 100);
|
|
|
+
|
|
|
+ // It should have extracted Copper, but completely ignored the Iron!
|
|
|
+ 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();
|
|
|
+
|
|
|
+ // --- PHASE 2: The Combinator Flips! ---
|
|
|
+ // The circuit network sends a new signal, changing the filter to Iron.
|
|
|
+ filterInserter.updateDynamicFilters(['iron-plate']);
|
|
|
+
|
|
|
+ // Run for another 100 ticks
|
|
|
+ orchestrator.tickUntil(() => false, 200);
|
|
|
+
|
|
|
+ // It should now have extracted Iron as well!
|
|
|
+ expect(mixedBelt.extractedCounts['iron-plate']).toBeGreaterThan(0);
|
|
|
+ expect(sortingChest.receivedCounts['iron-plate']).toBeGreaterThan(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ 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();
|
|
|
+
|
|
|
+ // Configure static filters for BOTH Iron and Copper
|
|
|
+ const filterInserter = new FilterableInserterSimulator(16, mixedBelt, sortingChest, ['iron-plate', 'copper-plate']);
|
|
|
+ orchestrator.registerInserter(filterInserter);
|
|
|
+
|
|
|
+ // Tick 1: It evaluates canWakeUp(). Iron is first in the list, so it locks onto Iron!
|
|
|
+ orchestrator.tick();
|
|
|
+
|
|
|
+ expect(filterInserter.state).toBe(InserterState.Picking);
|
|
|
+ expect(filterInserter.currentTargetItem).toBe('iron-plate'); // Locked!
|
|
|
+ expect(filterInserter.heldItems).toBe(4); // Belt pickup rate is 4
|
|
|
+
|
|
|
+ // 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');
|
|
|
+ });
|
|
|
+
|
|
|
+ // src/engine/simulator.test.ts
|
|
|
+
|
|
|
+ it('Advanced Filtering: Single multi-filter inserter autonomously feeds a Red Science machine', () => {
|
|
|
+ const orchestrator = new FactorioEngineOrchestrator();
|
|
|
+
|
|
|
+ // 1. 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
|
|
|
+ };
|
|
|
+
|
|
|
+ // 2. 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();
|
|
|
+
|
|
|
+ // 3. The Star of the Show: 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.registerInserter(output);
|
|
|
+ orchestrator.registerMachine(machine);
|
|
|
+
|
|
|
+ // 4. Run the simulation
|
|
|
+ // It takes 300 ticks for a single craft.
|
|
|
+ // We run it for 800 ticks, which gives enough time for 2 complete crafts and inserter swings.
|
|
|
+ const targetTick = orchestrator.currentTick + 800;
|
|
|
+ orchestrator.tickUntil(() => false, targetTick);
|
|
|
+
|
|
|
+ // --- VALIDATION ---
|
|
|
+
|
|
|
+ // 1. Did the machine successfully craft?
|
|
|
+ // Yes! 800 ticks is enough for 2 completed crafts.
|
|
|
+ expect(sinkChest.receivedCounts['automation-science-pack']).toBeGreaterThanOrEqual(2);
|
|
|
+
|
|
|
+ // 2. Did the filter inserter successfully switch items dynamically?
|
|
|
+ // 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);
|
|
|
+
|
|
|
+ // 3. Buffer Backpressure works!
|
|
|
+ // 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.
|
|
|
+ const copperPulled = mixedBelt.extractedCounts['copper-plate'];
|
|
|
+ const gearsPulled = mixedBelt.extractedCounts['iron-gear-wheel'];
|
|
|
+
|
|
|
+ // They should be reasonably close to each other (within 1 hand size margin)
|
|
|
+ expect(Math.abs(copperPulled - gearsPulled)).toBeLessThanOrEqual(4);
|
|
|
+ });
|