[sim] splitters, buffer tanks, heat, commission queue, starved, item ids

Round 2 per lanes/LANE-SIM.md.

- splitters: round-robin across outward-facing belts, blocked outputs
  skipped not waited on; rank now traces THROUGH splitters and takes the
  best outcome reachable across every terminal a lane reaches
- buffer tanks: charge on surplus, discharge to cover deficit, brownout
  only once dry; bandwidth.stored is the pool
- heat v1: heatPerTick while active + RecipeDef.heat per craft, ambient
  cooling, linear throttle 1.0@0.7 -> 0.5@1.0, scram at 1.0, restart
  under 0.5, both edges emit 'scram'. Throttle applies to powerGen too,
  so a hot decoder sags before it drops off the bus
- commission queue: data-order activation, repeat re-enters at the back
- jammed:'starved' (edge-triggered) and BeltItem.id (stable through belt
  transfers and splitters alike)

15 new fixture tests for the M2 mechanics -- data/ carries none of the v2
fields yet, so heat and tanks are tested against a fixture this lane owns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LANE-SIM 2026-07-17 17:28:25 +10:00
parent 90703c4bcd
commit ff43c09972
5 changed files with 868 additions and 80 deletions

View File

@ -26,21 +26,38 @@ import { makeRng, type Rng } from './rng';
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 chains longer than this are treated as unterminated (cheap loop guard). */
/** 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. */
const HEAT_DISSIPATION = 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 {
@ -49,6 +66,7 @@ export function createSim(): Sim {
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.
@ -58,15 +76,16 @@ export function createSim(): Sim {
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 -> id of the machine its chain ends at (null = dead end or loop) */
const traceCache = new Map<number, number | null>();
/** 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 commission: CommissionDef | null = null;
let commissionDoneEmitted = false;
let stored = 0;
let commissionQueue: string[] = [];
const cmdQueue: Command[] = [];
let events: SimEvent[] = [];
@ -108,36 +127,83 @@ export function createSim(): Sim {
return !!r && (r.inputs[item] ?? 0) > 0;
};
/** Walks a belt chain to the machine it ends at. Cached; invalidated on any topology change. */
function traceTerminal(beltId: number): number | null {
/** 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;
let cur = byId.get(beltId);
let result: number | null = null;
for (let steps = 0; cur && cur.def.kind === 'belt' && steps < MAX_TRACE; steps++) {
const next = beltTarget(cur);
if (!next) break; // dead end: items ride to the end and pile up
if (next.def.kind !== 'belt') { result = next.state.id; break; }
cur = next;
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:
* 2 = the belt's chain ends at a machine that consumes it
* 1 = it ends at a shipper (takes anything a sink, not a destination)
* 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 = it ends at a machine that will never take it pushing here would deadlock the line
* -1 = every machine it reaches will refuse this item pushing here deadlocks the lane
*/
function outputBeltRank(beltId: number, item: string): number {
const term = traceTerminal(beltId);
if (term === null) return 0;
const e = byId.get(term);
if (!e) return 0;
if (e.def.kind === 'shipper') return 1;
return machineWants(e, item) ? 2 : -1;
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 {
@ -147,15 +213,37 @@ export function createSim(): Sim {
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): void {
function setJam(e: Ent, reason: string | null): void {
if (e.state.jammed === reason) return; // edge-triggered: one event per stall
e.state.jammed = reason;
events.push({ kind: 'jammed', entity: e.state.id, reason, tick: curTick });
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
@ -185,7 +273,7 @@ export function createSim(): Sim {
jammed: null,
heat: 0,
};
const e: Ent = { state, def, crafting: false, tiles };
const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0 };
ents.push(e);
entityStates.push(state);
byId.set(state.id, e);
@ -268,7 +356,7 @@ export function createSim(): Sim {
function startCrafts(): void {
for (const e of ents) {
if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue;
if (e.crafting) continue;
if (e.scrammed || e.crafting) continue;
const r = recipeOf(e);
if (!r) continue;
@ -277,11 +365,15 @@ export function createSim(): Sim {
if ((e.state.outputBuf[k] ?? 0) >= r.outputs[k] * OUTPUT_STALL_FACTOR) { stalled = true; break; }
}
if (stalled) { setJam(e, 'output full'); continue; }
e.state.jammed = null;
let ready = true;
for (const k in r.inputs) if ((e.state.inputBuf[k] ?? 0) < r.inputs[k]) { ready = false; break; }
if (!ready) continue;
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];
@ -294,17 +386,20 @@ export function createSim(): Sim {
}
/**
* Bandwidth v1: gen vs draw, no storage. Compression pays for itself a recipe with
* negative bandwidth (the quantizer) generates instead of drawing. Short of supply,
* the whole factory runs at gen/draw speed: the brownout.
* Bandwidth v2: 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 gen/draw speed.
*/
function computePower(): number {
let gen = 0;
let draw = 0;
let capacity = 0;
for (const e of ents) {
draw += e.def.powerDraw;
if (e.def.powerGen) gen += e.def.powerGen;
if (e.crafting) {
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;
@ -312,16 +407,29 @@ export function createSim(): Sim {
}
}
}
const on = draw > gen;
if (stored > capacity) stored = capacity;
let supplied = gen;
if (gen >= draw) {
const room = capacity - stored;
const charge = Math.min(gen - draw, room);
if (charge > 0) stored += charge;
} else {
const drawn = Math.min(draw - gen, stored);
stored -= drawn;
supplied = gen + drawn;
}
const on = supplied < draw;
if (on !== brownout) {
brownout = on;
events.push({ kind: 'brownout', on, tick: curTick });
}
snap.bandwidth.gen = gen;
snap.bandwidth.draw = draw;
snap.bandwidth.stored = 0;
snap.bandwidth.stored = stored;
snap.bandwidth.brownout = on;
return on ? (draw > 0 ? gen / draw : 1) : 1;
return on ? (draw > 0 ? supplied / draw : 1) : 1;
}
function advanceCrafts(mult: number): void {
@ -329,34 +437,37 @@ export function createSim(): Sim {
if (!e.crafting) continue;
const r = recipeOf(e);
if (!r) { e.crafting = false; continue; }
e.state.progress += mult / r.ticks;
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 });
}
}
/** Belts adjacent to this machine 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);
}
/** Heat v1: work heats, everything cools, hot machines throttle, boiling machines scram. */
function updateHeat(): void {
for (const e of ents) {
const hpt = e.def.heatPerTick ?? 0;
const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting);
let h = e.state.heat + (active ? hpt : 0) - HEAT_DISSIPATION;
if (h < 0) h = 0;
else if (h > 1) h = 1;
e.state.heat = h;
if (!e.scrammed && h >= 1) {
e.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;
events.push({ kind: 'scram', entity: e.state.id, on: false, tick: curTick });
}
}
out.sort((a, b) => a - b);
}
function pushOutputs(): void {
@ -370,9 +481,10 @@ export function createSim(): Sim {
for (const item of Object.keys(e.state.outputBuf).sort()) {
if ((e.state.outputBuf[item] ?? 0) <= 0) continue;
// Pick the best-ranked belt for this item, then a belt of that rank with room.
// 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.
// 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);
@ -387,9 +499,7 @@ export function createSim(): Sim {
}
if (target < 0) continue;
const it: BeltItem = { item, entity: target, t: 0 };
beltContents.get(target)!.push(it);
allBeltItems.push(it);
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];
@ -397,12 +507,47 @@ export function createSim(): Sim {
}
}
/**
* 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): boolean {
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;
@ -456,7 +601,7 @@ export function createSim(): Sim {
if (c < cap) cap = c;
}
if (target > cap) target = cap;
} else if (next && tryPushToMachine(next, it.item)) {
} else if (next && tryPushToMachine(next, it.item, it.id ?? 0)) {
items.splice(idx, 1);
removeFlat(it);
return true;
@ -487,16 +632,30 @@ export function createSim(): Sim {
}
}
/** 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.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 {
if (!commission || commissionDoneEmitted) return;
const want = commission.wants[item] ?? 0;
if (want <= 0) return;
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 commission.wants) {
if ((snap.commissionProgress[k] ?? 0) < commission.wants[k]) return;
for (const k in c.wants) {
if ((snap.commissionProgress[k] ?? 0) < c.wants[k]) return;
}
commissionDoneEmitted = true;
events.push({ kind: 'commissionDone', commission: commission.id, tick: curTick });
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 {
@ -520,12 +679,14 @@ export function createSim(): Sim {
init(gameData: GameData, seed: number): void {
data = gameData;
rng = makeRng(seed);
void rng; // seeded and ready; nothing in M1 rolls dice yet
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 = [];
@ -539,11 +700,12 @@ export function createSim(): Sim {
moved.clear();
nextId = 1;
nextItemId = 1;
curTick = 0;
paused = false;
brownout = false;
commission = data.commissions[0] ?? null;
commissionDoneEmitted = false;
stored = 0;
commissionQueue = data.commissions.map((c) => c.id);
snap.tick = 0;
snap.paused = false;
@ -551,9 +713,7 @@ export function createSim(): Sim {
snap.beltItems = allBeltItems;
snap.bandwidth = { gen: 0, draw: 0, stored: 0, brownout: false };
snap.shippedTotal = {};
snap.activeCommission = commission?.id ?? null;
snap.commissionProgress = {};
if (commission) for (const k in commission.wants) snap.commissionProgress[k] = 0;
activateCommission();
},
enqueue(cmd: Command): void {
@ -568,7 +728,9 @@ export function createSim(): Sim {
startCrafts();
const mult = computePower();
advanceCrafts(mult);
updateHeat();
pushOutputs();
stepSplitters();
moveBelts(mult);
drainShippers();

View File

@ -0,0 +1,399 @@
/**
* M2 mechanics: splitters, buffer tanks, heat, the commission queue.
*
* These run on a synthetic fixture rather than data/*.json, deliberately. Two reasons:
* the real data carries none of the v2 fields yet (no heatPerTick, no bufferCap, no
* recipe heat, no repeat see NOTES round 2), and mechanics are LANE-SIM's to pin down
* while the numbers are LANE-DATA's to move. Round 1 taught that the hard way.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import type {
Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
} from '../contracts';
const N: Dir = 0, E: Dir = 1, S: Dir = 2;
const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
const FIXTURE: GameData = {
items: ['rock', 'gravel', 'sand'].map(item),
machines: [
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
mach({ id: 'split', kind: 'splitter' }),
mach({ id: 'ship', kind: 'shipper' }),
mach({ id: 'gen', kind: 'power', powerGen: 10 }),
mach({ id: 'hotgen', kind: 'power', powerGen: 10, heatPerTick: 0.02 }),
mach({ id: 'tank', kind: 'buffer', bufferCap: 100 }),
mach({ id: 'load', kind: 'crafter', powerDraw: 20 }),
mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }),
mach({ id: 'sifter', kind: 'crafter', recipes: ['sift'] }),
mach({ id: 'oven', kind: 'crafter', recipes: ['bake'] }),
],
recipes: [
rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 30 }),
rec({ id: 'grind', machine: 'grinder', inputs: { rock: 1 }, outputs: { gravel: 1 } }),
rec({ id: 'sift', machine: 'sifter', inputs: { rock: 1 }, outputs: { sand: 1 } }),
rec({ id: 'bake', machine: 'oven', inputs: { rock: 1 }, outputs: { gravel: 1 }, ticks: 5, heat: 0.3 }),
],
tech: [],
commissions: [
{ id: 'c1', flavor: 'two rocks', wants: { rock: 2 }, rewardBandwidth: 1 },
{ id: 'c2', flavor: 'three rocks, forever', wants: { rock: 3 }, rewardBandwidth: 1, repeat: true },
],
};
function newSim(seed = 1): Sim {
const sim = createSim();
sim.init(FIXTURE, seed);
return sim;
}
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
({ kind: 'place', def, pos: { x, y }, dir });
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
for (let i = 0; i < ticks; i++) {
sim.tick();
const evs = sim.drainEvents();
if (sink) for (const e of evs) sink.push(e);
}
}
const at = (sim: Sim, x: number, y: number): EntityState | undefined =>
sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);
describe('splitters', () => {
it('round-robins across both outputs', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('split', 2, 0));
sim.enqueue(place('belt', 3, 0, E)); // branch A -> gravel
sim.enqueue(place('grinder', 4, 0));
sim.enqueue(place('belt', 5, 0, E));
sim.enqueue(place('ship', 6, 0));
sim.enqueue(place('belt', 2, 1, S)); // branch B -> sand
sim.enqueue(place('sifter', 2, 2));
sim.enqueue(place('belt', 2, 3, S));
sim.enqueue(place('ship', 2, 4));
run(sim, 4000);
const { gravel = 0, sand = 0 } = sim.snapshot().shippedTotal;
expect(gravel).toBeGreaterThan(10);
expect(sand).toBeGreaterThan(10);
expect(Math.abs(gravel - sand)).toBeLessThanOrEqual(1); // strict alternation
});
it('skips a blocked output instead of waiting on it', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('split', 2, 0));
sim.enqueue(place('belt', 3, 0, E)); // branch A -> uplink
sim.enqueue(place('ship', 4, 0));
sim.enqueue(place('belt', 2, 1, S)); // branch B -> dead end, fills and wedges
sim.enqueue(place('belt', 2, 2, S));
run(sim, 600);
const early = sim.snapshot().shippedTotal['rock'] ?? 0;
run(sim, 600);
const late = sim.snapshot().shippedTotal['rock'] ?? 0;
expect(early).toBeGreaterThan(0);
expect(late).toBeGreaterThan(early); // the good lane keeps flowing
expect(at(sim, 0, 0)!.jammed).not.toBe('output full'); // the miner never wedges
const stuck = sim.snapshot().beltItems.filter((i) => i.entity === at(sim, 2, 1)!.id
|| i.entity === at(sim, 2, 2)!.id);
expect(stuck).toHaveLength(4); // dead branch holds 2 belts x 2 items
});
it('is traced through when ranking a lane — best reachable outcome wins', () => {
// The miner's belt only reaches a consumer *through* the splitter. If the trace
// stopped at the splitter the lane would rank 0 and this would still pass, so the
// dead-end control below is what actually proves it.
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('split', 2, 0));
sim.enqueue(place('belt', 2, 1, S));
sim.enqueue(place('grinder', 2, 2)); // rock consumer, reachable only via the splitter
sim.enqueue(place('belt', 2, 3, S));
sim.enqueue(place('ship', 2, 4));
// A rival lane straight to an uplink: rank 1, must lose to the rank-2 lane above.
sim.enqueue(place('belt', 0, 1, S));
sim.enqueue(place('ship', 0, 2));
run(sim, 2000);
const snap = sim.snapshot();
expect(snap.shippedTotal['gravel'] ?? 0).toBeGreaterThan(0); // went the consumer's way
expect(snap.shippedTotal['rock'] ?? 0).toBe(0); // never took the uplink
});
});
describe('buffer tanks', () => {
it('charges on surplus, covers a deficit, and only browns out once dry', () => {
const sim = newSim();
sim.enqueue(place('gen', 0, 0));
sim.enqueue(place('gen', 1, 0));
sim.enqueue(place('gen', 2, 0)); // gen 30
sim.enqueue(place('tank', 3, 0)); // cap 100
run(sim, 1);
expect(sim.snapshot().bandwidth.stored).toBe(30); // surplus 30 -> charge 30
run(sim, 3);
expect(sim.snapshot().bandwidth.stored).toBe(100); // clamps at capacity
expect(sim.snapshot().bandwidth.brownout).toBe(false);
// Draw 40 against gen 30: a 10/tick deficit the tank should cover for 10 ticks.
sim.enqueue(place('load', 0, 1));
sim.enqueue(place('load', 1, 1));
const evs: SimEvent[] = [];
run(sim, 1, evs);
expect(sim.snapshot().bandwidth.draw).toBe(40);
expect(sim.snapshot().bandwidth.stored).toBe(90);
expect(sim.snapshot().bandwidth.brownout).toBe(false); // tank is carrying it
run(sim, 9, evs);
expect(sim.snapshot().bandwidth.stored).toBe(0);
expect(sim.snapshot().bandwidth.brownout).toBe(false); // that last tick was still covered
run(sim, 1, evs);
expect(sim.snapshot().bandwidth.brownout).toBe(true); // dry -> the lights go
expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(1);
});
it('browns out immediately with no tank at all', () => {
const sim = newSim();
sim.enqueue(place('gen', 0, 0)); // 10
sim.enqueue(place('load', 0, 1)); // 20
run(sim, 2);
expect(sim.snapshot().bandwidth.brownout).toBe(true);
expect(sim.snapshot().bandwidth.stored).toBe(0);
});
});
describe('heat', () => {
it('throttles a hot generator, scrams it at 1.0, and restarts it below 0.5', () => {
const sim = newSim();
sim.enqueue(place('hotgen', 0, 0));
const evs: SimEvent[] = [];
// Climb: +0.02/tick of work against -0.004/tick of ambient loss.
let throttledGen = -1;
for (let i = 0; i < 200; i++) {
run(sim, 1, evs);
const h = at(sim, 0, 0)!.heat;
if (h > 0.7 && h < 1) throttledGen = sim.snapshot().bandwidth.gen;
if (evs.some((e) => e.kind === 'scram' && e.on)) break;
}
// Between 0.7 and 1.0 it visibly slows without stopping: the lore's fan-scream band.
expect(throttledGen).toBeGreaterThan(5);
expect(throttledGen).toBeLessThan(10);
const scramOn = evs.filter((e) => e.kind === 'scram' && e.on);
expect(scramOn).toHaveLength(1);
expect(at(sim, 0, 0)!.heat).toBe(1);
run(sim, 1);
expect(sim.snapshot().bandwidth.gen).toBe(0); // scrammed: contributes nothing
// Cool: 1.0 -> below 0.5 at 0.004/tick is ~125 ticks. It restarts the moment it gets
// there and immediately starts reheating, so catch the heat on the restart tick.
const evs2: SimEvent[] = [];
let heatAtRestart = -1;
let restartTick = -1;
for (let i = 0; i < 200; i++) {
run(sim, 1, evs2);
if (evs2.some((e) => e.kind === 'scram' && !e.on)) {
heatAtRestart = at(sim, 0, 0)!.heat;
restartTick = i + 1;
break;
}
}
expect(evs2.filter((e) => e.kind === 'scram' && !e.on)).toHaveLength(1);
expect(heatAtRestart).toBeLessThan(0.5);
expect(restartTick).toBeGreaterThan(100); // it genuinely had to cool down
run(sim, 1);
expect(sim.snapshot().bandwidth.gen).toBe(10); // back on the bus
});
it('duty-cycles: a generator hot enough to scram keeps restarting and scramming', () => {
const sim = newSim();
sim.enqueue(place('hotgen', 0, 0));
const evs: SimEvent[] = [];
run(sim, 1200, evs);
const scrams = evs.filter((e) => e.kind === 'scram');
expect(scrams.filter((e) => (e as { on: boolean }).on).length).toBeGreaterThan(2);
expect(scrams.filter((e) => !(e as { on: boolean }).on).length).toBeGreaterThan(2);
// Edges alternate — never two ons in a row.
const ons = scrams.map((e) => (e as { on: boolean }).on);
for (let i = 1; i < ons.length; i++) expect(ons[i]).not.toBe(ons[i - 1]);
});
it('adds recipe heat per craft and sheds it when idle', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('oven', 2, 0));
const evs: SimEvent[] = [];
let heatAfterCraft = -1;
for (let i = 0; i < 400; i++) {
run(sim, 1, evs);
if (evs.some((e) => e.kind === 'crafted' && e.recipe === 'bake')) {
heatAfterCraft = at(sim, 2, 0)!.heat;
break;
}
}
// 0.3 from the bake, less one tick of ambient loss.
expect(heatAfterCraft).toBeCloseTo(0.3 - 0.004, 5);
const before = at(sim, 2, 0)!.heat;
sim.enqueue({ kind: 'remove', pos: { x: 0, y: 0 } }); // cut the feed, let it cool
run(sim, 20);
expect(at(sim, 2, 0)!.heat).toBeLessThan(before);
});
it('leaves cold machines alone', () => {
const sim = newSim();
sim.enqueue(place('gen', 0, 0)); // no heatPerTick
run(sim, 200);
expect(at(sim, 0, 0)!.heat).toBe(0);
expect(sim.snapshot().bandwidth.gen).toBe(10);
});
});
describe('commission queue', () => {
it('advances in data order and re-queues standing orders', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('ship', 2, 0));
run(sim, 1);
expect(sim.snapshot().activeCommission).toBe('c1');
const evs: SimEvent[] = [];
run(sim, 400, evs);
const done = evs
.filter((e) => e.kind === 'commissionDone')
.map((e) => (e as { commission: string }).commission);
expect(done.slice(0, 2)).toEqual(['c1', 'c2']); // data order
expect(done.filter((c) => c === 'c1')).toHaveLength(1); // no repeat: gone for good
expect(done.filter((c) => c === 'c2').length).toBeGreaterThan(1); // standing order returns
expect(sim.snapshot().activeCommission).toBe('c2');
expect(sim.snapshot().commissionProgress['rock']).toBeLessThan(3); // counting afresh
});
it('banks nothing for orders that are not active', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('grinder', 2, 0)); // rock -> gravel: nothing any commission wants
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('ship', 4, 0));
run(sim, 600);
expect(sim.snapshot().shippedTotal['gravel']).toBeGreaterThan(0);
expect(sim.snapshot().activeCommission).toBe('c1');
expect(sim.snapshot().commissionProgress['rock']).toBe(0);
});
});
describe('starved', () => {
it('reports an idle recipe with nothing to eat, edge-triggered', () => {
const sim = newSim();
sim.enqueue(place('grinder', 5, 5)); // a recipe, no feed
const evs: SimEvent[] = [];
run(sim, 100, evs);
expect(at(sim, 5, 5)!.jammed).toBe('starved');
expect(evs.filter((e) => e.kind === 'jammed')).toHaveLength(1); // once, not per tick
sim.enqueue(place('mine', 3, 5));
sim.enqueue(place('belt', 4, 5, E));
sim.enqueue(place('belt', 6, 5, E)); // and somewhere to put the gravel
sim.enqueue(place('ship', 7, 5));
// The grinder eats a rock a tick and the mine yields one per 30, so it goes back to
// starved between deliveries — the flag clears on the tick it actually gets fed.
let cleared = false;
for (let i = 0; i < 400 && !cleared; i++) {
run(sim, 1);
cleared = at(sim, 5, 5)!.jammed === null;
}
expect(cleared).toBe(true);
run(sim, 200); // and it really is producing, not just briefly unflagged
expect(sim.snapshot().shippedTotal['gravel']).toBeGreaterThan(0);
});
it('never calls an extractor starved — it has nothing to be starved of', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
run(sim, 20);
expect(at(sim, 0, 0)!.jammed).toBeNull();
});
});
describe('belt item ids', () => {
it('are unique and survive belt transfers and splitters', () => {
const sim = newSim();
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('split', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('belt', 4, 0, E));
sim.enqueue(place('belt', 2, 1, S));
sim.enqueue(place('belt', 2, 2, S));
/** entity each id has been seen on, to prove an id crosses tiles rather than respawning */
const seenOn = new Map<number, Set<number>>();
for (let i = 0; i < 400; i++) {
run(sim, 1);
const ids = new Set<number>();
for (const it of sim.snapshot().beltItems) {
expect(it.id).toBeDefined();
expect(ids.has(it.id!)).toBe(false); // unique within a snapshot
ids.add(it.id!);
const where = seenOn.get(it.id!) ?? new Set<number>();
where.add(it.entity);
seenOn.set(it.id!, where);
}
}
// At least one item kept its id across more than one belt (and through the splitter).
const travelled = [...seenOn.values()].filter((s) => s.size > 1);
expect(travelled.length).toBeGreaterThan(0);
expect(Math.max(...[...seenOn.values()].map((s) => s.size))).toBeGreaterThanOrEqual(3);
});
});
describe('determinism holds with the new mechanics', () => {
it('two fixture sims with splitters, tanks and heat agree after 1,000 ticks', () => {
const build = (): Sim => {
const sim = newSim(99);
sim.enqueue(place('hotgen', 0, 5));
sim.enqueue(place('gen', 1, 5));
sim.enqueue(place('tank', 2, 5));
sim.enqueue(place('mine', 0, 0));
sim.enqueue(place('belt', 1, 0, E));
sim.enqueue(place('split', 2, 0));
sim.enqueue(place('belt', 3, 0, E));
sim.enqueue(place('oven', 4, 0));
sim.enqueue(place('belt', 5, 0, E));
sim.enqueue(place('ship', 6, 0));
sim.enqueue(place('belt', 2, 1, S));
sim.enqueue(place('sifter', 2, 2));
sim.enqueue(place('belt', 2, 3, S));
sim.enqueue(place('ship', 2, 4));
run(sim, 1000);
return sim;
};
expect(build().snapshot()).toEqual(build().snapshot());
});
});

View File

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory } from './reference';
import type { GameData, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import tech from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
function build(): Sim {
const sim = createSim();
sim.init(DATA, 1337);
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
return sim;
}
/** Runs `ticks` and returns how much melt was shipped during exactly that window. */
function meltIn(sim: Sim, ticks: number, firstAt?: { tick: number }): number {
let melt = 0;
for (let i = 0; i < ticks; i++) {
sim.tick();
for (const e of sim.drainEvents()) {
if (e.kind === 'shipped' && e.item === 'melt') {
melt += e.count;
if (firstAt && firstAt.tick < 0) firstAt.tick = e.tick;
}
}
}
return melt;
}
describe('reference factory', () => {
it('ships melt inside 2,000 ticks', () => {
const sim = build();
const first = { tick: -1 };
const melt = meltIn(sim, 2000, first);
expect(first.tick).toBeGreaterThan(0);
expect(first.tick).toBeLessThanOrEqual(2000);
expect(melt).toBeGreaterThan(0);
});
it('is still shipping melt at 20,000 ticks — the deadlock regression net', () => {
const sim = build();
meltIn(sim, 2000);
const early = sim.snapshot().shippedTotal['melt'] ?? 0;
meltIn(sim, 16000);
// The window that matters: round 1's build had wedged solid long before here.
const late = meltIn(sim, 2000);
const total = sim.snapshot().shippedTotal['melt'] ?? 0;
expect(early).toBeGreaterThan(0);
expect(late).toBeGreaterThan(0);
expect(total).toBeGreaterThan(early);
});
it('keeps the demuxer alive by voiding surplus rather than jamming', () => {
const sim = build();
meltIn(sim, 20000);
const snap = sim.snapshot();
const demuxer = snap.entities.find((e) => e.def === 'demuxer')!;
// The whole point: the head of the chain never wedges.
expect(demuxer.jammed).not.toBe('output full');
// Surplus is being turned into product, not silently dropped.
expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0);
expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);
});
it('refuses to build against drifted data instead of deadlocking mysteriously', () => {
const stripped = { ...DATA, recipes: DATA.recipes.filter((r) => r.id !== 'quantize-crime') };
expect(() => referenceFactory(stripped)).toThrow(/quantize-crime/);
});
});

150
fktry/src/sim/reference.ts Normal file
View File

@ -0,0 +1,150 @@
/**
* The reference factory the official deadlock-free M1 build.
*
* Round 1 shipped melt and then wedged solid at ~tick 1200: luma, coefficient packs and
* anchor slabs all overproduce, and with no splitter the surplus had nowhere legal to go,
* so every machine upstream jammed in turn. This layout fixes that with the two tools the
* graph actually gives you:
*
* - a splitter feeding TWO quantizers. When the press is full, quantizer A1 jams, its
* lane backs up, the splitter skips it, and every luma bar goes to A2 -> the crime
* quantizer -> macroblock bricks -> uplink. The demuxer never stalls.
* - a splitter on the slurry line with one branch to an uplink, so surplus chroma is
* voided instead of backing up into the demuxer.
*
* Nothing here is priority routing: splitters just skip blocked outputs, and that alone
* makes the factory self-balancing. Each consumer takes what it can swallow and the
* surplus keeps walking.
*
* ore x3 -> demuxer -+-> luma -> [split] -+-> quantizer A1 -> coeff -> DCT press --+
* | +-> quantizer A2 -> coeff -> crime -> bricks
* +-> slurry -> [split] -+-> subsampler 4:2:2 -> 4:2:0 ----------+
* | +-> uplink (void) |
* +-> mv-flux -> p-caster -> delta wafers ------------+ |
* v v
* GOP assembler A <- slab <- DCT press <---+
* |
* +-> GOP crate -> mosh -+-> MELT -> uplink
* +-> slab -> assembler B
* (i-only) -> mosh
*/
import type { Command, Dir, GameData } from '../contracts';
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
export function referenceFactory(data: GameData): Command[] {
// Fail loudly and specifically if DATA moves under us — a missing id here would
// otherwise surface as a mystery deadlock hours later.
const machine = (id: string): string => {
if (!data.machines.some((m) => m.id === id)) {
throw new Error(`referenceFactory: data/machines.json has no machine "${id}"`);
}
return id;
};
const recipeId = (id: string): string => {
if (!data.recipes.some((r) => r.id === id)) {
throw new Error(`referenceFactory: data/recipes.json has no recipe "${id}"`);
}
return id;
};
const cmds: Command[] = [];
let placed = 0;
/** Places a machine and returns the entity id the sim will assign it. */
const P = (def: string, x: number, y: number, dir: Dir = N): number => {
cmds.push({ kind: 'place', def: machine(def), pos: { x, y }, dir });
return ++placed;
};
const b = (x: number, y: number, dir: Dir): void => { P('belt', x, y, dir); };
const runX = (x0: number, x1: number, y: number, dir: Dir): void => {
const s = x0 <= x1 ? 1 : -1;
for (let x = x0; ; x += s) { b(x, y, dir); if (x === x1) break; }
};
const runY = (y0: number, y1: number, x: number, dir: Dir): void => {
const s = y0 <= y1 ? 1 : -1;
for (let y = y0; ; y += s) { b(x, y, dir); if (y === y1) break; }
};
const setRecipe = (entity: number, recipe: string): void => {
cmds.push({ kind: 'setRecipe', entity, recipe: recipeId(recipe) });
};
// ---- power. Four decoders cover ~146 draw at full tilt, with room for the i-only
// assembler's 24-bandwidth spikes.
for (let i = 0; i < 4; i++) P('software-decoder', -30 + i * 3, -14);
// ---- mining. Three seams keep the demuxer at its 45-tick cadence (it eats 3 ore).
P('seam-extractor', -30, 0);
P('seam-extractor', -30, -3);
P('seam-extractor', -30, 3);
runY(-2, -1, -28, S); // north seam joins the trunk
runY(3, 1, -28, N); // south seam joins the trunk
runX(-28, -27, 0, E);
P('demuxer', -26, 0);
// ---- mv-flux -> delta wafers. This lane is the whole factory's bottleneck (a GOP
// crate needs 6 wafers = 12 mv-flux = 12 demux crafts), so nothing here overproduces.
runY(-1, -3, -25, N);
P('p-caster', -26, -5);
runX(-26, -11, -6, E);
runY(-6, -1, -10, S);
// ---- luma -> two quantizers behind a splitter. A1 feeds the press; when the press is
// satisfied A1 jams, the splitter skips it, and everything goes to A2 -> crime.
runX(-24, -23, 0, E);
P('lane-splitter', -22, 0);
runX(-21, -20, 0, E);
P('quantizer', -19, 0); // A1: quantize (default)
runX(-17, -16, 0, E); // coefficient packs -> press
runY(-1, -2, -19, N); // hf dust -> uplink
P('shipper', -19, -4);
runY(1, 3, -22, S);
P('quantizer', -23, 4); // A2: quantize (default)
runX(-21, -20, 4, E); // coefficient packs -> crime
const crime = P('quantizer', -19, 4);
setRecipe(crime, 'quantize-crime');
runY(6, 7, -23, S); // A2's hf dust -> uplink
P('shipper', -24, 8);
b(-19, 6, S); // crime's bricks + dust -> uplink (two lanes: crime
b(-18, 6, S); // outruns a single belt when A2 is at full tilt)
b(-18, 7, W);
P('shipper', -20, 7);
// ---- chroma. The splitter's void branch is what keeps surplus slurry from backing up
// into the demuxer and starving the mv-flux lane.
runY(2, 9, -26, S);
P('lane-splitter', -26, 10);
runY(11, 12, -26, S); // void branch
P('shipper', -27, 13);
runX(-25, -24, 10, E); // working branch
P('subsampler', -23, 10); // 4:2:2 (default)
runX(-21, -20, 10, E);
const sub420 = P('subsampler', -19, 10);
setRecipe(sub420, 'subsample-420');
b(-17, 10, E);
runY(10, 3, -16, N); // chroma 4:2:0 climbs to the press
b(-16, 2, E);
// ---- the press: 6 coefficient packs + 1 chroma 4:2:0 -> one anchor slab.
const press = P('dct-press', -15, 0);
setRecipe(press, 'press-anchor-slab');
runX(-12, -11, 0, E);
// ---- assembly -> mosh -> MELT.
P('gop-assembler', -10, 0); // assemble-gop (default)
runX(-7, -6, 0, E);
P('mosh-reactor', -5, 0);
runX(-2, -1, 0, E); // melt -> uplink
P('shipper', 0, 0);
// Recovered anchor slabs come back out of the reactor. Rather than sell them, feed the
// i-only assembler: 8 slabs -> another GOP crate -> more melt.
runY(3, 4, -5, S);
const iOnly = P('gop-assembler', -6, 5);
setRecipe(iOnly, 'assemble-gop-i-only');
runY(5, 3, -3, N);
return cmds;
}

View File

@ -453,12 +453,13 @@ describe('shipping', () => {
const snap = sim.snapshot();
expect(snap.shippedTotal['melt'] ?? 0).toBeGreaterThanOrEqual(1);
expect(snap.activeCommission).toBe('first-taste');
expect(snap.commissionProgress['melt']).toBe(1);
const done = evs.filter((e) => e.kind === 'commissionDone');
expect(done).toHaveLength(1); // fires once, however much melt follows
expect(done[0]).toMatchObject({ commission: 'first-taste' });
// Closing it advances the queue to the next order in data order, counting afresh.
expect(snap.activeCommission).toBe(DATA.commissions[1].id);
expect(snap.commissionProgress['melt']).toBeUndefined();
// Routing did its job: the byproduct went to its own uplink, not into the reactor.
expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);