From 68bddbec0ae143b3f20b9c5abffcbe625b97fb03 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 18:49:36 +1000 Subject: [PATCH] [sim] parity mites + firewall counter (recipe-shape) + reference v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fktry/lanes/LANE-SIM.md | 90 +++++++++++ fktry/src/sim/constants.ts | 37 +++++ fktry/src/sim/index.ts | 221 ++++++++++++++++++++++++++- fktry/src/sim/mite.test.ts | 258 ++++++++++++++++++++++++++++++++ fktry/src/sim/reference.test.ts | 16 +- fktry/src/sim/reference.ts | 30 ++++ 6 files changed, 643 insertions(+), 9 deletions(-) create mode 100644 fktry/src/sim/mite.test.ts diff --git a/fktry/lanes/LANE-SIM.md b/fktry/lanes/LANE-SIM.md index 12036a9..d023af5 100644 --- a/fktry/lanes/LANE-SIM.md +++ b/fktry/lanes/LANE-SIM.md @@ -587,3 +587,93 @@ NEXT (round 6 if asked) the 30s-timeout scare this round is the third time it would have paid for itself). - Per-seam `richness` driving extraction rate, once DATA wants it — the field is already there. - Swarm behaviour beyond "sit and bite": drifting between targets, or fleeing a trap. + +### Round 6 — 2026-07-20 — Opus 4.8 + +SHIPPED +- **Parity mites v1** (contracts v8 `'parity-mite'` + the `repaired` event). They ride the + wildlife array. Lifecycle: spawn from the world RIM as corruption crosses thresholds → + hunt the nearest tier-2 belt item → dwell on station while the proboscis works → repair it + (eat the item, emit `{kind:'repaired'; item; pos; tick}`) → sated, carry the correction back + to the rim and leave. They **never touch tier-0/1 cargo** — corruption only, which is both + the fiction and the balance (the DoD point). Deterministic; save/load carried with **no + save-format bump** (mites are just WildlifeState entries). `size` encodes the state machine + (0 hunting / 0(); + /** recipe ids that CAN a mite (empty inputs, output includes MITE_ITEM) — the firewalls */ + const firewallRecipeIds = new Set(); + /** item id -> tier, so mites can tell corruption (tier ≥ 2) from raw material */ + const itemTier = new Map(); /** authored seam rects; empty means "no seam map" → extractors mine anywhere */ let seams: SeamDef[] = []; /** DATA's kept-clear corner for the relic, when the seam map supplies one */ @@ -510,6 +522,8 @@ export function createSim(): Sim { // A trap runs on presence, not on a belt: no inputs, but it only closes on a swarm // that's actually in range. Idle traps look starved, which is honest — they're waiting. if (trapRecipeIds.has(r.id) && !swarmInTrapRange(e)) { setJam(e, 'starved'); continue; } + // A firewall is the same shape aimed at mites: it only closes on a mite in range. + if (firewallRecipeIds.has(r.id) && !miteInFirewallRange(e)) { setJam(e, 'starved'); continue; } let ready = true; let needsInput = false; @@ -602,6 +616,12 @@ export function createSim(): Sim { if (!prey) { e.state.progress = 1; continue; } catchSwarm(prey.id); } + // A firewall cans a mite the same way: hold the jar open if its target drifted off. + if (firewallRecipeIds.has(r.id)) { + const prey = miteInFirewallRange(e); + if (!prey) { e.state.progress = 1; continue; } + catchMite(prey.id); + } e.state.progress = 0; e.crafting = false; @@ -1166,6 +1186,190 @@ export function createSim(): Sim { wildlifeEvent('cleared', w); } + // -------------------------------------------------------------- parity mites + // + // THE CORRECTION walks. Mites are drones that patrol from the world rim toward your + // tier-2 product on the belts and repair it out of existence — the work is not destroyed, + // it is *corrected*, which is worse. They never touch tier-0/1 cargo: they correct + // corruption, not raw material. Pressure escalates with how much corruption you've shipped. + + /** Total tier-2+ product shipped so far — "corruption" the immune system reacts to. */ + function corruptionShipped(): number { + let n = 0; + for (const k in snap.shippedTotal) { + if ((itemTier.get(k) ?? 0) >= MITE_TIER_MIN) n += snap.shippedTotal[k]; + } + return n; + } + + /** World-space position of a belt item (tile centre offset by its progress along the belt). */ + function beltItemPos(it: BeltItem): Vec2 | null { + const belt = byId.get(it.entity); + if (!belt) return null; + const v = DIR_VEC[belt.state.dir]; + return { x: belt.state.pos.x + v.x * (it.t - 0.5), y: belt.state.pos.y + v.y * (it.t - 0.5) }; + } + + /** Remove a belt item from both its lane and the flat list — a mite has corrected it. */ + function eatBeltItem(it: BeltItem): void { + const lane = beltContents.get(it.entity); + if (lane) { + const i = lane.indexOf(it); + if (i >= 0) lane.splice(i, 1); + } + removeFlat(it); + } + + /** Nearest tier-2 belt item to a point, ties broken by stable item id. */ + function nearestCorruptItem(x: number, y: number): BeltItem | null { + let best: BeltItem | null = null; + let bestD = Infinity; + for (const it of allBeltItems) { + if ((itemTier.get(it.item) ?? 0) < MITE_TIER_MIN) continue; + const p = beltItemPos(it); + if (!p) continue; + const d = (p.x - x) * (p.x - x) + (p.y - y) * (p.y - y); + if (d < bestD || (d === bestD && best !== null && it.id < best.id)) { + best = it; bestD = d; + } + } + return best; + } + + /** A rim tile, chosen by the seeded RNG — mites arrive from the edge of the file. */ + function rimSpawn(): Vec2 { + const span = GRID_MAX - GRID_MIN; + const along = GRID_MIN + rng.int(span + 1); + switch (rng.int(4)) { + case 0: return { x: along, y: GRID_MIN }; // north edge + case 1: return { x: GRID_MAX, y: along }; // east edge + case 2: return { x: along, y: GRID_MAX }; // south edge + default: return { x: GRID_MIN, y: along }; // west edge + } + } + + /** The nearest point on the world rim to (x,y) — where a sated mite is heading home. */ + function nearestRim(x: number, y: number): Vec2 { + const toW = x - GRID_MIN, toE = GRID_MAX - x, toN = y - GRID_MIN, toS = GRID_MAX - y; + const m = Math.min(toW, toE, toN, toS); + if (m === toW) return { x: GRID_MIN, y }; + if (m === toE) return { x: GRID_MAX, y }; + if (m === toN) return { x, y: GRID_MIN }; + return { x, y: GRID_MAX }; + } + + /** Move a mite toward a point by one step; returns the chebyshev gap remaining. */ + function driftMite(w: WildlifeState, tx: number, ty: number, step: number): number { + const dx = tx - w.pos.x; + const dy = ty - w.pos.y; + const gap = Math.max(Math.abs(dx), Math.abs(dy)); + const len = Math.hypot(dx, dy) || 1; + w.pos.x += (dx / len) * step; + w.pos.y += (dy / len) * step; + return gap; + } + + /** + * Mites: spawn from the rim as corruption climbs, seek the nearest tier-2 belt item, dwell + * on station a beat while the proboscis works, then — sated — carry the correction back to the + * rim and leave. That patrol cycle (in, fix ONE, out) is deliberate: a mite that camped the + * slow melt lane would zero it, which is a wipe, not the codex's "early nuisance". A fresh mite + * respawns while corruption stays over threshold, so the pressure is a steady trickle. The + * repair dwell is also the firewall's window: a firewall in range cans the mite mid-repair and + * the product survives — that's the counter. + * + * `size` encodes the state (and gives the renderer its tell): 0 = hunting (approaching), + * 0= 0; i--) { + const w = wildlife[i]; + if (w.kind !== 'parity-mite') continue; + + // Sated: head home. Reaching the rim, the mite leaves with its correction. + if (w.size >= 1) { + w.target = undefined; + const rim = nearestRim(w.pos.x, w.pos.y); + if (driftMite(w, rim.x, rim.y, step) <= step) { + wildlife.splice(i, 1); + wildlifeEvent('cleared', w); + } + continue; + } + + // On station: dwell while the repair completes. Stationary, so a firewall can pin it. + if (w.size > 0) { + w.size += 1 / MITE_REPAIR_TICKS; + if (w.size < 1) continue; + // Correction lands: consume the nearest product still within reach (the belt may have + // shuffled during the dwell). If nothing's left in reach, the repair fizzles — back to + // hunting rather than manufacturing a phantom fix. + const done = nearestCorruptItem(w.pos.x, w.pos.y); + if (done) { + const p = beltItemPos(done)!; + if (Math.max(Math.abs(p.x - w.pos.x), Math.abs(p.y - w.pos.y)) <= MITE_REACH_RANGE + 1) { + const item = done.item; + eatBeltItem(done); + events.push({ + kind: 'repaired', item, + pos: { x: Math.round(w.pos.x), y: Math.round(w.pos.y) }, tick: curTick, + }); + w.size = 1; // sated: now carrying the correction home + continue; + } + } + w.size = 0; // nothing to fix here anymore; resume hunting + continue; + } + + // Hunting: converge on the nearest corruption; on contact, go on station and start work. + const target = nearestCorruptItem(w.pos.x, w.pos.y); + if (!target) { w.target = undefined; continue; } + w.target = target.entity; // the belt it's converging on — renderer can point it + const p = beltItemPos(target)!; + if (driftMite(w, p.x, p.y, step) <= MITE_REACH_RANGE) { + w.size = 1 / MITE_REPAIR_TICKS; // arrived: begin the dwell + } + } + } + + /** Is a mite close enough for this firewall to can? Nearest wins, ties by id. */ + const miteInFirewallRange = (e: Ent): WildlifeState | undefined => { + let best: WildlifeState | undefined; + let bestGap = Infinity; + for (const w of wildlife) { + if (w.kind !== 'parity-mite') continue; + const g = gapToEntity(e, Math.round(w.pos.x), Math.round(w.pos.y)); + if (g <= FIREWALL_RADIUS && (g < bestGap || (best && g === bestGap && w.id < best.id))) { + best = w; + bestGap = g; + } + } + return best; + }; + + function catchMite(id: number): void { + const i = wildlife.findIndex((w) => w.id === id); + if (i < 0) return; + const [w] = wildlife.splice(i, 1); + wildlifeEvent('cleared', w); + } + // --------------------------------------------------------------------- api return { @@ -1187,13 +1391,19 @@ export function createSim(): Sim { for (const u of t.unlocks) gatedIds.add(u); // named by a tech => locked until it lands } // A trap is any recipe that cans a live swarm out of thin air — no inputs, swarm out. - // Data-driven so DATA can name the machine whatever it likes. + // A firewall is the identical shape aimed at mites. Both are data-driven by recipe + // SHAPE, not machine id, so DATA can name the machines whatever it likes and they + // light up with zero code here. trapRecipeIds.clear(); + firewallRecipeIds.clear(); for (const r of gameData.recipes) { - if ((r.outputs[SWARM_ITEM] ?? 0) > 0 && Object.keys(r.inputs).length === 0) { - trapRecipeIds.add(r.id); - } + if (Object.keys(r.inputs).length !== 0) continue; + if ((r.outputs[SWARM_ITEM] ?? 0) > 0) trapRecipeIds.add(r.id); + if ((r.outputs[MITE_ITEM] ?? 0) > 0) firewallRecipeIds.add(r.id); } + // Tier lookup: mites correct corruption (tier ≥ 2), never raw material. + itemTier.clear(); + for (const it of gameData.items) itemTier.set(it.id, it.tier); // Seams are optional until DATA authors them + the orchestrator wires GameData.seams; // absent, the whole map is minable and nothing changes. See NOTES round 5. // Accepts DATA's file shape ({relicReserve, seams:[...]}) or a bare rect list. @@ -1254,6 +1464,7 @@ export function createSim(): Sim { // Wildlife first: this tick's bites are decided before anything spends them, so the // slowdown a swarm inflicts lands on the same tick the player can see it attached. updateWildlife(1); + updateMites(1); startCrafts(); const mult = computePower(); advanceCrafts(mult); diff --git a/fktry/src/sim/mite.test.ts b/fktry/src/sim/mite.test.ts new file mode 100644 index 0000000..54af038 --- /dev/null +++ b/fktry/src/sim/mite.test.ts @@ -0,0 +1,258 @@ +/** + * Parity mites v1 — THE CORRECTION's first unit (codex §5). + * + * The design points these tests protect: + * - Mites are driven by CORRUPTION shipped (tier-2 product), not by a timer. Ship nothing + * and none arrive; the immune system only notices you once you're making product. + * - They correct corruption, never raw material — tier-0/1 cargo is invisible to them. + * That's the balance as much as the fiction, and it's the first thing that would rot. + * - A FIREWALL is the counter, recognised by recipe SHAPE (empty inputs, cans a mite), + * exactly like the mosquito trap — so DATA's real firewall lights up with zero code here. + * + * Fixture-based: DATA carries no firewall machine or `parity-mite` item yet (see NOTES round + * 6), so both are modelled here the way the orders describe. The sim keys off shape and tier, + * not ids, so the real data will drop straight in. + */ +import { describe, expect, it } from 'vitest'; +import { createSim } from './index'; +import { FIREWALL_RADIUS, MITE_ITEM, MITE_MAX, MITE_SPAWN_PER } from './constants'; +import type { + Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, WildlifeState, +} from '../contracts'; + +const N: Dir = 0, E: Dir = 1; + +const item = (id: string, tier: number): ItemDef => + ({ id, name: id, codex: 'fixture', tier, color: '#fff' }); +const mach = (m: Partial & Pick): MachineDef => ({ + name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0, + asset: 'fixture', ...m, +}); +const rec = (r: Partial & Pick): RecipeDef => ({ + inputs: {}, ticks: 1, bandwidth: 0, ...r, +}); + +// raw = tier-0 (raw material: mites must ignore it). product = tier-2 (corruption: mites eat). +const FIXTURE: GameData = { + items: [item('raw', 0), item('product', 2), item(MITE_ITEM, 2)], + machines: [ + mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }), + mach({ id: 'belt', kind: 'belt', beltSpeed: 3 }), + mach({ id: 'ship', kind: 'shipper' }), + mach({ id: 'gen', kind: 'power', powerGen: 100 }), + mach({ id: 'forge', kind: 'crafter', recipes: ['make'] }), + // The firewall: no inputs, cans a live mite. The sim spots it by recipe shape. + mach({ id: 'firewall', kind: 'crafter', recipes: ['burn'], powerDraw: 1 }), + ], + recipes: [ + rec({ id: 'dig', machine: 'mine', outputs: { raw: 1 }, ticks: 4 }), + rec({ id: 'make', machine: 'forge', inputs: { raw: 1 }, outputs: { product: 1 }, ticks: 4 }), + rec({ id: 'burn', machine: 'firewall', inputs: {}, outputs: { [MITE_ITEM]: 1 }, ticks: 20 }), + ], + tech: [], + commissions: [{ id: 'c1', flavor: 'product', wants: { product: 99999 }, rewardBandwidth: 1 }], +}; + +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 mites = (sim: Sim): WildlifeState[] => + sim.snapshot().wildlife!.filter((w) => w.kind === 'parity-mite'); + +/** + * A factory near origin that ships tier-2 product: mine -> raw belt -> forge -> a long + * product belt -> shipper. The raw belt sits right at origin where mites converge, so + * "mites ignore raw" is a real test, not a vacuous one. + */ +function corruptionFactory(sim: Sim, opts: { firewall?: boolean } = {}): void { + sim.enqueue(place('gen', 0, 12)); + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); // carries RAW (tier-0) + sim.enqueue(place('forge', 2, 0)); + for (let x = 3; x <= 7; x++) sim.enqueue(place('belt', x, 0, E)); // carries PRODUCT (tier-2) + sim.enqueue(place('ship', 8, 0)); + if (opts.firewall) sim.enqueue(place('firewall', 5, 2)); // guards the whole short product line +} + +describe('parity mites — corruption drives them', () => { + it('do not appear until enough corruption has shipped', () => { + const sim = newSim(); + corruptionFactory(sim); + const evs: SimEvent[] = []; + // Run until the first mite spawns, tracking corruption at that moment. + let shippedAtSpawn = -1; + for (let i = 0; i < 20000 && shippedAtSpawn < 0; i++) { + run(sim, 1, evs); + if (mites(sim).length > 0) shippedAtSpawn = sim.snapshot().shippedTotal['product'] ?? 0; + } + expect(shippedAtSpawn, 'no mite ever spawned').toBeGreaterThanOrEqual(0); + // A mite only arrives once corruption crosses the threshold — never on tick 0. + expect(shippedAtSpawn).toBeGreaterThanOrEqual(MITE_SPAWN_PER); + }); + + it('a factory that ships NO corruption is never noticed', () => { + // Same machines, but the product belt is severed before the shipper: nothing ships. + const sim = newSim(); + sim.enqueue(place('gen', 0, 12)); + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); + sim.enqueue(place('forge', 2, 0)); + // product piles in the forge and on a stub belt; none reaches a shipper. + sim.enqueue(place('belt', 3, 0, E)); + run(sim, 8000); + expect(sim.snapshot().shippedTotal['product'] ?? 0).toBe(0); + expect(mites(sim)).toHaveLength(0); + }); +}); + +describe('parity mites — what they correct', () => { + it('seek a tier-2 product on a belt and repair it out of existence', () => { + const sim = newSim(); + corruptionFactory(sim); + const evs: SimEvent[] = []; + run(sim, 6000, evs); + + const repairs = evs.filter((e) => e.kind === 'repaired'); + expect(repairs.length, 'no product was ever repaired').toBeGreaterThan(0); + // Every correction is a product (tier-2), and the event carries the item and a position. + for (const r of repairs) { + expect(r.kind).toBe('repaired'); + if (r.kind === 'repaired') { + expect(r.item).toBe('product'); + expect(r.pos).toBeDefined(); + } + } + }); + + it('THE BALANCE POINT: never touch tier-0/1 cargo', () => { + const sim = newSim(); + corruptionFactory(sim); + const evs: SimEvent[] = []; + // The raw belt at origin is thick with tier-0 items the whole run, and mites pass right + // over it to reach the product line — yet raw is never corrected. + run(sim, 12000, evs); + const raw = evs.filter((e) => e.kind === 'repaired' && e.item === 'raw'); + expect(raw).toHaveLength(0); + // And there really WERE raw items sitting under the mites' path (not vacuously true). + expect(sim.snapshot().beltItems.some((b) => b.item === 'raw')).toBe(true); + }); +}); + +describe('parity mites — escalation and cap', () => { + it('scale with corruption but never exceed MITE_MAX', () => { + const sim = newSim(); + corruptionFactory(sim); + let peak = 0; + for (let i = 0; i < 40000; i++) { + run(sim, 1); + peak = Math.max(peak, mites(sim).length); + } + // Deep into a heavily-corrupting run the patrol saturates the cap, and never breaches it. + expect(peak).toBe(MITE_MAX); + const shipped = sim.snapshot().shippedTotal['product'] ?? 0; + expect(shipped).toBeGreaterThan(MITE_SPAWN_PER * MITE_MAX); // corruption was well past the cap + }); +}); + +describe('the firewall — the counter', () => { + it('cans a mite that patrols into range, and the catch is cargo', () => { + const sim = newSim(); + corruptionFactory(sim, { firewall: true }); + const evs: SimEvent[] = []; + run(sim, 12000, evs); + + // A firewall in range of the product line cans mites: the canned mite is a real item. + const fw = sim.snapshot().entities.find((e) => e.def === 'firewall')!; + expect(fw.outputBuf[MITE_ITEM] ?? 0).toBeGreaterThan(0); + // Those catches show up as wildlife cleared events for parity-mites. + const caught = evs.filter( + (e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'parity-mite', + ); + expect(caught.length).toBeGreaterThan(0); + }); + + it('with no mites in the world, a firewall just waits — it never cans air', () => { + // Sever the product line before the shipper: nothing ships, so corruption stays 0 and no + // mite ever arrives. The firewall has nothing to catch and holds honestly at 'starved'. + const sim = newSim(); + sim.enqueue(place('gen', 0, 12)); + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); + sim.enqueue(place('forge', 2, 0)); + sim.enqueue(place('belt', 3, 0, E)); + sim.enqueue(place('firewall', 6, 0)); + run(sim, 12000); + expect(mites(sim)).toHaveLength(0); // precondition: nothing to catch + const fw = sim.snapshot().entities.find((e) => e.def === 'firewall')!; + expect(fw.outputBuf[MITE_ITEM] ?? 0).toBe(0); + expect(fw.jammed).toBe('starved'); // an idle firewall is waiting, not broken + }); + + it('guarding the approach cancels corrections — the counter works', () => { + const guarded = newSim(7); + corruptionFactory(guarded, { firewall: true }); + const gEvs: SimEvent[] = []; + run(guarded, 20000, gEvs); + + const open = newSim(7); + corruptionFactory(open); + const oEvs: SimEvent[] = []; + run(open, 20000, oEvs); + + // Same seed, same layout but for the firewall. The firewall cans mites on the approach, + // so far fewer products are corrected mid-belt than on the undefended line. + const guardedRepairs = gEvs.filter((e) => e.kind === 'repaired').length; + const openRepairs = oEvs.filter((e) => e.kind === 'repaired').length; + expect(openRepairs).toBeGreaterThan(0); // the undefended line really was being corrected + expect(guardedRepairs).toBeLessThan(openRepairs); + // And the guard did its job by canning mites, not by luck. + const fw = guarded.snapshot().entities.find((e) => e.def === 'firewall')!; + expect(fw.outputBuf[MITE_ITEM] ?? 0).toBeGreaterThan(0); + }); + + it('FIREWALL_RADIUS is a real, bounded reach', () => { + expect(FIREWALL_RADIUS).toBeGreaterThan(0); + expect(Number.isFinite(FIREWALL_RADIUS)).toBe(true); + }); +}); + +describe('parity mites — determinism and persistence', () => { + it('two sims with the same seed run the same wave', () => { + const build = (): Sim => { + const sim = newSim(4242); + corruptionFactory(sim, { firewall: true }); + run(sim, 10000); + return sim; + }; + const a = build(); + const b = build(); + expect(mites(a).length + (a.snapshot().shippedTotal[MITE_ITEM] ? 1 : 0)).toBeGreaterThanOrEqual(0); + expect(b.snapshot()).toEqual(a.snapshot()); + }); + + it('survives save/load mid-wave, and the future still matches', () => { + const a = newSim(77); + corruptionFactory(a); + run(a, 7000); + expect(mites(a).length, 'no wave to persist').toBeGreaterThan(0); + + const b = newSim(77); + b.load(a.save()); + expect(b.snapshot().wildlife).toEqual(a.snapshot().wildlife); + + run(a, 5000); + run(b, 5000); + expect(b.snapshot()).toEqual(a.snapshot()); + }); +}); diff --git a/fktry/src/sim/reference.test.ts b/fktry/src/sim/reference.test.ts index 1b4761e..fd7aea9 100644 --- a/fktry/src/sim/reference.test.ts +++ b/fktry/src/sim/reference.test.ts @@ -64,11 +64,19 @@ describe('reference factory', () => { it('keeps the demuxer alive by voiding surplus rather than jamming', () => { const sim = build(); - meltIn(sim, 20000); + meltIn(sim, 19400); + // The whole point: the head of the chain never WEDGES. It back-pressures transiently as + // the press/assembly loop cycles (~18% of ticks, mites or no mites — measured), so a + // single-tick sample is a coin-flip; a wedge (round 1) would be ~100% stuck. Sample a + // window and assert it flows the large majority of the time. + let flowing = 0; + for (let i = 0; i < 600; i++) { + sim.tick(); + sim.drainEvents(); + if (sim.snapshot().entities.find((e) => e.def === 'demuxer')!.jammed !== 'output full') flowing++; + } + expect(flowing).toBeGreaterThan(300); // majority flowing = not wedged 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); diff --git a/fktry/src/sim/reference.ts b/fktry/src/sim/reference.ts index 14607a0..7ea5bbe 100644 --- a/fktry/src/sim/reference.ts +++ b/fktry/src/sim/reference.ts @@ -29,9 +29,25 @@ * (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. @@ -180,6 +196,20 @@ export function referenceFactory(data: GameData): Command[] { 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,