glytch/fktry/src/sim/reference.ts
type-two 45003db31a [ui] REMOVE mode: X arms it, click demolishes, Esc stands down
A misplaced machine is finally undoable by mouse. The REMOVE button sits
with the tabs rather than on a page, because it's a mode, not a machine, and
it has to be reachable from any page. It stays armed between clicks —
demolition is usually plural — and clicking open ground does nothing, so a
stray click never reads as a broken tool. Toast: "UNIT RECLAIMED. THE FLOOR
REMEMBERS."

LANE-RENDER: the hover hook is the __remove ghost sentinel. The UI has no
Renderer reference and the only channel to the ghost is selectedBuild(),
which main.ts feeds to setGhost — so while remove is armed, bus._sel reads
{def: '__remove', dir: 0}. Key the demolition tint off that def id. It's safe
because sim's place handler ignores unknown defs, so main.ts's click-to-place
fires a place for __remove and nothing happens; the UI dispatches the real
remove itself. Exported as REMOVE_DEF and pinned by a test.

A magic string shared between two lanes via a NOTES file is not a design;
contract request filed for UIBus.setGhostMode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:34:05 +10:00

166 lines
7.5 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';
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: 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);
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;
}