/** * 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(); 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; }