From ff43c0997232c27cb32a3cce739dadec93cf30c9 Mon Sep 17 00:00:00 2001 From: LANE-SIM Date: Fri, 17 Jul 2026 17:28:25 +1000 Subject: [PATCH] [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 --- fktry/src/sim/index.ts | 318 ++++++++++++++++++------- fktry/src/sim/pressure.test.ts | 399 ++++++++++++++++++++++++++++++++ fktry/src/sim/reference.test.ts | 76 ++++++ fktry/src/sim/reference.ts | 150 ++++++++++++ fktry/src/sim/sim.test.ts | 5 +- 5 files changed, 868 insertions(+), 80 deletions(-) create mode 100644 fktry/src/sim/pressure.test.ts create mode 100644 fktry/src/sim/reference.test.ts create mode 100644 fktry/src/sim/reference.ts diff --git a/fktry/src/sim/index.ts b/fktry/src/sim/index.ts index 8db5fc9..3b8c6ea 100644 --- a/fktry/src/sim/index.ts +++ b/fktry/src/sim/index.ts @@ -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(); const recipeDefs = new Map(); + const commissionDefs = new Map(); // ents and entityStates are parallel and always in ascending-id order — the tick // loop walks them in that order, which is what makes the whole sim deterministic. @@ -58,15 +76,16 @@ export function createSim(): Sim { const occ = new Map(); // tileKey -> entity id const beltContents = new Map(); // 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(); + /** belt id -> every machine its chain can reach (through splitters). [] = dead end/loop. */ + const traceCache = new Map(); let nextId = 1; + let nextItemId = 1; let curTick = 0; let paused = false; let brownout = false; - let 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(); + 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 = {}; + 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(); diff --git a/fktry/src/sim/pressure.test.ts b/fktry/src/sim/pressure.test.ts new file mode 100644 index 0000000..d9e6fed --- /dev/null +++ b/fktry/src/sim/pressure.test.ts @@ -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 & Pick): MachineDef => ({ + name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0, + asset: 'fixture', ...m, +}); +const rec = (r: Partial & Pick): 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>(); + for (let i = 0; i < 400; i++) { + run(sim, 1); + const ids = new Set(); + 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(); + 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()); + }); +}); diff --git a/fktry/src/sim/reference.test.ts b/fktry/src/sim/reference.test.ts new file mode 100644 index 0000000..658b766 --- /dev/null +++ b/fktry/src/sim/reference.test.ts @@ -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/); + }); +}); diff --git a/fktry/src/sim/reference.ts b/fktry/src/sim/reference.ts new file mode 100644 index 0000000..77cc7a3 --- /dev/null +++ b/fktry/src/sim/reference.ts @@ -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; +} diff --git a/fktry/src/sim/sim.test.ts b/fktry/src/sim/sim.test.ts index ad7ad98..4ac0d70 100644 --- a/fktry/src/sim/sim.test.ts +++ b/fktry/src/sim/sim.test.ts @@ -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);