glytch/fktry/src/sim/index.ts
type-two 45003db31a [ui] REMOVE mode: X arms it, click demolishes, Esc stands down
A misplaced machine is finally undoable by mouse. The REMOVE button sits
with the tabs rather than on a page, because it's a mode, not a machine, and
it has to be reachable from any page. It stays armed between clicks —
demolition is usually plural — and clicking open ground does nothing, so a
stray click never reads as a broken tool. Toast: "UNIT RECLAIMED. THE FLOOR
REMEMBERS."

LANE-RENDER: the hover hook is the __remove ghost sentinel. The UI has no
Renderer reference and the only channel to the ghost is selectedBuild(),
which main.ts feeds to setGhost — so while remove is armed, bus._sel reads
{def: '__remove', dir: 0}. Key the demolition tint off that def id. It's safe
because sim's place handler ignores unknown defs, so main.ts's click-to-place
fires a place for __remove and nothing happens; the UI dispatches the real
remove itself. Exported as REMOVE_DEF and pinned by a test.

A magic string shared between two lanes via a NOTES file is not a design;
contract request filed for UIBus.setGhostMode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:34:05 +10:00

879 lines
30 KiB
TypeScript

/**
* 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<string, MachineDef>();
const recipeDefs = new Map<string, RecipeDef>();
const commissionDefs = new Map<string, CommissionDef>();
// 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<number, Ent>();
const occ = new Map<number, number>(); // tileKey -> entity id
const beltContents = new Map<number, BeltItem[]>(); // 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<number, number[]>();
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<BeltItem>();
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<number>();
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<string, number> = {};
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;
},
};
}