simulator.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. import { computeMachineStats } from "./stats";
  2. import type { MachineSetup, CalculatedTimings } from "./types";
  3. import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
  4. export enum ContainerType {
  5. Chest = "Chest",
  6. Belt = "Belt",
  7. Splitter = "Splitter",
  8. Machine = "Machine",
  9. }
  10. export interface IContainer {
  11. readonly type: ContainerType;
  12. // For Inserter checking if it should wake up
  13. canAccept(itemId: string): boolean;
  14. getAvailable(itemId: string): number;
  15. // For Inserter executing the transfer
  16. insert(itemId: string, amount: number): void;
  17. extract(itemId: string, maxAmount: number): number;
  18. // Optional tick method for active containers (Machines)
  19. tick?(): void;
  20. }
  21. export const INSERTER_TIMINGS = {
  22. ROTATION: 3,
  23. PICKUP_RATE: {
  24. [ContainerType.Chest]: Infinity, // Grabs full hand instantly
  25. [ContainerType.Machine]: Infinity, // Grabs full hand instantly
  26. [ContainerType.Belt]: 4, // Grabs 4 items per tick
  27. [ContainerType.Splitter]: 4, // Grabs 4 items per tick
  28. },
  29. DROP_DELAY: {
  30. [ContainerType.Chest]: 1,
  31. [ContainerType.Machine]: 1,
  32. [ContainerType.Splitter]: 4,
  33. [ContainerType.Belt]: 5,
  34. },
  35. };
  36. export enum InserterState {
  37. Idle,
  38. Picking,
  39. SwingingForward,
  40. Dropping,
  41. SwingingBack,
  42. }
  43. export class MachineSimulator implements IContainer {
  44. public readonly type = ContainerType.Machine;
  45. public inputBuffer: Record<string, number> = {};
  46. public outputBuffer: Record<string, number> = {};
  47. public craftProgress = 0;
  48. public prodProgress = 0;
  49. public isCrafting = false;
  50. public timings: CalculatedTimings;
  51. private progressPerTick: number;
  52. private solidIngredients: { name: string; amount: number }[] = [];
  53. private solidResults: { name: string; amount: number }[] = [];
  54. private overloadLimits: Record<string, number> = {};
  55. private outputBlockLimits: Record<string, number> = {};
  56. constructor(
  57. public setup: MachineSetup,
  58. public recipe: Recipe,
  59. itemStackSizes: Record<string, number> = {},
  60. ) {
  61. this.timings = computeMachineStats(setup, recipe);
  62. this.progressPerTick = 1 / this.timings.singleCraftTicks;
  63. const ingredients = recipe.ingredients || [];
  64. for (const ing of ingredients) {
  65. if (ing.type === "item") {
  66. this.solidIngredients.push({ name: ing.name, amount: ing.amount });
  67. this.overloadLimits[ing.name] = ing.amount * this.timings.overloadMultiplier;
  68. this.inputBuffer[ing.name] = 0;
  69. }
  70. }
  71. const results = recipe.results || [];
  72. const hasIngredients = this.solidIngredients.length > 0;
  73. for (const res of results) {
  74. if (res.type === "item") {
  75. const amount = (res as any).amount ?? (res as any).amount_min ?? 1;
  76. this.solidResults.push({ name: res.name, amount });
  77. this.outputBuffer[res.name] = 0;
  78. const maxStack = itemStackSizes[res.name] ?? 50;
  79. this.outputBlockLimits[res.name] = hasIngredients
  80. ? Math.min(maxStack, this.timings.overloadMultiplier * amount)
  81. : maxStack;
  82. }
  83. }
  84. }
  85. public tick() {
  86. let progressRemaining = this.progressPerTick;
  87. while (progressRemaining > 0) {
  88. if (!this.isCrafting) {
  89. if (this.hasEnoughInputs() && !this.isOutputBlocked()) {
  90. this.consumeInputs();
  91. this.isCrafting = true;
  92. } else {
  93. break;
  94. }
  95. }
  96. if (this.isCrafting) {
  97. const progressToNextFinish = 1.0 - this.craftProgress;
  98. const step = Math.min(progressRemaining, progressToNextFinish);
  99. this.craftProgress += step;
  100. progressRemaining -= step;
  101. this.prodProgress += step * this.timings.productivityBonus;
  102. if (this.craftProgress >= 0.99999) {
  103. this.addResults(1);
  104. this.craftProgress = 0;
  105. this.isCrafting = false;
  106. }
  107. while (this.prodProgress >= 0.99999) {
  108. this.addResults(1);
  109. this.prodProgress -= 1.0;
  110. }
  111. }
  112. }
  113. }
  114. private hasEnoughInputs(): boolean {
  115. for (const ing of this.solidIngredients) {
  116. if (this.inputBuffer[ing.name] < ing.amount) return false;
  117. }
  118. return true;
  119. }
  120. private isOutputBlocked(): boolean {
  121. for (const res of this.solidResults) {
  122. if (this.outputBuffer[res.name] >= this.outputBlockLimits[res.name]) return true;
  123. }
  124. return false;
  125. }
  126. private consumeInputs() {
  127. for (const ing of this.solidIngredients) {
  128. this.inputBuffer[ing.name] -= ing.amount;
  129. }
  130. }
  131. private addResults(multiplier: number) {
  132. for (const res of this.solidResults) {
  133. this.outputBuffer[res.name] += res.amount * multiplier;
  134. }
  135. }
  136. public canAccept(itemId: string): boolean {
  137. const limit = this.overloadLimits[itemId];
  138. if (limit === undefined) return false; // Doesn't accept this item
  139. return this.inputBuffer[itemId] < limit && !this.isOutputBlocked();
  140. }
  141. public insert(itemId: string, amount: number): void {
  142. this.inputBuffer[itemId] = (this.inputBuffer[itemId] || 0) + amount;
  143. }
  144. public getAvailable(itemId: string): number {
  145. return this.outputBuffer[itemId] || 0;
  146. }
  147. public extract(itemId: string, maxAmount: number): number {
  148. const available = this.getAvailable(itemId);
  149. const toPick = Math.min(maxAmount, available);
  150. this.outputBuffer[itemId] -= toPick;
  151. return toPick;
  152. }
  153. }
  154. export class Chest implements IContainer {
  155. public readonly type = ContainerType.Chest;
  156. public receivedCounts: Record<string, number> = {};
  157. public extractedCounts: Record<string, number> = {};
  158. private readonly isSink: boolean;
  159. constructor(public providedItem?: string) {
  160. this.isSink = this.providedItem === undefined;
  161. }
  162. public canAccept(itemId: string): boolean {
  163. // Only accepts items if it wasn't configured as a source
  164. return this.isSink;
  165. }
  166. public getAvailable(itemId: string): number {
  167. return this.providedItem === itemId ? Infinity : 0;
  168. }
  169. public extract(itemId: string, maxAmount: number): number {
  170. if (this.getAvailable(itemId)) {
  171. this.extractedCounts[itemId] = (this.extractedCounts[itemId] || 0) + maxAmount;
  172. return maxAmount;
  173. }
  174. return 0;
  175. }
  176. public insert(itemId: string, amount: number): void {
  177. if (this.canAccept(itemId)) {
  178. this.receivedCounts[itemId] = (this.receivedCounts[itemId] || 0) + amount;
  179. }
  180. }
  181. }
  182. export class Belt implements IContainer {
  183. public readonly type = ContainerType.Belt;
  184. public receivedCounts: Record<string, number> = {};
  185. public extractedCounts: Record<string, number> = {};
  186. private readonly isSink: boolean;
  187. private readonly providedItems = new Set<string>();
  188. constructor(providedItem1?: string, providedItem2?: string) {
  189. this.isSink = providedItem1 === undefined && providedItem2 === undefined;
  190. if (providedItem1) this.providedItems.add(providedItem1);
  191. if (providedItem2) this.providedItems.add(providedItem2);
  192. }
  193. public canAccept(itemId: string): boolean {
  194. return this.isSink;
  195. }
  196. public getAvailable(itemId: string): number {
  197. return this.providedItems.has(itemId) ? Infinity : 0;
  198. }
  199. public extract(itemId: string, maxAmount: number): number {
  200. if (this.getAvailable(itemId)) {
  201. const amount = Math.min(maxAmount, 4);
  202. this.extractedCounts[itemId] = (this.extractedCounts[itemId] || 0) + amount;
  203. return amount;
  204. }
  205. return 0;
  206. }
  207. public insert(itemId: string, amount: number): void {
  208. if (this.canAccept(itemId)) {
  209. this.receivedCounts[itemId] = (this.receivedCounts[itemId] || 0) + amount;
  210. }
  211. }
  212. }
  213. export class InserterSimulator {
  214. public state: InserterState = InserterState.Idle;
  215. public ticksInState = 0;
  216. public heldItems = 0;
  217. public swingCount = 0;
  218. public isActive = true;
  219. public currentTargetItem: string;
  220. private readonly pickupRate: number;
  221. private readonly dropTicks: number;
  222. protected needed: number;
  223. protected toPick: number;
  224. constructor(
  225. public handSize: number,
  226. public source: IContainer,
  227. public destination: IContainer,
  228. public targetItemId: string,
  229. ) {
  230. this.currentTargetItem = targetItemId;
  231. this.pickupRate = INSERTER_TIMINGS.PICKUP_RATE[source.type];
  232. this.dropTicks = INSERTER_TIMINGS.DROP_DELAY[destination.type];
  233. this.needed = 0;
  234. this.toPick = 0;
  235. }
  236. public tick() {
  237. if (this.state === InserterState.Idle) {
  238. if (this.canWakeUp()) {
  239. this.state = InserterState.Picking;
  240. this.ticksInState = 0;
  241. // Initialize caches
  242. this.needed = this.handSize;
  243. this.toPick = Math.min(this.needed, this.pickupRate);
  244. } else {
  245. return;
  246. }
  247. }
  248. this.ticksInState++;
  249. switch (this.state) {
  250. case InserterState.Picking:
  251. if (this.isActive && this.toPick > 0) {
  252. const picked = this.source.extract(this.targetItemId, this.toPick);
  253. if (picked > 0) {
  254. this.heldItems += picked;
  255. this.needed -= picked;
  256. this.toPick = Math.min(this.needed, this.pickupRate);
  257. }
  258. }
  259. if (!this.needed) {
  260. this.state = InserterState.SwingingForward;
  261. this.ticksInState = 0;
  262. }
  263. break;
  264. case InserterState.SwingingForward:
  265. if (this.ticksInState >= INSERTER_TIMINGS.ROTATION) {
  266. this.state = InserterState.Dropping;
  267. this.ticksInState = 0;
  268. }
  269. break;
  270. case InserterState.Dropping:
  271. if (!this.destination.canAccept(this.targetItemId)) {
  272. this.ticksInState--;
  273. break;
  274. }
  275. if (this.ticksInState >= this.dropTicks) {
  276. this.destination.insert(this.targetItemId, this.heldItems);
  277. this.heldItems = 0;
  278. this.swingCount++;
  279. this.state = InserterState.SwingingBack;
  280. this.ticksInState = 0;
  281. }
  282. break;
  283. case InserterState.SwingingBack:
  284. if (this.ticksInState >= INSERTER_TIMINGS.ROTATION) {
  285. this.state = InserterState.Idle;
  286. this.ticksInState = 0;
  287. }
  288. break;
  289. }
  290. }
  291. protected canWakeUp(): boolean {
  292. return (
  293. this.isActive && this.source.getAvailable(this.targetItemId) > 0 && this.destination.canAccept(this.targetItemId)
  294. );
  295. }
  296. }
  297. export class FilterableInserterSimulator extends InserterSimulator {
  298. public staticFilters: string[] = [];
  299. public useDynamicFilters = false;
  300. public dynamicFilters: string[] = [];
  301. constructor(handSize: number, source: IContainer, destination: IContainer, filters: string[] = []) {
  302. // Pass the first filter as a dummy fallback to super()
  303. super(handSize, source, destination, filters[0] || "");
  304. this.staticFilters = filters;
  305. }
  306. // Called by the Circuit Network (Pub/Sub)
  307. public updateDynamicFilters(filters: string[]) {
  308. this.dynamicFilters = filters;
  309. // FACTORIO RULE: Partial Hand Eviction on Filter Change
  310. if (this.state === InserterState.Picking && this.useDynamicFilters) {
  311. if (!this.dynamicFilters.includes(this.currentTargetItem)) {
  312. if (this.heldItems > 0) {
  313. // Force it to swing forward with the partial hand
  314. this.needed = 0;
  315. this.toPick = 0;
  316. } else {
  317. // If empty, abort the pick and return to Idle
  318. this.state = InserterState.Idle;
  319. this.ticksInState = 0;
  320. }
  321. }
  322. }
  323. }
  324. protected override canWakeUp(): boolean {
  325. if (!this.isActive) return false;
  326. const activeFilters = this.useDynamicFilters ? this.dynamicFilters : this.staticFilters;
  327. // Scan the filters in order of priority (left to right in Factorio UI)
  328. for (const itemId of activeFilters) {
  329. if (this.source.getAvailable(itemId) > 0 && this.destination.canAccept(itemId)) {
  330. this.currentTargetItem = itemId;
  331. return true;
  332. }
  333. }
  334. return false;
  335. }
  336. }
  337. export class OptimizedClockRow {
  338. public rowId: string;
  339. public isActive = false;
  340. // Maps exact local tick -> target active state (true/false)
  341. private transitionMap = new Map<number, boolean>();
  342. private subscribers: ((isActive: boolean) => void)[] = [];
  343. constructor(
  344. rowId: string,
  345. blocks: { start: number; end: number }[],
  346. public cycleDuration: number,
  347. ) {
  348. this.rowId = rowId;
  349. // 1. Compile blocks into a temporary bitmap of length cycleDuration
  350. const bitmap = new Uint8Array(cycleDuration);
  351. for (const b of blocks) {
  352. let t = b.start;
  353. const end = b.end;
  354. while (t < end) {
  355. bitmap[t % cycleDuration] = 1;
  356. t++;
  357. }
  358. }
  359. // 2. Extract exact transition points where state changes
  360. for (let t = 0; t < cycleDuration; t++) {
  361. const prev = bitmap[(t - 1 + cycleDuration) % cycleDuration];
  362. const curr = bitmap[t];
  363. if (curr !== prev) {
  364. this.transitionMap.set(t, curr === 1);
  365. }
  366. }
  367. // Set initial state based on tick 0
  368. this.isActive = bitmap[0] === 1;
  369. }
  370. /**
  371. * Inserters subscribe to receive direct state change callbacks.
  372. */
  373. public subscribe(callback: (isActive: boolean) => void) {
  374. this.subscribers.push(callback);
  375. // Push initial state immediately upon subscription
  376. callback(this.isActive);
  377. }
  378. public tick(globalTick: number) {
  379. const localTick = globalTick % this.cycleDuration;
  380. // O(1) Check: Is this specific tick a transition boundary?
  381. if (this.transitionMap.has(localTick)) {
  382. const newState = this.transitionMap.get(localTick)!;
  383. // STATE CHANGE: Only trigger mutations when the boolean actually flips!
  384. if (newState !== this.isActive) {
  385. this.isActive = newState;
  386. for (const callback of this.subscribers) {
  387. callback(this.isActive);
  388. }
  389. }
  390. }
  391. }
  392. }
  393. export class SimulationOrchestrator {
  394. private tickables: { tick?(): void }[] = [];
  395. public currentTick = 0;
  396. /**
  397. * Add entities in strict topological order to mimic perfect Factorio build order.
  398. * e.g., Sources -> Input Inserters -> Machines -> Output Inserters -> Sinks
  399. */
  400. public register(entity: { tick?(): void }) {
  401. this.tickables.push(entity);
  402. }
  403. public tick() {
  404. for (const entity of this.tickables) {
  405. if (entity.tick) entity.tick();
  406. }
  407. this.currentTick++;
  408. }
  409. public tickUntil(condition: () => boolean, maxTicks = 10000): boolean {
  410. while (!condition() && this.currentTick < maxTicks) {
  411. this.tick();
  412. }
  413. return this.currentTick < maxTicks; // Returns true if condition met, false if timed out
  414. }
  415. }
  416. export class FactorioEngineOrchestrator {
  417. public currentTick = 0;
  418. // Entity Managers (Strict Execution Order!)
  419. private rows = new Map<string, OptimizedClockRow>();
  420. private belts: IContainer[] = [];
  421. private inserters: InserterSimulator[] = [];
  422. private machines: MachineSimulator[] = [];
  423. public registerBelt(belt: IContainer) {
  424. this.belts.push(belt);
  425. }
  426. public registerMachine(machine: MachineSimulator) {
  427. this.machines.push(machine);
  428. }
  429. public registerInserter(inserter: InserterSimulator) {
  430. this.inserters.push(inserter);
  431. }
  432. public registerRow(rowId: string, blocks: { start: number; end: number }[], cycleDuration: number) {
  433. const row = new OptimizedClockRow(rowId, blocks, cycleDuration);
  434. this.rows.set(rowId, row);
  435. }
  436. public bindInserterToRow(inserter: InserterSimulator, rowId: string) {
  437. const row = this.rows.get(rowId);
  438. if (!row) throw new Error(`Row ${rowId} not found in orchestrator`);
  439. // Pub/Sub binding: Inserter's isActive updates *only* when the row signals a state change
  440. row.subscribe((active) => {
  441. inserter.isActive = active;
  442. });
  443. }
  444. public tick() {
  445. // --- CLOCK & NETWORK STATE UPDATES ---
  446. for (const row of this.rows.values()) {
  447. row.tick(this.currentTick);
  448. }
  449. // Belts
  450. for (const belt of this.belts) {
  451. if (belt.tick) belt.tick();
  452. }
  453. // Inserters
  454. const sleepingOrHoveringInserters: InserterSimulator[] = [];
  455. const successfullyPickedInserters: InserterSimulator[] = [];
  456. for (const inserter of this.inserters) {
  457. const heldBefore = inserter.heldItems;
  458. inserter.tick();
  459. const pickedItemsThisTick = inserter.heldItems > heldBefore;
  460. if (pickedItemsThisTick) {
  461. // It got items, so it yields priority for the next tick
  462. successfullyPickedInserters.push(inserter);
  463. } else {
  464. // It got nothing (starved), so it maintains its priority at the front of the line
  465. sleepingOrHoveringInserters.push(inserter);
  466. }
  467. }
  468. this.inserters = [...sleepingOrHoveringInserters, ...successfullyPickedInserters];
  469. // Assemblers
  470. for (const machine of this.machines) {
  471. machine.tick();
  472. }
  473. this.currentTick++;
  474. }
  475. public tickUntil(condition: () => boolean, maxTicks = 10000): boolean {
  476. while (!condition() && this.currentTick < maxTicks) {
  477. this.tick();
  478. }
  479. return this.currentTick < maxTicks;
  480. }
  481. }