glytch/fktry/src/sim/reference.ts
type-two 68bddbec0a [sim] parity mites + firewall counter (recipe-shape) + reference v6
Round 6 (M3's enemy — THE CORRECTION walks).

- Parity mites (contracts v8): spawn from the world rim as tier-2 corruption
  crosses thresholds, patrol to the nearest tier-2 belt item, dwell, and repair
  it out of existence (emitting 'repaired'), then return to the rim sated. Never
  touch tier-0/1 cargo. Deterministic, save/load carried (no format bump).
- The firewall counter, detected by recipe shape (empty inputs, cans a mite) —
  the same pattern as the mosquito trap, so DATA's real firewall (landed
  mid-round) lights up with zero code. The repair dwell is the window a firewall
  needs to intercept a correction before it lands.
- Reference factory v6 auto-places DATA's firewall on the melt uplink approach,
  belted to a shipper so it never clogs; melt regression + zero-brownout intact.
- 11 new mite tests; 113 sim tests, 481 repo tests green; tsc clean. Verified in
  the browser: melt tick 1247 identical to Node, firewall canning live.
- Reshaped reference.test's demuxer assertion to sample a window (the ~18%
  transient press back-pressure is pre-existing; a single-tick check was a
  coin-flip). See LANE-SIM NOTES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:49:36 +10:00

295 lines
14 KiB
TypeScript

