simulator.ts 18 KB

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