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>
879 lines
30 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|