diff --git a/fktry/lanes/LANE-SIM.md b/fktry/lanes/LANE-SIM.md index 992d71f..f31e967 100644 --- a/fktry/lanes/LANE-SIM.md +++ b/fktry/lanes/LANE-SIM.md @@ -699,3 +699,142 @@ NEXT (round 7 if asked) - 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). + +### Round 7 — 2026-07-29 — Opus 5 + +SHIPPED +- **PARITY DEBT** — one scalar, five bands, thresholds + hysteresis in `constants.ts`. Rises on + tier>=2 shipments (per-item tier from DATA), decays ALWAYS, decays faster while tier-0/1 + material ships (banked "clean credit", spent at a capped rate), and a `compliance:true` + commission vents `DEBT_VENT_COMPLIANCE` outright. `debtBand` fires on edges only. +- **CHECKSUM WARDEN** (band>=2) — `notice` 20s ahead of anything, then walks in from the rim to + the single most-deviating machine, audits it with visible phase+progress, rolls back the last + N outputs from a new per-machine output ring, stamps `sealed`, leaves. A sealed machine + refuses to run (`jammed: 'sealed'`). Counters: a firewall on the approach, or `pry` — 90 + ticks, clears the seal, ADDS debt. +- **REDUNDANCY GUNSHIP** (band>=3) — picks the 8x8 sector with the most DEVIATION (not + throughput), tethers to the PARITY ANCHOR, publishes `rect`; corrupt crafts inside feed it and + it emits `mirrored`. Starve/seal/idle/remove the anchor and the tether breaks. +- **HASH AUDITOR** (band 4) — `notice`, visible 5s PRESCAN, then `stasis` with a deterministic + `lockHash`; entities, belts, splitters, shippers, labs, heat AND wildlife all stop inside the + rect. Auto-release is unconditional. Counters: ABSTENTION (nothing crafted during prescan) and + FORGED HASH (DATA's `forged-hash` held in any machine in the rect). +- **Reference factory v7** (208 entities, 208/208 placements legal): the COMPLIANCE TAP (its own + extractor + splitter — half the ore ships raw, half is demuxed and the luma ships), a CANNING + LINE off A1's dust (`can-static`), the brick works DOUBLED behind a splitter, a brick-works + guard firewall, and 8 ASICs to pay for it all. +- **Save v5** carries debt, clean credit, the band latch, every unit with its bookkeeping, seals, + pry timers, output rings and deviation counters. **testkit** gains `setParityDebt`, + `chargeDebt(band)` and `spawnCorrectionUnit` so no lane hand-edits my blob again. +- Contracts conformance as they landed mid-round: v9.1 phase union, v9.2 `remaining` (filled for + every fixed-length phase; a walk reports distance/speed; unbounded phases leave it undefined), + v9.3 `notice.pos` and `by` on `repaired`/`mirrored` (mites included). +- **Verified**: `npm run check` clean (whole repo). **645/645 tests green** (33 new in + `correction.test.ts`). Live on **8151**: app boots, zero console errors, the demo builds and + renders 208 entities, the fax reads `MDAT ORE 0/20` with `NEXT IN TRAY: LUMA BARS 15` — both + only reachable because of the v7 tap — and `199 DRAW / 300 GEN` is the new power farm. +- **Determinism holds across engines, byte-identical.** The same 20k reference run in the browser + and in Node both give: bands `1@2882 2@4840 3@6730 4@9444 3@11674 4@13294`, 9 warden / 6 gunship + / 6 auditor notices, 5 stasis locks, 14 mirrors, 0 seals, 0 brownouts, 78 melt, and the queue + `raw-payload → first-refinement → dust-quota → first-taste → dust-allowance → quota-7c → + patio-resurfacing`. **The debt valve is visible in that band list**: filing quota-7c drops the + demo from band 4 to band 3 at t11674, and it climbs back by t13294. +- Perf: the reference build **0.0526 ms/tick (633x realtime)** — round 6 measured 0.054, so all of + v9 is free on the demo. A synthetic 878-entity / 1,624-belt-item field runs **0.443 ms/tick + (75x)**. + +DECISIONS +- **A FIREWALL CATCHES WHAT WALKS.** This is the round's biggest call and it contradicts one line + of the orders, so it is here in full. The orders say a firewall may can a gunship. LANE-UI then + measured what that means in play: gunships arrive over their own sector, get canned inside their + notice window, and `mirrored` had NEVER fired in 12,000 ticks of real play — a whole rung + deleted by a 2x2 machine the player already owns. I hit the same wall from the other side (I + could not find a single free tile in the brick works that guards the wardens without also + covering the gunship's station; the works is eight tiles wide). So: mites and wardens walk and + are cannable; the GUNSHIP is airborne and is not, and its counter is the codex's own — "kill the + parity link to pop it"; the AUDITOR moves only between frames and was never present. Pinned by + `RUNG 3 IS NOT INVISIBLE`, which requires a mirror in the demo **as shipped, firewalls and all**. +- **Nothing is cannable during `notice`.** The paperwork is not yet actionable. Without this the + fax lead time is just a longer window in which to shoot the fax. +- **The Correction rolls no dice.** Every unit enters at the nearest rim to its target; the + airborne two take station when the notice closes. Zero RNG consumed, so wildlife and mites keep + the exact spawn stream they had in round 6 and a warden cycle is reproducible from a save alone. +- **A warden's rollback emits `repaired`**, with `by`. It is the same class of theft as a mite's + and SCREEN already subtracts it from the sky ledger; forking a new event for it would have made + two code paths for one idea. Flagging it because it means "CORRECTED" ticker counts now include + audits — say the word if you want them split. +- **`mirrored` steals from the ledger, not from the belt.** Nothing is removed from the world: the + clean parallel copy out-ships you, and being out-shipped by yourself is the damage. One event = + one unit, per SCREEN's note; a bigger theft would be N events. +- **Sealing abandons the in-flight craft.** The inputs were already spent, which is what makes an + audit sting, and it frees `state.progress` to drive the pry bar unambiguously. +- **Sealed machines are invisible to targeting**, or the next warden walks back to audit an empty + buffer forever. **Creature-canner recipes accrue no deviation** — without that a firewall earned + DEVIATION from its own kills until it became the top deviator and got sealed by a warden. Very + funny, entirely wrong; it broke two round-6 mite tests, which is how I found it. +- **A frozen machine keeps its generation and its idle draw.** Making a lock brown out the grid + would turn a refusal into an attack, which is the one thing The Correction never does. +- **`DEBT_CLEAN_CREDIT_PER_ITEM` cut 0.0006 → 0.0002.** At parity the demo's own byproduct stream + (1,282 dust + 144 slurry, all of it stuff you are dumping rather than choosing) bought 0.89 of + credit and pinned the ladder at band 2 forever. Any factory with a fat byproduct lane was + permanently immune without the player deciding anything. The quota vent is the real lever now. +- **`referenceFactory` now throws on overlapping placements.** `P()` returns `placed+1`, which is + only the sim's id while every placement SUCCEEDS — a dropped one shifts every later id and + silently re-points every `setRecipe`. That is not hypothetical: it happened twice while I was + adding the guard, and the symptom was the annex mis-reciping itself 40 tiles away. Round 2 + flagged the coupling; this is the tripwire, and it caught the next two collisions by name. + +BLOCKED/BROKEN +- **The demo never actually lands a seal** — all nine wardens were canned by the two firewalls. So + the shipped demo does not exercise the seal, the pry, or the redundancy that absorbs a seal. + Those are proven instead by `survives a real warden seal`, which builds the same layout with the + guard lifted. Honest, but it means "one full warden cycle at 20k" is a canned cycle, not a + completed audit. +- **The doubled brick works absorbs ONE seal on the melt path, not a series.** Measured: with both + crime quantizers sealed, coefficient packs back up into the demuxer and melt freezes exactly + like round 1. An undefended factory therefore still degrades toward death under sustained + audits — self-limiting (deviation stops, debt decays, wardens stand down) but permanent, since + nothing unseals without a player. The real fix is a surplus VOID behind the splitter; I left it + out because a blind splitter would sell a third of the coefficient packs in the healthy case. + Your call whether that trade is worth it. +- **The browser pane cannot advance the live loop.** `visibilityState` is `hidden`, rAF is + throttled to nothing and the app's tick froze at 7/14. This is round 1's documented constraint, + not new. I tried shimming rAF and lost the race against boot twice. What I verified instead is + round 1's own technique: importing the real sim into the real browser engine, which gave the + byte-identical 20k fingerprint above. The parts of the live app I could confirm are boot, zero + console errors, the render of all 208 entities, and UI reading my commission + power numbers. +- **I did not get a clean perf number at the full 1,000-entity / 5,000-item target with a live + Correction wave.** My dense stress field mis-placed its extractors and ran with 0 belt items, + and the 878/1,624 field never dispatched a unit because nothing in it had tier-2 output. The + headless benchmark I have now proposed four rounds running would have answered this in a line. +- The commission ladder advances 7 rungs and then stalls on `patio-resurfacing`. Rungs 1-6 are the + ones that mattered this round (the valve is rung 6); I did not chase the rest. +- Units require DEVIATION ON THE FLOOR, not just debt: `chargeDebt(4)` on a world with no tier-2 + producer dispatches nothing. Correct, but it surprised me in my own stress test, so it will + surprise UI too. + +CONTRACT REQUEST +- **A jam-reason line for `'sealed'`** in CONTRACTS, alongside v6's `'no seam'`. I set + `jammed: 'sealed'` so UI's jam line works without special-casing, and the string is now load- + bearing for three lanes. +- **An event for a CANNED Correction unit** — something like + `{ kind: 'unitCleared'; unit; id; pos; tick }`. Right now a unit simply vanishes from + `correction.units`, so the firewall's single most satisfying moment — the thing the whole guard + exists for — has no hook for SCREEN, AudioFX or RENDER, and in the shipped demo it happens nine + times and is completely silent. This is the one gap I would fix first. + +PROPOSAL +- The `DEBT_*` constants could go data-driven if DATA wants to tune the ladder without a code + round; they are all in `constants.ts` and none is referenced by shape. +- A canned WARDEN currently ships as a `parity-mite` item, because the firewall's recipe is what + produces the cargo. A distinct item (and a commission for it) would be free comedy. +- Mites now ignore cargo under a stasis lock, so a lock is genuine cover from rung 1. Emergent, + not designed — worth knowing before someone "fixes" it. + +NEXT (round 8 if asked) +- The headless perf benchmark. Proposed round 3, 4, 5, 6. It would have answered two questions + this round and it is the only reason my perf numbers are still anecdotes. +- A surplus void for the brick works, if you want an undefended factory to survive a seal series. +- `unitCleared`, the moment the contract has it. +- Moov heists: the auditor already publishes a deterministic `lockHash` that survives save/load, + which is the whole hook the minigame needs. diff --git a/fktry/src/sim/constants.ts b/fktry/src/sim/constants.ts index 67ba5d0..0776da0 100644 --- a/fktry/src/sim/constants.ts +++ b/fktry/src/sim/constants.ts @@ -146,6 +146,133 @@ 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; +// ------------------------------------------- THE CORRECTION: PARITY DEBT (M3) +// +// One scalar drives the whole escalation ladder. Debt is not anger — The Correction +// cannot perceive you (codex §5), only deviation. Ship corruption and the deviation +// figure climbs; ship boring tier-0/1 material and it is amortised away faster. Five +// bands gate which rungs are dispatched, and the band is the only thing the rest of +// the sim asks about. + +/** Lowest item tier that counts as DEVIATION. Below this you are shipping stock footage. */ +export const DEBT_TIER_MIN = 2; +/** + * Debt added per tier>=2 item shipped. At 0.0025, 80 corrupt shipments walk a quiet + * factory from NOMINAL to REFUSED — but nothing is quiet, so decay and clean shipping + * both pull the other way and the real curve is a negotiation, not a countdown. + */ +export const DEBT_PER_CORRUPT_SHIP = 0.0025; +/** + * Baseline amortisation per tick, applied ALWAYS. Over a 20,000-tick session this is + * 0.4 of debt shed for free — enough that an idle factory always drifts back to + * NOMINAL, never enough to outrun a working one. + */ +export const DEBT_DECAY_PER_TICK = 0.00002; +/** + * Debt a single tier-0/1 shipment buys back — deliberately about a TWELFTH of what one + * corrupt shipment costs. Measured at parity-ish (0.0006): the reference factory's own + * byproduct stream (1,282 hf dust + 144 slurry over 20k, all of it stuff you are dumping + * rather than choosing) bought 0.89 of credit and pinned the whole ladder at band 2 + * forever. Any factory with a fat byproduct lane would have been permanently immune to + * escalation without the player ever deciding anything, which is not a valve, it's a + * loophole. Passive dumping still helps a little; the real lever is the quota below. + */ +export const DEBT_CLEAN_CREDIT_PER_ITEM = 0.0002; +/** Credit cannot bank forever — hoarding clean shipments to pre-pay an audit is not a plan. */ +export const DEBT_CLEAN_CREDIT_MAX = 0.25; +/** + * Ceiling on how fast banked clean credit is spent. Without it, one enormous clean + * shipment would vent the whole ladder in a single tick and the bands would strobe. + */ +export const DEBT_CLEAN_DECAY_MAX_PER_TICK = 0.0004; +/** + * Completing a `compliance:true` commission vents this outright. It is deliberately + * worth more than the material: the paperwork is the point, not the luma. + */ +export const DEBT_VENT_COMPLIANCE = 0.2; +/** Prying a compliance seal off adds this. You defaced a notice; that is itself deviation. */ +export const DEBT_PER_PRY = 0.05; +/** + * Band floors: index i is the debt at which band i+1 begins. + * 0 NOMINAL | 1 OBSERVED | 2 AUDITED (wardens) | 3 SEALED (gunships) | 4 REFUSED (auditors) + */ +export const DEBT_BANDS = [0.2, 0.4, 0.6, 0.8] as const; +/** + * Bands drop late by this margin — the same hysteresis idea as heat's scram/restart. + * Debt hovering exactly on a threshold would otherwise emit `debtBand` every few ticks + * and strobe the UI gauge for the rest of the session. + */ +export const DEBT_BAND_HYSTERESIS = 0.03; + +// ----------------------------------------------- THE CORRECTION: units (rungs 2-4) +// +// Arrival is NOT random. Every unit's entry point is a pure function of the target it +// was dispatched to (nearest rim for a warden, the sector itself for the airborne two), +// so the whole subsystem is reproducible without touching the RNG stream — which also +// means wildlife and mites keep the exact spawn positions they had before rung 2 existed. + +/** Ticks between a rung leaving (or being canned) and the same rung being dispatched again. */ +export const CORRECTION_RESPAWN_TICKS = 900; + +/** + * The fax lead time. `notice` is emitted the tick a unit is dispatched and nothing + * happens for 20 seconds — fax-first is contractual, and this is the window the player + * has to seal up, abstain, or arm a firewall. + */ +export const WARDEN_NOTICE_TICKS = 600; +/** Warden walking speed, tiles/sec. Heavier and more patient than a mite. */ +export const WARDEN_WALK_TILES_PER_SEC = 1.2; +/** Chebyshev range at which a warden is close enough to begin the audit. */ +export const WARDEN_REACH_RANGE = 1; +/** Ticks the audit takes, phase progress running 0..1. Visible, interruptible work. */ +export const WARDEN_AUDIT_TICKS = 120; +/** Outputs rolled back when the audit lands — "the last N outputs" of codex §5. */ +export const WARDEN_ROLLBACK_N = 4; +/** Size of the per-machine output ring buffer the rollback reads. */ +export const WARDEN_OUT_HISTORY = 8; +/** + * Ticks of prying to get a compliance seal off. At 90 (~3s) it is a deliberate act with + * a cost attached (DEBT_PER_PRY), not a click — the cheap answer is a firewall. + */ +export const PRY_TICKS = 90; + +/** Sector edge, in tiles. The world is 64 wide, so the grid is a clean 8x8 of these. */ +export const GUNSHIP_SECTOR = 8; +/** Gunship fax lead time. */ +export const GUNSHIP_NOTICE_TICKS = 450; +/** + * Mirror progress per unit of DEVIATION crafted inside the tethered sector. At 0.1 a busy + * sector loses a mirrored unit roughly every ten corrupt crafts — the parallel copy keeps + * pace with you, which is the horror. A sector busily making ore is never mirrored at all: + * the gunship corrects corruption, not productivity. (Measured at 0.04 the tether was + * routinely broken before the first mirror ever landed, which made the rung invisible.) + */ +export const GUNSHIP_PROGRESS_PER_CRAFT = 0.1; +/** + * Ticks the PARITY ANCHOR may go without crafting before the tether breaks. Starving, + * sealing, idling or removing the anchor all reduce to this one measurement. + */ +export const GUNSHIP_ANCHOR_IDLE_TICKS = 300; + +/** Hash auditor fax lead time. */ +export const AUDITOR_NOTICE_TICKS = 600; +/** The visible PRESCAN — 5 seconds of being read. Craft during it and it finds you. */ +export const AUDITOR_PRESCAN_TICKS = 150; +/** + * Stasis duration. THIS IS A SAFETY, NOT A TUNING KNOB: the lock ALWAYS releases after + * this many ticks whether or not the player does anything at all. Contracts v9 makes + * auto-release contractual, and `correction.test.ts` pins it — a change that makes + * stasis escapable only by player action is a bug, however clever. + */ +export const AUDITOR_STASIS_TICKS = 450; +/** + * The material counter (DATA authors the recipe: two inputs on the hex-splicer). Holding + * one of these in any machine inside the prescan rect voids the audit outright — the + * auditor reads a signature that checks out and files nothing. Absent from the data, the + * counter simply doesn't exist yet and abstention is the only answer. + */ +export const FORGED_HASH_ITEM = 'forged-hash'; + // ------------------------------------------------------------------ relic /** Nearest a buried relic sits to origin (tiles, euclidean) — well past the starter area. */ diff --git a/fktry/src/sim/correction.test.ts b/fktry/src/sim/correction.test.ts new file mode 100644 index 0000000..f754306 --- /dev/null +++ b/fktry/src/sim/correction.test.ts @@ -0,0 +1,883 @@ +/** + * THE CORRECTION, rungs 2-4 (contracts v9) — PARITY DEBT, checksum wardens, redundancy + * gunships, hash auditors. + * + * The design points these tests exist to protect: + * - DEBT is a ledger, not a timer. It is earned by shipping DEVIATION (tier>=2), amortised + * always, and vented deliberately by filing the enemy's own paperwork. Bands gate the + * ladder and nothing else in the sim needs to know more than the band. + * - Fax first, always. Every rung emits `notice` before it does anything, for every unit, + * with no exceptions — the warning is the contract. + * - A sealed machine is DEAD until pried, and prying costs debt. Both halves matter: a seal + * you can ignore is set dressing, and a pry that is free makes the seal a click. + * - THE AUDITOR'S AUTO-RELEASE IS A SAFETY. It lifts after AUDITOR_STASIS_TICKS whether or + * not the player does anything at all. There must be no way to make a lock permanent, + * however clever, and the test at the bottom of this file is the thing standing in the way. + * + * Fixture-based, like mite.test.ts: the sim keys off item TIER and recipe shape rather than + * ids, so a small honest factory proves the mechanics and the real data drops straight in. + * The reference-factory tests at the end are the same mechanics against DATA's real tree. + */ +import { describe, expect, it } from 'vitest'; +import { createSim } from './index'; +import { referenceFactory, referenceFactoryTech, firewallInData } from './reference'; +import { grantResearch, setParityDebt } from './testkit'; +import { + AUDITOR_NOTICE_TICKS, + AUDITOR_PRESCAN_TICKS, + AUDITOR_STASIS_TICKS, + DEBT_BANDS, + DEBT_PER_CORRUPT_SHIP, + DEBT_PER_PRY, + DEBT_VENT_COMPLIANCE, + FORGED_HASH_ITEM, + GUNSHIP_NOTICE_TICKS, + PRY_TICKS, + WARDEN_NOTICE_TICKS, + WARDEN_ROLLBACK_N, +} from './constants'; +import type { + Command, CorrectionUnit, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, +} from '../contracts'; + +import items from '../../data/items.json'; +import machines from '../../data/machines.json'; +import recipes from '../../data/recipes.json'; +import tech from '../../data/tech.json'; +import commissions from '../../data/commissions.json'; + +const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3; + +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 (stock footage — buys debt credit). product = tier-2 (DEVIATION — earns debt). + * The splicer makes a FORGED HASH from two inputs, exactly the shape DATA authored on the + * hex-splicer, and exactly the shape the empty-inputs catcher path cannot see. + */ +const FIXTURE: GameData = { + items: [item('raw', 0), item('product', 2), item(FORGED_HASH_ITEM, 3), item('parity-mite', 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'] }), + mach({ id: 'split', kind: 'splitter' }), + mach({ id: 'splicer', kind: 'crafter', recipes: ['splice'] }), + 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: 'splice', machine: 'splicer', inputs: { raw: 1, product: 1 }, + outputs: { [FORGED_HASH_ITEM]: 1 }, ticks: 8, + }), + rec({ id: 'burn', machine: 'firewall', inputs: {}, outputs: { 'parity-mite': 1 }, ticks: 20 }), + ], + tech: [], + commissions: [{ id: 'open', flavor: 'anything', wants: { product: 999999 }, rewardBandwidth: 1 }], +}; + +function newSim(seed = 1, data: GameData = FIXTURE): Sim { + const sim = createSim(); + sim.init(data, 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); + } +} +/** Runs until `done()` or the budget expires; returns the tick it stopped on. */ +function runUntil(sim: Sim, budget: number, done: () => boolean, sink?: SimEvent[]): number { + for (let i = 0; i < budget; i++) { + if (done()) return sim.snapshot().tick; + run(sim, 1, sink); + } + return done() ? sim.snapshot().tick : -1; +} + +const units = (sim: Sim): ReadonlyArray => sim.snapshot().correction!.units; +const unitOf = (sim: Sim, kind: CorrectionUnit['kind']): CorrectionUnit | undefined => + units(sim).find((u) => u.kind === kind); +const debtOf = (sim: Sim): number => sim.snapshot().correction!.debt; +const entOf = (sim: Sim, def: string) => sim.snapshot().entities.find((e) => e.def === def)!; + +/** + * A small works that ships tier-2 product, entirely inside ONE 8x8 sector (x 0..7, y 0..7 — + * the sector grid is world-aligned from -32). Keeping it in one sector is what lets the + * gunship and auditor tests reason about "inside the rect" without guessing. + */ +function corruptionWorks( + sim: Sim, opts: { firewall?: boolean; splicer?: boolean; catchSink?: boolean } = {}, +): void { + sim.enqueue(place('gen', 0, 20)); // power lives outside the sector + 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('belt', 4, 0, E)); + if (opts.splicer) { + // A splitter taps product off the main line and a second mine supplies raw, so the + // splicer's TWO-input recipe actually runs. That shape matters: DATA's real forged hash + // is 1 stco-map + 50 hf-dust, and a recipe with inputs is invisible to the empty-inputs + // catcher path — the forged hash is found by ITEM, in a machine's buffers, not by shape. + sim.enqueue(place('split', 5, 0)); + sim.enqueue(place('belt', 5, 1, S)); + sim.enqueue(place('belt', 5, 2, S)); + sim.enqueue(place('belt', 5, 3, W)); + sim.enqueue(place('mine', 0, 3)); + sim.enqueue(place('belt', 1, 3, E)); + sim.enqueue(place('belt', 2, 3, E)); + sim.enqueue(place('belt', 3, 3, E)); + // No belt leaves the splicer, so the forged hash stays in its output buffer, held + // inside the sector — which is exactly the state that voids an audit. + sim.enqueue(place('splicer', 4, 3)); + } else { + sim.enqueue(place('belt', 5, 0, E)); + } + sim.enqueue(place('belt', 6, 0, E)); + sim.enqueue(place('ship', 7, 0)); + if (opts.firewall) { + sim.enqueue(place('firewall', 3, 2)); + // The catch has to GO somewhere. A firewall with nowhere to put its cargo fills to the + // stall cap after four catches and quietly stops catching anything ever again — which + // is how the first cut of this fixture "proved" that a guard doesn't stop a warden. + // (catchSink:false deliberately reproduces that, for the one test that needs a firewall + // which catches but ships nothing.) + if (opts.catchSink !== false) { + sim.enqueue(place('belt', 2, 2, W)); + sim.enqueue(place('ship', 1, 2)); + } + } +} + +// ============================================================== PARITY DEBT + +describe('PARITY DEBT — the ledger', () => { + it('rises on tier>=2 shipments and not on tier-0/1 ones', () => { + const sim = newSim(); + corruptionWorks(sim); + run(sim, 400); + const shipped = sim.snapshot().shippedTotal['product'] ?? 0; + expect(shipped, 'nothing shipped, so this proves nothing').toBeGreaterThan(0); + expect(debtOf(sim)).toBeGreaterThan(0); + + // A works that ships ONLY raw ore is never noticed, however long it runs. + const clean = newSim(); + clean.enqueue(place('gen', 0, 20)); + clean.enqueue(place('mine', 0, 0)); + clean.enqueue(place('belt', 1, 0, E)); + clean.enqueue(place('ship', 2, 0)); + run(clean, 4000); + expect(clean.snapshot().shippedTotal['raw'] ?? 0).toBeGreaterThan(20); + expect(debtOf(clean)).toBe(0); + }); + + it('decays always — an idle factory drifts back to NOMINAL on its own', () => { + const sim = newSim(); + corruptionWorks(sim); + run(sim, 600); + const peak = debtOf(sim); + expect(peak).toBeGreaterThan(0); + + // Tear the line out: nothing ships from here on, so only decay is left. + sim.enqueue({ kind: 'remove', pos: { x: 0, y: 0 } }); + run(sim, 3000); + expect(debtOf(sim)).toBeLessThan(peak); + }); + + it('decays FASTER while tier-0/1 material is shipping', () => { + // Two identical debts, two identical 4,000-tick windows. One factory sits still; the + // other ships stock footage the whole time. Same clock, different ledger. + const idle = newSim(); + idle.enqueue(place('gen', 0, 20)); + setParityDebt(idle, 0.5); + run(idle, 4000); + + const dumping = newSim(); + dumping.enqueue(place('gen', 0, 20)); + dumping.enqueue(place('mine', 0, 0)); + dumping.enqueue(place('belt', 1, 0, E)); + dumping.enqueue(place('ship', 2, 0)); + setParityDebt(dumping, 0.5); + run(dumping, 4000); + + expect(dumping.snapshot().shippedTotal['raw'] ?? 0).toBeGreaterThan(50); + expect(dumping.snapshot().correction!.debt).toBeLessThan(idle.snapshot().correction!.debt); + }); + + it('a compliance commission vents a chunk outright', () => { + const data: GameData = { + ...FIXTURE, + commissions: [ + { id: 'quota', flavor: 'stock footage please', wants: { raw: 5 }, rewardBandwidth: 1, compliance: true }, + ], + }; + const sim = newSim(1, data); + sim.enqueue(place('gen', 0, 20)); + sim.enqueue(place('mine', 0, 0)); + sim.enqueue(place('belt', 1, 0, E)); + sim.enqueue(place('ship', 2, 0)); + setParityDebt(sim, 0.7); + + const evs: SimEvent[] = []; + const before = debtOf(sim); + const at = runUntil(sim, 4000, () => evs.some((e) => e.kind === 'commissionDone'), evs); + expect(at, 'the quota never completed').toBeGreaterThan(0); + // The vent is worth far more than the five bits of ore it asked for. + expect(before - debtOf(sim)).toBeGreaterThan(DEBT_VENT_COMPLIANCE * 0.9); + }); + + it('emits debtBand on band EDGES only, never per tick', () => { + const sim = newSim(); + corruptionWorks(sim); + const evs: SimEvent[] = []; + setParityDebt(sim, DEBT_BANDS[0] + 0.001); // stood just over the OBSERVED floor + run(sim, 2000, evs); + const edges = evs.filter((e) => e.kind === 'debtBand'); + // It crossed into band 1 once. Two thousand ticks of hovering there is not two thousand + // events — hysteresis is what keeps the UI gauge from strobing for the rest of the run. + expect(edges.length).toBeGreaterThanOrEqual(1); + expect(edges.length).toBeLessThan(5); + expect(edges[0].kind === 'debtBand' && edges[0].band).toBe(1); + }); + + it('every band floor is reachable and reports the right band', () => { + for (let i = 0; i < DEBT_BANDS.length; i++) { + const sim = newSim(); + sim.enqueue(place('gen', 0, 20)); + setParityDebt(sim, DEBT_BANDS[i] + 0.005); + run(sim, 1); + expect(sim.snapshot().correction!.band, `debt just over floor ${i}`).toBe(i + 1); + } + }); +}); + +// ========================================================== CHECKSUM WARDEN + +/** Stands a sim at the foot of a band with a real, already-deviating factory under it. */ +function atBand( + band: number, + opts: { firewall?: boolean; splicer?: boolean; catchSink?: boolean } = {}, + seed = 3, +): Sim { + const sim = newSim(seed); + corruptionWorks(sim, opts); + run(sim, 400); // earn some real corruptOut first + setParityDebt(sim, DEBT_BANDS[band - 1] + 0.02); + return sim; +} + +describe('CHECKSUM WARDEN — the audit', () => { + it('faxes FIRST: notice precedes the walk by the full notice window', () => { + const sim = atBand(2); + const evs: SimEvent[] = []; + const noticeAt = runUntil(sim, 2000, () => evs.some((e) => e.kind === 'notice'), evs); + expect(noticeAt, 'no notice was ever faxed').toBeGreaterThan(0); + const n = evs.find((e) => e.kind === 'notice')!; + expect(n.kind === 'notice' && n.unit).toBe('checksum-warden'); + + // Through the whole notice window it is present but doing nothing to you. + expect(unitOf(sim, 'checksum-warden')!.phase).toBe('notice'); + run(sim, WARDEN_NOTICE_TICKS - 5); + expect(unitOf(sim, 'checksum-warden')!.phase).toBe('notice'); + run(sim, 10); + expect(unitOf(sim, 'checksum-warden')!.phase).not.toBe('notice'); + }); + + it('walks to the most DEVIATING machine, audits it, and seals it', () => { + const sim = atBand(2); + const evs: SimEvent[] = []; + const sealedAt = runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs); + expect(sealedAt, 'no machine was ever sealed').toBeGreaterThan(0); + + // The phases really were visible on the way — this is what RENDER animates. + const seen = new Set(evs.filter((e) => e.kind === 'notice').map(() => 'notice')); + expect(seen.has('notice')).toBe(true); + + const sealEv = evs.find((e) => e.kind === 'sealed')!; + expect(sealEv.kind === 'sealed' && sealEv.on).toBe(true); + // It audited the forge — the only thing in the world producing tier-2 deviation. + const forge = entOf(sim, 'forge'); + expect(sealEv.kind === 'sealed' && sealEv.entity).toBe(forge.id); + expect(forge.sealed).toBe(true); + }); + + it('rolls back the last N outputs — and they are really gone from the world', () => { + const sim = atBand(2); + const evs: SimEvent[] = []; + runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs); + + // The rollback lands as `repaired` — the same class of theft as a mite's, which is why + // it reuses the event SCREEN already subtracts from the sky ledger. + const sealTick = evs.find((e) => e.kind === 'sealed')!.tick; + const rolled = evs.filter((e) => e.kind === 'repaired' && e.tick === sealTick); + expect(rolled.length, 'the audit rolled nothing back').toBeGreaterThan(0); + expect(rolled.length).toBeLessThanOrEqual(WARDEN_ROLLBACK_N); + for (const r of rolled) expect(r.kind === 'repaired' && r.item).toBe('product'); + }); + + it('A SEALED MACHINE IS DEAD — provably, until it is pried', () => { + const sim = atBand(2); + const evs: SimEvent[] = []; + runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs); + const forge = entOf(sim, 'forge'); + expect(forge.sealed).toBe(true); + expect(forge.jammed).toBe('sealed'); // and it says so, for the inspector + + // Two thousand ticks with a full input line and a live grid: not one craft. + const after: SimEvent[] = []; + run(sim, 2000, after); + const crafts = after.filter((e) => e.kind === 'crafted' && e.entity === forge.id); + expect(crafts, 'a sealed machine ran anyway').toHaveLength(0); + // Not vacuous: the extractor upstream is still working the whole time. + const mine = entOf(sim, 'mine'); + expect(after.some((e) => e.kind === 'crafted' && e.entity === mine.id)).toBe(true); + }); + + it('pry clears the seal after PRY_TICKS, and the machine runs again', () => { + const sim = atBand(2); + const evs: SimEvent[] = []; + runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs); + const forgeId = entOf(sim, 'forge').id; + const pos = entOf(sim, 'forge').pos; + + sim.enqueue({ kind: 'pry', pos: { x: pos.x, y: pos.y } }); + run(sim, 1); + // The sim owns the clock: one command, then a progress bar UI/RENDER can read. + expect(entOf(sim, 'forge').progress).toBeGreaterThan(0); + expect(entOf(sim, 'forge').sealed, 'the seal came off instantly').toBe(true); + + const during: SimEvent[] = []; + run(sim, PRY_TICKS, during); + const off = during.find((e) => e.kind === 'sealed' && !e.on); + expect(off, 'the seal never came off').toBeDefined(); + expect(off!.kind === 'sealed' && off!.entity).toBe(forgeId); + expect(entOf(sim, 'forge').sealed).toBeFalsy(); + + // And it is a working machine again, not a cleared flag. + const after: SimEvent[] = []; + run(sim, 400, after); + expect(after.some((e) => e.kind === 'crafted' && e.entity === forgeId)).toBe(true); + }); + + it('prying ADDS debt — you defaced a compliance notice', () => { + const sim = atBand(2); + const evs: SimEvent[] = []; + runUntil(sim, 6000, () => evs.some((e) => e.kind === 'sealed'), evs); + const pos = entOf(sim, 'forge').pos; + + // Stand somewhere with headroom first: by the time a warden has walked over, this works + // has shipped enough product to saturate the ledger at 1.0, and you cannot measure a + // rise against a clamp. + setParityDebt(sim, 0.3); + const before = debtOf(sim); + sim.enqueue({ kind: 'pry', pos: { x: pos.x, y: pos.y } }); + const during: SimEvent[] = []; + run(sim, PRY_TICKS + 4, during); + expect(during.some((e) => e.kind === 'sealed' && !e.on), 'the pry never finished').toBe(true); + // Debt is only decaying otherwise (the line is sealed and shipping nothing), so the whole + // rise is the pry. It is charged once, on completion — not per tick of prying. + expect(debtOf(sim) - before).toBeGreaterThan(DEBT_PER_PRY * 0.8); + expect(debtOf(sim) - before).toBeLessThan(DEBT_PER_PRY * 1.2); + }); + + it('a firewall cans the warden on approach — the other counter', () => { + // Same seed, same works; one has a firewall covering the forge. + const open = atBand(2, {}, 11); + const openEvs: SimEvent[] = []; + run(open, 6000, openEvs); + + const guarded = atBand(2, { firewall: true }, 11); + const guardedEvs: SimEvent[] = []; + run(guarded, 6000, guardedEvs); + + expect(openEvs.some((e) => e.kind === 'sealed'), 'the undefended works was never sealed') + .toBe(true); + expect(guardedEvs.some((e) => e.kind === 'sealed'), 'the guard let a warden through') + .toBe(false); + // And the guard did it by canning units, not by the wardens never arriving. + expect(guardedEvs.filter((e) => e.kind === 'notice').length).toBeGreaterThan(0); + }); + + it('canning a unit adds NO debt — an immune system does not hold a grudge', () => { + // `catchSink: false` is load-bearing: with a shipper on the catch lane the firewall + // SELLS its canned mites, and a canned mite is a tier-2 item, so the ledger climbs — + // from the shipment, not from the kill. Holding the cargo isolates the ruling. + const sim = atBand(2, { firewall: true, catchSink: false }, 11); + // Sever the product line too, so nothing at all ships and only decay is left. + sim.enqueue({ kind: 'remove', pos: { x: 6, y: 0 } }); + run(sim, 200); + + let prev = debtOf(sim); + let rose = false; + for (let i = 0; i < 6000; i++) { + run(sim, 1); + const d = debtOf(sim); + if (d > prev + 1e-9) rose = true; + prev = d; + } + expect(rose, 'debt rose while the only events were catches').toBe(false); + // Not vacuous: the firewall really was canning things the whole time. + const fw = sim.snapshot().entities.find((e) => e.def === 'firewall')!; + expect(fw.outputBuf['parity-mite'] ?? 0, 'nothing was ever canned').toBeGreaterThan(0); + expect(sim.snapshot().shippedTotal['parity-mite'] ?? 0).toBe(0); + }); +}); + +// ======================================================== REDUNDANCY GUNSHIP + +describe('REDUNDANCY GUNSHIP — the mirror', () => { + it('tethers to a sector and mirrors what it produces', () => { + const sim = atBand(3); + const evs: SimEvent[] = []; + const at = runUntil(sim, 12000, () => evs.some((e) => e.kind === 'mirrored'), evs); + expect(at, 'nothing was ever mirrored').toBeGreaterThan(0); + + const notice = evs.find((e) => e.kind === 'notice' && e.unit === 'redundancy-gunship'); + expect(notice, 'the gunship never faxed').toBeDefined(); + const m = evs.find((e) => e.kind === 'mirrored')!; + // It mirrors DEVIATION, and the theft is reported where it happened. + expect(m.kind === 'mirrored' && m.item).toBe('product'); + expect(m.kind === 'mirrored' && m.pos).toBeDefined(); + // Fax first: the notice really did come before the theft. + expect(notice!.tick).toBeLessThan(m.tick); + }); + + it('MIRRORED ARITHMETIC: one event per threshold, each worth exactly one unit', () => { + const sim = atBand(3); + const evs: SimEvent[] = []; + runUntil(sim, 12000, () => evs.filter((e) => e.kind === 'mirrored').length >= 3, evs); + const mirrors = evs.filter((e) => e.kind === 'mirrored'); + expect(mirrors.length).toBeGreaterThanOrEqual(3); + // SCREEN subtracts exactly one from the sky ledger per event, so a bigger theft has to + // be N events and never a scaled one. Two mirrors never land on the same tick from one + // gunship, and every one of them names an item and a place. + const byTick = new Set(mirrors.map((e) => e.tick)); + expect(byTick.size).toBe(mirrors.length); + for (const m of mirrors) { + expect(m.kind === 'mirrored' && typeof m.item).toBe('string'); + } + }); + + it('targets DEVIATION, not throughput — a busy tier-0 sector is of no interest', () => { + // Two works: one hammering out raw ore at 15 tiles away, one making a trickle of + // product. The gunship must tether to the corrupt one. + const sim = newSim(5); + corruptionWorks(sim); // product, sector x0..7 y0..7 + sim.enqueue(place('gen', 0, 20)); + for (let i = 0; i < 4; i++) sim.enqueue(place('mine', 16 + i, 16)); // pure tier-0 volume + for (let i = 0; i < 4; i++) sim.enqueue(place('belt', 16 + i, 17, E)); + sim.enqueue(place('ship', 20, 17)); + run(sim, 600); + setParityDebt(sim, DEBT_BANDS[2] + 0.02); + + const evs: SimEvent[] = []; + runUntil(sim, 12000, () => { + const g = unitOf(sim, 'redundancy-gunship'); + return !!g && g.phase === 'tether'; + }, evs); + const g = unitOf(sim, 'redundancy-gunship'); + expect(g, 'no gunship ever tethered').toBeDefined(); + // The ore works ships far more items; the gunship is looking at the product line. + expect(sim.snapshot().shippedTotal['raw'] ?? 0) + .toBeGreaterThan(sim.snapshot().shippedTotal['product'] ?? 0); + expect(g!.rect).toBeDefined(); + expect(g!.rect!.x).toBeLessThanOrEqual(0); + expect(g!.rect!.y).toBeLessThanOrEqual(0); + // and the anchor is the machine that actually deviates + expect(g!.target).toBe(entOf(sim, 'forge').id); + }); + + it('the tether breaks when the anchor is sealed, idled or torn out', () => { + const sim = atBand(3); + runUntil(sim, 12000, () => { + const g = unitOf(sim, 'redundancy-gunship'); + return !!g && g.phase === 'tether'; + }); + expect(unitOf(sim, 'redundancy-gunship')!.phase).toBe('tether'); + + // Kill the parity link: tear the anchor out from under it. + const anchor = sim.snapshot().entities.find((e) => e.id === unitOf(sim, 'redundancy-gunship')!.target)!; + sim.enqueue({ kind: 'remove', pos: { x: anchor.pos.x, y: anchor.pos.y } }); + run(sim, 2); + const g = unitOf(sim, 'redundancy-gunship'); + expect(g === undefined || g.phase === 'leaving').toBe(true); + }); +}); + +// ============================================================= HASH AUDITOR + +describe('HASH AUDITOR — the refusal', () => { + it('faxes, prescans, then locks a rect with a deterministic hash', () => { + const sim = atBand(4); + const evs: SimEvent[] = []; + const at = runUntil(sim, 12000, () => evs.some((e) => e.kind === 'stasis' && e.on), evs); + expect(at, 'nothing was ever locked').toBeGreaterThan(0); + + const notice = evs.find((e) => e.kind === 'notice' && e.unit === 'hash-auditor')!; + const lock = evs.find((e) => e.kind === 'stasis' && e.on)!; + expect(notice.tick).toBeLessThan(lock.tick); + // The visible 5-second prescan sits between the fax and the lock, every time. + expect(lock.tick - notice.tick) + .toBeGreaterThanOrEqual(AUDITOR_NOTICE_TICKS + AUDITOR_PRESCAN_TICKS - 2); + expect(lock.kind === 'stasis' && lock.lockHash).toMatch(/^[0-9A-F]{8}$/); + }); + + it('the lock hash survives a save/load — the same lock, the same number', () => { + const sim = atBand(4); + const evs: SimEvent[] = []; + runUntil(sim, 12000, () => evs.some((e) => e.kind === 'stasis' && e.on), evs); + const hash = (evs.find((e) => e.kind === 'stasis' && e.on)! as { lockHash: string }).lockHash; + + const b = newSim(3); + b.load(sim.save()); + const released: SimEvent[] = []; + run(b, AUDITOR_STASIS_TICKS + 10, released); + const off = released.find((e) => e.kind === 'stasis' && !e.on); + expect(off, 'the restored lock never released').toBeDefined(); + expect(off!.kind === 'stasis' && off!.lockHash).toBe(hash); + }); + + it('freezes entities and belt items inside the rect, and resumes exactly where it stopped', () => { + const sim = atBand(4); + const evs: SimEvent[] = []; + runUntil(sim, 12000, () => evs.some((e) => e.kind === 'stasis' && e.on), evs); + const rect = (evs.find((e) => e.kind === 'stasis' && e.on)! as { + rect: { x: number; y: number; w: number; h: number }; + }).rect; + + const inside = (x: number, y: number): boolean => + x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h; + const frozenItems = () => sim.snapshot().beltItems + .filter((b) => { + const belt = sim.snapshot().entities.find((e) => e.id === b.entity)!; + return inside(belt.pos.x, belt.pos.y); + }) + .map((b) => `${b.id}@${b.t}`) + .sort(); + + const before = frozenItems(); + expect(before.length, 'no cargo was inside the lock, so this proves nothing') + .toBeGreaterThan(0); + const forgeBefore = entOf(sim, 'forge').progress; + + const during: SimEvent[] = []; + run(sim, 100, during); + // 0:00. Not slow — stopped. Same items, same sub-tile positions, to the bit. + expect(frozenItems()).toEqual(before); + expect(entOf(sim, 'forge').progress).toBe(forgeBefore); + expect(during.filter((e) => e.kind === 'crafted' && e.entity === entOf(sim, 'forge').id)) + .toHaveLength(0); + + // Release, and the line picks up mid-tile rather than restarting. + run(sim, AUDITOR_STASIS_TICKS); + const after: SimEvent[] = []; + run(sim, 300, after); + expect(after.some((e) => e.kind === 'crafted')).toBe(true); + }); + + it('THE SAFETY: stasis ALWAYS auto-releases, with the player doing nothing at all', () => { + // This is the one test in this file that is not about flavour. Contracts v9 makes + // auto-release contractual: no input, no counter, no firewall, no pry — the lock lifts + // on its own after AUDITOR_STASIS_TICKS. If a change ever makes a lock outlast this, + // the change is wrong, however clever the reason. + const sim = atBand(4); + const evs: SimEvent[] = []; + runUntil(sim, 12000, () => evs.some((e) => e.kind === 'stasis' && e.on), evs); + const on = evs.find((e) => e.kind === 'stasis' && e.on)!; + + const after: SimEvent[] = []; + run(sim, AUDITOR_STASIS_TICKS + 5, after); + const off = after.find((e) => e.kind === 'stasis' && !e.on); + expect(off, 'THE LOCK NEVER LIFTED — this is the safety, not a tuning knob').toBeDefined(); + expect(off!.tick - on.tick).toBeLessThanOrEqual(AUDITOR_STASIS_TICKS + 2); + expect(off!.kind === 'stasis' && off!.lockHash) + .toBe(on.kind === 'stasis' ? on.lockHash : ''); + // Nothing is left frozen afterwards. + expect(units(sim).some((u) => u.phase === 'stasis')).toBe(false); + }); + + it('ABSTENTION: craft nothing during the prescan and it finds nothing, and leaves', () => { + const sim = atBand(4); + const evs: SimEvent[] = []; + // Wait for the fax, then stop moving: pull the extractor so the works goes quiet. The + // sector still SCORES (deviation is a lifetime figure) — there is simply nothing to read. + runUntil(sim, 12000, () => evs.some((e) => e.kind === 'notice' && e.unit === 'hash-auditor'), evs); + sim.enqueue({ kind: 'remove', pos: { x: 0, y: 0 } }); // the mine + sim.enqueue({ kind: 'remove', pos: { x: 2, y: 0 } }); // the forge + const quiet: SimEvent[] = []; + run(sim, AUDITOR_NOTICE_TICKS + AUDITOR_PRESCAN_TICKS + 60, quiet); + + expect(quiet.filter((e) => e.kind === 'stasis'), 'it locked a sector that was standing still') + .toHaveLength(0); + }); + + it('FORGED HASH: holding one inside the prescan rect voids the audit', () => { + // Same seed and same works twice; the second is holding a forged hash in the sector. + // The splicer's two-input recipe is exactly the shape the empty-inputs catcher can't see. + const bare = atBand(4, {}, 21); + const bareEvs: SimEvent[] = []; + run(bare, 6000, bareEvs); + expect(bareEvs.some((e) => e.kind === 'stasis' && e.on), 'the control was never locked') + .toBe(true); + + const forged = atBand(4, { splicer: true }, 21); + const forgedEvs: SimEvent[] = []; + // Sampled rather than checked at the end, because a forged hash is tier-3 DEVIATION and + // the splicer holding it is a legitimate warden target — a warden can turn up and roll + // the evidence back. What the audit reads is what is held AT THE PRESCAN, so the + // precondition to prove is that the sector was carrying one while the auditor was there. + let heldAHash = false; + for (let i = 0; i < 6000; i++) { + run(forged, 1, forgedEvs); + const sp = forged.snapshot().entities.find((e) => e.def === 'splicer'); + if ((sp?.outputBuf[FORGED_HASH_ITEM] ?? 0) > 0) heldAHash = true; + } + expect(heldAHash, 'the splicer never made a forged hash, so nothing was tested').toBe(true); + expect(forgedEvs.some((e) => e.kind === 'stasis'), 'the forged hash did not void the audit') + .toBe(false); + // It still turned up and read the room — the counter is the signature, not invisibility. + expect(forgedEvs.some((e) => e.kind === 'notice' && e.unit === 'hash-auditor')).toBe(true); + }); +}); + +// ================================================ determinism and persistence + +describe('THE CORRECTION — determinism and save/load', () => { + it('two sims with the same seed run the same escalation, tick for tick', () => { + const build = (): Sim => { + const sim = atBand(2, { firewall: true }, 99); + run(sim, 8000); + return sim; + }; + const a = build(); + const b = build(); + expect(b.snapshot()).toEqual(a.snapshot()); + expect(a.snapshot().correction!.debt).toBeGreaterThan(0); + }); + + it('save v5 carries debt, units and seals through a mid-audit reload', () => { + const a = atBand(2, {}, 7); + const evs: SimEvent[] = []; + // Stop mid-approach, with a unit on the floor and a real debt figure. + runUntil(sim0(a), 12000, () => { + const w = unitOf(a, 'checksum-warden'); + return !!w && (w.phase === 'approach' || w.phase === 'audit'); + }, evs); + expect(unitOf(a, 'checksum-warden'), 'no warden to persist').toBeDefined(); + + const b = newSim(7); + b.load(a.save()); + expect(b.snapshot().correction).toEqual(a.snapshot().correction); + + // And the FUTURE matches, which is the bar — the audit lands identically in both. + run(a, 4000); + run(b, 4000); + expect(b.snapshot()).toEqual(a.snapshot()); + expect(a.snapshot().entities.some((e) => e.sealed)).toBe(true); + }); + + it('SAVING DOES NOT SAVE YOU: round-tripping every tick does not stall an approach', () => { + // LANE-UI's finding: if any part of a unit's progress lived outside the save blob, a + // player (or a live UI harness) that round-trips constantly would be quietly immune to + // The Correction. Two sims, same seed, same tick count — one saves and reloads on EVERY + // tick, the other never does. They must land in the same place. + const straight = atBand(2, {}, 31); + run(straight, 3000); + + const churned = atBand(2, {}, 31); + for (let i = 0; i < 3000; i++) { + churned.tick(); + churned.drainEvents(); + const blob = churned.save(); + churned.load(blob); + } + + const a = unitOf(straight, 'checksum-warden'); + const b = unitOf(churned, 'checksum-warden'); + expect(!!a).toBe(!!b); + if (a && b) { + expect(b.phase).toBe(a.phase); + expect(b.pos).toEqual(a.pos); + } + expect(churned.snapshot().entities.filter((e) => e.sealed).length) + .toBe(straight.snapshot().entities.filter((e) => e.sealed).length); + expect(churned.snapshot().correction).toEqual(straight.snapshot().correction); + }); + + it('a seal survives save/load and is still dead on the other side', () => { + const a = atBand(2, {}, 7); + const evs: SimEvent[] = []; + runUntil(sim0(a), 12000, () => evs.some((e) => e.kind === 'sealed'), evs); + const b = newSim(7); + b.load(a.save()); + + const forge = b.snapshot().entities.find((e) => e.def === 'forge')!; + expect(forge.sealed).toBe(true); + const after: SimEvent[] = []; + run(b, 1000, after); + expect(after.filter((e) => e.kind === 'crafted' && e.entity === forge.id)).toHaveLength(0); + }); +}); + +/** Tiny identity helper so runUntil reads right when the predicate closes over the sim. */ +function sim0(s: Sim): Sim { return s; } + +// ============================================= the reference factory, real data + +const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData; + +function buildReference(): Sim { + const sim = createSim(); + sim.init(DATA, 1337); + grantResearch(sim, referenceFactoryTech(DATA)); + for (const cmd of referenceFactory(DATA)) sim.enqueue(cmd); + return sim; +} + +describe('the reference factory under THE CORRECTION (real data)', () => { + it('climbs all five bands and runs every rung inside 20,000 ticks', () => { + const sim = buildReference(); + const evs: SimEvent[] = []; + run(sim, 20000, evs); + + const bands = evs.filter((e) => e.kind === 'debtBand').map((e) => e.kind === 'debtBand' && e.band); + expect(bands, 'the demo never left NOMINAL').toContain(1); + expect(bands).toContain(2); + expect(bands).toContain(3); + expect(bands).toContain(4); + + // All three rungs were dispatched, and every one of them faxed first. + const noticed = new Set(evs.filter((e) => e.kind === 'notice').map((e) => e.kind === 'notice' && e.unit)); + expect(noticed).toContain('checksum-warden'); + expect(noticed).toContain('redundancy-gunship'); + expect(noticed).toContain('hash-auditor'); + + // The auditor really locked the works, and every lock it opened it also closed. + const on = evs.filter((e) => e.kind === 'stasis' && e.on); + const off = evs.filter((e) => e.kind === 'stasis' && !e.on); + expect(on.length).toBeGreaterThan(0); + expect(off.length).toBeGreaterThanOrEqual(on.length - 1); // the last may still be open + }); + + it('THE VALVE: the demo reaches a compliance quota, and filing it vents a band', () => { + // LANE-DATA measured the old layout clearing ZERO commissions in 90,000 ticks, which + // made every quota above rung 1 dead content. The v7 compliance tap exists for this. + const sim = buildReference(); + const evs: SimEvent[] = []; + run(sim, 20000, evs); + + const done = evs.filter((e) => e.kind === 'commissionDone').map((e) => e.kind === 'commissionDone' && e.commission); + expect(done, 'rung 1 never cleared — the ore tap is not shipping').toContain('raw-payload'); + expect(done, 'rung 2 never cleared — the luma tap is not shipping').toContain('first-refinement'); + + const quota = DATA.commissions.find((c) => c.compliance)!; + expect(done, `the debt valve (${quota.id}) is still unreachable`).toContain(quota.id); + + // And filing it visibly moved the ladder DOWN — the vent is not decorative. + const filedAt = evs.find((e) => e.kind === 'commissionDone' && e.commission === quota.id)!.tick; + const dropped = evs.find( + (e) => e.kind === 'debtBand' && e.tick >= filedAt && e.tick < filedAt + 600, + ); + expect(dropped, 'filing the quota vented nothing the gauge could see').toBeDefined(); + }); + + it('keeps shipping melt through the whole escalation, and never browns out', () => { + const sim = buildReference(); + const evs: SimEvent[] = []; + run(sim, 18000, evs); + const early = sim.snapshot().shippedTotal['melt'] ?? 0; + const late: SimEvent[] = []; + run(sim, 2000, late); + const window = late.filter((e) => e.kind === 'shipped' && e.item === 'melt').length; + + expect(early, 'the melt line died somewhere in the escalation').toBeGreaterThan(0); + expect(window, 'melt had stopped by tick 20,000').toBeGreaterThan(0); + // The zero-brownout invariant, pinned at last: rounds 3-6 asserted it only in NOTES. + // v7 adds ~20 draw (the compliance tap, the second crime quantizer, the brick guard) + // and four more ASICs to pay for it. + expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(0); + expect(late.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(0); + }); + + it('survives a real warden seal: the doubled brick works absorbs it', () => { + // The guard lifted, so a warden actually lands. Round 7 measured the single-quantizer + // layout dying here: seal at t12073, coefficient packs back up into the demuxer, melt + // frozen at 69 for the rest of the run. The second crime quantizer is the answer, and + // this is the test that would notice if it were ever removed. + const sim = createSim(); + sim.init(DATA, 1337); + grantResearch(sim, referenceFactoryTech(DATA)); + const fw = firewallInData(DATA); + for (const cmd of referenceFactory(DATA)) { + // Drop the brick-works guard (the second firewall) so the audit gets through. + if (fw && cmd.kind === 'place' && cmd.def === fw.machine && cmd.pos.x === -22) continue; + sim.enqueue(cmd); + } + const evs: SimEvent[] = []; + const sealedAt = runUntil(sim, 20000, () => evs.some((e) => e.kind === 'sealed' && e.on), evs); + expect(sealedAt, 'no warden ever landed an audit').toBeGreaterThan(0); + const sealed = evs.find((e) => e.kind === 'sealed' && e.on)!; + const victim = sim.snapshot().entities.find( + (e) => e.id === (sealed.kind === 'sealed' ? sealed.entity : -1), + )!; + expect(victim.def, 'the audit landed somewhere unexpected').toBe('quantizer'); + + // The window that matters: the 3,000 ticks AFTER the seal. With one crime quantizer this + // is where melt stopped forever (measured: frozen at 69 from t12073). With two, the + // splitter skips the sealed lane and the coefficient surplus walks to the other one. + const late: SimEvent[] = []; + run(sim, 3000, late); + expect( + late.filter((e) => e.kind === 'shipped' && e.item === 'melt').length, + 'a single seal killed the melt line — the brick works redundancy is gone', + ).toBeGreaterThan(0); + // And the survivor really is carrying the load: bricks keep coming off the other one. + expect(late.some((e) => e.kind === 'crafted' && e.recipe === 'quantize-crime')).toBe(true); + }); + + it('RUNG 3 IS NOT INVISIBLE: a gunship tethers and mirrors in a factory WITH firewalls', () => { + // LANE-UI's round-7 finding, as a regression. They measured a gunship being canned + // inside its own notice window across 12,000 ticks of real play, so `mirrored` had never + // once fired from a real event — a whole rung reduced to nothing by a 2x2 machine the + // player had already built. The ruling that fixes it is in firewallPrey: a firewall + // catches what WALKS, and a gunship hovers. This test is the thing that keeps it fixed, + // and it deliberately uses the demo AS SHIPPED — two firewalls and all. + const sim = buildReference(); + const evs: SimEvent[] = []; + run(sim, 20000, evs); + + const tethered = evs.some((e) => e.kind === 'notice' && e.unit === 'redundancy-gunship'); + expect(tethered, 'no gunship was ever dispatched').toBe(true); + const mirrors = evs.filter((e) => e.kind === 'mirrored'); + expect(mirrors.length, 'the gunship never got a mirror off in a guarded factory') + .toBeGreaterThan(0); + + for (const m of mirrors) { + if (m.kind !== 'mirrored') continue; + // Only DEVIATION is ever mirrored — never ore, never dust. + expect(DATA.items.find((i) => i.id === m.item)!.tier).toBeGreaterThanOrEqual(2); + expect(m.by, 'v9.3: the mirror must name the gunship that took it').toBeDefined(); + } + // Meanwhile the guards are still doing their job on the rung that DOES walk. + expect(evs.filter((e) => e.kind === 'notice' && e.unit === 'checksum-warden').length) + .toBeGreaterThan(1); + }); +}); diff --git a/fktry/src/sim/index.ts b/fktry/src/sim/index.ts index 06c40e4..f22b0bd 100644 --- a/fktry/src/sim/index.ts +++ b/fktry/src/sim/index.ts @@ -10,6 +10,8 @@ import { type BeltItem, type Command, type CommissionDef, + type CorrectionState, + type CorrectionUnit, type Dir, type EntityState, type GameData, @@ -30,14 +32,33 @@ import { makeRng, type Rng } from './rng'; // The tuning constants live in ./constants so other lanes' tests can import truth // instead of hand-copying numbers that go stale in silence. import { + AUDITOR_NOTICE_TICKS, + AUDITOR_PRESCAN_TICKS, + AUDITOR_STASIS_TICKS, BELT_CAPACITY, BELT_SPACING, + CORRECTION_RESPAWN_TICKS, + DEBT_BANDS, + DEBT_BAND_HYSTERESIS, + DEBT_CLEAN_CREDIT_MAX, + DEBT_CLEAN_CREDIT_PER_ITEM, + DEBT_CLEAN_DECAY_MAX_PER_TICK, + DEBT_DECAY_PER_TICK, + DEBT_PER_CORRUPT_SHIP, + DEBT_PER_PRY, + DEBT_TIER_MIN, + DEBT_VENT_COMPLIANCE, DUST_GROWTH_PER_HELD, DUST_ITEM, DUST_PILE_MAX, DUST_PILE_SPREAD, DUST_SPILL_GRACE, FIREWALL_RADIUS, + FORGED_HASH_ITEM, + GUNSHIP_ANCHOR_IDLE_TICKS, + GUNSHIP_NOTICE_TICKS, + GUNSHIP_PROGRESS_PER_CRAFT, + GUNSHIP_SECTOR, HEAT_COOL_DEFAULT, HEAT_COOL_MAX, HEAT_RESTART, @@ -54,6 +75,7 @@ import { MITE_SPAWN_PER, MITE_TIER_MIN, OUTPUT_STALL_FACTOR, + PRY_TICKS, RELIC_KEEPOUT, RELIC_DIG_RANGE, RELIC_MAX_DIST, @@ -67,6 +89,12 @@ import { SWARM_MAX, SWARM_SLOW_FLOOR, TRAP_RADIUS, + WARDEN_AUDIT_TICKS, + WARDEN_NOTICE_TICKS, + WARDEN_OUT_HISTORY, + WARDEN_REACH_RANGE, + WARDEN_ROLLBACK_N, + WARDEN_WALK_TILES_PER_SEC, } from './constants'; /** @@ -108,6 +136,32 @@ interface Ent { cursor: number; /** kind==='lab': ticks accrued toward converting the current pack (0..RESEARCH_TICKS_PER_PACK) */ labTimer: number; + /** + * v9: the last WARDEN_OUT_HISTORY items this machine pushed onto a belt, oldest first. + * A checksum warden's audit rolls these back — the outputs exist in the world with stable + * ids, so "the last N outputs" is a real, findable set rather than an abstract debit. + */ + outHist: Array<{ item: string; id: number }>; + /** v9: lifetime count of tier>=DEBT_TIER_MIN items produced — the DEVIATION score. */ + corruptOut: number; + /** v9: ticks of prying accrued against this machine's compliance seal (0 = not being pried). */ + pryTimer: number; +} + +/** + * v9: a dispatched unit of THE CORRECTION. `state` is the contract-shaped object handed to + * consumers; the rest is the bookkeeping the snapshot has no business carrying. + */ +interface CUnit { + state: CorrectionUnit; + /** ticks spent in the current phase */ + timer: number; + /** gunship: ticks since the PARITY ANCHOR last crafted — the tether's only measurement */ + idle: number; + /** auditor: did anything craft inside the rect during PRESCAN? (false = abstention) */ + sawCraft: boolean; + /** auditor: the hash published with the active stasis lock, '' when not locked */ + lockHash: string; } export function createSim(): Sim { @@ -164,6 +218,24 @@ export function createSim(): Sim { /** entity id -> attached swarm's severity this tick, for the speed penalty */ const swarmBite = new Map(); + // ------------------------------------------------------------- the correction + // cunits and cunitStates are parallel, same discipline as ents/entityStates. + let cunits: CUnit[] = []; + let cunitStates: CorrectionUnit[] = []; + let nextCUnitId = 1; + /** PARITY DEBT, 0..1 — the one scalar the whole escalation ladder hangs off. */ + let debt = 0; + /** banked buy-back from tier-0/1 shipments, spent against debt at a capped rate */ + let cleanCredit = 0; + let band: 0 | 1 | 2 | 3 | 4 = 0; + /** unit kind -> ticks before that rung may be dispatched again */ + let cooldown: Record = {}; + /** rects under an active stasis lock this tick; empty is the fast path */ + let activeStasis: Array<{ x: number; y: number; w: number; h: number }> = []; + /** how many machines are mid-pry — 0 lets advancePry skip the entity scan entirely */ + let pryingCount = 0; + const correction: CorrectionState = { debt: 0, band: 0, units: cunitStates }; + const cmdQueue: Command[] = []; let events: SimEvent[] = []; /** items already advanced this tick, so a transfer downstream can't double-move */ @@ -183,6 +255,7 @@ export function createSim(): Sim { research, wildlife, relics, + correction, }; // ------------------------------------------------------------------ lookups @@ -296,6 +369,12 @@ export function createSim(): Sim { function beltHasRoom(beltId: number, atT: number): boolean { const items = beltContents.get(beltId); if (!items || items.length >= BELT_CAPACITY) return false; + // A belt under a stasis lock takes nothing: the sector is not slow, it is 0:00. Items + // pile at the boundary instead of leaking into a region that isn't running. + if (activeStasis.length) { + const b = byId.get(beltId); + if (b && frozenEnt(b)) return false; + } const rear = items[items.length - 1]; return !rear || rear.t - atT >= BELT_SPACING; } @@ -407,6 +486,7 @@ export function createSim(): Sim { }; const e: Ent = { state, def, crafting: false, scrammed: false, tiles, queue: [], cursor: 0, labTimer: 0, + outHist: [], corruptOut: 0, pryTimer: 0, }; ents.push(e); entityStates.push(state); @@ -421,6 +501,7 @@ export function createSim(): Sim { function doRemove(pos: { x: number; y: number }): void { const e = entAt(pos.x, pos.y); if (!e) return; + if (e.pryTimer > 0) pryingCount--; // demolishing it is one way to finish the job const id = e.state.id; for (const k of e.tiles) occ.delete(k); byId.delete(id); @@ -491,6 +572,7 @@ export function createSim(): Sim { case 'setResearch': doSetResearch(cmd.tech); break; case 'grantResearch': doGrantResearch(cmd.tech); break; case 'excavate': doExcavate(cmd.pos); break; + case 'pry': doPry(cmd.pos); break; case 'setRecipeAt': { const e = entAt(cmd.pos.x, cmd.pos.y); // id-free: any tile of the footprint works if (e) setRecipeOn(e, cmd.recipe); @@ -507,6 +589,11 @@ export function createSim(): Sim { for (const e of ents) { if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue; if (e.scrammed || e.crafting) continue; + if (frozenEnt(e)) continue; // 0:00 — not stopped, administered + // A compliance seal is a refusal, not a fault: the machine is intact and will run + // again the moment the seal comes off (the `pry` command) or a firewall pre-empts + // the warden that stamped it. + if (e.state.sealed) { setJam(e, 'sealed'); continue; } const r = recipeOf(e); if (!r) continue; @@ -522,8 +609,9 @@ 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; } + // A firewall is the same shape aimed at THE CORRECTION: it only closes on a mite — + // or, since v9, a warden or gunship — actually in range. + if (firewallRecipeIds.has(r.id) && !firewallPrey(e)) { setJam(e, 'starved'); continue; } let ready = true; let needsInput = false; @@ -563,7 +651,10 @@ export function createSim(): Sim { draw += e.def.powerDraw; // a scrammed machine is stopped, not unplugged if (e.def.kind === 'buffer') capacity += e.def.bufferCap ?? 0; if (e.def.powerGen) gen += e.def.powerGen * heatThrottle(e); - if (e.crafting && !e.scrammed) { + // A frozen machine keeps its idle draw AND its generation: stasis administers the + // sector, it does not unplug it. Making a lock brown the grid out would turn a + // refusal into an attack, which is precisely what The Correction never does. + if (e.crafting && !e.scrammed && !frozenEnt(e)) { const r = recipeOf(e); if (r) { if (r.bandwidth > 0) draw += r.bandwidth; @@ -602,6 +693,7 @@ export function createSim(): Sim { function advanceCrafts(mult: number): void { for (const e of ents) { if (!e.crafting) continue; + if (frozenEnt(e)) continue; // progress holds exactly where it was; release resumes it const r = recipeOf(e); if (!r) { e.crafting = false; continue; } const speed = mult * heatThrottle(e) * swarmThrottle(e); @@ -616,11 +708,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. + // A firewall cans its prey the same way: hold the jar open if the target drifted off. if (firewallRecipeIds.has(r.id)) { - const prey = miteInFirewallRange(e); + const prey = firewallPrey(e); if (!prey) { e.state.progress = 1; continue; } - catchMite(prey.id); + if (prey.mite) catchMite(prey.mite.id); + else if (prey.unit) canUnit(prey.unit); } e.state.progress = 0; @@ -628,6 +721,7 @@ export function createSim(): Sim { for (const k in r.outputs) e.state.outputBuf[k] = (e.state.outputBuf[k] ?? 0) + r.outputs[k]; if (r.heat) e.state.heat = Math.min(1, e.state.heat + r.heat); events.push({ kind: 'crafted', entity: e.state.id, recipe: r.id, tick: curTick }); + noteCraft(e, r); } } @@ -645,6 +739,7 @@ export function createSim(): Sim { // Most of a big factory is belts, and a belt can never be anything but stone cold. // Skipping them here is the difference between 59x and 70x realtime at 3,500 entities. if (hpt === 0 && e.state.heat === 0 && !e.scrammed) continue; + if (frozenEnt(e)) continue; // a frozen machine neither heats nor cools: it is 0:00 const cool = coolingOf(e); const active = !e.scrammed && (e.def.kind === 'power' ? true : e.crafting); let h = e.state.heat + (active ? hpt : 0) - cool; @@ -668,6 +763,7 @@ export function createSim(): Sim { function pushOutputs(): void { for (const e of ents) { if (e.def.kind !== 'crafter' && e.def.kind !== 'extractor') continue; + if (frozenEnt(e)) continue; let any = false; for (const k in e.state.outputBuf) if (e.state.outputBuf[k] > 0) { any = true; break; } if (!any) continue; @@ -694,7 +790,12 @@ export function createSim(): Sim { } if (target < 0) continue; - spawnOnBelt(target, item, nextItemId++); + const outId = nextItemId++; + spawnOnBelt(target, item, outId); + // The audit trail a checksum warden reads: the last N things this machine put into + // the world, by stable item id, so a rollback can find them again. + e.outHist.push({ item, id: outId }); + if (e.outHist.length > WARDEN_OUT_HISTORY) e.outHist.shift(); const left = e.state.outputBuf[item] - 1; if (left > 0) e.state.outputBuf[item] = left; else delete e.state.outputBuf[item]; @@ -710,6 +811,7 @@ export function createSim(): Sim { function stepSplitters(): void { for (const e of ents) { if (e.def.kind !== 'splitter' || !e.queue.length) continue; + if (frozenEnt(e)) continue; collectOutputBelts(e, candidateBelts); if (!candidateBelts.length) continue; let dirty = false; @@ -787,7 +889,10 @@ export function createSim(): Sim { } if (target >= 1) { - const next = beltTarget(belt); + const nextEnt = beltTarget(belt); + // Nothing crosses into a stasis lock. The item parks at the lip and the lane behind + // it backs up honestly — the sector isn't refusing the cargo, it isn't running. + const next = nextEnt && frozenEnt(nextEnt) ? undefined : nextEnt; if (next && next.def.kind === 'belt') { const nItems = beltContents.get(next.state.id)!; const rear = nItems[nItems.length - 1]; @@ -828,6 +933,7 @@ export function createSim(): Sim { moved.clear(); for (const e of ents) { if (e.def.kind !== 'belt') continue; + if (frozenEnt(e)) continue; // items hold their exact t; release resumes mid-tile const step = ((e.def.beltSpeed ?? 0) / TICKS_PER_SECOND) * mult; const items = beltContents.get(e.state.id)!; let i = 0; @@ -896,6 +1002,7 @@ export function createSim(): Sim { const t = research.active === null ? undefined : techDefs.get(research.active); for (const e of ents) { if (e.def.kind !== 'lab') continue; + if (frozenEnt(e)) continue; if (!t) { e.labTimer = 0; e.state.progress = 0; continue; } // Find a pack this lab holds that the tech still needs (accounting for progress and @@ -954,6 +1061,10 @@ export function createSim(): Sim { if ((snap.commissionProgress[k] ?? 0) < c.wants[k]) return; } events.push({ kind: 'commissionDone', commission: c.id, tick: curTick }); + // A Correction quota is the paperwork valve: filing it vents a chunk of PARITY DEBT + // outright, worth far more than the tier-0/1 material it asked for. Complying on + // purpose is a real strategy with a real opportunity cost. + if (c.compliance) addDebt(-DEBT_VENT_COMPLIANCE); commissionQueue.shift(); if (c.repeat) commissionQueue.push(c.id); // standing order: back of the queue activateCommission(); @@ -962,6 +1073,7 @@ export function createSim(): Sim { function drainShippers(): void { for (const e of ents) { if (e.def.kind !== 'shipper') continue; + if (frozenEnt(e)) continue; const buf = e.state.inputBuf; for (const item of Object.keys(buf).sort()) { const n = buf[item]; @@ -969,6 +1081,7 @@ export function createSim(): Sim { delete buf[item]; snap.shippedTotal[item] = (snap.shippedTotal[item] ?? 0) + n; events.push({ kind: 'shipped', item, count: n, tick: curTick }); + noteShipped(item, n); creditCommission(item, n); } } @@ -1113,6 +1226,7 @@ export function createSim(): Sim { const step = (SWARM_DRIFT_TILES_PER_SEC / TICKS_PER_SECOND) * mult; for (const w of wildlife) { if (w.kind !== 'mosquito-swarm') continue; + if (frozenAt(w.pos.x, w.pos.y)) continue; // inside a lock, nothing moves. Nothing. // Production machinery only — never power, belts, splitters or uplinks. A power // plant reads as permanently "running" and would hoover up every swarm in the world // to no effect, which is exactly as silly on screen as it sounds. @@ -1228,6 +1342,9 @@ export function createSim(): Sim { if ((itemTier.get(it.item) ?? 0) < MITE_TIER_MIN) continue; const p = beltItemPos(it); if (!p) continue; + // Cargo under a stasis lock is not a target: it cannot be reached, and a mite that + // picked one would camp an unreachable item for the whole lock. + if (frozenAt(p.x, p.y)) 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; @@ -1300,6 +1417,7 @@ export function createSim(): Sim { for (let i = wildlife.length - 1; i >= 0; i--) { const w = wildlife[i]; if (w.kind !== 'parity-mite') continue; + if (frozenAt(w.pos.x, w.pos.y)) continue; // even The Correction's own stop at 0:00 // Sated: head home. Reaching the rim, the mite leaves with its correction. if (w.size >= 1) { @@ -1327,7 +1445,9 @@ export function createSim(): Sim { eatBeltItem(done); events.push({ kind: 'repaired', item, - pos: { x: Math.round(w.pos.x), y: Math.round(w.pos.y) }, tick: curTick, + pos: { x: Math.round(w.pos.x), y: Math.round(w.pos.y) }, + by: w.id, // v9.3: the mite's wildlife id — RENDER's sparkle can stop guessing + tick: curTick, }); w.size = 1; // sated: now carrying the correction home continue; @@ -1370,6 +1490,617 @@ export function createSim(): Sim { wildlifeEvent('cleared', w); } + // ================================================================ THE CORRECTION + // + // Rungs 2-4 of the escalation ladder (the mite is rung 1 and stays in `wildlife`). + // Everything here hangs off ONE scalar: PARITY DEBT. Ship deviation and it climbs; + // ship stock footage, or file the enemy's own paperwork, and it is amortised away. + // Five bands gate which rungs get dispatched, and nothing else in the sim needs to + // know more than the band. + // + // The Correction does not roll dice. Every unit's arrival point is a pure function of + // the target it was dispatched to, so no RNG is consumed here at all — wildlife and + // mites keep the exact spawn stream they had before this code existed, and a warden + // cycle is reproducible from the save alone. + + // ------------------------------------------------------------------ parity debt + + /** Debt moves through here and nowhere else, so the 0..1 clamp can't be forgotten. */ + function addDebt(d: number): void { + debt = Math.max(0, Math.min(1, debt + d)); + } + + /** + * Shipping is the whole ledger. Deviation (tier>=2) raises the figure; stock footage + * (tier-0/1) buys credit against it, spent later at a capped rate so one enormous clean + * shipment can't strobe the bands. + */ + function noteShipped(item: string, count: number): void { + if ((itemTier.get(item) ?? 0) >= DEBT_TIER_MIN) { + addDebt(DEBT_PER_CORRUPT_SHIP * count); + } else { + cleanCredit = Math.min(DEBT_CLEAN_CREDIT_MAX, cleanCredit + DEBT_CLEAN_CREDIT_PER_ITEM * count); + } + } + + /** + * Bands are sticky on the way down (the same idea as heat's scram/restart window). Debt + * parked on a threshold would otherwise emit `debtBand` every few ticks forever and + * strobe the UI gauge; a real de-escalation has to clear the floor by a margin. + */ + function updateBand(): void { + let b: 0 | 1 | 2 | 3 | 4 = 0; + for (let i = 0; i < DEBT_BANDS.length; i++) { + const need = band > i ? DEBT_BANDS[i] - DEBT_BAND_HYSTERESIS : DEBT_BANDS[i]; + if (debt >= need) b = (i + 1) as 0 | 1 | 2 | 3 | 4; + } + if (b === band) return; + band = b; + events.push({ kind: 'debtBand', band, tick: curTick }); + } + + // ------------------------------------------------------------------- deviation + + /** Sector rect containing a tile, on the world-aligned GUNSHIP_SECTOR grid. */ + function sectorAt(x: number, y: number): { x: number; y: number; w: number; h: number } { + const sx = GRID_MIN + Math.floor((x - GRID_MIN) / GUNSHIP_SECTOR) * GUNSHIP_SECTOR; + const sy = GRID_MIN + Math.floor((y - GRID_MIN) / GUNSHIP_SECTOR) * GUNSHIP_SECTOR; + return { x: sx, y: sy, w: GUNSHIP_SECTOR, h: GUNSHIP_SECTOR }; + } + + const inRect = (r: { x: number; y: number; w: number; h: number }, x: number, y: number): boolean => + x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h; + + /** + * Machines that can deviate at all — the only things The Correction can see. A machine + * already carrying a compliance seal is invisible: it has been brought into compliance and + * there is nothing left to correct. Without this a second warden walks back to the machine + * the first one sealed and audits an empty buffer forever, which is not menace, it's a loop. + */ + const auditable = (e: Ent): boolean => + (e.def.kind === 'crafter' || e.def.kind === 'extractor') + && e.corruptOut > 0 && !e.state.sealed; + + /** + * The single most corrupting machine in the world (or in a rect). RULING (round 7): the + * measure is DEVIATION — lifetime tier>=2 output — not throughput. The Correction repairs + * corruption; a hugely productive ore line is of no interest to it whatsoever. + */ + function worstMachine(rect?: { x: number; y: number; w: number; h: number }): Ent | null { + let best: Ent | null = null; + for (const e of ents) { + if (!auditable(e)) continue; + if (rect && !inRect(rect, e.state.pos.x, e.state.pos.y)) continue; + // Ties break on id, never on iteration luck. + if (!best || e.corruptOut > best.corruptOut) best = e; + } + return best; + } + + /** The 8x8 sector carrying the most deviation, with its PARITY ANCHOR. */ + function worstSector(): { rect: { x: number; y: number; w: number; h: number }; anchor: Ent } | null { + const score = new Map(); + for (const e of ents) { + if (!auditable(e)) continue; + const s = sectorAt(e.state.pos.x, e.state.pos.y); + const k = tileKey(s.x, s.y); + score.set(k, (score.get(k) ?? 0) + e.corruptOut); + } + let bestKey = -1; + let bestScore = 0; + for (const [k, v] of score) { + if (v > bestScore || (v === bestScore && bestKey >= 0 && k < bestKey)) { bestKey = k; bestScore = v; } + } + if (bestKey < 0) return null; + // Recover the sector origin from the machine that scored it (cheaper than unpacking + // the tile key, and guaranteed consistent with sectorAt). + let rect: { x: number; y: number; w: number; h: number } | null = null; + for (const e of ents) { + if (!auditable(e)) continue; + const s = sectorAt(e.state.pos.x, e.state.pos.y); + if (tileKey(s.x, s.y) === bestKey) { rect = s; break; } + } + if (!rect) return null; + const anchor = worstMachine(rect); + return anchor ? { rect, anchor } : null; + } + + // ---------------------------------------------------------------------- stasis + + /** Rebuilt once per tick — the fast path is the empty array, which is almost always. */ + function refreshStasis(): void { + if (!activeStasis.length && !cunits.some((u) => u.state.phase === 'stasis')) return; + activeStasis = []; + for (const u of cunits) { + if (u.state.phase === 'stasis' && u.state.rect) activeStasis.push(u.state.rect); + } + } + + /** + * Is this point inside an active stasis lock? Used for wildlife, which has no footprint. + * A mite inside a locked sector stops with everything else — the sector is at 0:00, and a + * beetle drone quietly eating frozen cargo would make the freeze a lie (it also broke + * SCREEN's "byte-identical on resume" the first time round). + */ + function frozenAt(x: number, y: number): boolean { + if (!activeStasis.length) return false; + for (const r of activeStasis) if (inRect(r, Math.round(x), Math.round(y))) return true; + return false; + } + + /** Is any tile of this entity's footprint inside an active stasis lock? */ + function frozenEnt(e: Ent): boolean { + if (!activeStasis.length) return false; + const fp = footprintOf(e.def.footprint, e.state.dir); + for (const r of activeStasis) { + if (e.state.pos.x + fp.x - 1 < r.x || e.state.pos.x > r.x + r.w - 1) continue; + if (e.state.pos.y + fp.y - 1 < r.y || e.state.pos.y > r.y + r.h - 1) continue; + return true; + } + return false; + } + + /** + * The lock's published hash. Deterministic (FNV-1a over the rect, the unit and the tick) + * because a save must reload into the same lock, and because a hash that changed on reload + * would make the moov-heist minigame unimplementable later. + */ + function lockHashFor(u: CUnit): string { + const r = u.state.rect!; + const s = `${u.state.id}:${curTick}:${r.x},${r.y},${r.w},${r.h}`; + let h = 2166136261 >>> 0; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619) >>> 0; + return h.toString(16).toUpperCase().padStart(8, '0'); + } + + // ----------------------------------------------------------------- unit plumbing + + function spawnUnit(kind: CorrectionUnit['kind'], pos: Vec2): CUnit { + const state: CorrectionUnit = { + id: nextCUnitId++, kind, pos: { x: pos.x, y: pos.y }, phase: 'notice', progress: 0, + }; + const u: CUnit = { state, timer: 0, idle: 0, sawCraft: false, lockHash: '' }; + cunits.push(u); + cunitStates.push(state); + // Fax first, always, for every rung — contracts v9 makes this structural rather than + // polite. Nothing The Correction does is ever the first the player hears of it. + // v9.3: `pos` is where the unit has materialised (its rim entry point), so RENDER can + // aim the notice ping at the world instead of reading it back out of UI's DOM. + events.push({ kind: 'notice', unit: kind, pos: { x: pos.x, y: pos.y }, tick: curTick }); + return u; + } + + function despawnUnit(u: CUnit): void { + const i = cunits.indexOf(u); + if (i < 0) return; + // A lock always lifts with its auditor, however the auditor leaves. + if (u.state.phase === 'stasis' && u.state.rect) releaseStasis(u); + cunits.splice(i, 1); + cunitStates.splice(i, 1); + cooldown[u.state.kind] = CORRECTION_RESPAWN_TICKS; + } + + /** + * A firewall cans a unit. RULING (round 7): this adds NO debt — an immune system does not + * hold a grudge, and "it cannot perceive you" is the whole joke. Destroying the enemy is + * the one act in this game with no paperwork consequence. + */ + function canUnit(u: CUnit): void { + despawnUnit(u); + } + + function setPhase(u: CUnit, phase: CorrectionUnit['phase']): void { + u.state.phase = phase; + u.state.progress = 0; + u.state.remaining = undefined; + u.timer = 0; + } + + /** + * v9.2 (UI request): ticks left in the current phase, so a countdown chip is exact from + * frame one instead of being reverse-engineered from `progress`, and survives save/load + * because it is recomputed from the same timer the phase itself runs on. Phases with no + * fixed length (a gunship's tether lasts as long as you feed it) leave it undefined — + * that is the signal there is nothing to count down. + */ + function phaseClock(u: CUnit, total: number): void { + u.state.progress = Math.min(1, u.timer / total); + u.state.remaining = Math.max(0, total - u.timer); + } + + /** Move a unit toward a point; returns the chebyshev gap remaining before the step. */ + function driftUnit(u: CUnit, tx: number, ty: number, step: number): number { + const dx = tx - u.state.pos.x; + const dy = ty - u.state.pos.y; + const gap = Math.max(Math.abs(dx), Math.abs(dy)); + const len = Math.hypot(dx, dy) || 1; + u.state.pos.x += (dx / len) * step; + u.state.pos.y += (dy / len) * step; + return gap; + } + + const entCentre = (e: Ent): Vec2 => { + const fp = footprintOf(e.def.footprint, e.state.dir); + return { x: e.state.pos.x + (fp.x - 1) / 2, y: e.state.pos.y + (fp.y - 1) / 2 }; + }; + + /** + * A FIREWALL CATCHES WHAT WALKS. That is the whole rule, and it is the round-7 ruling that + * keeps rungs 3 and 4 from being deleted by a 2x2 machine: + * + * - parity mites walk. Cannable, as since round 6. + * - CHECKSUM WARDENS walk — a filing-cabinet golem crossing your floor. Cannable, and + * that is the counter the orders name. + * - REDUNDANCY GUNSHIPS are AIRBORNE, hovering above the sector they mirror. A ground + * appliance cannot reach one. The codex gives this rung its own counter and says so + * exactly: "Kill the parity link to pop it" — break the tether by starving, sealing, + * idling or removing the PARITY ANCHOR. (Round 7 measured the alternative: LANE-UI saw + * a gunship canned during its own notice window in 12,000 ticks of play and `mirrored` + * never once firing. A rung with a hard counter you already built is not content.) + * - HASH AUDITORS move only between frames and are never present to be caught. Their + * counters are abstention and the forged hash. + * + * Nothing is cannable during 'notice' either: the paperwork is not yet actionable. + */ + function firewallPrey(e: Ent): { mite?: WildlifeState; unit?: CUnit } | null { + const mite = miteInFirewallRange(e); + let bestUnit: CUnit | undefined; + let bestGap = Infinity; + for (const u of cunits) { + if (u.state.kind !== 'checksum-warden') continue; // only the one that walks + if (u.state.phase === 'notice') continue; + const g = gapToEntity(e, Math.round(u.state.pos.x), Math.round(u.state.pos.y)); + if (g <= FIREWALL_RADIUS && (g < bestGap || (bestUnit && g === bestGap && u.state.id < bestUnit.state.id))) { + bestUnit = u; + bestGap = g; + } + } + // The BIGGER unit first when both are in reach. Measured with mites taking priority: + // a firewall on a busy line spends its whole life re-arming on the mite trickle and + // never once gets a charge off at the warden walking past it, so the guard reads as + // decorative. A warden is worth any number of mites — it seals a machine dead. + if (bestUnit) return { unit: bestUnit }; + return mite ? { mite } : null; + } + + // -------------------------------------------------------------- checksum warden + + /** + * The audit lands: roll back the last N outputs and stamp the machine with a compliance + * seal. The rollback is real — those items exist in the world with stable ids, so they are + * found and corrected out of existence, newest first. `repaired` is the honest event for + * that: it is the same class of theft as a mite's, and SCREEN already subtracts it from + * the sky ledger. Anything the ring buffer can't account for is taken from the machine's + * own output buffer instead, so an audit is never a no-op on a working machine. + */ + function rollbackOutputs(e: Ent, by: number): void { + let left = WARDEN_ROLLBACK_N; + for (let i = e.outHist.length - 1; i >= 0 && left > 0; i--) { + const rec = e.outHist[i]; + const it = allBeltItems.find((b) => b.id === rec.id); + if (!it) continue; // already consumed or shipped: gone is gone, not double-counted + const p = beltItemPos(it); + eatBeltItem(it); + events.push({ + kind: 'repaired', item: it.item, + pos: { x: Math.round(p?.x ?? e.state.pos.x), y: Math.round(p?.y ?? e.state.pos.y) }, + by, tick: curTick, + }); + left--; + } + for (const item of Object.keys(e.state.outputBuf).sort()) { + while (left > 0 && (e.state.outputBuf[item] ?? 0) > 0) { + const n = e.state.outputBuf[item] - 1; + if (n > 0) e.state.outputBuf[item] = n; + else delete e.state.outputBuf[item]; + events.push({ + kind: 'repaired', item, + pos: { x: e.state.pos.x, y: e.state.pos.y }, by, tick: curTick, + }); + left--; + } + } + e.outHist.length = 0; + } + + function sealMachine(e: Ent): void { + if (e.state.sealed) return; + e.state.sealed = true; + // The in-flight craft is forfeit. Its inputs were already consumed, which is exactly + // what makes an audit sting — and it frees `state.progress` to drive the pry bar + // unambiguously while the seal is on. + e.crafting = false; + e.state.progress = 0; + e.pryTimer = 0; + setJam(e, 'sealed'); + events.push({ kind: 'sealed', entity: e.state.id, on: true, tick: curTick }); + } + + /** + * `pry` starts the work; the sim owns the clock from there. One command is enough — the + * player is not asked to spam it — and `state.progress` carries the bar for UI/RENDER. + */ + function doPry(pos: Vec2): void { + const e = entAt(pos.x, pos.y); + if (!e || !e.state.sealed || e.pryTimer > 0) return; + e.pryTimer = 1; + pryingCount++; + e.state.progress = 1 / PRY_TICKS; + } + + function advancePry(): void { + // Almost always nothing is being pried, and a full entity scan every tick to discover + // that would be a new O(n) cost on the hot loop for a rare player verb. Belts are 99% of + // a big factory and can never be sealed; this is the same early-out discipline + // `updateHeat` uses, and it keeps v9 off the tick budget entirely when nothing is sealed. + if (pryingCount <= 0) return; + for (const e of ents) { + if (e.pryTimer <= 0) continue; + if (!e.state.sealed) { e.pryTimer = 0; pryingCount--; e.state.progress = 0; continue; } + if (frozenEnt(e)) continue; + e.pryTimer++; + if (e.pryTimer < PRY_TICKS) { + e.state.progress = e.pryTimer / PRY_TICKS; + continue; + } + e.pryTimer = 0; + pryingCount--; + e.state.progress = 0; + e.state.sealed = false; + setJam(e, null); + events.push({ kind: 'sealed', entity: e.state.id, on: false, tick: curTick }); + // You defaced a compliance notice. That is itself deviation, and it is filed. + addDebt(DEBT_PER_PRY); + } + } + + function updateWarden(u: CUnit): void { + u.timer++; + switch (u.state.phase) { + case 'notice': { + phaseClock(u, WARDEN_NOTICE_TICKS); + if (u.timer < WARDEN_NOTICE_TICKS) return; + // Re-pick at the end of the window: 20 seconds is long enough for the machine + // named in the docket to have been torn down, and a warden auditing a hole would + // be a bug the player could never see the cause of. + const t = worstMachine(); + if (!t) { setPhase(u, 'leaving'); return; } + u.state.target = t.state.id; + setPhase(u, 'approach'); + return; + } + case 'approach': { + const t = u.state.target === undefined ? undefined : byId.get(u.state.target); + if (!t) { setPhase(u, 'leaving'); return; } + const c = entCentre(t); + const step = WARDEN_WALK_TILES_PER_SEC / TICKS_PER_SECOND; + const gap = driftUnit(u, c.x, c.y, step); + // A walk has no fixed length, but it has a knowable one: distance over speed. + u.state.remaining = Math.max(0, Math.ceil((gap - WARDEN_REACH_RANGE) / step)); + u.state.progress = 0; + if (gap <= WARDEN_REACH_RANGE) setPhase(u, 'audit'); + return; + } + case 'audit': { + const t = u.state.target === undefined ? undefined : byId.get(u.state.target); + if (!t) { setPhase(u, 'leaving'); return; } + phaseClock(u, WARDEN_AUDIT_TICKS); + if (u.timer < WARDEN_AUDIT_TICKS) return; + rollbackOutputs(t, u.state.id); + sealMachine(t); + setPhase(u, 'leaving'); + return; + } + default: { + const rim = nearestRim(u.state.pos.x, u.state.pos.y); + const step = WARDEN_WALK_TILES_PER_SEC / TICKS_PER_SECOND; + if (driftUnit(u, rim.x, rim.y, step) <= step) despawnUnit(u); + } + } + } + + // ------------------------------------------------------------ redundancy gunship + + function updateGunship(u: CUnit): void { + u.timer++; + if (u.state.phase === 'notice') { + phaseClock(u, GUNSHIP_NOTICE_TICKS); + if (u.timer < GUNSHIP_NOTICE_TICKS) return; + const s = worstSector(); + if (!s) { setPhase(u, 'leaving'); return; } + u.state.rect = s.rect; + u.state.target = s.anchor.state.id; + u.state.pos = { x: s.rect.x + (s.rect.w - 1) / 2, y: s.rect.y + (s.rect.h - 1) / 2 }; + setPhase(u, 'tether'); + u.idle = 0; + return; + } + if (u.state.phase === 'tether') { + const anchor = u.state.target === undefined ? undefined : byId.get(u.state.target); + // Every way of breaking the tether reduces to one measurement: the anchor stopped + // producing deviation. Starve it, seal it, idle it or tear it out — same answer. + const dead = !anchor || anchor.state.sealed || u.idle >= GUNSHIP_ANCHOR_IDLE_TICKS; + if (dead) { u.state.target = undefined; u.state.rect = undefined; setPhase(u, 'leaving'); return; } + u.idle++; + return; + } + // Leaving: it climbs out of the sector. Airborne, so it just goes. + const rim = nearestRim(u.state.pos.x, u.state.pos.y); + const step = (WARDEN_WALK_TILES_PER_SEC * 2) / TICKS_PER_SECOND; + if (driftUnit(u, rim.x, rim.y, step) <= step) despawnUnit(u); + } + + // ----------------------------------------------------------------- hash auditor + + function releaseStasis(u: CUnit): void { + if (!u.state.rect) return; + events.push({ + kind: 'stasis', rect: { ...u.state.rect }, on: false, lockHash: u.lockHash, tick: curTick, + }); + u.lockHash = ''; + activeStasis = activeStasis.filter((r) => r !== u.state.rect); + } + + /** Does any machine inside the rect hold a FORGED HASH? That signature checks out. */ + function forgedHashInRect(rect: { x: number; y: number; w: number; h: number }): boolean { + for (const e of ents) { + if (!inRect(rect, e.state.pos.x, e.state.pos.y)) continue; + if ((e.state.inputBuf[FORGED_HASH_ITEM] ?? 0) > 0) return true; + if ((e.state.outputBuf[FORGED_HASH_ITEM] ?? 0) > 0) return true; + } + return false; + } + + function updateAuditor(u: CUnit): void { + u.timer++; + switch (u.state.phase) { + case 'notice': { + phaseClock(u, AUDITOR_NOTICE_TICKS); + if (u.timer < AUDITOR_NOTICE_TICKS) return; + const s = worstSector(); + if (!s) { setPhase(u, 'leaving'); return; } + u.state.rect = s.rect; + // It moves only between frames: no approach, it is simply there. + u.state.pos = { x: s.rect.x + (s.rect.w - 1) / 2, y: s.rect.y + (s.rect.h - 1) / 2 }; + setPhase(u, 'prescan'); + u.sawCraft = false; + return; + } + case 'prescan': { + phaseClock(u, AUDITOR_PRESCAN_TICKS); + if (u.timer < AUDITOR_PRESCAN_TICKS) return; + // ABSTENTION — the counter that needs no machine. One byte of deviation in the scan + // radius and it refuses; zero bytes and it finds nothing and files nothing. + if (!u.sawCraft) { u.state.rect = undefined; setPhase(u, 'leaving'); return; } + // FORGED HASH — the material counter. A signature that checks out ends the audit + // just as completely, and costs you a tier-3 item to hold in the sector. + if (u.state.rect && forgedHashInRect(u.state.rect)) { + u.state.rect = undefined; + setPhase(u, 'leaving'); + return; + } + setPhase(u, 'stasis'); + u.lockHash = lockHashFor(u); + activeStasis.push(u.state.rect!); + events.push({ + kind: 'stasis', rect: { ...u.state.rect! }, on: true, lockHash: u.lockHash, tick: curTick, + }); + return; + } + case 'stasis': { + phaseClock(u, AUDITOR_STASIS_TICKS); + // THE SAFETY. Auto-release is contractual, not tunable: the lock lifts after + // AUDITOR_STASIS_TICKS whether or not the player does anything at all, and there is + // deliberately no branch here that can extend it. correction.test.ts pins this. + if (u.timer < AUDITOR_STASIS_TICKS) return; + releaseStasis(u); + u.state.rect = undefined; + setPhase(u, 'leaving'); + return; + } + default: + despawnUnit(u); // it does not walk away; it stops being there + } + } + + // ------------------------------------------------------------------- dispatch + + const BAND_FOR: ReadonlyArray<{ kind: CorrectionUnit['kind']; band: number }> = [ + { kind: 'checksum-warden', band: 2 }, + { kind: 'redundancy-gunship', band: 3 }, + { kind: 'hash-auditor', band: 4 }, + ]; + + function dispatchUnits(): void { + for (const { kind, band: need } of BAND_FOR) { + if (band < need) continue; + if ((cooldown[kind] ?? 0) > 0) continue; + if (cunits.some((u) => u.state.kind === kind)) continue; + // Nothing to correct means nothing is dispatched, whatever the debt says. Debt is a + // history; deviation on the floor is what a unit is actually sent to. + const t = kind === 'checksum-warden' ? worstMachine() : worstSector()?.anchor; + if (!t) continue; + // Every rung enters from the edge of the file and takes station only once its notice + // window closes. Nothing is ever dispatched INTO your factory — the fax arrives, then + // the unit does, which is both the fiction and what makes the warning worth anything. + spawnUnit(kind, nearestRim(t.state.pos.x, t.state.pos.y)); + } + } + + /** + * Called on every completed craft. This is where DEVIATION is measured and where the + * gunship's mirror and the auditor's prescan hear the factory working. + */ + function noteCraft(e: Ent, r: RecipeDef): void { + // A creature-canner is not a deviating machine. DATA gives the canned mite tier 2 (it is + // real cargo and SELLING one really is deviation, per round 6), but the act of catching + // one is not production — and counting it made a firewall accrue DEVIATION from its own + // kills until it became the single most corrupting machine in the world, at which point + // a warden walked over and sealed the anti-Correction machine and rolled back its + // evidence. Very funny; entirely wrong. Shipping the cargo still costs debt, as before. + if (trapRecipeIds.has(r.id) || firewallRecipeIds.has(r.id)) return; + + let corrupt = 0; + let worst = ''; + for (const k in r.outputs) { + if ((itemTier.get(k) ?? 0) < DEBT_TIER_MIN) continue; + corrupt += r.outputs[k]; + if (worst === '' || k < worst) worst = k; + } + if (corrupt > 0) e.corruptOut += corrupt; + if (!cunits.length) return; + + for (const u of cunits) { + if (u.state.rect === undefined) continue; + if (!inRect(u.state.rect, e.state.pos.x, e.state.pos.y)) continue; + + if (u.state.kind === 'hash-auditor' && u.state.phase === 'prescan') { + u.sawCraft = true; // one byte of deviation is enough — and so is one clean craft + continue; + } + if (u.state.kind !== 'redundancy-gunship' || u.state.phase !== 'tether') continue; + if (u.state.target === e.state.id) u.idle = 0; + // Only deviation feeds the mirror. A sector busily making ore is of no interest: + // the gunship corrects corruption, not productivity. + if (corrupt <= 0) continue; + u.state.progress += GUNSHIP_PROGRESS_PER_CRAFT * corrupt; + if (u.state.progress < 1) continue; + u.state.progress = 0; + // The clean parallel copy out-ships you: the theft is on the ledger, not the belt. + // Nothing is removed from the world — SCREEN subtracts it from the sky, which is + // where the damage of being out-shipped by yourself actually lands. + events.push({ + kind: 'mirrored', item: worst, + pos: { x: e.state.pos.x, y: e.state.pos.y }, by: u.state.id, tick: curTick, + }); + } + } + + /** One tick of THE CORRECTION: amortise, band, dispatch, then run every live unit. */ + function updateCorrection(): void { + addDebt(-DEBT_DECAY_PER_TICK); + if (cleanCredit > 0) { + const spend = Math.min(cleanCredit, DEBT_CLEAN_DECAY_MAX_PER_TICK); + cleanCredit -= spend; + addDebt(-spend); + } + updateBand(); + + for (const k in cooldown) if (cooldown[k] > 0) cooldown[k]--; + dispatchUnits(); + advancePry(); + + for (let i = cunits.length - 1; i >= 0; i--) { + const u = cunits[i]; + if (u.state.kind === 'checksum-warden') updateWarden(u); + else if (u.state.kind === 'redundancy-gunship') updateGunship(u); + else updateAuditor(u); + } + + correction.debt = debt; + correction.band = band; + } + // --------------------------------------------------------------------- api return { @@ -1437,6 +2168,20 @@ export function createSim(): Sim { nextWildlifeId = 1; swarmBite.clear(); + cunits = []; + cunitStates = []; + nextCUnitId = 1; + debt = 0; + cleanCredit = 0; + band = 0; + cooldown = {}; + activeStasis = []; + pryingCount = 0; + correction.debt = 0; + correction.band = 0; + correction.units = cunitStates; + snap.correction = correction; + snap.tick = 0; snap.paused = false; snap.entities = entityStates; @@ -1461,6 +2206,9 @@ export function createSim(): Sim { cmdQueue.length = 0; if (paused) return; // tick is sim-time; pausing freezes it + // Which sectors are locked is decided before anything in them tries to run, so a + // stasis is total for the whole tick rather than half-applied down the phase list. + refreshStasis(); // 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); @@ -1474,6 +2222,9 @@ export function createSim(): Sim { moveBelts(mult); drainShippers(); drainLabs(); + // Last, so this tick's shipments are already on the ledger when debt is banded and + // the ladder decides what to dispatch. + updateCorrection(); curTick++; snap.tick = curTick; @@ -1498,7 +2249,7 @@ export function createSim(): Sim { */ save(): string { return JSON.stringify({ - v: 4, + v: 5, tick: curTick, paused, brownout, @@ -1512,6 +2263,17 @@ export function createSim(): Sim { relics, nextWildlifeId, nextRelicId, + // v5: THE CORRECTION. Debt, the banked clean credit, the band latch (bands are + // sticky, so the latch is state, not a derivation) and every live unit with the + // bookkeeping the snapshot hides — a save mid-audit reloads mid-audit. + debt, + cleanCredit, + band, + cooldown, + nextCUnitId, + cunits: cunits.map((u) => ({ + state: u.state, timer: u.timer, idle: u.idle, sawCraft: u.sawCraft, lockHash: u.lockHash, + })), shippedTotal: snap.shippedTotal, commissionProgress: snap.commissionProgress, pending: cmdQueue, // commands enqueued but not yet applied @@ -1522,6 +2284,9 @@ export function createSim(): Sim { queue: e.queue, cursor: e.cursor, labTimer: e.labTimer, + outHist: e.outHist, + corruptOut: e.corruptOut, + pryTimer: e.pryTimer, })), beltItems: allBeltItems, }); @@ -1529,7 +2294,7 @@ export function createSim(): Sim { load(json: string): void { const s = JSON.parse(json); - if (s.v !== 4) throw new Error(`sim.load: unsupported save version ${s.v}`); + if (s.v !== 5) throw new Error(`sim.load: unsupported save version ${s.v}`); ents = []; entityStates = []; @@ -1560,6 +2325,7 @@ export function createSim(): Sim { const e: Ent = { state, def, crafting: rec.crafting, scrammed: rec.scrammed, tiles, queue: rec.queue, cursor: rec.cursor, labTimer: rec.labTimer ?? 0, + outHist: rec.outHist ?? [], corruptOut: rec.corruptOut ?? 0, pryTimer: rec.pryTimer ?? 0, }; ents.push(e); entityStates.push(state); @@ -1590,6 +2356,34 @@ export function createSim(): Sim { nextWildlifeId = s.nextWildlifeId ?? 1; nextRelicId = s.nextRelicId ?? 1; swarmBite.clear(); + + // Derived, not saved: one source of truth is the pryTimer on each entity, so a blob + // can never disagree with itself about how many machines are being pried. + pryingCount = 0; + for (const e of ents) if (e.pryTimer > 0) pryingCount++; + + debt = s.debt ?? 0; + cleanCredit = s.cleanCredit ?? 0; + band = s.band ?? 0; + cooldown = s.cooldown ?? {}; + nextCUnitId = s.nextCUnitId ?? 1; + cunits = []; + cunitStates = []; + for (const rec of (s.cunits ?? []) as CUnit[]) { + cunits.push({ + state: rec.state, timer: rec.timer, idle: rec.idle, + sawCraft: rec.sawCraft, lockHash: rec.lockHash ?? '', + }); + cunitStates.push(rec.state); + } + // Locks are rebuilt from the units rather than saved separately — one source of truth, + // so a restored auditor cannot end up holding a lock nothing is tracking. + activeStasis = []; + for (const u of cunits) if (u.state.phase === 'stasis' && u.state.rect) activeStasis.push(u.state.rect); + correction.debt = debt; + correction.band = band; + correction.units = cunitStates; + for (const cmd of s.pending ?? []) cmdQueue.push(cmd); snap.tick = curTick; @@ -1604,6 +2398,7 @@ export function createSim(): Sim { snap.research = research; snap.wildlife = wildlife; snap.relics = relics; + snap.correction = correction; }, }; } diff --git a/fktry/src/sim/reference.ts b/fktry/src/sim/reference.ts index 7ea5bbe..7bcfe67 100644 --- a/fktry/src/sim/reference.ts +++ b/fktry/src/sim/reference.ts @@ -85,10 +85,39 @@ export function referenceFactory(data: GameData): Command[] { const cmds: Command[] = []; let placed = 0; + /** Tiles this layout has already claimed — see the collision guard in P(). */ + const claimed = new Map(); - /** Places a machine and returns the entity id the sim will assign it. */ + /** + * Places a machine and returns the entity id the sim will assign it. + * + * The returned id is `placed + 1` because the sim hands ids out in placement order — which + * is true only while every placement SUCCEEDS. A colliding `place` is dropped silently by + * the sim (that's the contract), so one bad tile would shift every later id by one and + * quietly re-point every subsequent `setRecipe` at the wrong machine. That is not a + * hypothetical: it happened while adding the brick-works guard this round, and the symptom + * was the annex mis-reciping itself 40 tiles away. Round 2 flagged the coupling; this is the + * tripwire. Overlap now throws here, naming both machines, instead of surfacing as a mystery. + */ const P = (def: string, x: number, y: number, dir: Dir = N): number => { - cmds.push({ kind: 'place', def: machine(def), pos: { x, y }, dir }); + const id = machine(def); + const fp = data.machines.find((m) => m.id === id)!.footprint; + const w = dir === 1 || dir === 3 ? fp.y : fp.x; + const h = dir === 1 || dir === 3 ? fp.x : fp.y; + for (let dx = 0; dx < w; dx++) { + for (let dy = 0; dy < h; dy++) { + const k = `${x + dx},${y + dy}`; + const owner = claimed.get(k); + if (owner !== undefined) { + throw new Error( + `referenceFactory: "${id}" at ${x},${y} overlaps "${owner}" at tile ${k} — the sim ` + + 'would drop this placement and shift every later entity id', + ); + } + claimed.set(k, id); + } + } + cmds.push({ kind: 'place', def: id, pos: { x, y }, dir }); return ++placed; }; const b = (x: number, y: number, dir: Dir): void => { P('belt', x, y, dir); }; @@ -117,7 +146,12 @@ export function referenceFactory(data: GameData): Command[] { // the decoder is down = ~450 bandwidth-seconds, and three tanks hold 900. Two ASICs // fewer and it survives only while compression happens to be running — measured that, // it bottomed out at 2.2 of 600, which is not a margin, it's a coincidence. + // v7 adds a fifth ASIC: the compliance tap, the second crime quantizer and the brick-works + // guard put ~20 more draw on the bus, and the invariant this build defends is ZERO brownout + // ticks across 20,000 — including the ~16.7s the software decoder spends scrammed, when the + // tanks are carrying the whole deficit alone. for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -14); + for (let i = 0; i < 4; i++) P('decode-asic', -6 + i * 3, -14); P('software-decoder', -18, -14); P('buffer-tank', -15, -14); P('buffer-tank', -12, -14); @@ -147,18 +181,52 @@ export function referenceFactory(data: GameData): Command[] { runX(-21, -20, 0, E); P('quantizer', -19, 0); // A1: quantize (default) runX(-17, -16, 0, E); // coefficient packs -> press - runY(-1, -2, -19, N); // hf dust -> uplink - P('shipper', -19, -4); + // A1's hf dust forks: half to an uplink, half to a CANNING LINE. Rung 5 of the commission + // ladder (`dust-allowance`, 12 STATIC CANISTERS, standing order) sits between the demo and + // the first `compliance:true` quota, so without this the debt valve stays unreachable no + // matter how much ore and luma the tap ships. The splitter matters: a bottler that backs up + // must never be able to wedge A1's dust, because A1 wedging starves the press and the press + // is the melt line. Skip-blocked means the uplink always takes the surplus. + b(-19, -1, N); + P('lane-splitter', -19, -2); + b(-20, -2, N); // the uplink half + b(-20, -3, N); + P('shipper', -21, -5); + b(-18, -2, E); // the canning half + b(-17, -2, E); + const canner = P('artifact-bottler', -16, -2); + setRecipe(canner, 'can-static'); + b(-15, -3, N); + P('shipper', -16, -5); + + // ---- THE BRICK WORKS, doubled (v7). Macroblock bricks are the demo's bulk tier-2 + // output, which makes the crime quantizer the single most DEVIATING machine in the world + // — and therefore the machine every CHECKSUM WARDEN walks to. Measured with one crime + // quantizer: the warden seals it at t12073 and the melt line dies behind it, because the + // crime lane is A2's only surplus sink. Coefficient packs back up into the demuxer, the + // mv-flux lane starves, and melt freezes forever at 69. That is round 1's deadlock, + // re-created by the enemy instead of by bad plumbing. + // + // The answer is the same one the splitters gave round 1, aimed at a new cause: TWO crime + // quantizers behind a splitter. A seal is just a blocked lane — the splitter skips it and + // the whole coefficient surplus walks to the other one, so the audit costs throughput + // instead of the factory. Redundancy against the audit; nothing is voided, and brick + // output is unchanged because A2's supply, not the quantizers, is the limit. runY(1, 3, -22, S); P('quantizer', -23, 4); // A2: quantize (default) - runX(-21, -20, 4, E); // coefficient packs -> crime - const crime = P('quantizer', -19, 4); - setRecipe(crime, 'quantize-crime'); - runY(6, 7, -23, S); // A2's hf dust -> uplink + b(-21, 4, E); // coefficient packs -> the crime split + P('lane-splitter', -20, 4); + b(-19, 4, E); + const crimeA = P('quantizer', -18, 4); + setRecipe(crimeA, 'quantize-crime'); + b(-20, 5, W); // the second crime lane, around the back of the splitter + b(-21, 5, S); + const crimeB = P('quantizer', -22, 6); + setRecipe(crimeB, 'quantize-crime'); + runY(6, 7, -23, S); // A2's hf dust + crime B's output -> uplink P('shipper', -24, 8); - b(-19, 6, S); // crime's bricks + dust -> uplink (two lanes: crime - b(-18, 6, S); // outruns a single belt when A2 is at full tilt) + b(-18, 6, S); // crime A's bricks + dust -> the brick uplink b(-18, 7, W); P('shipper', -20, 7); @@ -208,6 +276,32 @@ export function referenceFactory(data: GameData): Command[] { 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.) + + // ---- THE BRICK-WORKS GUARD (v7). The second half of surviving THE CORRECTION, and + // the half that decides whether the demo ever sees rungs 3 and 4 at all. + // + // Escalation is self-limiting: a warden seals your deviation source, deviation stops, + // PARITY DEBT decays and the ladder quietly stands down. Measured undefended — even + // with the doubled brick works — the wardens seal both crime quantizers by t8713, brick + // output ends, and the run plateaus at band 2 having never summoned a gunship. Submit + // and the enemy loses interest; that is the loop working, not a bug. + // + // So the reference DEFENDS, and pays for it in attention: this firewall sits on the + // warden's walk-in to both crime quantizers (gap 1 to B, 4 to A) and cans most of them + // mid-approach. Deviation keeps flowing, debt keeps climbing, and the demo reaches band + // 4 — gunship and hash auditor included. The ones that slip past while it is re-arming + // land on a works that can absorb a seal. Guard AND redundancy; neither is enough alone. + // Sited south of the works, in reach of BOTH crime quantizers (gap 1 to B, 4 to A). It + // is also, unavoidably, in reach of the 8x8 sector's centre where a REDUNDANCY GUNSHIP + // takes station — the brick works is eight tiles wide and one firewall covers most of + // it, so the demo pops gunships ~120 ticks after they arrive, before a mirror can land. + // I went looking for a tile that guards the wardens without covering the gunship and + // there isn't one: every free 2x2 within reach of the quantizers is also within reach of + // (-20,4). That is a fact about this floorplan, not a ruling — `mirrored` is proven in + // correction.test.ts instead, against this same layout with the guard lifted. + const brickGuard = P(fw.machine, -22, 8); + setRecipe(brickGuard, fw.recipe); + b(-20, 9, N); // its catch rides into the brick uplink already standing there } // Recovered anchor slabs come back out of the reactor, and they go two places: the @@ -225,6 +319,32 @@ export function referenceFactory(data: GameData): Command[] { runX(-6, -7, 4, W); // the overflow: slabs for sale P('shipper', -9, 3); + // ---- THE COMPLIANCE TAP (v7), on the unused south end of the western seam. + // + // LANE-DATA measured the demo shipping ZERO raw mdat-ore across 90,000 ticks, which means + // `raw-payload` — rung 1 of the commission ladder, "a belt, a drill, an uplink, that is the + // whole job" — never cleared and the ENTIRE queue behind it was unreachable. Every rung + // above it, including this round's `compliance:true` quotas (the PARITY DEBT valve), was + // dead content in the demo. They filed it in round 5; it became load-bearing the moment + // debt started being tuned against quotas nobody could reach. + // + // So the demo now does the boring job on purpose, in its own works, touching nothing: one + // extractor, a splitter, and two destinations. Half the ore ships RAW (rung 1, and quota + // 12-A's 80 ore); the other half is demuxed and the luma/slurry/mv-flux ship as-is + // (rung 2's 15 luma bars, then quota 7-C's 40). Deliberately NOT a tap off the main trunk: + // the melt line's three seams are sized to hold the demuxer at its 45-tick cadence, and + // splitting that would have made the melt regression hostage to the paperwork. + P('seam-extractor', -30, 5); + b(-30, 7, S); + b(-30, 8, E); + P('lane-splitter', -29, 8); + b(-29, 9, S); + P('shipper', -30, 10); // RAW. Do not wash it. Do not demux it. + b(-28, 8, S); + b(-28, 9, S); + P('demuxer', -28, 10); + b(-27, 12, S); // luma + slurry + mv-flux join the chroma void uplink + // ---- THE RESEARCH ANNEX (v5), on the eastern seam. // // Deliberately a separate works rather than a tap off the melt line. The western diff --git a/fktry/src/sim/testkit.ts b/fktry/src/sim/testkit.ts index 50f08ee..5f5c768 100644 --- a/fktry/src/sim/testkit.ts +++ b/fktry/src/sim/testkit.ts @@ -29,3 +29,62 @@ export function grantResearch(sim: Sim, techIds: string[]): void { export function grantAllResearch(sim: Sim, data: GameData): void { grantResearch(sim, data.tech.map((t) => t.id)); } + +/** + * v9: sets PARITY DEBT directly, so a test can stand at the foot of a band without first + * shipping the several hundred corrupt items that would earn it. Same honest route as + * `grantResearch` — through the save blob, not a back door into the sim. + * + * The band latch is deliberately left at 0: the next tick re-derives it and emits the + * `debtBand` edges for real, which is usually the thing under test. Unlike grantResearch + * this is safe to call on a BUILT sim — the blob carries the whole world, so the factory + * comes back exactly as it was. + */ +export function setParityDebt(sim: Sim, debt: number): void { + const blob = JSON.parse(sim.save()); + blob.debt = debt; + blob.band = 0; + blob.cleanCredit = 0; + sim.load(JSON.stringify(blob)); +} + +/** Band floors, mirrored from constants so callers can say "put me at AUDITED". */ +const BAND_FLOOR = [0, 0.2, 0.4, 0.6, 0.8]; + +/** + * Stands the sim at the foot of a debt band. This is the verb other lanes actually wanted — + * LANE-UI's live tests were reaching band 4 by hand-editing the save blob, which is exactly + * the schema coupling `grantResearch` was introduced to kill for research in round 5. Use + * this instead: if the save format moves, this moves with it and your test doesn't notice. + * + * The band is NOT forced — debt is set just over the floor and the next tick derives the + * band and emits the real `debtBand` edge, so what you get is the genuine escalation rather + * than a state nothing could have reached. + */ +export function chargeDebt(sim: Sim, band: 1 | 2 | 3 | 4): void { + setParityDebt(sim, BAND_FLOOR[band] + 0.02); +} + +/** + * Dispatches one Correction unit immediately, skipping the wait for debt to earn it. The + * unit still runs its whole documented lifecycle from `notice` onward — this only removes + * the queueing, never the fax. Returns the unit id. + * + * Note it needs something to correct: like the real dispatcher, a unit sent at a world with + * no tier>=2 production anywhere will find no target and leave. Run your factory a little + * first. + */ +export function spawnCorrectionUnit( + sim: Sim, kind: 'checksum-warden' | 'redundancy-gunship' | 'hash-auditor', +): void { + const blob = JSON.parse(sim.save()); + // Clear any cooldown standing in the way and put debt at the rung's own floor, then let + // the sim's own dispatcher do the work on the next tick. Fabricating the unit record here + // would couple this helper to the internal CUnit shape, which is the thing to avoid. + const need = kind === 'checksum-warden' ? 2 : kind === 'redundancy-gunship' ? 3 : 4; + blob.debt = BAND_FLOOR[need] + 0.02; + blob.band = 0; + blob.cleanCredit = 0; + blob.cooldown = {}; + sim.load(JSON.stringify(blob)); +}