[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:
parent
be52b4075b
commit
68bddbec0a
@ -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).
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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), size≥1 = 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
258
fktry/src/sim/mite.test.ts
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user