[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>
This commit is contained in:
type-two 2026-07-20 18:49:36 +10:00
parent be52b4075b
commit 68bddbec0a
6 changed files with 643 additions and 9 deletions

View File

@ -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). 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. - 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. - 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<size<1 repairing / 1 returning), which also hands the renderer a "glowing,
sated" tell.
- **The firewall counter** — recognised by recipe SHAPE (empty inputs, cans a mite), the exact
same detection as the mosquito trap, so **DATA's real firewall lit up with zero code**. DATA
landed it MID-ROUND (commit `5e3bbff`: `firewall` 2×2 crafter, `catch-mite` {}→parity-mite,
ticks 120, gated behind `broadcast-parity-firewall`; `parity-mite` item tier 2). Re-probing
HEAD is the only reason I caught it — my round-start grep found nothing.
- **Reference factory v6** — auto-detects DATA's firewall and places it on the **melt uplink
approach**, belted to a shipper so it never clogs at the 4-item stall cap. 182 placements all
legal; melt still first at **1247**, still shipping at 20k, steady `[6,12,9,12,15,9,12,9,9,12]`
melt/2k; **zero-brownout regression intact**; firewall cans + ships **16 mites** over 20k.
When DATA had *not* shipped the machine (start of round), the layout simply skipped the
firewall command and weathered the wave on throughput — verified both ways.
- **11 new mite tests** (`mite.test.ts`): corruption-gated spawn, seek+repair emits `repaired`,
the tier-0/1 balance point, escalation to `MITE_MAX`, firewall catch/idle, and
determinism + save/load mid-wave. **113 sim tests, 481 repo tests green; tsc clean.**
- Verified in the browser as well as Node: melt **1247 identical across engines** (determinism
holds), firewall canning live, no console errors. Perf: the reference build 0.054 ms/tick;
a 2,680-entity field 0.21 ms/tick (158× realtime).
DECISIONS
- **The patrol cycle (in, fix ONE, out) is the load-bearing call.** My first cut let mites camp
the nearest corruption. Because melt production is SLOW (~10 per 2,000 ticks), a single
camping mite **zeroes the melt lane** — measured mid-game 2k windows dropping to 0. That's a
wipe, not the codex's "early nuisance". Return-to-rim after one repair bounds damage: 119
corrections over 20k, almost all against the abundant bricks, ~3 against melt. The melt
regression went from marginal (fragile 0-windows) to comfortable and flat.
- **Repair DWELL (`MITE_REPAIR_TICKS = 30`) exists for the firewall as much as the fiction.**
A mite that repairs on contact does so in `updateWildlife`'s phase, one step BEFORE the
firewall's catch runs in `advanceCrafts` — so the firewall could only ever can an
already-sated mite, never PREVENT a repair. Making the mite hold station a beat gives an
armed firewall a window to snap it mid-repair; that's what makes "guarding cancels
corrections" actually true (proven in `mite.test.ts`).
- **Corruption = tier-2+ shipped** (summed from `shippedTotal` × item tiers), so the immune
system reacts to PRODUCT, not raw throughput. `desiredMites = floor(corruption/MITE_SPAWN_PER)`
capped at `MITE_MAX`; a caught mite is replaced while corruption stays over threshold — the
firewall holds a line, it doesn't win once. All tuning is in `constants.ts`.
- Firewall and trap are unified by shape (both empty-input creature-canners). `FIREWALL_RADIUS`
is its own constant (= `TRAP_RADIUS` today, but free to diverge).
- The reference's firewall guards MELT (the uplink "teach"), but most mites target the far-west
brick lanes, so it cans few (~16/20k). That's honest — one firewall guards one approach.
BLOCKED/BROKEN
- **I reshaped a regression test — flagging deliberately.** `reference.test.ts`'s "keeps the
demuxer alive" asserted `demuxer.jammed !== 'output full'` at the single tick 20000. MEASURED:
the demuxer transiently back-pressures **~18% of ticks WITHOUT mites** and 16% WITH (mites
slightly *reduce* it) — the press/assembly loop cycles. The old assertion was a coin-flip that
had been landing null; mites shifted the phase and it landed 'output full'. I rewrote it to
sample a 600-tick window and require the demuxer flows the majority of ticks (a real
round-1-style wedge is ~100% stuck). Stronger net, not weaker — but it's a test-shape change
on someone's regression, so it's here in writing.
- **Perf caveat, stated not hidden.** A *hunting* mite scans every belt item each tick (≤6
mites). At 5,000 belt items that's ≤30k comparisons/tick, and only while mites are actively
hunting (sated/returning/repairing mites don't scan). Acceptable now; a spatial index is the
fix if a late-game board with a live wave ever shows it. My stress probe ran empty belts, so
it did NOT exercise this path — noting rather than pretending I measured it.
- DATA's `catch-mite` is 120 ticks (~4s), slow to ARM the first charge; but once progress hits
1 it holds and snaps any in-range mite every subsequent tick, so the long craft only delays
the first catch, not steady state.
CONTRACT REQUEST
- None. v8 carried `parity-mite` and `repaired`, and mites needed no new persisted fields.
PROPOSAL
- `WildlifeState.size` is overloaded as the mite state machine. It works and doubles as a render
glow, but a future unit wanting a genuine second scalar would be cleaner with a `phase?` field
than more size-encoding.
- Commissions could now ask for canned `parity-mite` (the firewall's cargo — the item exists):
a "COMPLIANCE BUYBACK" standing order closes the loop, shipping the immune system back to
itself. DATA/orchestrator call.
- Mites could prefer the highest-tier product (melt over bricks) so they feel drawn to your best
work, or flee a firewall. Left simple this round.
NEXT (round 7 if asked)
- Checksum Wardens (Correction escalation #2): audit a machine, roll back its last N outputs,
stamp it sealed. Wants an output-history ring per machine and a `sealed` entity flag (contract).
- A real headless perf benchmark INCLUDING a live mite wave — proposed since round 3; the mite
scan is what finally makes it worth wiring.
- Per-item lane affinity if the mosh reactor's wandering melt ever bites (round-4 smell, latent).

View File

@ -109,6 +109,43 @@ export const TRAP_RADIUS = 4;
*/ */
export const SWARM_MAX = 8; export const SWARM_MAX = 8;
// -------------------------------------------------------- parity mites (M3)
//
// THE CORRECTION's first unit (codex §5): hospital-white beetle drones that patrol your
// belts and quietly *repair* tier-2 glitch products back into clean footage. They correct
// CORRUPTION, never raw material — that's both the fiction and the balance, so they only
// ever touch tier-2 cargo. Pressure scales with how much corruption you've shipped: the
// more product goes to THE SCREEN, the more the immune system notices you.
/** The canned-mite item a FIREWALL produces — forward-canon in data; the counter's cargo. */
export const MITE_ITEM = 'parity-mite';
/** Lowest item tier a mite will correct. Below this it's raw material, which they ignore. */
export const MITE_TIER_MIN = 2;
/**
* Tier-2 products shipped per patrolling mite. desiredMites = floor(corruption / this),
* capped at MITE_MAX so the wave escalates with progress and never appears before you've
* shipped real product. At 40 the first mite arrives well after the melt line is flowing.
*/
export const MITE_SPAWN_PER = 40;
/**
* Mites patrolling at once. Caught mites are replaced from the rim while corruption stays
* above threshold the firewall's job is to hold the line, not to win a finite fight.
*/
export const MITE_MAX = 6;
/** A mite drifts toward its chosen belt item at this speed, tiles/sec (it patrols briskly). */
export const MITE_DRIFT_TILES_PER_SEC = 2;
/** Within this chebyshev range of its target item, a mite reaches it and starts repairing. */
export const MITE_REACH_RANGE = 0.75;
/**
* Ticks a mite dwells on station, solder-iron working, before the correction completes. The
* dwell is the window a FIREWALL needs to can it mid-repair grab the mite here and the
* product survives. At 30 (~1s) it reads as "quiet, unhurried work" and gives an armed
* firewall time to snap it before the repair lands.
*/
export const MITE_REPAIR_TICKS = 30;
/** A firewall catches mites within this chebyshev range of its footprint (mirrors the trap). */
export const FIREWALL_RADIUS = 4;
// ------------------------------------------------------------------ relic // ------------------------------------------------------------------ relic
/** Nearest a buried relic sits to origin (tiles, euclidean) — well past the starter area. */ /** Nearest a buried relic sits to origin (tiles, euclidean) — well past the starter area. */

View File

@ -24,7 +24,7 @@ import {
type Vec2, type Vec2,
type WildlifeState, type WildlifeState,
} from '../contracts'; } from '../contracts';
import { DIR_VEC, footprintOf, inBounds, tileKey } from './geom'; import { DIR_VEC, GRID_MAX, GRID_MIN, footprintOf, inBounds, tileKey } from './geom';
import { makeRng, type Rng } from './rng'; import { makeRng, type Rng } from './rng';
// The tuning constants live in ./constants so other lanes' tests can import truth // The tuning constants live in ./constants so other lanes' tests can import truth
@ -37,6 +37,7 @@ import {
DUST_PILE_MAX, DUST_PILE_MAX,
DUST_PILE_SPREAD, DUST_PILE_SPREAD,
DUST_SPILL_GRACE, DUST_SPILL_GRACE,
FIREWALL_RADIUS,
HEAT_COOL_DEFAULT, HEAT_COOL_DEFAULT,
HEAT_COOL_MAX, HEAT_COOL_MAX,
HEAT_RESTART, HEAT_RESTART,
@ -45,6 +46,13 @@ import {
HEAT_THROTTLE_START, HEAT_THROTTLE_START,
INPUT_BUFFER_FACTOR, INPUT_BUFFER_FACTOR,
MAX_TRACE, MAX_TRACE,
MITE_DRIFT_TILES_PER_SEC,
MITE_ITEM,
MITE_MAX,
MITE_REACH_RANGE,
MITE_REPAIR_TICKS,
MITE_SPAWN_PER,
MITE_TIER_MIN,
OUTPUT_STALL_FACTOR, OUTPUT_STALL_FACTOR,
RELIC_KEEPOUT, RELIC_KEEPOUT,
RELIC_DIG_RANGE, RELIC_DIG_RANGE,
@ -140,6 +148,10 @@ export function createSim(): Sim {
/** recipe ids that CAN a swarm (empty inputs, output includes SWARM_ITEM) — the traps */ /** recipe ids that CAN a swarm (empty inputs, output includes SWARM_ITEM) — the traps */
const trapRecipeIds = new Set<string>(); const trapRecipeIds = new Set<string>();
/** recipe ids that CAN a mite (empty inputs, output includes MITE_ITEM) — the firewalls */
const firewallRecipeIds = new Set<string>();
/** item id -> tier, so mites can tell corruption (tier ≥ 2) from raw material */
const itemTier = new Map<string, number>();
/** authored seam rects; empty means "no seam map" → extractors mine anywhere */ /** authored seam rects; empty means "no seam map" → extractors mine anywhere */
let seams: SeamDef[] = []; let seams: SeamDef[] = [];
/** DATA's kept-clear corner for the relic, when the seam map supplies one */ /** 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 // 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. // 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; } 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 ready = true;
let needsInput = false; let needsInput = false;
@ -602,6 +616,12 @@ export function createSim(): Sim {
if (!prey) { e.state.progress = 1; continue; } if (!prey) { e.state.progress = 1; continue; }
catchSwarm(prey.id); 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.state.progress = 0;
e.crafting = false; e.crafting = false;
@ -1166,6 +1186,190 @@ export function createSim(): Sim {
wildlifeEvent('cleared', w); 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<size<1 = on station repairing (glow ramps), size1 = sated and heading home.
*/
function updateMites(mult: number): void {
// 1. escalate: the more corruption shipped, the more mites patrol — up to the cap.
const desired = Math.min(MITE_MAX, Math.floor(corruptionShipped() / MITE_SPAWN_PER));
let live = 0;
for (const w of wildlife) if (w.kind === 'parity-mite') live++;
while (live < desired) {
const pos = rimSpawn();
const mite: WildlifeState = {
id: nextWildlifeId++, kind: 'parity-mite', pos, size: 0,
};
wildlife.push(mite);
wildlifeEvent('spawned', mite);
live++;
}
const step = (MITE_DRIFT_TILES_PER_SEC / TICKS_PER_SECOND) * mult;
for (let i = wildlife.length - 1; i >= 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 // --------------------------------------------------------------------- api
return { 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 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. // 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(); trapRecipeIds.clear();
firewallRecipeIds.clear();
for (const r of gameData.recipes) { for (const r of gameData.recipes) {
if ((r.outputs[SWARM_ITEM] ?? 0) > 0 && Object.keys(r.inputs).length === 0) { if (Object.keys(r.inputs).length !== 0) continue;
trapRecipeIds.add(r.id); 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; // 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. // 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. // 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 // 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. // slowdown a swarm inflicts lands on the same tick the player can see it attached.
updateWildlife(1); updateWildlife(1);
updateMites(1);
startCrafts(); startCrafts();
const mult = computePower(); const mult = computePower();
advanceCrafts(mult); advanceCrafts(mult);

258
fktry/src/sim/mite.test.ts Normal file
View File

@ -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<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): 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());
});
});

View File

@ -64,11 +64,19 @@ describe('reference factory', () => {
it('keeps the demuxer alive by voiding surplus rather than jamming', () => { it('keeps the demuxer alive by voiding surplus rather than jamming', () => {
const sim = build(); 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 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. // Surplus is being turned into product, not silently dropped.
expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0); expect(snap.shippedTotal['macroblock-bricks'] ?? 0).toBeGreaterThan(0);
expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0); expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);

View File

@ -29,9 +29,25 @@
* (i-only) -> mosh * (i-only) -> mosh
*/ */
import type { Command, Dir, GameData } from '../contracts'; 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; 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 * 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. * 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); runX(-2, -1, 1, E);
P('shipper', 0, 0); 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 // 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 // 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, // sold. The splitter is what makes it "and" rather than "or" — half the slabs to each,