/**
* 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';
import { MITE_ITEM } from './constants';
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
/**
* The firewall the sim recognises by recipe SHAPE — empty inputs, cans a mite. Returns its
* machine + recipe id if DATA has authored one yet, else null. The reference layout places it
* only when it exists, so the demo lights up its own defence the round the counter lands (see
* NOTES round 6) and simply weathers the wave until then.
*/
export function firewallInData(data: GameData): { machine: string; recipe: string } | null {
for (const r of data.recipes) {
if (Object.keys(r.inputs).length !== 0) continue;
if ((r.outputs[MITE_ITEM] ?? 0) <= 0) continue;
if (data.machines.some((m) => m.id === r.machine)) return { machine: r.machine, recipe: r.id };
}
return null;
}
/**
* The techs this layout needs before it can be built at all — derived from the commands
* themselves, so it stays true if either the layout or DATA's tree moves.
*
* Contracts v4 gates every id a tech names, and this build uses two of them, so it is a
* post-research save rather than a cold start. Anything driving it (a test, the dev demo
* hook) has to open these first or the commands are silently dropped and the factory comes
* up underpowered — measured: the reactor's uplink runs at 100 draw against 80 gen and the
* whole sector browns out. See NOTES round 4.
*/
export function referenceFactoryTech(data: GameData): string[] {
const needs = new Set<string>();
for (const c of referenceFactory(data)) {
if (c.kind === 'place') needs.add(c.def);
else if (c.kind === 'setRecipe' && c.recipe !== null) needs.add(c.recipe);
}
return data.tech.filter((t) => t.unlocks.some((u) => needs.has(u))).map((t) => t.id);
}
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: ASIC base + software-decoder burst + tanks.
//
// Round 2 ran four naked software decoders. Once DATA gave them real heat they all
// scrammed in lockstep and the factory blacked out, which is why melt stopped arriving.
// The fix is the shape the codex always implied: cool inflexible ASICs hold the floor,
// one hot decoder bursts on top of them, and the tanks ride out its downtime.
//
// Sized against the WORST case, not the average: the quantizers and subsamplers hand
// back ~30/s of compression bandwidth, but not while they're between crafts. Assume
// none of it. Four ASICs (80/s) against ~107/s draw leaves a 27/s hole for the ~16.7s
// the decoder is down = ~450 bandwidth-seconds, and three tanks hold 900. Two ASICs
// fewer and it survives only while compression happens to be running — measured that,
// it bottomed out at 2.2 of 600, which is not a margin, it's a coincidence.
for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -14);
P('software-decoder', -18, -14);
P('buffer-tank', -15, -14);
P('buffer-tank', -12, -14);
P('buffer-tank', -9, -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);
// TWO melt lanes, and the second one is not decoration. The reactor pushes one melt and
// one slab per tick, so with a single lane melt #2 finds its own belt full, sees the slab
// lane also ends at an uplink (rank 1, same as its own), and takes it. The splitter down
// there then locks into perfect alternation with the reactor's push order — slab to the
// assembler, melt to the uplink, forever — and no slab is ever sold. It survives 20,000
// ticks looking correct. A second lane means melt never wants the slab lane at all.
runX(-2, -1, 0, E);
runX(-2, -1, 1, E);
P('shipper', 0, 0);
// ---- THE FIREWALL (v6). Once you ship real product, THE CORRECTION notices and sends
// parity mites — beetle drones that "repair" your melt into footage of a lake. A firewall
// on the uplink approach cans them (radius FIREWALL_RADIUS) before they reach the melt
// belts, which is the layout lesson: guard what you ship. Placed only if DATA has authored
// the machine; until then the two melt lanes simply out-run the trickle. See NOTES round 6.
const fw = firewallInData(data);
if (fw) {
const guard = P(fw.machine, -2, -3); // 2x2, guarding the melt belts (radius FIREWALL_RADIUS)
setRecipe(guard, fw.recipe);
b(-3, -3, W); // canned mites ride west to a shipper, or the firewall fills at 4 and
P('shipper', -5, -4); // stops catching. (Yes: a shipped mite is corruption — but a handful
// over 20k against ~900 bricks is a rounding error on the wave.)
}
// Recovered anchor slabs come back out of the reactor, and they go two places: the
// i-only assembler turns 8 of them into another GOP crate (more melt), and the rest are
// sold. The splitter is what makes it "and" rather than "or" — half the slabs to each,
// and if the assembler backs up, all of them go out the door instead of wedging the
// reactor. Selling them is also the only way the demo ever SHIPS a slab, which is what
// the uplink fiction upstairs is about.
b(-5, 3, S);
P('lane-splitter', -5, 4);
b(-5, 5, S);
const iOnly = P('gop-assembler', -6, 6);
setRecipe(iOnly, 'assemble-gop-i-only');
runY(6, 3, -3, N); // its crates rejoin the reactor
runX(-6, -7, 4, W); // the overflow: slabs for sale
P('shipper', -9, 3);
// ---- THE RESEARCH ANNEX (v5), on the eastern seam.
//
// Deliberately a separate works rather than a tap off the melt line. The western
// floorplan has no room left — every column between the quantizers and the press is
// spoken for — and threading three ingredients through it would have made the melt
// regression hostage to the science. DATA's second mdat seam (stream-crust-east,
// x 8..13, y -4..3) is empty ground, so the annex mines its own ore and shares only
// the bandwidth bus.
//
// ore -> demuxer -+-> luma -> quantizer -+-> crime -> BRICKS -+
// | +-> ringing -> HALOS -+-> bottler -> SPATIAL
// +-> slurry ----------------------------------+ PACK -> lab
// +-> mv-flux -> uplink (nothing here wants it)
for (let i = 0; i < 2; i++) P('decode-asic', 16 + i * 3, -7); // the annex pays its own way
P('seam-extractor', 8, -4);
P('seam-extractor', 8, -1);
P('seam-extractor', 8, 2);
runY(-3, 3, 10, S); // collector column down the seam's east face
b(10, 4, E);
b(11, 4, E);
P('demuxer', 12, 4);
runX(14, 15, 4, E); // luma
P('quantizer', 16, 4); // 'quantize' by default -> coefficient packs
b(16, 3, N); // its dust
P('shipper', 15, 1);
b(18, 4, E);
P('lane-splitter', 19, 4); // coefficient packs fork: bricks one way, halos the other
b(20, 4, E);
const annexCrime = P('quantizer', 21, 4);
setRecipe(annexCrime, 'quantize-crime');
runY(5, 6, 19, S);
const annexRing = P('quantizer', 18, 7);
setRecipe(annexRing, 'skim-ringing');
runX(23, 24, 4, E); // crime's dust
b(24, 5, S);
P('shipper', 24, 6);
runX(17, 16, 7, W); // ringing's dust
P('shipper', 14, 6);
const bottler = P('artifact-bottler', 21, 8);
setRecipe(bottler, 'bottle-spatial-pack');
runY(6, 7, 21, S); // crime's bricks drop into the bottler
b(20, 8, E); // ringing's halos come in from the west
runY(6, 9, 13, S); // slurry takes the long way round the works
runX(13, 20, 10, E);
b(21, 10, N);
runY(6, 8, 12, S); // mv-flux has no consumer out here — sell it, or the
P('shipper', 11, 9); // demuxer jams on its own byproduct
runX(23, 24, 8, E); // the packs, at last
P('archaeology-lab', 25, 8);
// Aim the labs at the cheapest thing they can actually pay for, so the demo researches
// something rather than sitting on a full pack rack.
const target = data.tech
.filter((t) => Object.keys(t.cost).length === 1 && (t.cost['spatial-pack'] ?? 0) > 0)
.sort((a, b) => a.cost['spatial-pack'] - b.cost['spatial-pack'])[0];
if (target) cmds.push({ kind: 'setResearch', tech: target.id });
return cmds;
}