[sim] splitters, buffer tanks, heat, commission queue, starved, item ids
Round 2 per lanes/LANE-SIM.md. - splitters: round-robin across outward-facing belts, blocked outputs skipped not waited on; rank now traces THROUGH splitters and takes the best outcome reachable across every terminal a lane reaches - buffer tanks: charge on surplus, discharge to cover deficit, brownout only once dry; bandwidth.stored is the pool - heat v1: heatPerTick while active + RecipeDef.heat per craft, ambient cooling, linear throttle 1.0@0.7 -> 0.5@1.0, scram at 1.0, restart under 0.5, both edges emit 'scram'. Throttle applies to powerGen too, so a hot decoder sags before it drops off the bus - commission queue: data-order activation, repeat re-enters at the back - jammed:'starved' (edge-triggered) and BeltItem.id (stable through belt transfers and splitters alike) 15 new fixture tests for the M2 mechanics -- data/ carries none of the v2 fields yet, so heat and tanks are tested against a fixture this lane owns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
90703c4bcd
commit
ff43c09972
@ -26,21 +26,38 @@ import { makeRng, type Rng } from './rng';
|
||||
const BELT_SPACING = 0.45;
|
||||
/** Items a single belt tile may hold. */
|
||||
const BELT_CAPACITY = 2;
|
||||
/** Items a splitter may hold while it waits for an output to open. */
|
||||
const SPLITTER_CAPACITY = 2;
|
||||
/** A machine stalls once any output has piled to this multiple of the recipe yield. */
|
||||
const OUTPUT_STALL_FACTOR = 4;
|
||||
/** A machine accepts an input up to this multiple of the recipe requirement. */
|
||||
const INPUT_BUFFER_FACTOR = 2;
|
||||
/** Belt chains longer than this are treated as unterminated (cheap loop guard). */
|
||||
/** Belt graphs bigger than this are treated as unterminated (cheap loop guard). */
|
||||
const MAX_TRACE = 512;
|
||||
|
||||
// Heat v1. Curve is LANE-SIM's call — see NOTES round 2.
|
||||
/** Below this, heat is cosmetic. */
|
||||
const HEAT_THROTTLE_START = 0.7;
|
||||
/** Speed multiplier at heat 1.0 — the lore's "tick rate visibly halves". */
|
||||
const HEAT_THROTTLE_FLOOR = 0.5;
|
||||
/** Heat shed per tick, always, active or not. */
|
||||
const HEAT_DISSIPATION = 0.004;
|
||||
/** Cool back below this and a scrammed machine comes back up. */
|
||||
const HEAT_RESTART = 0.5;
|
||||
|
||||
/** Internal entity record. `state` is the contract-shaped object handed to consumers. */
|
||||
interface Ent {
|
||||
state: EntityState;
|
||||
def: MachineDef;
|
||||
/** mid-craft: inputs already consumed, progress running */
|
||||
crafting: boolean;
|
||||
/** heat shutdown: no work, no generation, until it cools to HEAT_RESTART */
|
||||
scrammed: boolean;
|
||||
/** tile keys this entity occupies, for teardown */
|
||||
tiles: number[];
|
||||
/** kind==='splitter': items awaiting an open output, and the round-robin cursor */
|
||||
queue: Array<{ item: string; id: number }>;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
export function createSim(): Sim {
|
||||
@ -49,6 +66,7 @@ export function createSim(): Sim {
|
||||
|
||||
const defs = new Map<string, MachineDef>();
|
||||
const recipeDefs = new Map<string, RecipeDef>();
|
||||
const commissionDefs = new Map<string, CommissionDef>();
|
||||
|
||||
// ents and entityStates are parallel and always in ascending-id order — the tick
|
||||
// loop walks them in that order, which is what makes the whole sim deterministic.
|
||||
@ -58,15 +76,16 @@ export function createSim(): Sim {
|
||||
const occ = new Map<number, number>(); // tileKey -> entity id
|
||||
const beltContents = new Map<number, BeltItem[]>(); // belt id -> items, t descending
|
||||
let allBeltItems: BeltItem[] = [];
|
||||
/** belt id -> id of the machine its chain ends at (null = dead end or loop) */
|
||||
const traceCache = new Map<number, number | null>();
|
||||
/** belt id -> every machine its chain can reach (through splitters). [] = dead end/loop. */
|
||||
const traceCache = new Map<number, number[]>();
|
||||
|
||||
let nextId = 1;
|
||||
let nextItemId = 1;
|
||||
let curTick = 0;
|
||||
let paused = false;
|
||||
let brownout = false;
|
||||
let commission: CommissionDef | null = null;
|
||||
let commissionDoneEmitted = false;
|
||||
let stored = 0;
|
||||
let commissionQueue: string[] = [];
|
||||
|
||||
const cmdQueue: Command[] = [];
|
||||
let events: SimEvent[] = [];
|
||||
@ -108,36 +127,83 @@ export function createSim(): Sim {
|
||||
return !!r && (r.inputs[item] ?? 0) > 0;
|
||||
};
|
||||
|
||||
/** Walks a belt chain to the machine it ends at. Cached; invalidated on any topology change. */
|
||||
function traceTerminal(beltId: number): number | null {
|
||||
/** Belts adjacent to this entity that don't point back into it. */
|
||||
function collectOutputBelts(e: Ent, out: number[]): void {
|
||||
out.length = 0;
|
||||
const fp = footprintOf(e.def.footprint, e.state.dir);
|
||||
for (let dx = 0; dx < fp.x; dx++) {
|
||||
for (let dy = 0; dy < fp.y; dy++) {
|
||||
const x = e.state.pos.x + dx;
|
||||
const y = e.state.pos.y + dy;
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const v = DIR_VEC[d];
|
||||
const b = entAt(x + v.x, y + v.y);
|
||||
if (!b || b.def.kind !== 'belt') continue;
|
||||
const bv = DIR_VEC[b.state.dir];
|
||||
if (occ.get(tileKey(b.state.pos.x + bv.x, b.state.pos.y + bv.y)) === e.state.id) continue;
|
||||
if (!out.includes(b.state.id)) out.push(b.state.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every machine a belt's chain can reach. Splitters fan out, so this is a set, not a
|
||||
* single terminal: a lane through a splitter can reach both a consumer and an uplink.
|
||||
* Cached; invalidated on any topology or recipe change.
|
||||
*/
|
||||
function traceTerminals(beltId: number): number[] {
|
||||
const cached = traceCache.get(beltId);
|
||||
if (cached !== undefined) return cached;
|
||||
let cur = byId.get(beltId);
|
||||
let result: number | null = null;
|
||||
for (let steps = 0; cur && cur.def.kind === 'belt' && steps < MAX_TRACE; steps++) {
|
||||
const next = beltTarget(cur);
|
||||
if (!next) break; // dead end: items ride to the end and pile up
|
||||
if (next.def.kind !== 'belt') { result = next.state.id; break; }
|
||||
cur = next;
|
||||
|
||||
const result: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
const stack: number[] = [beltId];
|
||||
const branch: number[] = [];
|
||||
while (stack.length && seen.size < MAX_TRACE) {
|
||||
const id = stack.pop()!;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const e = byId.get(id);
|
||||
if (!e) continue;
|
||||
if (e.def.kind === 'splitter') {
|
||||
collectOutputBelts(e, branch);
|
||||
for (const b of branch) stack.push(b);
|
||||
continue;
|
||||
}
|
||||
const next = beltTarget(e);
|
||||
if (!next) continue; // dead end: items ride to the end and pile up
|
||||
if (next.def.kind === 'belt' || next.def.kind === 'splitter') {
|
||||
stack.push(next.state.id);
|
||||
} else if (!result.includes(next.state.id)) {
|
||||
result.push(next.state.id);
|
||||
}
|
||||
}
|
||||
result.sort((a, b) => a - b);
|
||||
traceCache.set(beltId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much a machine wants to hand this item to this belt:
|
||||
* 2 = the belt's chain ends at a machine that consumes it
|
||||
* 1 = it ends at a shipper (takes anything — a sink, not a destination)
|
||||
* How much a machine wants to hand this item to this belt — the best outcome reachable
|
||||
* down that lane:
|
||||
* 2 = it reaches a machine that consumes this item
|
||||
* 1 = it only reaches an uplink (takes anything — a sink, not a destination)
|
||||
* 0 = dead end or loop (items just pile; keeps a half-built belt from feeling broken)
|
||||
* -1 = it ends at a machine that will never take it — pushing here would deadlock the line
|
||||
* -1 = every machine it reaches will refuse this item — pushing here deadlocks the lane
|
||||
*/
|
||||
function outputBeltRank(beltId: number, item: string): number {
|
||||
const term = traceTerminal(beltId);
|
||||
if (term === null) return 0;
|
||||
const e = byId.get(term);
|
||||
if (!e) return 0;
|
||||
if (e.def.kind === 'shipper') return 1;
|
||||
return machineWants(e, item) ? 2 : -1;
|
||||
const terms = traceTerminals(beltId);
|
||||
if (!terms.length) return 0;
|
||||
let best = -1;
|
||||
for (const t of terms) {
|
||||
const e = byId.get(t);
|
||||
if (!e) continue;
|
||||
const r = e.def.kind === 'shipper' ? 1 : machineWants(e, item) ? 2 : -1;
|
||||
if (r > best) best = r;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function beltHasRoom(beltId: number, atT: number): boolean {
|
||||
@ -147,15 +213,37 @@ export function createSim(): Sim {
|
||||
return !rear || rear.t - atT >= BELT_SPACING;
|
||||
}
|
||||
|
||||
function spawnOnBelt(beltId: number, item: string, id: number): void {
|
||||
const it: BeltItem = { item, entity: beltId, t: 0, id };
|
||||
beltContents.get(beltId)!.push(it);
|
||||
allBeltItems.push(it);
|
||||
}
|
||||
|
||||
function removeFlat(it: BeltItem): void {
|
||||
const i = allBeltItems.indexOf(it);
|
||||
if (i >= 0) allBeltItems.splice(i, 1);
|
||||
}
|
||||
|
||||
function setJam(e: Ent, reason: string): void {
|
||||
function setJam(e: Ent, reason: string | null): void {
|
||||
if (e.state.jammed === reason) return; // edge-triggered: one event per stall
|
||||
e.state.jammed = reason;
|
||||
events.push({ kind: 'jammed', entity: e.state.id, reason, tick: curTick });
|
||||
if (reason !== null) events.push({ kind: 'jammed', entity: e.state.id, reason, tick: curTick });
|
||||
}
|
||||
|
||||
/** Splitter contents are mirrored into inputBuf so the inspector can see them. */
|
||||
function syncSplitter(e: Ent): void {
|
||||
const buf: Record<string, number> = {};
|
||||
for (const q of e.queue) buf[q.item] = (buf[q.item] ?? 0) + 1;
|
||||
e.state.inputBuf = buf;
|
||||
}
|
||||
|
||||
/** Scrammed and idle machines contribute nothing; hot ones work at reduced speed. */
|
||||
function heatThrottle(e: Ent): number {
|
||||
if (e.scrammed) return 0;
|
||||
const h = e.state.heat;
|
||||
if (h <= HEAT_THROTTLE_START) return 1;
|
||||
const over = (h - HEAT_THROTTLE_START) / (1 - HEAT_THROTTLE_START);
|
||||
return 1 - over * (1 - HEAT_THROTTLE_FLOOR);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- commands
|
||||
@ -185,7 +273,7 @@ export function createSim(): Sim {
|
||||
jammed: null,
|
||||
heat: 0,
|
||||
};
|
||||
const e: Ent = { state, def, crafting: false, tiles };
|
||||
const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0 };
|
||||
ents.push(e);
|
||||
entityStates.push(state);
|
||||
byId.set(state.id, e);
|
||||
@ -268,7 +356,7 @@ export function createSim(): Sim {
|
||||
function startCrafts(): void {
|
||||
for (const e of ents) {
|
||||
if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue;
|
||||
if (e.crafting) continue;
|
||||
if (e.scrammed || e.crafting) continue;
|
||||
const r = recipeOf(e);
|
||||
if (!r) continue;
|
||||
|
||||
@ -277,11 +365,15 @@ export function createSim(): Sim {
|
||||
if ((e.state.outputBuf[k] ?? 0) >= r.outputs[k] * OUTPUT_STALL_FACTOR) { stalled = true; break; }
|
||||
}
|
||||
if (stalled) { setJam(e, 'output full'); continue; }
|
||||
e.state.jammed = null;
|
||||
|
||||
let ready = true;
|
||||
for (const k in r.inputs) if ((e.state.inputBuf[k] ?? 0) < r.inputs[k]) { ready = false; break; }
|
||||
if (!ready) continue;
|
||||
let needsInput = false;
|
||||
for (const k in r.inputs) {
|
||||
needsInput = true;
|
||||
if ((e.state.inputBuf[k] ?? 0) < r.inputs[k]) { ready = false; break; }
|
||||
}
|
||||
if (!ready) { setJam(e, needsInput ? 'starved' : null); continue; }
|
||||
setJam(e, null);
|
||||
|
||||
for (const k in r.inputs) {
|
||||
const left = (e.state.inputBuf[k] ?? 0) - r.inputs[k];
|
||||
@ -294,17 +386,20 @@ export function createSim(): Sim {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bandwidth v1: gen vs draw, no storage. Compression pays for itself — a recipe with
|
||||
* negative bandwidth (the quantizer) generates instead of drawing. Short of supply,
|
||||
* the whole factory runs at gen/draw speed: the brownout.
|
||||
* Bandwidth v2: gen vs draw with tank storage. Compression pays for itself — a recipe
|
||||
* with negative bandwidth (the quantizer) generates instead of drawing. Surplus charges
|
||||
* the tanks; a deficit drains them. Only once the tanks run dry does the factory
|
||||
* brown out and run at gen/draw speed.
|
||||
*/
|
||||
function computePower(): number {
|
||||
let gen = 0;
|
||||
let draw = 0;
|
||||
let capacity = 0;
|
||||
for (const e of ents) {
|
||||
draw += e.def.powerDraw;
|
||||
if (e.def.powerGen) gen += e.def.powerGen;
|
||||
if (e.crafting) {
|
||||
draw += e.def.powerDraw; // a scrammed machine is stopped, not unplugged
|
||||
if (e.def.kind === 'buffer') capacity += e.def.bufferCap ?? 0;
|
||||
if (e.def.powerGen) gen += e.def.powerGen * heatThrottle(e);
|
||||
if (e.crafting && !e.scrammed) {
|
||||
const r = recipeOf(e);
|
||||
if (r) {
|
||||
if (r.bandwidth > 0) draw += r.bandwidth;
|
||||
@ -312,16 +407,29 @@ export function createSim(): Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
const on = draw > gen;
|
||||
if (stored > capacity) stored = capacity;
|
||||
|
||||
let supplied = gen;
|
||||
if (gen >= draw) {
|
||||
const room = capacity - stored;
|
||||
const charge = Math.min(gen - draw, room);
|
||||
if (charge > 0) stored += charge;
|
||||
} else {
|
||||
const drawn = Math.min(draw - gen, stored);
|
||||
stored -= drawn;
|
||||
supplied = gen + drawn;
|
||||
}
|
||||
|
||||
const on = supplied < draw;
|
||||
if (on !== brownout) {
|
||||
brownout = on;
|
||||
events.push({ kind: 'brownout', on, tick: curTick });
|
||||
}
|
||||
snap.bandwidth.gen = gen;
|
||||
snap.bandwidth.draw = draw;
|
||||
snap.bandwidth.stored = 0;
|
||||
snap.bandwidth.stored = stored;
|
||||
snap.bandwidth.brownout = on;
|
||||
return on ? (draw > 0 ? gen / draw : 1) : 1;
|
||||
return on ? (draw > 0 ? supplied / draw : 1) : 1;
|
||||
}
|
||||
|
||||
function advanceCrafts(mult: number): void {
|
||||
@ -329,34 +437,37 @@ export function createSim(): Sim {
|
||||
if (!e.crafting) continue;
|
||||
const r = recipeOf(e);
|
||||
if (!r) { e.crafting = false; continue; }
|
||||
e.state.progress += mult / r.ticks;
|
||||
const speed = mult * heatThrottle(e);
|
||||
if (speed <= 0) continue; // scrammed mid-craft: progress holds, resumes on restart
|
||||
e.state.progress += speed / r.ticks;
|
||||
if (e.state.progress < 1) continue;
|
||||
e.state.progress = 0;
|
||||
e.crafting = false;
|
||||
for (const k in r.outputs) e.state.outputBuf[k] = (e.state.outputBuf[k] ?? 0) + r.outputs[k];
|
||||
if (r.heat) e.state.heat = Math.min(1, e.state.heat + r.heat);
|
||||
events.push({ kind: 'crafted', entity: e.state.id, recipe: r.id, tick: curTick });
|
||||
}
|
||||
}
|
||||
|
||||
/** Belts adjacent to this machine that don't point back into it. */
|
||||
function collectOutputBelts(e: Ent, out: number[]): void {
|
||||
out.length = 0;
|
||||
const fp = footprintOf(e.def.footprint, e.state.dir);
|
||||
for (let dx = 0; dx < fp.x; dx++) {
|
||||
for (let dy = 0; dy < fp.y; dy++) {
|
||||
const x = e.state.pos.x + dx;
|
||||
const y = e.state.pos.y + dy;
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const v = DIR_VEC[d];
|
||||
const b = entAt(x + v.x, y + v.y);
|
||||
if (!b || b.def.kind !== 'belt') continue;
|
||||
const bv = DIR_VEC[b.state.dir];
|
||||
if (occ.get(tileKey(b.state.pos.x + bv.x, b.state.pos.y + bv.y)) === e.state.id) continue;
|
||||
if (!out.includes(b.state.id)) out.push(b.state.id);
|
||||
}
|
||||
/** Heat v1: work heats, everything cools, hot machines throttle, boiling machines scram. */
|
||||
function updateHeat(): void {
|
||||
for (const e of ents) {
|
||||
const hpt = e.def.heatPerTick ?? 0;
|
||||
const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting);
|
||||
let h = e.state.heat + (active ? hpt : 0) - HEAT_DISSIPATION;
|
||||
if (h < 0) h = 0;
|
||||
else if (h > 1) h = 1;
|
||||
e.state.heat = h;
|
||||
|
||||
if (!e.scrammed && h >= 1) {
|
||||
e.scrammed = true;
|
||||
setJam(e, null); // scram is reported by its own event, not as a flow jam
|
||||
events.push({ kind: 'scram', entity: e.state.id, on: true, tick: curTick });
|
||||
} else if (e.scrammed && h < HEAT_RESTART) {
|
||||
e.scrammed = false;
|
||||
events.push({ kind: 'scram', entity: e.state.id, on: false, tick: curTick });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function pushOutputs(): void {
|
||||
@ -370,9 +481,10 @@ export function createSim(): Sim {
|
||||
|
||||
for (const item of Object.keys(e.state.outputBuf).sort()) {
|
||||
if ((e.state.outputBuf[item] ?? 0) <= 0) continue;
|
||||
// Pick the best-ranked belt for this item, then a belt of that rank with room.
|
||||
// Pick the best-ranked lane for this item, then a belt of that rank with room.
|
||||
// Never fall back to a worse rank just because the good belt is momentarily
|
||||
// full — that's how coefficient packs end up shipped instead of moshed.
|
||||
// full — that's how coefficient packs end up shipped instead of moshed. A
|
||||
// splitter is the sanctioned way to route surplus somewhere else.
|
||||
let bestRank = -1;
|
||||
for (const bid of candidateBelts) {
|
||||
const r = outputBeltRank(bid, item);
|
||||
@ -387,9 +499,7 @@ export function createSim(): Sim {
|
||||
}
|
||||
if (target < 0) continue;
|
||||
|
||||
const it: BeltItem = { item, entity: target, t: 0 };
|
||||
beltContents.get(target)!.push(it);
|
||||
allBeltItems.push(it);
|
||||
spawnOnBelt(target, item, nextItemId++);
|
||||
const left = e.state.outputBuf[item] - 1;
|
||||
if (left > 0) e.state.outputBuf[item] = left;
|
||||
else delete e.state.outputBuf[item];
|
||||
@ -397,12 +507,47 @@ export function createSim(): Sim {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splitters are deliberately dumb: round-robin across every outward-facing belt, and a
|
||||
* blocked output is skipped rather than waited on. That skip is what makes them the
|
||||
* factory's pressure valve — when the good lane backs up, the surplus keeps moving.
|
||||
*/
|
||||
function stepSplitters(): void {
|
||||
for (const e of ents) {
|
||||
if (e.def.kind !== 'splitter' || !e.queue.length) continue;
|
||||
collectOutputBelts(e, candidateBelts);
|
||||
if (!candidateBelts.length) continue;
|
||||
let dirty = false;
|
||||
while (e.queue.length) {
|
||||
let sent = false;
|
||||
for (let n = 0; n < candidateBelts.length; n++) {
|
||||
const bid = candidateBelts[e.cursor % candidateBelts.length];
|
||||
e.cursor = (e.cursor + 1) % candidateBelts.length;
|
||||
if (!beltHasRoom(bid, 0)) continue;
|
||||
const q = e.queue.shift()!;
|
||||
spawnOnBelt(bid, q.item, q.id); // keeps its id across the splitter
|
||||
sent = true;
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
if (!sent) break;
|
||||
}
|
||||
if (dirty) syncSplitter(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Belt -> machine. Rejection is not failure: the item waits and the line backs up. */
|
||||
function tryPushToMachine(e: Ent, item: string): boolean {
|
||||
function tryPushToMachine(e: Ent, item: string, id: number): boolean {
|
||||
if (e.def.kind === 'shipper') {
|
||||
e.state.inputBuf[item] = (e.state.inputBuf[item] ?? 0) + 1;
|
||||
return true;
|
||||
}
|
||||
if (e.def.kind === 'splitter') {
|
||||
if (e.queue.length >= SPLITTER_CAPACITY) return false;
|
||||
e.queue.push({ item, id });
|
||||
syncSplitter(e);
|
||||
return true;
|
||||
}
|
||||
const r = recipeOf(e);
|
||||
if (!r) return false;
|
||||
const need = r.inputs[item] ?? 0;
|
||||
@ -456,7 +601,7 @@ export function createSim(): Sim {
|
||||
if (c < cap) cap = c;
|
||||
}
|
||||
if (target > cap) target = cap;
|
||||
} else if (next && tryPushToMachine(next, it.item)) {
|
||||
} else if (next && tryPushToMachine(next, it.item, it.id ?? 0)) {
|
||||
items.splice(idx, 1);
|
||||
removeFlat(it);
|
||||
return true;
|
||||
@ -487,16 +632,30 @@ export function createSim(): Sim {
|
||||
}
|
||||
}
|
||||
|
||||
/** Activates the commission at the head of the queue and zeroes its progress. */
|
||||
function activateCommission(): void {
|
||||
const id = commissionQueue[0] ?? null;
|
||||
snap.activeCommission = id;
|
||||
snap.commissionProgress = {};
|
||||
const c = id === null ? undefined : commissionDefs.get(id);
|
||||
if (c) for (const k in c.wants) snap.commissionProgress[k] = 0;
|
||||
}
|
||||
|
||||
function creditCommission(item: string, count: number): void {
|
||||
if (!commission || commissionDoneEmitted) return;
|
||||
const want = commission.wants[item] ?? 0;
|
||||
if (want <= 0) return;
|
||||
const id = commissionQueue[0];
|
||||
if (id === undefined) return;
|
||||
const c = commissionDefs.get(id);
|
||||
if (!c) return;
|
||||
const want = c.wants[item] ?? 0;
|
||||
if (want <= 0) return; // progress only counts toward the active order
|
||||
snap.commissionProgress[item] = Math.min(want, (snap.commissionProgress[item] ?? 0) + count);
|
||||
for (const k in commission.wants) {
|
||||
if ((snap.commissionProgress[k] ?? 0) < commission.wants[k]) return;
|
||||
for (const k in c.wants) {
|
||||
if ((snap.commissionProgress[k] ?? 0) < c.wants[k]) return;
|
||||
}
|
||||
commissionDoneEmitted = true;
|
||||
events.push({ kind: 'commissionDone', commission: commission.id, tick: curTick });
|
||||
events.push({ kind: 'commissionDone', commission: c.id, tick: curTick });
|
||||
commissionQueue.shift();
|
||||
if (c.repeat) commissionQueue.push(c.id); // standing order: back of the queue
|
||||
activateCommission();
|
||||
}
|
||||
|
||||
function drainShippers(): void {
|
||||
@ -520,12 +679,14 @@ export function createSim(): Sim {
|
||||
init(gameData: GameData, seed: number): void {
|
||||
data = gameData;
|
||||
rng = makeRng(seed);
|
||||
void rng; // seeded and ready; nothing in M1 rolls dice yet
|
||||
void rng; // seeded and ready; nothing rolls dice yet
|
||||
|
||||
defs.clear();
|
||||
recipeDefs.clear();
|
||||
commissionDefs.clear();
|
||||
for (const m of gameData.machines) defs.set(m.id, m);
|
||||
for (const r of gameData.recipes) recipeDefs.set(r.id, r);
|
||||
for (const c of gameData.commissions) commissionDefs.set(c.id, c);
|
||||
|
||||
ents = [];
|
||||
entityStates = [];
|
||||
@ -539,11 +700,12 @@ export function createSim(): Sim {
|
||||
moved.clear();
|
||||
|
||||
nextId = 1;
|
||||
nextItemId = 1;
|
||||
curTick = 0;
|
||||
paused = false;
|
||||
brownout = false;
|
||||
commission = data.commissions[0] ?? null;
|
||||
commissionDoneEmitted = false;
|
||||
stored = 0;
|
||||
commissionQueue = data.commissions.map((c) => c.id);
|
||||
|
||||
snap.tick = 0;
|
||||
snap.paused = false;
|
||||
@ -551,9 +713,7 @@ export function createSim(): Sim {
|
||||
snap.beltItems = allBeltItems;
|
||||
snap.bandwidth = { gen: 0, draw: 0, stored: 0, brownout: false };
|
||||
snap.shippedTotal = {};
|
||||
snap.activeCommission = commission?.id ?? null;
|
||||
snap.commissionProgress = {};
|
||||
if (commission) for (const k in commission.wants) snap.commissionProgress[k] = 0;
|
||||
activateCommission();
|
||||
},
|
||||
|
||||
enqueue(cmd: Command): void {
|
||||
@ -568,7 +728,9 @@ export function createSim(): Sim {
|
||||
startCrafts();
|
||||
const mult = computePower();
|
||||
advanceCrafts(mult);
|
||||
updateHeat();
|
||||
pushOutputs();
|
||||
stepSplitters();
|
||||
moveBelts(mult);
|
||||
drainShippers();
|
||||
|
||||
|
||||
399
fktry/src/sim/pressure.test.ts
Normal file
399
fktry/src/sim/pressure.test.ts
Normal file
@ -0,0 +1,399 @@
|
||||
/**
|
||||
* M2 mechanics: splitters, buffer tanks, heat, the commission queue.
|
||||
*
|
||||
* These run on a synthetic fixture rather than data/*.json, deliberately. Two reasons:
|
||||
* the real data carries none of the v2 fields yet (no heatPerTick, no bufferCap, no
|
||||
* recipe heat, no repeat — see NOTES round 2), and mechanics are LANE-SIM's to pin down
|
||||
* while the numbers are LANE-DATA's to move. Round 1 taught that the hard way.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSim } from './index';
|
||||
import type {
|
||||
Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
|
||||
} from '../contracts';
|
||||
|
||||
const N: Dir = 0, E: Dir = 1, S: Dir = 2;
|
||||
|
||||
const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' });
|
||||
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
|
||||
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
|
||||
asset: 'fixture', ...m,
|
||||
});
|
||||
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
|
||||
inputs: {}, ticks: 1, bandwidth: 0, ...r,
|
||||
});
|
||||
|
||||
const FIXTURE: GameData = {
|
||||
items: ['rock', 'gravel', 'sand'].map(item),
|
||||
machines: [
|
||||
mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
|
||||
mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
|
||||
mach({ id: 'split', kind: 'splitter' }),
|
||||
mach({ id: 'ship', kind: 'shipper' }),
|
||||
mach({ id: 'gen', kind: 'power', powerGen: 10 }),
|
||||
mach({ id: 'hotgen', kind: 'power', powerGen: 10, heatPerTick: 0.02 }),
|
||||
mach({ id: 'tank', kind: 'buffer', bufferCap: 100 }),
|
||||
mach({ id: 'load', kind: 'crafter', powerDraw: 20 }),
|
||||
mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }),
|
||||
mach({ id: 'sifter', kind: 'crafter', recipes: ['sift'] }),
|
||||
mach({ id: 'oven', kind: 'crafter', recipes: ['bake'] }),
|
||||
],
|
||||
recipes: [
|
||||
rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 30 }),
|
||||
rec({ id: 'grind', machine: 'grinder', inputs: { rock: 1 }, outputs: { gravel: 1 } }),
|
||||
rec({ id: 'sift', machine: 'sifter', inputs: { rock: 1 }, outputs: { sand: 1 } }),
|
||||
rec({ id: 'bake', machine: 'oven', inputs: { rock: 1 }, outputs: { gravel: 1 }, ticks: 5, heat: 0.3 }),
|
||||
],
|
||||
tech: [],
|
||||
commissions: [
|
||||
{ id: 'c1', flavor: 'two rocks', wants: { rock: 2 }, rewardBandwidth: 1 },
|
||||
{ id: 'c2', flavor: 'three rocks, forever', wants: { rock: 3 }, rewardBandwidth: 1, repeat: true },
|
||||
],
|
||||
};
|
||||
|
||||
function newSim(seed = 1): Sim {
|
||||
const sim = createSim();
|
||||
sim.init(FIXTURE, seed);
|
||||
return sim;
|
||||
}
|
||||
const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
|
||||
({ kind: 'place', def, pos: { x, y }, dir });
|
||||
|
||||
function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
sim.tick();
|
||||
const evs = sim.drainEvents();
|
||||
if (sink) for (const e of evs) sink.push(e);
|
||||
}
|
||||
}
|
||||
const at = (sim: Sim, x: number, y: number): EntityState | undefined =>
|
||||
sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);
|
||||
|
||||
describe('splitters', () => {
|
||||
it('round-robins across both outputs', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
sim.enqueue(place('belt', 1, 0, E));
|
||||
sim.enqueue(place('split', 2, 0));
|
||||
sim.enqueue(place('belt', 3, 0, E)); // branch A -> gravel
|
||||
sim.enqueue(place('grinder', 4, 0));
|
||||
sim.enqueue(place('belt', 5, 0, E));
|
||||
sim.enqueue(place('ship', 6, 0));
|
||||
sim.enqueue(place('belt', 2, 1, S)); // branch B -> sand
|
||||
sim.enqueue(place('sifter', 2, 2));
|
||||
sim.enqueue(place('belt', 2, 3, S));
|
||||
sim.enqueue(place('ship', 2, 4));
|
||||
|
||||
run(sim, 4000);
|
||||
const { gravel = 0, sand = 0 } = sim.snapshot().shippedTotal;
|
||||
expect(gravel).toBeGreaterThan(10);
|
||||
expect(sand).toBeGreaterThan(10);
|
||||
expect(Math.abs(gravel - sand)).toBeLessThanOrEqual(1); // strict alternation
|
||||
});
|
||||
|
||||
it('skips a blocked output instead of waiting on it', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
sim.enqueue(place('belt', 1, 0, E));
|
||||
sim.enqueue(place('split', 2, 0));
|
||||
sim.enqueue(place('belt', 3, 0, E)); // branch A -> uplink
|
||||
sim.enqueue(place('ship', 4, 0));
|
||||
sim.enqueue(place('belt', 2, 1, S)); // branch B -> dead end, fills and wedges
|
||||
sim.enqueue(place('belt', 2, 2, S));
|
||||
|
||||
run(sim, 600);
|
||||
const early = sim.snapshot().shippedTotal['rock'] ?? 0;
|
||||
run(sim, 600);
|
||||
const late = sim.snapshot().shippedTotal['rock'] ?? 0;
|
||||
|
||||
expect(early).toBeGreaterThan(0);
|
||||
expect(late).toBeGreaterThan(early); // the good lane keeps flowing
|
||||
expect(at(sim, 0, 0)!.jammed).not.toBe('output full'); // the miner never wedges
|
||||
const stuck = sim.snapshot().beltItems.filter((i) => i.entity === at(sim, 2, 1)!.id
|
||||
|| i.entity === at(sim, 2, 2)!.id);
|
||||
expect(stuck).toHaveLength(4); // dead branch holds 2 belts x 2 items
|
||||
});
|
||||
|
||||
it('is traced through when ranking a lane — best reachable outcome wins', () => {
|
||||
// The miner's belt only reaches a consumer *through* the splitter. If the trace
|
||||
// stopped at the splitter the lane would rank 0 and this would still pass, so the
|
||||
// dead-end control below is what actually proves it.
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
sim.enqueue(place('belt', 1, 0, E));
|
||||
sim.enqueue(place('split', 2, 0));
|
||||
sim.enqueue(place('belt', 2, 1, S));
|
||||
sim.enqueue(place('grinder', 2, 2)); // rock consumer, reachable only via the splitter
|
||||
sim.enqueue(place('belt', 2, 3, S));
|
||||
sim.enqueue(place('ship', 2, 4));
|
||||
// A rival lane straight to an uplink: rank 1, must lose to the rank-2 lane above.
|
||||
sim.enqueue(place('belt', 0, 1, S));
|
||||
sim.enqueue(place('ship', 0, 2));
|
||||
|
||||
run(sim, 2000);
|
||||
const snap = sim.snapshot();
|
||||
expect(snap.shippedTotal['gravel'] ?? 0).toBeGreaterThan(0); // went the consumer's way
|
||||
expect(snap.shippedTotal['rock'] ?? 0).toBe(0); // never took the uplink
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer tanks', () => {
|
||||
it('charges on surplus, covers a deficit, and only browns out once dry', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('gen', 0, 0));
|
||||
sim.enqueue(place('gen', 1, 0));
|
||||
sim.enqueue(place('gen', 2, 0)); // gen 30
|
||||
sim.enqueue(place('tank', 3, 0)); // cap 100
|
||||
|
||||
run(sim, 1);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(30); // surplus 30 -> charge 30
|
||||
run(sim, 3);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(100); // clamps at capacity
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(false);
|
||||
|
||||
// Draw 40 against gen 30: a 10/tick deficit the tank should cover for 10 ticks.
|
||||
sim.enqueue(place('load', 0, 1));
|
||||
sim.enqueue(place('load', 1, 1));
|
||||
const evs: SimEvent[] = [];
|
||||
run(sim, 1, evs);
|
||||
expect(sim.snapshot().bandwidth.draw).toBe(40);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(90);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(false); // tank is carrying it
|
||||
|
||||
run(sim, 9, evs);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(0);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(false); // that last tick was still covered
|
||||
|
||||
run(sim, 1, evs);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(true); // dry -> the lights go
|
||||
expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('browns out immediately with no tank at all', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('gen', 0, 0)); // 10
|
||||
sim.enqueue(place('load', 0, 1)); // 20
|
||||
run(sim, 2);
|
||||
expect(sim.snapshot().bandwidth.brownout).toBe(true);
|
||||
expect(sim.snapshot().bandwidth.stored).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heat', () => {
|
||||
it('throttles a hot generator, scrams it at 1.0, and restarts it below 0.5', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('hotgen', 0, 0));
|
||||
const evs: SimEvent[] = [];
|
||||
|
||||
// Climb: +0.02/tick of work against -0.004/tick of ambient loss.
|
||||
let throttledGen = -1;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
run(sim, 1, evs);
|
||||
const h = at(sim, 0, 0)!.heat;
|
||||
if (h > 0.7 && h < 1) throttledGen = sim.snapshot().bandwidth.gen;
|
||||
if (evs.some((e) => e.kind === 'scram' && e.on)) break;
|
||||
}
|
||||
// Between 0.7 and 1.0 it visibly slows without stopping: the lore's fan-scream band.
|
||||
expect(throttledGen).toBeGreaterThan(5);
|
||||
expect(throttledGen).toBeLessThan(10);
|
||||
|
||||
const scramOn = evs.filter((e) => e.kind === 'scram' && e.on);
|
||||
expect(scramOn).toHaveLength(1);
|
||||
expect(at(sim, 0, 0)!.heat).toBe(1);
|
||||
|
||||
run(sim, 1);
|
||||
expect(sim.snapshot().bandwidth.gen).toBe(0); // scrammed: contributes nothing
|
||||
|
||||
// Cool: 1.0 -> below 0.5 at 0.004/tick is ~125 ticks. It restarts the moment it gets
|
||||
// there and immediately starts reheating, so catch the heat on the restart tick.
|
||||
const evs2: SimEvent[] = [];
|
||||
let heatAtRestart = -1;
|
||||
let restartTick = -1;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
run(sim, 1, evs2);
|
||||
if (evs2.some((e) => e.kind === 'scram' && !e.on)) {
|
||||
heatAtRestart = at(sim, 0, 0)!.heat;
|
||||
restartTick = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(evs2.filter((e) => e.kind === 'scram' && !e.on)).toHaveLength(1);
|
||||
expect(heatAtRestart).toBeLessThan(0.5);
|
||||
expect(restartTick).toBeGreaterThan(100); // it genuinely had to cool down
|
||||
run(sim, 1);
|
||||
expect(sim.snapshot().bandwidth.gen).toBe(10); // back on the bus
|
||||
});
|
||||
|
||||
it('duty-cycles: a generator hot enough to scram keeps restarting and scramming', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('hotgen', 0, 0));
|
||||
const evs: SimEvent[] = [];
|
||||
run(sim, 1200, evs);
|
||||
const scrams = evs.filter((e) => e.kind === 'scram');
|
||||
expect(scrams.filter((e) => (e as { on: boolean }).on).length).toBeGreaterThan(2);
|
||||
expect(scrams.filter((e) => !(e as { on: boolean }).on).length).toBeGreaterThan(2);
|
||||
// Edges alternate — never two ons in a row.
|
||||
const ons = scrams.map((e) => (e as { on: boolean }).on);
|
||||
for (let i = 1; i < ons.length; i++) expect(ons[i]).not.toBe(ons[i - 1]);
|
||||
});
|
||||
|
||||
it('adds recipe heat per craft and sheds it when idle', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
sim.enqueue(place('belt', 1, 0, E));
|
||||
sim.enqueue(place('oven', 2, 0));
|
||||
|
||||
const evs: SimEvent[] = [];
|
||||
let heatAfterCraft = -1;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
run(sim, 1, evs);
|
||||
if (evs.some((e) => e.kind === 'crafted' && e.recipe === 'bake')) {
|
||||
heatAfterCraft = at(sim, 2, 0)!.heat;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 0.3 from the bake, less one tick of ambient loss.
|
||||
expect(heatAfterCraft).toBeCloseTo(0.3 - 0.004, 5);
|
||||
|
||||
const before = at(sim, 2, 0)!.heat;
|
||||
sim.enqueue({ kind: 'remove', pos: { x: 0, y: 0 } }); // cut the feed, let it cool
|
||||
run(sim, 20);
|
||||
expect(at(sim, 2, 0)!.heat).toBeLessThan(before);
|
||||
});
|
||||
|
||||
it('leaves cold machines alone', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('gen', 0, 0)); // no heatPerTick
|
||||
run(sim, 200);
|
||||
expect(at(sim, 0, 0)!.heat).toBe(0);
|
||||
expect(sim.snapshot().bandwidth.gen).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commission queue', () => {
|
||||
it('advances in data order and re-queues standing orders', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
sim.enqueue(place('belt', 1, 0, E));
|
||||
sim.enqueue(place('ship', 2, 0));
|
||||
run(sim, 1);
|
||||
expect(sim.snapshot().activeCommission).toBe('c1');
|
||||
|
||||
const evs: SimEvent[] = [];
|
||||
run(sim, 400, evs);
|
||||
const done = evs
|
||||
.filter((e) => e.kind === 'commissionDone')
|
||||
.map((e) => (e as { commission: string }).commission);
|
||||
|
||||
expect(done.slice(0, 2)).toEqual(['c1', 'c2']); // data order
|
||||
expect(done.filter((c) => c === 'c1')).toHaveLength(1); // no repeat: gone for good
|
||||
expect(done.filter((c) => c === 'c2').length).toBeGreaterThan(1); // standing order returns
|
||||
expect(sim.snapshot().activeCommission).toBe('c2');
|
||||
expect(sim.snapshot().commissionProgress['rock']).toBeLessThan(3); // counting afresh
|
||||
});
|
||||
|
||||
it('banks nothing for orders that are not active', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
sim.enqueue(place('belt', 1, 0, E));
|
||||
sim.enqueue(place('grinder', 2, 0)); // rock -> gravel: nothing any commission wants
|
||||
sim.enqueue(place('belt', 3, 0, E));
|
||||
sim.enqueue(place('ship', 4, 0));
|
||||
|
||||
run(sim, 600);
|
||||
expect(sim.snapshot().shippedTotal['gravel']).toBeGreaterThan(0);
|
||||
expect(sim.snapshot().activeCommission).toBe('c1');
|
||||
expect(sim.snapshot().commissionProgress['rock']).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starved', () => {
|
||||
it('reports an idle recipe with nothing to eat, edge-triggered', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('grinder', 5, 5)); // a recipe, no feed
|
||||
const evs: SimEvent[] = [];
|
||||
run(sim, 100, evs);
|
||||
expect(at(sim, 5, 5)!.jammed).toBe('starved');
|
||||
expect(evs.filter((e) => e.kind === 'jammed')).toHaveLength(1); // once, not per tick
|
||||
|
||||
sim.enqueue(place('mine', 3, 5));
|
||||
sim.enqueue(place('belt', 4, 5, E));
|
||||
sim.enqueue(place('belt', 6, 5, E)); // and somewhere to put the gravel
|
||||
sim.enqueue(place('ship', 7, 5));
|
||||
|
||||
// The grinder eats a rock a tick and the mine yields one per 30, so it goes back to
|
||||
// starved between deliveries — the flag clears on the tick it actually gets fed.
|
||||
let cleared = false;
|
||||
for (let i = 0; i < 400 && !cleared; i++) {
|
||||
run(sim, 1);
|
||||
cleared = at(sim, 5, 5)!.jammed === null;
|
||||
}
|
||||
expect(cleared).toBe(true);
|
||||
run(sim, 200); // and it really is producing, not just briefly unflagged
|
||||
expect(sim.snapshot().shippedTotal['gravel']).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never calls an extractor starved — it has nothing to be starved of', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
run(sim, 20);
|
||||
expect(at(sim, 0, 0)!.jammed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('belt item ids', () => {
|
||||
it('are unique and survive belt transfers and splitters', () => {
|
||||
const sim = newSim();
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
sim.enqueue(place('belt', 1, 0, E));
|
||||
sim.enqueue(place('split', 2, 0));
|
||||
sim.enqueue(place('belt', 3, 0, E));
|
||||
sim.enqueue(place('belt', 4, 0, E));
|
||||
sim.enqueue(place('belt', 2, 1, S));
|
||||
sim.enqueue(place('belt', 2, 2, S));
|
||||
|
||||
/** entity each id has been seen on, to prove an id crosses tiles rather than respawning */
|
||||
const seenOn = new Map<number, Set<number>>();
|
||||
for (let i = 0; i < 400; i++) {
|
||||
run(sim, 1);
|
||||
const ids = new Set<number>();
|
||||
for (const it of sim.snapshot().beltItems) {
|
||||
expect(it.id).toBeDefined();
|
||||
expect(ids.has(it.id!)).toBe(false); // unique within a snapshot
|
||||
ids.add(it.id!);
|
||||
const where = seenOn.get(it.id!) ?? new Set<number>();
|
||||
where.add(it.entity);
|
||||
seenOn.set(it.id!, where);
|
||||
}
|
||||
}
|
||||
// At least one item kept its id across more than one belt (and through the splitter).
|
||||
const travelled = [...seenOn.values()].filter((s) => s.size > 1);
|
||||
expect(travelled.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...[...seenOn.values()].map((s) => s.size))).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('determinism holds with the new mechanics', () => {
|
||||
it('two fixture sims with splitters, tanks and heat agree after 1,000 ticks', () => {
|
||||
const build = (): Sim => {
|
||||
const sim = newSim(99);
|
||||
sim.enqueue(place('hotgen', 0, 5));
|
||||
sim.enqueue(place('gen', 1, 5));
|
||||
sim.enqueue(place('tank', 2, 5));
|
||||
sim.enqueue(place('mine', 0, 0));
|
||||
sim.enqueue(place('belt', 1, 0, E));
|
||||
sim.enqueue(place('split', 2, 0));
|
||||
sim.enqueue(place('belt', 3, 0, E));
|
||||
sim.enqueue(place('oven', 4, 0));
|
||||
sim.enqueue(place('belt', 5, 0, E));
|
||||
sim.enqueue(place('ship', 6, 0));
|
||||
sim.enqueue(place('belt', 2, 1, S));
|
||||
sim.enqueue(place('sifter', 2, 2));
|
||||
sim.enqueue(place('belt', 2, 3, S));
|
||||
sim.enqueue(place('ship', 2, 4));
|
||||
run(sim, 1000);
|
||||
return sim;
|
||||
};
|
||||
expect(build().snapshot()).toEqual(build().snapshot());
|
||||
});
|
||||
});
|
||||
76
fktry/src/sim/reference.test.ts
Normal file
76
fktry/src/sim/reference.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSim } from './index';
|
||||
import { referenceFactory } from './reference';
|
||||
import type { GameData, Sim, SimEvent } from '../contracts';
|
||||
|
||||
import items from '../../data/items.json';
|
||||
import machines from '../../data/machines.json';
|
||||
import recipes from '../../data/recipes.json';
|
||||
import tech from '../../data/tech.json';
|
||||
import commissions from '../../data/commissions.json';
|
||||
|
||||
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
|
||||
|
||||
function build(): Sim {
|
||||
const sim = createSim();
|
||||
sim.init(DATA, 1337);
|
||||
for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd);
|
||||
return sim;
|
||||
}
|
||||
|
||||
/** Runs `ticks` and returns how much melt was shipped during exactly that window. */
|
||||
function meltIn(sim: Sim, ticks: number, firstAt?: { tick: number }): number {
|
||||
let melt = 0;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
sim.tick();
|
||||
for (const e of sim.drainEvents()) {
|
||||
if (e.kind === 'shipped' && e.item === 'melt') {
|
||||
melt += e.count;
|
||||
if (firstAt && firstAt.tick < 0) firstAt.tick = e.tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
return melt;
|
||||
}
|
||||
|
||||
describe('reference factory', () => {
|
||||
it('ships melt inside 2,000 ticks', () => {
|
||||
const sim = build();
|
||||
const first = { tick: -1 };
|
||||
const melt = meltIn(sim, 2000, first);
|
||||
expect(first.tick).toBeGreaterThan(0);
|
||||
expect(first.tick).toBeLessThanOrEqual(2000);
|
||||
expect(melt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('is still shipping melt at 20,000 ticks — the deadlock regression net', () => {
|
||||
const sim = build();
|
||||
meltIn(sim, 2000);
|
||||
const early = sim.snapshot().shippedTotal['melt'] ?? 0;
|
||||
meltIn(sim, 16000);
|
||||
// The window that matters: round 1's build had wedged solid long before here.
|
||||
const late = meltIn(sim, 2000);
|
||||
const total = sim.snapshot().shippedTotal['melt'] ?? 0;
|
||||
|
||||
expect(early).toBeGreaterThan(0);
|
||||
expect(late).toBeGreaterThan(0);
|
||||
expect(total).toBeGreaterThan(early);
|
||||
});
|
||||
|
||||
it('keeps the demuxer alive by voiding surplus rather than jamming', () => {
|
||||
const sim = build();
|
||||
meltIn(sim, 20000);
|
||||
const snap = sim.snapshot();
|
||||
const demuxer = snap.entities.find((e) => e.def === 'demuxer')!;
|
||||
// The whole point: the head of the chain never wedges.
|
||||
expect(demuxer.jammed).not.toBe('output full');
|
||||
// Surplus is being turned into product, not silently dropped.
|
||||
expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0);
|
||||
expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('refuses to build against drifted data instead of deadlocking mysteriously', () => {
|
||||
const stripped = { ...DATA, recipes: DATA.recipes.filter((r) => r.id !== 'quantize-crime') };
|
||||
expect(() => referenceFactory(stripped)).toThrow(/quantize-crime/);
|
||||
});
|
||||
});
|
||||
150
fktry/src/sim/reference.ts
Normal file
150
fktry/src/sim/reference.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The reference factory — the official deadlock-free M1 build.
|
||||
*
|
||||
* Round 1 shipped melt and then wedged solid at ~tick 1200: luma, coefficient packs and
|
||||
* anchor slabs all overproduce, and with no splitter the surplus had nowhere legal to go,
|
||||
* so every machine upstream jammed in turn. This layout fixes that with the two tools the
|
||||
* graph actually gives you:
|
||||
*
|
||||
* - a splitter feeding TWO quantizers. When the press is full, quantizer A1 jams, its
|
||||
* lane backs up, the splitter skips it, and every luma bar goes to A2 -> the crime
|
||||
* quantizer -> macroblock bricks -> uplink. The demuxer never stalls.
|
||||
* - a splitter on the slurry line with one branch to an uplink, so surplus chroma is
|
||||
* voided instead of backing up into the demuxer.
|
||||
*
|
||||
* Nothing here is priority routing: splitters just skip blocked outputs, and that alone
|
||||
* makes the factory self-balancing. Each consumer takes what it can swallow and the
|
||||
* surplus keeps walking.
|
||||
*
|
||||
* ore x3 -> demuxer -+-> luma -> [split] -+-> quantizer A1 -> coeff -> DCT press --+
|
||||
* | +-> quantizer A2 -> coeff -> crime -> bricks
|
||||
* +-> slurry -> [split] -+-> subsampler 4:2:2 -> 4:2:0 ----------+
|
||||
* | +-> uplink (void) |
|
||||
* +-> mv-flux -> p-caster -> delta wafers ------------+ |
|
||||
* v v
|
||||
* GOP assembler A <- slab <- DCT press <---+
|
||||
* |
|
||||
* +-> GOP crate -> mosh -+-> MELT -> uplink
|
||||
* +-> slab -> assembler B
|
||||
* (i-only) -> mosh
|
||||
*/
|
||||
import type { Command, Dir, GameData } from '../contracts';
|
||||
|
||||
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
|
||||
|
||||
export function referenceFactory(data: GameData): Command[] {
|
||||
// Fail loudly and specifically if DATA moves under us — a missing id here would
|
||||
// otherwise surface as a mystery deadlock hours later.
|
||||
const machine = (id: string): string => {
|
||||
if (!data.machines.some((m) => m.id === id)) {
|
||||
throw new Error(`referenceFactory: data/machines.json has no machine "${id}"`);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
const recipeId = (id: string): string => {
|
||||
if (!data.recipes.some((r) => r.id === id)) {
|
||||
throw new Error(`referenceFactory: data/recipes.json has no recipe "${id}"`);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const cmds: Command[] = [];
|
||||
let placed = 0;
|
||||
|
||||
/** Places a machine and returns the entity id the sim will assign it. */
|
||||
const P = (def: string, x: number, y: number, dir: Dir = N): number => {
|
||||
cmds.push({ kind: 'place', def: machine(def), pos: { x, y }, dir });
|
||||
return ++placed;
|
||||
};
|
||||
const b = (x: number, y: number, dir: Dir): void => { P('belt', x, y, dir); };
|
||||
const runX = (x0: number, x1: number, y: number, dir: Dir): void => {
|
||||
const s = x0 <= x1 ? 1 : -1;
|
||||
for (let x = x0; ; x += s) { b(x, y, dir); if (x === x1) break; }
|
||||
};
|
||||
const runY = (y0: number, y1: number, x: number, dir: Dir): void => {
|
||||
const s = y0 <= y1 ? 1 : -1;
|
||||
for (let y = y0; ; y += s) { b(x, y, dir); if (y === y1) break; }
|
||||
};
|
||||
const setRecipe = (entity: number, recipe: string): void => {
|
||||
cmds.push({ kind: 'setRecipe', entity, recipe: recipeId(recipe) });
|
||||
};
|
||||
|
||||
// ---- power. Four decoders cover ~146 draw at full tilt, with room for the i-only
|
||||
// assembler's 24-bandwidth spikes.
|
||||
for (let i = 0; i < 4; i++) P('software-decoder', -30 + i * 3, -14);
|
||||
|
||||
// ---- mining. Three seams keep the demuxer at its 45-tick cadence (it eats 3 ore).
|
||||
P('seam-extractor', -30, 0);
|
||||
P('seam-extractor', -30, -3);
|
||||
P('seam-extractor', -30, 3);
|
||||
runY(-2, -1, -28, S); // north seam joins the trunk
|
||||
runY(3, 1, -28, N); // south seam joins the trunk
|
||||
runX(-28, -27, 0, E);
|
||||
P('demuxer', -26, 0);
|
||||
|
||||
// ---- mv-flux -> delta wafers. This lane is the whole factory's bottleneck (a GOP
|
||||
// crate needs 6 wafers = 12 mv-flux = 12 demux crafts), so nothing here overproduces.
|
||||
runY(-1, -3, -25, N);
|
||||
P('p-caster', -26, -5);
|
||||
runX(-26, -11, -6, E);
|
||||
runY(-6, -1, -10, S);
|
||||
|
||||
// ---- luma -> two quantizers behind a splitter. A1 feeds the press; when the press is
|
||||
// satisfied A1 jams, the splitter skips it, and everything goes to A2 -> crime.
|
||||
runX(-24, -23, 0, E);
|
||||
P('lane-splitter', -22, 0);
|
||||
|
||||
runX(-21, -20, 0, E);
|
||||
P('quantizer', -19, 0); // A1: quantize (default)
|
||||
runX(-17, -16, 0, E); // coefficient packs -> press
|
||||
runY(-1, -2, -19, N); // hf dust -> uplink
|
||||
P('shipper', -19, -4);
|
||||
|
||||
runY(1, 3, -22, S);
|
||||
P('quantizer', -23, 4); // A2: quantize (default)
|
||||
runX(-21, -20, 4, E); // coefficient packs -> crime
|
||||
const crime = P('quantizer', -19, 4);
|
||||
setRecipe(crime, 'quantize-crime');
|
||||
runY(6, 7, -23, S); // A2's hf dust -> uplink
|
||||
P('shipper', -24, 8);
|
||||
b(-19, 6, S); // crime's bricks + dust -> uplink (two lanes: crime
|
||||
b(-18, 6, S); // outruns a single belt when A2 is at full tilt)
|
||||
b(-18, 7, W);
|
||||
P('shipper', -20, 7);
|
||||
|
||||
// ---- chroma. The splitter's void branch is what keeps surplus slurry from backing up
|
||||
// into the demuxer and starving the mv-flux lane.
|
||||
runY(2, 9, -26, S);
|
||||
P('lane-splitter', -26, 10);
|
||||
runY(11, 12, -26, S); // void branch
|
||||
P('shipper', -27, 13);
|
||||
runX(-25, -24, 10, E); // working branch
|
||||
P('subsampler', -23, 10); // 4:2:2 (default)
|
||||
runX(-21, -20, 10, E);
|
||||
const sub420 = P('subsampler', -19, 10);
|
||||
setRecipe(sub420, 'subsample-420');
|
||||
b(-17, 10, E);
|
||||
runY(10, 3, -16, N); // chroma 4:2:0 climbs to the press
|
||||
b(-16, 2, E);
|
||||
|
||||
// ---- the press: 6 coefficient packs + 1 chroma 4:2:0 -> one anchor slab.
|
||||
const press = P('dct-press', -15, 0);
|
||||
setRecipe(press, 'press-anchor-slab');
|
||||
runX(-12, -11, 0, E);
|
||||
|
||||
// ---- assembly -> mosh -> MELT.
|
||||
P('gop-assembler', -10, 0); // assemble-gop (default)
|
||||
runX(-7, -6, 0, E);
|
||||
P('mosh-reactor', -5, 0);
|
||||
runX(-2, -1, 0, E); // melt -> uplink
|
||||
P('shipper', 0, 0);
|
||||
|
||||
// Recovered anchor slabs come back out of the reactor. Rather than sell them, feed the
|
||||
// i-only assembler: 8 slabs -> another GOP crate -> more melt.
|
||||
runY(3, 4, -5, S);
|
||||
const iOnly = P('gop-assembler', -6, 5);
|
||||
setRecipe(iOnly, 'assemble-gop-i-only');
|
||||
runY(5, 3, -3, N);
|
||||
|
||||
return cmds;
|
||||
}
|
||||
@ -453,12 +453,13 @@ describe('shipping', () => {
|
||||
const snap = sim.snapshot();
|
||||
|
||||
expect(snap.shippedTotal['melt'] ?? 0).toBeGreaterThanOrEqual(1);
|
||||
expect(snap.activeCommission).toBe('first-taste');
|
||||
expect(snap.commissionProgress['melt']).toBe(1);
|
||||
|
||||
const done = evs.filter((e) => e.kind === 'commissionDone');
|
||||
expect(done).toHaveLength(1); // fires once, however much melt follows
|
||||
expect(done[0]).toMatchObject({ commission: 'first-taste' });
|
||||
// Closing it advances the queue to the next order in data order, counting afresh.
|
||||
expect(snap.activeCommission).toBe(DATA.commissions[1].id);
|
||||
expect(snap.commissionProgress['melt']).toBeUndefined();
|
||||
|
||||
// Routing did its job: the byproduct went to its own uplink, not into the reactor.
|
||||
expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user