/** * LANE-SIM — the deterministic heart of FKTRY. * * Headless: no DOM, no clock, no unseeded randomness. Same seed + same command * sequence = identical snapshots forever. Everything mutates through Commands; * everything observable leaves through snapshot() and drainEvents(). */ import { TICKS_PER_SECOND, type BeltItem, type Command, type CommissionDef, type Dir, type EntityState, type GameData, type MachineDef, type RecipeDef, type Sim, type SimEvent, type SimSnapshot, } from '../contracts'; import { DIR_VEC, footprintOf, inBounds, tileKey } from './geom'; import { makeRng, type Rng } from './rng'; /** Minimum gap between two items measured in belt-tiles. */ const BELT_SPACING = 0.45; /** Items a single belt tile may hold. */ const BELT_CAPACITY = 2; /** Items a splitter may hold while it waits for an output to open. */ const SPLITTER_CAPACITY = 2; /** A machine stalls once any output has piled to this multiple of the recipe yield. */ const OUTPUT_STALL_FACTOR = 4; /** A machine accepts an input up to this multiple of the recipe requirement. */ const INPUT_BUFFER_FACTOR = 2; /** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */ const MAX_TRACE = 512; // Heat v1. Curve is LANE-SIM's call — see NOTES round 2. /** Below this, heat is cosmetic. */ const HEAT_THROTTLE_START = 0.7; /** Speed multiplier at heat 1.0 — the lore's "tick rate visibly halves". */ const HEAT_THROTTLE_FLOOR = 0.5; /** Heat shed per tick, always, active or not. Overridden per machine by `coolPerTick`. */ const HEAT_COOL_DEFAULT = 0.004; /** Cool back below this and a scrammed machine comes back up. */ const HEAT_RESTART = 0.5; /** Internal entity record. `state` is the contract-shaped object handed to consumers. */ interface Ent { state: EntityState; def: MachineDef; /** mid-craft: inputs already consumed, progress running */ crafting: boolean; /** heat shutdown: no work, no generation, until it cools to HEAT_RESTART */ scrammed: boolean; /** tile keys this entity occupies, for teardown */ tiles: number[]; /** kind==='splitter': items awaiting an open output, and the round-robin cursor */ queue: Array<{ item: string; id: number }>; cursor: number; } export function createSim(): Sim { let data: GameData = { items: [], machines: [], recipes: [], tech: [], commissions: [] }; let rng: Rng = makeRng(0); const defs = new Map(); const recipeDefs = new Map(); const commissionDefs = new Map(); // ents and entityStates are parallel and always in ascending-id order — the tick // loop walks them in that order, which is what makes the whole sim deterministic. let ents: Ent[] = []; let entityStates: EntityState[] = []; const byId = new Map(); const occ = new Map(); // tileKey -> entity id const beltContents = new Map(); // belt id -> items, t descending let allBeltItems: BeltItem[] = []; /** belt id -> every machine its chain can reach (through splitters). [] = dead end/loop. */ const traceCache = new Map(); let nextId = 1; let nextItemId = 1; let curTick = 0; let paused = false; let brownout = false; let stored = 0; let commissionQueue: string[] = []; const cmdQueue: Command[] = []; let events: SimEvent[] = []; /** items already advanced this tick, so a transfer downstream can't double-move */ const moved = new Set(); const candidateBelts: number[] = []; const snap: SimSnapshot = { tick: 0, paused: false, entities: entityStates, beltItems: allBeltItems, bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false }, shippedTotal: {}, activeCommission: null, commissionProgress: {}, }; // ------------------------------------------------------------------ lookups const entAt = (x: number, y: number): Ent | undefined => { const id = occ.get(tileKey(x, y)); return id === undefined ? undefined : byId.get(id); }; const recipeOf = (e: Ent): RecipeDef | null => e.state.recipe === null ? null : recipeDefs.get(e.state.recipe) ?? null; /** The tile a belt feeds into. */ const beltTarget = (e: Ent): Ent | undefined => { const v = DIR_VEC[e.state.dir]; return entAt(e.state.pos.x + v.x, e.state.pos.y + v.y); }; /** Static appetite: does this machine's recipe ever call for this item? */ const machineWants = (e: Ent, item: string): boolean => { if (e.def.kind === 'shipper') return true; const r = recipeOf(e); return !!r && (r.inputs[item] ?? 0) > 0; }; /** Belts adjacent to this entity that don't point back into it. */ function collectOutputBelts(e: Ent, out: number[]): void { out.length = 0; const fp = footprintOf(e.def.footprint, e.state.dir); for (let dx = 0; dx < fp.x; dx++) { for (let dy = 0; dy < fp.y; dy++) { const x = e.state.pos.x + dx; const y = e.state.pos.y + dy; for (let d = 0; d < 4; d++) { const v = DIR_VEC[d]; const b = entAt(x + v.x, y + v.y); if (!b || b.def.kind !== 'belt') continue; const bv = DIR_VEC[b.state.dir]; if (occ.get(tileKey(b.state.pos.x + bv.x, b.state.pos.y + bv.y)) === e.state.id) continue; if (!out.includes(b.state.id)) out.push(b.state.id); } } } out.sort((a, b) => a - b); } /** * Every machine a belt's chain can reach. Splitters fan out, so this is a set, not a * single terminal: a lane through a splitter can reach both a consumer and an uplink. * Cached; invalidated on any topology or recipe change. */ function traceTerminals(beltId: number): number[] { const cached = traceCache.get(beltId); if (cached !== undefined) return cached; const result: number[] = []; const seen = new Set(); const stack: number[] = [beltId]; const branch: number[] = []; while (stack.length && seen.size < MAX_TRACE) { const id = stack.pop()!; if (seen.has(id)) continue; seen.add(id); const e = byId.get(id); if (!e) continue; if (e.def.kind === 'splitter') { collectOutputBelts(e, branch); for (const b of branch) stack.push(b); continue; } const next = beltTarget(e); if (!next) continue; // dead end: items ride to the end and pile up if (next.def.kind === 'belt' || next.def.kind === 'splitter') { stack.push(next.state.id); } else if (!result.includes(next.state.id)) { result.push(next.state.id); } } result.sort((a, b) => a - b); traceCache.set(beltId, result); return result; } /** * How much a machine wants to hand this item to this belt — the best outcome reachable * down that lane: * 2 = it reaches a machine that consumes this item * 1 = it only reaches an uplink (takes anything — a sink, not a destination) * 0 = dead end or loop (items just pile; keeps a half-built belt from feeling broken) * -1 = every machine it reaches will refuse this item — pushing here deadlocks the lane */ function outputBeltRank(beltId: number, item: string): number { const terms = traceTerminals(beltId); if (!terms.length) return 0; let best = -1; for (const t of terms) { const e = byId.get(t); if (!e) continue; const r = e.def.kind === 'shipper' ? 1 : machineWants(e, item) ? 2 : -1; if (r > best) best = r; } return best; } function beltHasRoom(beltId: number, atT: number): boolean { const items = beltContents.get(beltId); if (!items || items.length >= BELT_CAPACITY) return false; const rear = items[items.length - 1]; return !rear || rear.t - atT >= BELT_SPACING; } function spawnOnBelt(beltId: number, item: string, id: number): void { const it: BeltItem = { item, entity: beltId, t: 0, id }; beltContents.get(beltId)!.push(it); allBeltItems.push(it); } function removeFlat(it: BeltItem): void { const i = allBeltItems.indexOf(it); if (i >= 0) allBeltItems.splice(i, 1); } function setJam(e: Ent, reason: string | null): void { if (e.state.jammed === reason) return; // edge-triggered: one event per stall e.state.jammed = reason; if (reason !== null) events.push({ kind: 'jammed', entity: e.state.id, reason, tick: curTick }); } /** Splitter contents are mirrored into inputBuf so the inspector can see them. */ function syncSplitter(e: Ent): void { const buf: Record = {}; for (const q of e.queue) buf[q.item] = (buf[q.item] ?? 0) + 1; e.state.inputBuf = buf; } /** Scrammed and idle machines contribute nothing; hot ones work at reduced speed. */ function heatThrottle(e: Ent): number { if (e.scrammed) return 0; const h = e.state.heat; if (h <= HEAT_THROTTLE_START) return 1; const over = (h - HEAT_THROTTLE_START) / (1 - HEAT_THROTTLE_START); return 1 - over * (1 - HEAT_THROTTLE_FLOOR); } // ----------------------------------------------------------------- commands function doPlace(def: MachineDef, pos: { x: number; y: number }, dir: Dir): void { const fp = footprintOf(def.footprint, dir); const tiles: number[] = []; for (let dx = 0; dx < fp.x; dx++) { for (let dy = 0; dy < fp.y; dy++) { const x = pos.x + dx; const y = pos.y + dy; if (!inBounds(x, y)) return; const k = tileKey(x, y); if (occ.has(k)) return; tiles.push(k); } } const state: EntityState = { id: nextId++, def: def.id, pos: { x: pos.x, y: pos.y }, dir, recipe: def.recipes.length ? def.recipes[0] : null, progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, scrammed: false, }; const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0 }; ents.push(e); entityStates.push(state); byId.set(state.id, e); for (const k of tiles) occ.set(k, state.id); if (def.kind === 'belt') beltContents.set(state.id, []); traceCache.clear(); events.push({ kind: 'placed', entity: state.id, def: def.id, tick: curTick }); } function doRemove(pos: { x: number; y: number }): void { const e = entAt(pos.x, pos.y); if (!e) return; const id = e.state.id; for (const k of e.tiles) occ.delete(k); byId.delete(id); const i = ents.indexOf(e); ents.splice(i, 1); entityStates.splice(i, 1); const items = beltContents.get(id); if (items) { for (const it of items) removeFlat(it); // cargo dies with the belt beltContents.delete(id); } traceCache.clear(); events.push({ kind: 'removed', entity: id, def: e.def.id, tick: curTick }); } function doRotate(pos: { x: number; y: number }): void { const e = entAt(pos.x, pos.y); if (!e) return; const dir = ((e.state.dir + 1) % 4) as Dir; const fp = footprintOf(e.def.footprint, dir); const tiles: number[] = []; for (let dx = 0; dx < fp.x; dx++) { for (let dy = 0; dy < fp.y; dy++) { const x = e.state.pos.x + dx; const y = e.state.pos.y + dy; if (!inBounds(x, y)) return; const k = tileKey(x, y); const owner = occ.get(k); if (owner !== undefined && owner !== e.state.id) return; // rotation would collide tiles.push(k); } } for (const k of e.tiles) occ.delete(k); for (const k of tiles) occ.set(k, e.state.id); e.tiles = tiles; e.state.dir = dir; traceCache.clear(); } function doSetRecipe(entity: number, recipe: string | null): void { const e = byId.get(entity); if (!e) return; setRecipeOn(e, recipe); } function setRecipeOn(e: Ent, recipe: string | null): void { if (recipe !== null && !e.def.recipes.includes(recipe)) return; e.state.recipe = recipe; e.state.progress = 0; e.crafting = false; e.state.jammed = null; traceCache.clear(); // routing keys off what machines want, which just changed } function applyCommand(cmd: Command): void { switch (cmd.kind) { case 'place': { const def = defs.get(cmd.def); if (def) doPlace(def, cmd.pos, cmd.dir); break; } case 'remove': doRemove(cmd.pos); break; case 'rotate': doRotate(cmd.pos); break; case 'setRecipe': doSetRecipe(cmd.entity, cmd.recipe); break; case 'setRecipeAt': { const e = entAt(cmd.pos.x, cmd.pos.y); // id-free: any tile of the footprint works if (e) setRecipeOn(e, cmd.recipe); break; } case 'setPaused': paused = cmd.paused; snap.paused = paused; break; } } // -------------------------------------------------------------- tick phases /** Consume inputs and light the fuse. Runs before power so a starting craft pays this tick. */ function startCrafts(): void { for (const e of ents) { if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue; if (e.scrammed || e.crafting) continue; const r = recipeOf(e); if (!r) continue; let stalled = false; for (const k in r.outputs) { if ((e.state.outputBuf[k] ?? 0) >= r.outputs[k] * OUTPUT_STALL_FACTOR) { stalled = true; break; } } if (stalled) { setJam(e, 'output full'); continue; } let ready = true; let needsInput = false; for (const k in r.inputs) { needsInput = true; if ((e.state.inputBuf[k] ?? 0) < r.inputs[k]) { ready = false; break; } } if (!ready) { setJam(e, needsInput ? 'starved' : null); continue; } setJam(e, null); for (const k in r.inputs) { const left = (e.state.inputBuf[k] ?? 0) - r.inputs[k]; if (left > 0) e.state.inputBuf[k] = left; else delete e.state.inputBuf[k]; } e.crafting = true; e.state.progress = 0; } } /** * Bandwidth v3: gen vs draw with tank storage. Compression pays for itself — a recipe * with negative bandwidth (the quantizer) generates instead of drawing. Surplus charges * the tanks; a deficit drains them. Only once the tanks run dry does the factory brown * out and run at supplied/draw speed. * * UNITS (v3 ruling): gen and draw are bandwidth per SECOND; stored is bandwidth-SECONDS. * So a tick only ever moves one tick's worth of charge — surplus/TICKS_PER_SECOND — into * or out of the tanks. A 30/s deficit drains 30 stored per second, not per tick, which is * what makes DATA's bufferCap 240 the 8 seconds of cover they intended. */ function computePower(): number { let gen = 0; let draw = 0; let capacity = 0; for (const e of ents) { draw += e.def.powerDraw; // a scrammed machine is stopped, not unplugged if (e.def.kind === 'buffer') capacity += e.def.bufferCap ?? 0; if (e.def.powerGen) gen += e.def.powerGen * heatThrottle(e); if (e.crafting && !e.scrammed) { const r = recipeOf(e); if (r) { if (r.bandwidth > 0) draw += r.bandwidth; else gen -= r.bandwidth; } } } if (stored > capacity) stored = capacity; let supplied = gen; if (gen >= draw) { const charge = Math.min((gen - draw) / TICKS_PER_SECOND, capacity - stored); if (charge > 0) stored += charge; } else { const wanted = (draw - gen) / TICKS_PER_SECOND; // bandwidth-seconds owed this tick const drawn = Math.min(wanted, stored); stored -= drawn; supplied = gen + drawn * TICKS_PER_SECOND; } // Epsilon: a tank covering a deficit exactly lands supplied on draw, and float drift // there would otherwise flicker the brownout flag on and off every tick. const on = supplied < draw - 1e-9; if (on !== brownout) { brownout = on; events.push({ kind: 'brownout', on, tick: curTick }); } snap.bandwidth.gen = gen; snap.bandwidth.draw = draw; snap.bandwidth.stored = stored; snap.bandwidth.brownout = on; return on ? (draw > 0 ? supplied / draw : 1) : 1; } function advanceCrafts(mult: number): void { for (const e of ents) { if (!e.crafting) continue; const r = recipeOf(e); if (!r) { e.crafting = false; continue; } const speed = mult * heatThrottle(e); if (speed <= 0) continue; // scrammed mid-craft: progress holds, resumes on restart e.state.progress += speed / r.ticks; if (e.state.progress < 1) continue; e.state.progress = 0; e.crafting = false; for (const k in r.outputs) e.state.outputBuf[k] = (e.state.outputBuf[k] ?? 0) + r.outputs[k]; if (r.heat) e.state.heat = Math.min(1, e.state.heat + r.heat); events.push({ kind: 'crafted', entity: e.state.id, recipe: r.id, tick: curTick }); } } /** * Heat v1: work heats, everything cools, hot machines throttle, boiling machines scram. * * Cooling is applied every tick whether or not the machine is working, so `heatPerTick` * is GROSS heat and the net climb is heatPerTick - coolPerTick. A machine whose * heatPerTick doesn't clear its cooling simply never overheats. That's what makes the * codex's ASIC coolers mean something — and it's the trap DATA is currently in; see NOTES. */ function updateHeat(): void { for (const e of ents) { const hpt = e.def.heatPerTick ?? 0; // Most of a big factory is belts, and a belt can never be anything but stone cold. // Skipping them here is the difference between 59x and 70x realtime at 3,500 entities. if (hpt === 0 && e.state.heat === 0 && !e.scrammed) continue; const cool = e.def.coolPerTick ?? HEAT_COOL_DEFAULT; const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting); let h = e.state.heat + (active ? hpt : 0) - cool; if (h < 0) h = 0; else if (h > 1) h = 1; e.state.heat = h; if (!e.scrammed && h >= 1) { e.scrammed = true; e.state.scrammed = true; setJam(e, null); // scram is reported by its own event, not as a flow jam events.push({ kind: 'scram', entity: e.state.id, on: true, tick: curTick }); } else if (e.scrammed && h < HEAT_RESTART) { e.scrammed = false; e.state.scrammed = false; events.push({ kind: 'scram', entity: e.state.id, on: false, tick: curTick }); } } } function pushOutputs(): void { for (const e of ents) { if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue; let any = false; for (const k in e.state.outputBuf) if (e.state.outputBuf[k] > 0) { any = true; break; } if (!any) continue; collectOutputBelts(e, candidateBelts); if (!candidateBelts.length) continue; for (const item of Object.keys(e.state.outputBuf).sort()) { if ((e.state.outputBuf[item] ?? 0) <= 0) continue; // Pick the best-ranked lane for this item, then a belt of that rank with room. // Never fall back to a worse rank just because the good belt is momentarily // full — that's how coefficient packs end up shipped instead of moshed. A // splitter is the sanctioned way to route surplus somewhere else. let bestRank = -1; for (const bid of candidateBelts) { const r = outputBeltRank(bid, item); if (r > bestRank) bestRank = r; } if (bestRank < 0) continue; let target = -1; for (const bid of candidateBelts) { if (outputBeltRank(bid, item) !== bestRank || !beltHasRoom(bid, 0)) continue; target = bid; break; } if (target < 0) continue; spawnOnBelt(target, item, nextItemId++); const left = e.state.outputBuf[item] - 1; if (left > 0) e.state.outputBuf[item] = left; else delete e.state.outputBuf[item]; } } } /** * Splitters are deliberately dumb: round-robin across every outward-facing belt, and a * blocked output is skipped rather than waited on. That skip is what makes them the * factory's pressure valve — when the good lane backs up, the surplus keeps moving. */ function stepSplitters(): void { for (const e of ents) { if (e.def.kind !== 'splitter' || !e.queue.length) continue; collectOutputBelts(e, candidateBelts); if (!candidateBelts.length) continue; let dirty = false; while (e.queue.length) { let sent = false; for (let n = 0; n < candidateBelts.length; n++) { const bid = candidateBelts[e.cursor % candidateBelts.length]; e.cursor = (e.cursor + 1) % candidateBelts.length; if (!beltHasRoom(bid, 0)) continue; const q = e.queue.shift()!; spawnOnBelt(bid, q.item, q.id); // keeps its id across the splitter sent = true; dirty = true; break; } if (!sent) break; } if (dirty) syncSplitter(e); } } /** Belt -> machine. Rejection is not failure: the item waits and the line backs up. */ function tryPushToMachine(e: Ent, item: string, id: number): boolean { if (e.def.kind === 'shipper') { e.state.inputBuf[item] = (e.state.inputBuf[item] ?? 0) + 1; return true; } if (e.def.kind === 'splitter') { if (e.queue.length >= SPLITTER_CAPACITY) return false; e.queue.push({ item, id }); syncSplitter(e); return true; } const r = recipeOf(e); if (!r) return false; const need = r.inputs[item] ?? 0; if (need <= 0) return false; const have = e.state.inputBuf[item] ?? 0; if (have >= need * INPUT_BUFFER_FACTOR) return false; e.state.inputBuf[item] = have + 1; return true; } /** * Advance one item. Returns true if it left this belt (and was spliced out of `items`). * `aheadT` is the item in front on this same belt, already moved this tick, or null * if this is the front item. */ function advanceItem( belt: Ent, it: BeltItem, aheadT: number | null, step: number, items: BeltItem[], idx: number, ): boolean { let target = it.t + step; if (aheadT !== null) { const cap = aheadT - BELT_SPACING; if (target > cap) target = cap; if (target > it.t) it.t = target; return false; } if (target >= 1) { const next = beltTarget(belt); if (next && next.def.kind === 'belt') { const nItems = beltContents.get(next.state.id)!; const rear = nItems[nItems.length - 1]; if (nItems.length < BELT_CAPACITY) { let entryT = target - 1; if (rear && rear.t - entryT < BELT_SPACING) entryT = rear.t - BELT_SPACING; if (entryT >= 0) { items.splice(idx, 1); it.entity = next.state.id; it.t = entryT; // strictly behind `rear`, so the t-descending order holds nItems.push(it); moved.add(it); return true; } } // Blocked. Hold station one spacing behind the item across the seam, so the // gap stays honest instead of everything bunching at the tile boundary. let cap = 1; if (rear) { const c = 1 + rear.t - BELT_SPACING; if (c < cap) cap = c; } if (target > cap) target = cap; } else if (next && tryPushToMachine(next, it.item, it.id)) { items.splice(idx, 1); removeFlat(it); return true; } else if (target > 1) { target = 1; // machine full, or nothing there: park at the lip } } if (target > it.t) it.t = target; // items never travel backwards return false; } function moveBelts(mult: number): void { moved.clear(); for (const e of ents) { if (e.def.kind !== 'belt') continue; const step = ((e.def.beltSpeed ?? 0) / TICKS_PER_SECOND) * mult; const items = beltContents.get(e.state.id)!; let i = 0; while (i < items.length) { const it = items[i]; if (moved.has(it)) { i++; continue; } // On a transfer the item is spliced out at i, so whatever slid into this slot // is now the front item and gets its own shot this tick — don't advance i. if (advanceItem(e, it, i > 0 ? items[i - 1].t : null, step, items, i)) continue; i++; } } } /** Activates the commission at the head of the queue and zeroes its progress. */ function activateCommission(): void { const id = commissionQueue[0] ?? null; snap.activeCommission = id; snap.commissionQueue = commissionQueue; // active first, then upcoming: UI's "next in tray" snap.commissionProgress = {}; const c = id === null ? undefined : commissionDefs.get(id); if (c) for (const k in c.wants) snap.commissionProgress[k] = 0; } function creditCommission(item: string, count: number): void { const id = commissionQueue[0]; if (id === undefined) return; const c = commissionDefs.get(id); if (!c) return; const want = c.wants[item] ?? 0; if (want <= 0) return; // progress only counts toward the active order snap.commissionProgress[item] = Math.min(want, (snap.commissionProgress[item] ?? 0) + count); for (const k in c.wants) { if ((snap.commissionProgress[k] ?? 0) < c.wants[k]) return; } events.push({ kind: 'commissionDone', commission: c.id, tick: curTick }); commissionQueue.shift(); if (c.repeat) commissionQueue.push(c.id); // standing order: back of the queue activateCommission(); } function drainShippers(): void { for (const e of ents) { if (e.def.kind !== 'shipper') continue; const buf = e.state.inputBuf; for (const item of Object.keys(buf).sort()) { const n = buf[item]; if (!n) continue; delete buf[item]; snap.shippedTotal[item] = (snap.shippedTotal[item] ?? 0) + n; events.push({ kind: 'shipped', item, count: n, tick: curTick }); creditCommission(item, n); } } } // --------------------------------------------------------------------- api return { init(gameData: GameData, seed: number): void { data = gameData; rng = makeRng(seed); void rng; // seeded and ready; nothing rolls dice yet defs.clear(); recipeDefs.clear(); commissionDefs.clear(); for (const m of gameData.machines) defs.set(m.id, m); for (const r of gameData.recipes) recipeDefs.set(r.id, r); for (const c of gameData.commissions) commissionDefs.set(c.id, c); ents = []; entityStates = []; allBeltItems = []; byId.clear(); occ.clear(); beltContents.clear(); traceCache.clear(); cmdQueue.length = 0; events = []; moved.clear(); nextId = 1; nextItemId = 1; curTick = 0; paused = false; brownout = false; stored = 0; commissionQueue = data.commissions.map((c) => c.id); snap.tick = 0; snap.paused = false; snap.entities = entityStates; snap.beltItems = allBeltItems; snap.bandwidth = { gen: 0, draw: 0, stored: 0, brownout: false }; snap.shippedTotal = {}; activateCommission(); }, enqueue(cmd: Command): void { cmdQueue.push(cmd); }, tick(): void { for (const cmd of cmdQueue) applyCommand(cmd); // build while paused: allowed cmdQueue.length = 0; if (paused) return; // tick is sim-time; pausing freezes it startCrafts(); const mult = computePower(); advanceCrafts(mult); updateHeat(); pushOutputs(); stepSplitters(); moveBelts(mult); drainShippers(); curTick++; snap.tick = curTick; }, snapshot(): SimSnapshot { return snap; }, drainEvents(): SimEvent[] { const out = events; events = []; return out; }, /** * Everything the tick loop reads, including the state the snapshot deliberately hides: * the crafting/scram latches, splitter queues and cursors, and the RNG position. Miss * any of those and a loaded save diverges a few hundred ticks later, which is exactly * the bug that never reproduces. `data` is not saved — load() into a sim init()'d with * the same GameData. */ save(): string { return JSON.stringify({ v: 3, tick: curTick, paused, brownout, stored, nextId, nextItemId, rng: rng.state(), commissionQueue, shippedTotal: snap.shippedTotal, commissionProgress: snap.commissionProgress, pending: cmdQueue, // commands enqueued but not yet applied entities: ents.map((e) => ({ state: e.state, crafting: e.crafting, scrammed: e.scrammed, queue: e.queue, cursor: e.cursor, })), beltItems: allBeltItems, }); }, load(json: string): void { const s = JSON.parse(json); if (s.v !== 3) throw new Error(`sim.load: unsupported save version ${s.v}`); ents = []; entityStates = []; allBeltItems = []; byId.clear(); occ.clear(); beltContents.clear(); traceCache.clear(); cmdQueue.length = 0; events = []; moved.clear(); for (const rec of s.entities) { const def = defs.get(rec.state.def); if (!def) throw new Error(`sim.load: this GameData has no machine "${rec.state.def}"`); const state = rec.state as EntityState; const fp = footprintOf(def.footprint, state.dir); const tiles: number[] = []; for (let dx = 0; dx < fp.x; dx++) { for (let dy = 0; dy < fp.y; dy++) tiles.push(tileKey(state.pos.x + dx, state.pos.y + dy)); } const e: Ent = { state, def, crafting: rec.crafting, scrammed: rec.scrammed, tiles, queue: rec.queue, cursor: rec.cursor, }; ents.push(e); entityStates.push(state); byId.set(state.id, e); for (const k of tiles) occ.set(k, state.id); if (def.kind === 'belt') beltContents.set(state.id, []); } for (const it of s.beltItems as BeltItem[]) { allBeltItems.push(it); beltContents.get(it.entity)?.push(it); } // Belt contents are ordered front-first everywhere else; restore that invariant // rather than trusting the blob's ordering. for (const list of beltContents.values()) list.sort((a, b) => b.t - a.t); curTick = s.tick; paused = s.paused; brownout = s.brownout; stored = s.stored; nextId = s.nextId; nextItemId = s.nextItemId; rng.restore(s.rng); commissionQueue = s.commissionQueue; for (const cmd of s.pending ?? []) cmdQueue.push(cmd); snap.tick = curTick; snap.paused = paused; snap.entities = entityStates; snap.beltItems = allBeltItems; snap.bandwidth = { gen: 0, draw: 0, stored, brownout }; snap.shippedTotal = s.shippedTotal; snap.commissionProgress = s.commissionProgress; snap.activeCommission = commissionQueue[0] ?? null; snap.commissionQueue = commissionQueue; }, }; }