| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578 |
- 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<string, number> = {};
- public outputBuffer: Record<string, number> = {};
- 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<string, number> = {};
- private outputBlockLimits: Record<string, number> = {};
- constructor(
- public setup: MachineSetup,
- public recipe: Recipe,
- itemStackSizes: Record<string, number> = {},
- ) {
- 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<string, number> = {};
- public extractedCounts: Record<string, number> = {};
- 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<string, number> = {};
- public extractedCounts: Record<string, number> = {};
- private readonly isSink: boolean;
- private readonly providedItems = new Set<string>();
- 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<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 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<string, OptimizedClockRow>();
- 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;
- }
- }
|