diff --git a/fktry/src/sim/geom.ts b/fktry/src/sim/geom.ts new file mode 100644 index 0000000..ae6b361 --- /dev/null +++ b/fktry/src/sim/geom.ts @@ -0,0 +1,26 @@ +/** Grid math. Origin at world center, +x east, +y south, Dir 0..3 = N,E,S,W clockwise. */ +import type { Dir, Vec2 } from '../contracts'; + +export const DIR_VEC: ReadonlyArray = [ + { x: 0, y: -1 }, // 0 N + { x: 1, y: 0 }, // 1 E + { x: 0, y: 1 }, // 2 S + { x: -1, y: 0 }, // 3 W +]; + +export const GRID_MIN = -32; +export const GRID_MAX = 31; + +/** Packs a tile into a unique number so occupancy can live in a flat Map. */ +export function tileKey(x: number, y: number): number { + return (x + 32768) * 65536 + (y + 32768); +} + +export function inBounds(x: number, y: number): boolean { + return x >= GRID_MIN && x <= GRID_MAX && y >= GRID_MIN && y <= GRID_MAX; +} + +/** Footprint after rotation — facing E or W swaps width and height. */ +export function footprintOf(fp: Vec2, dir: Dir): Vec2 { + return dir === 1 || dir === 3 ? { x: fp.y, y: fp.x } : { x: fp.x, y: fp.y }; +} diff --git a/fktry/src/sim/index.ts b/fktry/src/sim/index.ts index c61cec2..8db5fc9 100644 --- a/fktry/src/sim/index.ts +++ b/fktry/src/sim/index.ts @@ -1,19 +1,589 @@ -/** LANE-SIM territory. Stub — replace per lanes/LANE-SIM.md. */ -import type { Command, GameData, Sim, SimEvent, SimSnapshot } from '../contracts'; +/** + * 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; +/** 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). */ +const MAX_TRACE = 512; + +/** 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; + /** tile keys this entity occupies, for teardown */ + tiles: number[]; +} export function createSim(): Sim { - let tick = 0; + let data: GameData = { items: [], machines: [], recipes: [], tech: [], commissions: [] }; + let rng: Rng = makeRng(0); + + const defs = new Map(); + const recipeDefs = new Map(); + + // ents and entityStates are parallel and always in ascending-id order — the tick + // loop walks them in that order, which is what makes the whole sim deterministic. + let ents: Ent[] = []; + let entityStates: EntityState[] = []; + const byId = new Map(); + const occ = new Map(); // tileKey -> entity id + const beltContents = new Map(); // belt id -> items, t descending + let allBeltItems: BeltItem[] = []; + /** belt id -> id of the machine its chain ends at (null = dead end or loop) */ + const traceCache = new Map(); + + let nextId = 1; + let curTick = 0; + let paused = false; + let brownout = false; + let commission: CommissionDef | null = null; + let commissionDoneEmitted = false; + + const cmdQueue: Command[] = []; + let events: SimEvent[] = []; + /** items already advanced this tick, so a transfer downstream can't double-move */ + const moved = new Set(); + const candidateBelts: number[] = []; + const snap: SimSnapshot = { - tick: 0, paused: false, entities: [], beltItems: [], + tick: 0, + paused: false, + entities: entityStates, + beltItems: allBeltItems, bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false }, - shippedTotal: {}, activeCommission: null, commissionProgress: {}, + shippedTotal: {}, + activeCommission: null, + commissionProgress: {}, }; - const events: SimEvent[] = []; + + // ------------------------------------------------------------------ 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; + }; + + /** Walks a belt chain to the machine it ends at. Cached; invalidated on any topology change. */ + function traceTerminal(beltId: number): number | null { + 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; + } + 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) + * 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 + */ + 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; + } + + 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 removeFlat(it: BeltItem): void { + const i = allBeltItems.indexOf(it); + if (i >= 0) allBeltItems.splice(i, 1); + } + + function setJam(e: Ent, reason: string): 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 }); + } + + // ----------------------------------------------------------------- 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, + }; + const e: Ent = { state, def, crafting: false, tiles }; + 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; + 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 '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.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; } + 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; + + 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 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. + */ + function computePower(): number { + let gen = 0; + let draw = 0; + for (const e of ents) { + draw += e.def.powerDraw; + if (e.def.powerGen) gen += e.def.powerGen; + if (e.crafting) { + const r = recipeOf(e); + if (r) { + if (r.bandwidth > 0) draw += r.bandwidth; + else gen -= r.bandwidth; + } + } + } + const on = draw > gen; + 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.brownout = on; + return on ? (draw > 0 ? gen / 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; } + e.state.progress += mult / 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]; + 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); + } + } + } + out.sort((a, b) => a - b); + } + + 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 belt 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. + 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; + + const it: BeltItem = { item, entity: target, t: 0 }; + beltContents.get(target)!.push(it); + allBeltItems.push(it); + const left = e.state.outputBuf[item] - 1; + if (left > 0) e.state.outputBuf[item] = left; + else delete e.state.outputBuf[item]; + } + } + } + + /** Belt -> machine. Rejection is not failure: the item waits and the line backs up. */ + function tryPushToMachine(e: Ent, item: string): boolean { + if (e.def.kind === 'shipper') { + e.state.inputBuf[item] = (e.state.inputBuf[item] ?? 0) + 1; + 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)) { + 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++; + } + } + } + + function creditCommission(item: string, count: number): void { + if (!commission || commissionDoneEmitted) return; + const want = commission.wants[item] ?? 0; + if (want <= 0) return; + 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; + } + commissionDoneEmitted = true; + events.push({ kind: 'commissionDone', commission: commission.id, tick: curTick }); + } + + 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(_data: GameData, _seed: number) {}, - enqueue(_cmd: Command) {}, - tick() { tick++; snap.tick = tick; }, - snapshot() { return snap; }, - drainEvents() { return events.splice(0); }, + init(gameData: GameData, seed: number): void { + data = gameData; + rng = makeRng(seed); + void rng; // seeded and ready; nothing in M1 rolls dice yet + + defs.clear(); + recipeDefs.clear(); + for (const m of gameData.machines) defs.set(m.id, m); + for (const r of gameData.recipes) recipeDefs.set(r.id, r); + + ents = []; + entityStates = []; + allBeltItems = []; + byId.clear(); + occ.clear(); + beltContents.clear(); + traceCache.clear(); + cmdQueue.length = 0; + events = []; + moved.clear(); + + nextId = 1; + curTick = 0; + paused = false; + brownout = false; + commission = data.commissions[0] ?? null; + commissionDoneEmitted = false; + + snap.tick = 0; + snap.paused = false; + snap.entities = entityStates; + 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; + }, + + 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); + pushOutputs(); + moveBelts(mult); + drainShippers(); + + curTick++; + snap.tick = curTick; + }, + + snapshot(): SimSnapshot { + return snap; + }, + + drainEvents(): SimEvent[] { + const out = events; + events = []; + return out; + }, }; } diff --git a/fktry/src/sim/rng.ts b/fktry/src/sim/rng.ts new file mode 100644 index 0000000..9220fb7 --- /dev/null +++ b/fktry/src/sim/rng.ts @@ -0,0 +1,24 @@ +/** + * The sim's only source of randomness. mulberry32: small, fast, fully seedable. + * Nothing in the sim may call Math.random() or read the clock — same seed plus the + * same command sequence must reproduce the same world forever. + */ + +export interface Rng { + /** float in [0, 1) */ + next(): number; + /** integer in [0, maxExclusive) */ + int(maxExclusive: number): number; +} + +export function makeRng(seed: number): Rng { + let a = seed >>> 0; + const next = (): number => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { next, int: (maxExclusive: number) => Math.floor(next() * maxExclusive) }; +} diff --git a/fktry/src/sim/sim.test.ts b/fktry/src/sim/sim.test.ts new file mode 100644 index 0000000..ad7ad98 --- /dev/null +++ b/fktry/src/sim/sim.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, it } from 'vitest'; +import { createSim } from './index'; +import { makeRng } from './rng'; +import type { Command, Dir, EntityState, GameData, RecipeDef, 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; + +const N: Dir = 0, E: Dir = 1, S: Dir = 2; +const BELT_STEP = 2 / 30; // beltSpeed 2 tiles/sec at 30 tps +const BELT_SPACING_MIN = 0.45 - 1e-9; + +// Assertions read the recipe graph rather than hard-coding it: LANE-DATA owns those +// numbers and they move. Mechanics are what this suite is actually pinning down. +const recipe = (id: string): RecipeDef => { + const r = DATA.recipes.find((x) => x.id === id); + if (!r) throw new Error(`test fixture drift: no recipe "${id}"`); + return r; +}; + +function newSim(seed = 1337): Sim { + const sim = createSim(); + sim.init(DATA, 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 entityAt = (sim: Sim, x: number, y: number): EntityState | undefined => + sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y); + +const countCrafts = (evs: SimEvent[], id: string): number => + evs.filter((e) => e.kind === 'crafted' && e.recipe === id).length; + +/** A miner and a power plant: the minimum viable factory for most of these tests. */ +function seed(sim: Sim): void { + sim.enqueue(place('decode-asic', 0, 10)); + sim.enqueue(place('seam-extractor', 0, 0)); +} + +describe('rng', () => { + it('is reproducible from a seed and diverges across seeds', () => { + const a = makeRng(99), b = makeRng(99), c = makeRng(100); + const seqA = Array.from({ length: 8 }, () => a.next()); + const seqB = Array.from({ length: 8 }, () => b.next()); + const seqC = Array.from({ length: 8 }, () => c.next()); + expect(seqA).toEqual(seqB); + expect(seqA).not.toEqual(seqC); + for (const v of seqA) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); +}); + +describe('placement', () => { + it('rejects overlapping and out-of-bounds footprints, keeps the legal ones', () => { + const sim = newSim(); + const evs: SimEvent[] = []; + sim.enqueue(place('demuxer', 0, 0)); // occupies (0,0)(1,0)(0,1)(1,1) + sim.enqueue(place('demuxer', 1, 1)); // overlaps (1,1) -> dropped + sim.enqueue(place('demuxer', 2, 0)); // free -> placed + sim.enqueue(place('demuxer', 30, 30)); // ends exactly at the grid edge -> placed + sim.enqueue(place('demuxer', 31, 31)); // would need (32,32) -> dropped + sim.enqueue(place('nonexistent-machine', 20, 20)); // unknown def -> dropped + run(sim, 1, evs); + + expect(sim.snapshot().entities.map((e) => e.pos)).toEqual([ + { x: 0, y: 0 }, { x: 2, y: 0 }, { x: 30, y: 30 }, + ]); + expect(evs.filter((e) => e.kind === 'placed')).toHaveLength(3); + }); + + it('rotation swaps a non-square footprint and is refused when it would collide', () => { + const sim = newSim(); + sim.enqueue(place('demosaicer', 0, 0)); // 3x2 -> (0..2, 0..1) + sim.enqueue(place('demuxer', 0, 3)); // parks in the way of the rotated footprint + run(sim, 1); + sim.enqueue({ kind: 'rotate', pos: { x: 0, y: 0 } }); // would need (0..1, 0..2) -> free? no: 2x3 + run(sim, 1); + // 3x2 rotated to face E becomes 2x3 -> needs (0..1, 0..2); (0,3) is clear of that, so it turns. + expect(entityAt(sim, 0, 0)?.dir).toBe(E); + + // Now a genuine collision: something sitting inside the rotated footprint. + const sim2 = newSim(); + sim2.enqueue(place('demosaicer', 0, 0)); // occupies (0..2, 0..1) + sim2.enqueue(place('belt', 0, 2, N)); // inside the would-be rotated footprint + run(sim2, 1); + sim2.enqueue({ kind: 'rotate', pos: { x: 0, y: 0 } }); + run(sim2, 1); + expect(entityAt(sim2, 0, 0)?.dir).toBe(N); // refused, stays put + }); + + it('defaults a machine to its first recipe and removes on command', () => { + const sim = newSim(); + sim.enqueue(place('demuxer', 0, 0)); + run(sim, 1); + expect(entityAt(sim, 0, 0)?.recipe).toBe(DATA.machines.find((m) => m.id === 'demuxer')!.recipes[0]); + + sim.enqueue({ kind: 'remove', pos: { x: 1, y: 1 } }); // any occupied tile works + const evs: SimEvent[] = []; + run(sim, 1, evs); + expect(sim.snapshot().entities).toHaveLength(0); + expect(evs.filter((e) => e.kind === 'removed')).toHaveLength(1); + }); + + it('only accepts recipes the machine can actually run', () => { + const sim = newSim(); + sim.enqueue(place('quantizer', 0, 0)); + run(sim, 1); + const id = entityAt(sim, 0, 0)!.id; + + sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'quantize-crime' }); + run(sim, 1); + expect(entityAt(sim, 0, 0)!.recipe).toBe('quantize-crime'); + + sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'mosh' }); // not a quantizer recipe + run(sim, 1); + expect(entityAt(sim, 0, 0)!.recipe).toBe('quantize-crime'); // unchanged + }); +}); + +describe('belts', () => { + it('carries an item down a line at the belt speed on the def', () => { + const sim = newSim(); + const beltXs = [2, 3, 4, 5, 6, 7]; + seed(sim); + for (const x of beltXs) sim.enqueue(place('belt', x, 0, E)); + run(sim, 1); + + const lineIds = beltXs.map((x) => entityAt(sim, x, 0)!.id); + const globalPos = (it: { entity: number; t: number }) => lineIds.indexOf(it.entity) + it.t; + + let firstSeen = -1; + for (let i = 0; i < 200 && firstSeen < 0; i++) { + run(sim, 1); + if (sim.snapshot().beltItems.length > 0) firstSeen = sim.snapshot().tick; + } + expect(firstSeen).toBeGreaterThan(0); + const startPos = globalPos(sim.snapshot().beltItems[0]); + + // The line dead-ends, so the lead item parks at t=1 on the last belt: 6 tiles of travel. + let arrived = -1; + for (let i = 0; i < 400 && arrived < 0; i++) { + run(sim, 1); + const lead = sim.snapshot().beltItems[0]; + if (lead.entity === lineIds[lineIds.length - 1] && lead.t >= 1) arrived = sim.snapshot().tick; + } + expect(arrived).toBeGreaterThan(0); + + const expected = Math.ceil((beltXs.length - startPos) / BELT_STEP); + expect(arrived - firstSeen).toBeGreaterThanOrEqual(expected); + expect(arrived - firstSeen).toBeLessThanOrEqual(expected + 1); + expect(sim.snapshot().beltItems[0].t).toBe(1); // parked exactly at the lip + }); + + it('backs up cleanly when the line is full — no items lost, no items duplicated', () => { + const sim = newSim(); + const beltXs = [2, 3, 4, 5]; + seed(sim); + for (const x of beltXs) sim.enqueue(place('belt', x, 0, E)); // dead-ends at (6,0) + + const evs: SimEvent[] = []; + run(sim, 2000, evs); + const snap = sim.snapshot(); + + const perBelt = new Map(); + for (const it of snap.beltItems) { + expect(it.t).toBeLessThanOrEqual(1 + 1e-9); + const ts = perBelt.get(it.entity) ?? []; + ts.push(it.t); + perBelt.set(it.entity, ts); + } + for (const ts of perBelt.values()) { + expect(ts.length).toBeLessThanOrEqual(2); + ts.sort((a, b) => b - a); + for (let i = 1; i < ts.length; i++) { + expect(ts[i - 1] - ts[i]).toBeGreaterThanOrEqual(BELT_SPACING_MIN); + } + } + + // Conservation: every ore ever mined is on a belt or still held by the extractor. + const perCraft = recipe('extract-mdat').outputs['mdat-ore']; + const mined = countCrafts(evs, 'extract-mdat') * perCraft; + const held = entityAt(sim, 0, 0)!.outputBuf['mdat-ore'] ?? 0; + expect(mined).toBeGreaterThan(0); + expect(snap.beltItems.length + held).toBe(mined); + + // A full line stalls the miner rather than deleting ore. + expect(entityAt(sim, 0, 0)!.jammed).toBe('output full'); + expect(evs.filter((e) => e.kind === 'jammed')).toHaveLength(1); // once per stall, not per tick + }); + + it('merges two lines onto one without losing cargo', () => { + const sim = newSim(); + sim.enqueue(place('decode-asic', 0, 10)); + sim.enqueue(place('seam-extractor', 0, 0)); // feeds (2,0) heading E + sim.enqueue(place('seam-extractor', 0, 3)); // feeds (2,3) heading N, merging at (2,1) + sim.enqueue(place('belt', 2, 3, N)); + sim.enqueue(place('belt', 2, 2, N)); + sim.enqueue(place('belt', 2, 1, N)); + sim.enqueue(place('belt', 2, 0, E)); + sim.enqueue(place('belt', 3, 0, E)); + sim.enqueue(place('shipper', 4, 0)); + + const evs: SimEvent[] = []; + run(sim, 900, evs); + const perCraft = recipe('extract-mdat').outputs['mdat-ore']; + const mined = countCrafts(evs, 'extract-mdat') * perCraft; + const shipped = sim.snapshot().shippedTotal['mdat-ore'] ?? 0; + const inFlight = sim.snapshot().beltItems.length; + const held = (entityAt(sim, 0, 0)!.outputBuf['mdat-ore'] ?? 0) + + (entityAt(sim, 0, 3)!.outputBuf['mdat-ore'] ?? 0); + + expect(shipped).toBeGreaterThan(0); + expect(shipped + inFlight + held).toBe(mined); // nothing vanished at the merge + }); +}); + +describe('crafting', () => { + it('consumes a full recipe of ore and yields exactly the recipe outputs', () => { + const sim = newSim(); + seed(sim); + sim.enqueue(place('belt', 2, 0, E)); + sim.enqueue(place('belt', 3, 0, E)); + sim.enqueue(place('demuxer', 4, 0)); + + const demuxRecipe = recipe('demux-ore'); + let crafts = 0; + const evs: SimEvent[] = []; + for (let i = 0; i < 800 && crafts === 0; i++) { + run(sim, 1, evs); + crafts = countCrafts(evs, 'demux-ore'); + } + expect(crafts).toBe(1); + + // No output belts here, so the yield sits in the buffer exactly as the recipe states. + expect(entityAt(sim, 4, 0)!.outputBuf).toEqual(demuxRecipe.outputs); + expect(countCrafts(evs, 'extract-mdat')).toBeGreaterThanOrEqual(demuxRecipe.inputs['mdat-ore']); + }); + + it('stalls with a reason once the output buffer is full', () => { + const sim = newSim(); + seed(sim); // extractor with nowhere to put ore + const evs: SimEvent[] = []; + run(sim, 600, evs); + + const ext = entityAt(sim, 0, 0)!; + const cap = recipe('extract-mdat').outputs['mdat-ore'] * 4; + expect(ext.outputBuf['mdat-ore']).toBe(cap); + expect(ext.jammed).toBe('output full'); + expect(evs.filter((e) => e.kind === 'jammed')).toHaveLength(1); + }); +}); + +describe('bandwidth', () => { + it('halves everything when draw is twice gen', () => { + const sim = newSim(); + const beltXs = [2, 3, 4, 5, 6, 7]; + sim.enqueue(place('decode-asic', 0, 10)); // gen 20 + sim.enqueue(place('seam-extractor', 0, 0)); // draw 2, +2 while extracting + for (const x of beltXs) sim.enqueue(place('belt', x, 0, E)); // draw 0 + for (let i = 0; i < 9; i++) { // 9 idle quantizers: draw 4 each = 36 + sim.enqueue(place('quantizer', 20 + (i % 3) * 3, ((i / 3) | 0) * 3)); + } + + const evs: SimEvent[] = []; + let seen = false; + for (let i = 0; i < 400 && !seen; i++) { + run(sim, 1, evs); + seen = sim.snapshot().beltItems.length > 0; + } + expect(seen).toBe(true); + + const snap = sim.snapshot(); + expect(snap.bandwidth.gen).toBe(20); + expect(snap.bandwidth.draw).toBe(40); + expect(snap.bandwidth.brownout).toBe(true); + expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(1); + + const lineIds = beltXs.map((x) => entityAt(sim, x, 0)!.id); + const globalPos = () => { + const it = sim.snapshot().beltItems[0]; + return lineIds.indexOf(it.entity) + it.t; + }; + const before = globalPos(); + run(sim, 30); + // 30 ticks at half of 2 tiles/sec is exactly 1 tile. + expect(globalPos() - before).toBeCloseTo(1, 6); + }); + + it('reports no brownout when generation covers draw', () => { + const sim = newSim(); + seed(sim); + run(sim, 10); + expect(sim.snapshot().bandwidth.brownout).toBe(false); + expect(sim.snapshot().bandwidth.draw).toBe(4); // 2 idle + 2 while extracting + expect(sim.snapshot().bandwidth.gen).toBe(20); + }); + + it('counts compression as generation — the quantizer pays for itself', () => { + const q = recipe('quantize'); + expect(q.bandwidth).toBeLessThan(0); // guards the premise of this test + + const sim = newSim(); + seed(sim); + sim.enqueue(place('belt', 2, 0, E)); + sim.enqueue(place('demuxer', 3, 0)); + sim.enqueue(place('belt', 5, 0, E)); + sim.enqueue(place('quantizer', 6, 0)); + + let genWhileCompressing = 0; + for (let i = 0; i < 900; i++) { + run(sim, 1); + if (entityAt(sim, 6, 0)!.progress > 0) { + genWhileCompressing = sim.snapshot().bandwidth.gen; + break; + } + } + // 20 from the ASIC plus the quantizer's negative bandwidth handed back as supply. + expect(genWhileCompressing).toBe(20 - q.bandwidth); + }); +}); + +// ---------------------------------------------------------------- the M1 chain +// +// MELT, from raw ore, using the real codex graph: +// ore -> demuxer -> luma -> quantizer -> coefficient packs ─┐ +// -> slurry -> subsampler 4:2:2 -> 4:2:0 ─────┴-> DCT press -> anchor slab -┐ +// -> mv-flux -> p-caster -> delta wafers ──────────────────────────────────-┴-> GOP assembler +// GOP crate -> mosh reactor -> MELT +// HF dust gets its own uplink because nothing downstream will take it. + +function meltChain(): Command[] { + const c: Command[] = [ + place('software-decoder', -30, -10), + place('software-decoder', -27, -10), + place('software-decoder', -24, -10), + + place('seam-extractor', -30, 0), + place('seam-extractor', -30, -3), + place('seam-extractor', -30, 3), + place('belt', -28, -2, S), place('belt', -28, -1, S), // north miner joins the spine + place('belt', -28, 3, N), place('belt', -28, 2, N), place('belt', -28, 1, N), + place('belt', -28, 0, E), place('belt', -27, 0, E), + place('demuxer', -26, 0), + + place('belt', -24, 0, E), place('belt', -23, 0, E), // luma + place('quantizer', -22, 0), + place('belt', -20, 0, E), place('belt', -19, 0, E), // coefficient packs + place('dct-press', -18, 0), + place('belt', -21, 2, S), place('belt', -21, 3, E), // hf dust + place('shipper', -20, 2), + + place('belt', -25, -1, N), place('belt', -25, -2, N), place('belt', -25, -3, N), // mv-flux + place('p-caster', -26, -5), + + place('belt', -26, 2, S), place('belt', -26, 3, S), place('belt', -26, 4, S), // slurry + place('subsampler', -27, 5), + place('belt', -25, 5, E), place('belt', -24, 5, E), + place('subsampler', -23, 5), + place('belt', -21, 5, E), place('belt', -20, 5, E), place('belt', -19, 5, E), + place('belt', -18, 5, E), place('belt', -17, 5, N), place('belt', -17, 4, N), + place('belt', -17, 3, N), + + place('belt', -15, 0, E), place('belt', -14, 0, E), // anchor slabs + place('gop-assembler', -13, 0), + place('belt', -10, 0, E), place('belt', -9, 0, E), // GOP crates + place('mosh-reactor', -8, 0), + place('belt', -5, 0, E), // melt + recovered slabs + place('shipper', -4, 0), + ]; + for (let x = -24; x <= -13; x++) c.push(place('belt', x, -4, E)); // delta wafer trunk + for (let y = -4; y <= -1; y++) c.push(place('belt', -12, y, S)); + return c; +} + +/** Places the chain and dials in the two machines whose default recipe isn't the one we want. */ +function buildMeltChain(sim: Sim): void { + for (const cmd of meltChain()) sim.enqueue(cmd); + sim.tick(); + sim.drainEvents(); + sim.enqueue({ kind: 'setRecipe', entity: entityAt(sim, -18, 0)!.id, recipe: 'press-anchor-slab' }); + sim.enqueue({ kind: 'setRecipe', entity: entityAt(sim, -23, 5)!.id, recipe: 'subsample-420' }); + sim.tick(); + sim.drainEvents(); +} + +describe('determinism', () => { + it('two sims with the same seed and commands agree after 500 ticks', () => { + const a = newSim(4242); + const b = newSim(4242); + buildMeltChain(a); + buildMeltChain(b); + run(a, 500); + run(b, 500); + + expect(a.snapshot().beltItems.length).toBeGreaterThan(0); // not vacuously equal + expect(a.snapshot().entities.length).toBeGreaterThan(0); + expect(a.snapshot()).toEqual(b.snapshot()); + }); + + it('replays identically when commands arrive mid-run', () => { + const script: Array<[number, Command]> = meltChain().map((c, i) => [i * 3, c]); + const play = (): Sim => { + const sim = newSim(7); + for (let t = 0; t < 900; t++) { + for (const [at, cmd] of script) if (at === t) sim.enqueue(cmd); + sim.tick(); + sim.drainEvents(); + } + return sim; + }; + expect(play().snapshot()).toEqual(play().snapshot()); + }); +}); + +describe('shipping', () => { + it('ships whatever it is handed', () => { + const sim = newSim(); + seed(sim); + sim.enqueue(place('belt', 2, 0, E)); + sim.enqueue(place('belt', 3, 0, E)); + sim.enqueue(place('shipper', 4, 0)); + + const evs: SimEvent[] = []; + run(sim, 300, evs); + expect(sim.snapshot().shippedTotal['mdat-ore']).toBeGreaterThan(0); + expect(evs.filter((e) => e.kind === 'shipped')).not.toHaveLength(0); + expect(evs.filter((e) => e.kind === 'commissionDone')).toHaveLength(0); // wants melt, not ore + }); + + it('runs the whole M1 chain: raw ore in, MELT on THE SCREEN, commission closed', () => { + const sim = newSim(); + buildMeltChain(sim); + + const evs: SimEvent[] = []; + run(sim, 4000, evs); + 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' }); + + // Routing did its job: the byproduct went to its own uplink, not into the reactor. + expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0); + // And the reactor's recovered anchor slabs came back out to be sold. + expect(snap.shippedTotal['anchor-slab'] ?? 0).toBeGreaterThan(0); + }); +}); + +describe('pausing', () => { + it('freezes sim time but still accepts building', () => { + const sim = newSim(); + seed(sim); + run(sim, 60); + const before = sim.snapshot().tick; + + sim.enqueue({ kind: 'setPaused', paused: true }); + run(sim, 60); + expect(sim.snapshot().tick).toBe(before); + expect(sim.snapshot().paused).toBe(true); + + sim.enqueue(place('belt', 2, 0, E)); + run(sim, 1); + expect(entityAt(sim, 2, 0)).toBeDefined(); + + sim.enqueue({ kind: 'setPaused', paused: false }); + run(sim, 10); + expect(sim.snapshot().tick).toBe(before + 10); + }); +});