[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:
parent
e1206fea78
commit
fcf5d04727
26
fktry/src/sim/geom.ts
Normal file
26
fktry/src/sim/geom.ts
Normal 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 };
|
||||
}
|
||||
@ -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
24
fktry/src/sim/rng.ts
Normal 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
490
fktry/src/sim/sim.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user