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>
295 lines
14 KiB
TypeScript
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;
|
|
}
|