|
@@ -1,665 +0,0 @@
|
|
|
-// 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);
|
|
|
|
|
- });
|
|
|