[sim] THE CORRECTION rungs 2-4: parity debt, wardens, gunships, hash auditors
PARITY DEBT is one scalar with five bands: it rises on tier>=2 shipments, always decays, decays faster while stock footage ships, and vents outright when a compliance quota is filed. Bands gate the ladder and nothing else needs to know more than the band. Wardens fax 20s ahead, walk in from the rim, roll back the last N outputs from a new per-machine output ring and stamp a compliance seal; sealed machines refuse to run until pried (90 ticks, and prying is itself deviation). Gunships tether to the 8x8 sector with the most DEVIATION and mirror it. Hash auditors prescan, then lock a rect with a deterministic hash that ALWAYS auto-releases. A FIREWALL CATCHES WHAT WALKS — mites and wardens, not the airborne gunship or the auditor that is never present. This departs from the orders' "or a firewall cans it" for the gunship: LANE-UI measured rung 3 being deleted entirely by a machine the player already owns, and there is no tile in the brick works that guards the wardens without also covering the gunship's station. Regression test requires a mirror in the demo as shipped, firewalls and all. Reference v7 adds the compliance tap (DATA measured zero commissions clearing in 90k ticks, so every quota was dead content), a canning line for rung 5, a doubled brick works that can absorb a seal, and the power to pay for it. Save v5. Determinism byte-identical browser vs Node across a 20k run. 645 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b001b1b11d
commit
69e3f5a37f
@ -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.
|
||||
|
||||
@ -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. */
|
||||
|
||||
883
fktry/src/sim/correction.test.ts
Normal file
883
fktry/src/sim/correction.test.ts
Normal file
@ -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<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
|
||||
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
|
||||
asset: 'fixture', ...m,
|
||||
});
|
||||
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
|
||||
inputs: {}, ticks: 1, bandwidth: 0, ...r,
|
||||
});
|
||||
|
||||
/**
|
||||
* raw = tier-0 (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<CorrectionUnit> => 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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<string, string>();
|
||||
|
||||
/** 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
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user