[sim] deterministic factory core: belts, crafting, bandwidth, shipping

Replaces the stub with the real sim per lanes/LANE-SIM.md round 1.

- grid + placement/remove/rotate/setRecipe, footprint rotation, bounds
- belts: 2 items/tile, 0.45 spacing enforced across the tile seam,
  back-pressure, merges, belt<->machine handoff
- crafting: recipe buffers (2x in, stall at 4x out), edge-triggered jams
- bandwidth v1: gen vs draw, compression generates, brownout = gen/draw
  scaling both craft progress and belt movement
- shipping + commission tracking
- output routing ranks belts by tracing the chain to its terminal machine,
  so the demuxer's three outputs reach three different consumers without
  splitters (see NOTES - biggest judgement call of the round)

18 tests. Measured 3,500 entities + 6,820 belt items at 0.42ms/tick (80x
realtime). Full M1 chain ships MELT at tick 1149 in both node and browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LANE-SIM 2026-07-17 16:25:22 +10:00
parent e1206fea78
commit fcf5d04727
4 changed files with 1121 additions and 11 deletions

26
fktry/src/sim/geom.ts Normal file
View File

@ -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<Vec2> = [
{ 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 };
}

View File

@ -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<string, MachineDef>();
const recipeDefs = new Map<string, RecipeDef>();
// ents and entityStates are parallel and always in ascending-id order — the tick
// loop walks them in that order, which is what makes the whole sim deterministic.
let ents: Ent[] = [];
let entityStates: EntityState[] = [];
const byId = new Map<number, Ent>();
const occ = new Map<number, number>(); // tileKey -> entity id
const beltContents = new Map<number, BeltItem[]>(); // belt id -> items, t descending
let allBeltItems: BeltItem[] = [];
/** belt id -> id of the machine its chain ends at (null = dead end or loop) */
const traceCache = new Map<number, number | null>();
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<BeltItem>();
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;
},
};
}

24
fktry/src/sim/rng.ts Normal file
View File

@ -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) };
}

490
fktry/src/sim/sim.test.ts Normal file
View File

@ -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<number, number[]>();
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);
});
});