import { computeMachineStats } from "./stats"; import type { MachineSetup, CalculatedTimings } from "./types"; import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper"; export enum ContainerType { Chest = "Chest", Belt = "Belt", Splitter = "Splitter", Machine = "Machine", } export interface IContainer { readonly type: ContainerType; // For Inserter checking if it should wake up canAccept(itemId: string): boolean; getAvailable(itemId: string): number; // For Inserter executing the transfer insert(itemId: string, amount: number): void; extract(itemId: string, maxAmount: number): number; // Optional tick method for active containers (Machines) tick?(): void; } export const INSERTER_TIMINGS = { ROTATION: 3, PICKUP_RATE: { [ContainerType.Chest]: Infinity, // Grabs full hand instantly [ContainerType.Machine]: Infinity, // Grabs full hand instantly [ContainerType.Belt]: 4, // Grabs 4 items per tick [ContainerType.Splitter]: 4, // Grabs 4 items per tick }, DROP_DELAY: { [ContainerType.Chest]: 1, [ContainerType.Machine]: 1, [ContainerType.Splitter]: 4, [ContainerType.Belt]: 5, }, }; export enum InserterState { Idle, Picking, SwingingForward, Dropping, SwingingBack, } export class MachineSimulator implements IContainer { public readonly type = ContainerType.Machine; public inputBuffer: Record = {}; public outputBuffer: Record = {}; public craftProgress = 0; public prodProgress = 0; public isCrafting = false; public timings: CalculatedTimings; private progressPerTick: number; private solidIngredients: { name: string; amount: number }[] = []; private solidResults: { name: string; amount: number }[] = []; private overloadLimits: Record = {}; private outputBlockLimits: Record = {}; constructor( public setup: MachineSetup, public recipe: Recipe, itemStackSizes: Record = {}, ) { this.timings = computeMachineStats(setup, recipe); this.progressPerTick = 1 / this.timings.singleCraftTicks; const ingredients = recipe.ingredients || []; for (const ing of ingredients) { if (ing.type === "item") { this.solidIngredients.push({ name: ing.name, amount: ing.amount }); this.overloadLimits[ing.name] = ing.amount * this.timings.overloadMultiplier; this.inputBuffer[ing.name] = 0; } } const results = recipe.results || []; const hasIngredients = this.solidIngredients.length > 0; for (const res of results) { if (res.type === "item") { const amount = (res as any).amount ?? (res as any).amount_min ?? 1; this.solidResults.push({ name: res.name, amount }); this.outputBuffer[res.name] = 0; const maxStack = itemStackSizes[res.name] ?? 50; this.outputBlockLimits[res.name] = hasIngredients ? Math.min(maxStack, this.timings.overloadMultiplier * amount) : maxStack; } } } public tick() { let progressRemaining = this.progressPerTick; while (progressRemaining > 0) { if (!this.isCrafting) { if (this.hasEnoughInputs() && !this.isOutputBlocked()) { this.consumeInputs(); this.isCrafting = true; } else { break; } } if (this.isCrafting) { const progressToNextFinish = 1.0 - this.craftProgress; const step = Math.min(progressRemaining, progressToNextFinish); this.craftProgress += step; progressRemaining -= step; this.prodProgress += step * this.timings.productivityBonus; if (this.craftProgress >= 0.99999) { this.addResults(1); this.craftProgress = 0; this.isCrafting = false; } while (this.prodProgress >= 0.99999) { this.addResults(1); this.prodProgress -= 1.0; } } } } private hasEnoughInputs(): boolean { for (const ing of this.solidIngredients) { if (this.inputBuffer[ing.name] < ing.amount) return false; } return true; } private isOutputBlocked(): boolean { for (const res of this.solidResults) { if (this.outputBuffer[res.name] >= this.outputBlockLimits[res.name]) return true; } return false; } private consumeInputs() { for (const ing of this.solidIngredients) { this.inputBuffer[ing.name] -= ing.amount; } } private addResults(multiplier: number) { for (const res of this.solidResults) { this.outputBuffer[res.name] += res.amount * multiplier; } } public canAccept(itemId: string): boolean { const limit = this.overloadLimits[itemId]; if (limit === undefined) return false; // Doesn't accept this item return this.inputBuffer[itemId] < limit && !this.isOutputBlocked(); } public insert(itemId: string, amount: number): void { this.inputBuffer[itemId] = (this.inputBuffer[itemId] || 0) + amount; } public getAvailable(itemId: string): number { return this.outputBuffer[itemId] || 0; } public extract(itemId: string, maxAmount: number): number { const available = this.getAvailable(itemId); const toPick = Math.min(maxAmount, available); this.outputBuffer[itemId] -= toPick; return toPick; } } export class Chest implements IContainer { public readonly type = ContainerType.Chest; public receivedCounts: Record = {}; public extractedCounts: Record = {}; private readonly isSink: boolean; constructor(public providedItem?: string) { this.isSink = this.providedItem === undefined; } public canAccept(itemId: string): boolean { // Only accepts items if it wasn't configured as a source return this.isSink; } public getAvailable(itemId: string): number { return this.providedItem === itemId ? Infinity : 0; } public extract(itemId: string, maxAmount: number): number { if (this.getAvailable(itemId)) { this.extractedCounts[itemId] = (this.extractedCounts[itemId] || 0) + maxAmount; return maxAmount; } return 0; } public insert(itemId: string, amount: number): void { if (this.canAccept(itemId)) { this.receivedCounts[itemId] = (this.receivedCounts[itemId] || 0) + amount; } } } export class Belt implements IContainer { public readonly type = ContainerType.Belt; public receivedCounts: Record = {}; public extractedCounts: Record = {}; private readonly isSink: boolean; private readonly providedItems = new Set(); constructor(providedItem1?: string, providedItem2?: string) { this.isSink = providedItem1 === undefined && providedItem2 === undefined; if (providedItem1) this.providedItems.add(providedItem1); if (providedItem2) this.providedItems.add(providedItem2); } public canAccept(itemId: string): boolean { return this.isSink; } public getAvailable(itemId: string): number { return this.providedItems.has(itemId) ? Infinity : 0; } public extract(itemId: string, maxAmount: number): number { if (this.getAvailable(itemId)) { const amount = Math.min(maxAmount, 4); this.extractedCounts[itemId] = (this.extractedCounts[itemId] || 0) + amount; return amount; } return 0; } public insert(itemId: string, amount: number): void { if (this.canAccept(itemId)) { this.receivedCounts[itemId] = (this.receivedCounts[itemId] || 0) + amount; } } } export class InserterSimulator { public state: InserterState = InserterState.Idle; public ticksInState = 0; public heldItems = 0; public swingCount = 0; public isActive = true; public currentTargetItem: string; private readonly pickupRate: number; private readonly dropTicks: number; protected needed: number; protected toPick: 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]; this.needed = 0; this.toPick = 0; } 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: if (this.isActive && this.toPick > 0) { const picked = this.source.extract(this.targetItemId, this.toPick); if (picked > 0) { this.heldItems += picked; this.needed -= picked; this.toPick = Math.min(this.needed, this.pickupRate); } } 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 { return ( this.isActive && this.source.getAvailable(this.targetItemId) > 0 && this.destination.canAccept(this.targetItemId) ); } } 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)) { this.currentTargetItem = itemId; return true; } } return false; } } export class OptimizedClockRow { public rowId: string; public isActive = false; // Maps exact local tick -> target active state (true/false) private transitionMap = new Map(); 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 SimulationOrchestrator { private tickables: { tick?(): void }[] = []; public currentTick = 0; /** * Add entities in strict topological order to mimic perfect Factorio build order. * e.g., Sources -> Input Inserters -> Machines -> Output Inserters -> Sinks */ public register(entity: { tick?(): void }) { this.tickables.push(entity); } public tick() { for (const entity of this.tickables) { if (entity.tick) entity.tick(); } this.currentTick++; } public tickUntil(condition: () => boolean, maxTicks = 10000): boolean { while (!condition() && this.currentTick < maxTicks) { this.tick(); } return this.currentTick < maxTicks; // Returns true if condition met, false if timed out } } export class FactorioEngineOrchestrator { public currentTick = 0; // Entity Managers (Strict Execution Order!) private rows = new Map(); private belts: IContainer[] = []; private inserters: InserterSimulator[] = []; private machines: MachineSimulator[] = []; public registerBelt(belt: IContainer) { this.belts.push(belt); } public registerMachine(machine: MachineSimulator) { this.machines.push(machine); } public registerInserter(inserter: InserterSimulator) { this.inserters.push(inserter); } 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; }); } public tick() { // --- CLOCK & NETWORK STATE UPDATES --- for (const row of this.rows.values()) { row.tick(this.currentTick); } // Belts for (const belt of this.belts) { if (belt.tick) belt.tick(); } // 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) { // It got items, so it yields priority for the next tick successfullyPickedInserters.push(inserter); } else { // It got nothing (starved), so it maintains its priority at the front of the line sleepingOrHoveringInserters.push(inserter); } } this.inserters = [...sleepingOrHoveringInserters, ...successfullyPickedInserters]; // Assemblers for (const machine of this.machines) { machine.tick(); } this.currentTick++; } public tickUntil(condition: () => boolean, maxTicks = 10000): boolean { while (!condition() && this.currentTick < maxTicks) { this.tick(); } return this.currentTick < maxTicks; } }