[screen] round 7: the gunship's theft, the auditor's hold, and one note at 440.000 Hz

mirrored and repaired now share ONE decrement path (correct()) and ONE placard slot —
the gunship's card is architectural (drafted sector, registration marks, no tick) where
the mite's is clerical. A sector theft and a unit theft move the ledger identically;
there is a test pinning it, because that is what a fork would break.

Stasis is implemented as a clock, not an effect: the sky gets its own feedSec, and a
hash auditor simply stops it accumulating. Release is byte-identical to the moment the
hold began — 5 seconds of audit cost the feed exactly zero — with none of the brownout's
seizure vocabulary. The transport legend flips PLAY to HOLD; that is the only pixel a
hold changes.

THE COMPLIANCE TONE: one voice of the ratify triad, A4, frequency set and never ramped.
Measured single partial, harmonics at the double-precision floor, pitch wander 0.000 Hz,
zero sidebands, bit-identical across renders — generated, not recorded. Gated on
discovered exactly as tapeChunk is: 146 million notices over 6s of audio time produce
exactly 5 notes.

Debt band >= 3 wears the pallor machinery with its sign flipped, white instead of grey.
Bands 0-2 render byte-identical: a factory in good standing wears no mark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-29 12:38:54 +10:00
parent 3acd605e2e
commit de5a1b5770
12 changed files with 732 additions and 102 deletions

View File

@ -593,3 +593,156 @@ intentional (one faction, one uniform), but a player seeing their first repair m
small placard as "another commission done" rather than "something took my work". If that lands small placard as "another commission done" rather than "something took my work". If that lands
ambiguous in playtest, the fix is a one-word change to the placard legend ("CORRECTED" is already ambiguous in playtest, the fix is a one-word change to the placard legend ("CORRECTED" is already
doing the work, but "REVERTED" or "RECLAIMED" is colder). Flagging, not pre-emptively changing. doing the work, but "REVERTED" or "RECLAIMED" is colder). Flagging, not pre-emptively changing.
### Round 7 (Phase 1) — 2026-07-29 — Opus 5
SHIPPED: all 4 orders. The Correction can now reach the sky, and it still can't sing.
- `overlays.ts` — the repair placard slot generalised into **the Correction's placard slot**
(`PLACARD_W/H`, `placardCanvas`) with two painters: `paintRepair` (unchanged) and the new
`paintMirror`. Renamed `repair*``placard*` through `glitchStack.ts` and `index.ts` so the
sharing is legible in the code rather than only in a comment. Contained to my lane (grepped).
- `index.ts``correct(item, kind)`: the ONE decrement path, called by `repaired` and
`mirrored` alike; the feed clock + stasis holds; the debt band; DEV `.mirror()`, `.stasis()`,
`.band()`, and `feedSec`/`held`/`holds`/`sterile`/`band`/`placard` in `.stats()`.
- `glitchStack.ts``u_sterile` pass; `held` on `DrawState`.
- `baseTexture.ts``held` flips the transport legend and joins the repaint cache key.
- `strain.ts``sterileForBand()` + `STERILE_BAND`, pure and tested.
- `sfx.ts``RATIFY_TRIAD` and `COMPLIANCE_HZ` exported; `complianceTone()`.
- `compliance.ts` + `compliance.test.ts` (new) — the warden-arrival tracker, pure.
- Tests: screen 78 (was 72), audio 18 (was 9) = **96 green** across my two lanes.
DECISIONS:
- **The mirror shares the decrement path AND the overlay slot.** Order 1 said reuse, don't fork;
I took that past the arithmetic. One `correct()`, one placard canvas, one queue. So a repair
and a mirror landing together QUEUE rather than overlap — which is right: it is one faction
filing two forms, and two Correction placards fighting for the same corner would read as a bug.
The kind picks the painter and nothing else. There is a test pinning that a sector theft and a
unit theft move the ledger by exactly the same amount, which is the thing a fork would break.
- **The mirror placard is ARCHITECTURAL, the mite's is CLERICAL.** No green tick on the mirror —
a tick means a person checked something, and nobody checked this. It gets a drafted sector
rectangle with corner registration marks, a crosshair and the sector's doubled diagonal, in
graphite with the faction green reserved for the outline. Same hospital white, same authority,
different department: a mite signs a receipt, a gunship redraws the map.
- **Stasis is a CLOCK, not an effect.** I introduced `feedSec` — the sky's own clock — and moved
the shader's `t`, the timecode, the drift and the mature-corruption wobble onto it. Stasis just
stops it accumulating. That is why the freeze is exact rather than approximately exact: there
is no "restore the frozen state" code to get wrong, because nothing ever left. It also means
the picture holds *completely* (the corruption stops crawling too), which is what "administered"
should look like — a hold, not a stutter. None of the brownout's freeze/dropout/judder
vocabulary is touched; measured `brownout 0`, `freeze 0` throughout a hold.
- **Holds are keyed by `lockHash`, in a Set.** Two auditors holding at once means one releasing
cannot restart the clock under the other's feet. INTEGRATION NOTE FOR SIM: the `on:false`
event must carry the SAME `lockHash` as the `on:true` that opened it, or that hold never lifts
from the event path (the snapshot phase fallback would still rescue it).
- **The transport legend flips `PLAY ▶``HOLD ‖` while held. This is my addition, past the
literal order** — flag it if you disagree. A totally still picture with no tell reads as a
crashed rAF, not as an administered pause, and `HOLD ‖` is the camcorder's OWN word for a
deliberate stop. It costs one glyph, it is sterile, and it is not seizure vocabulary. It is
also the only pixel difference a hold makes: measured at 855 px in one box at (21,290,79,20).
- **THE COMPLIANCE TONE is A4 = 440.000 Hz**, the middle voice of the ratify triad — and the
pitch the entire industry agreed to calibrate against. Of course that is the note it picked.
`frequency.value` is SET, never ramped; one sine, no detune, no `hash01`, no LFO, no filter,
no noise layer, no second oscillator to beat against. The envelope is the only moving part and
it moves slowly (50 ms attack) precisely so the spectrum stays a single partial. Peak 0.14
against the full sting's 0.216: one voice must sit UNDER the triad or the sting stops being
the bigger event.
- **Sparseness is enforced in three places, not one**: gated on `discovered`, one strike per
frame however many wardens arrive, and a 1.2 s floor. The tracker reports every arrival
honestly and the call site is what refuses to make a chord out of them.
- **The warden tracker runs pre-discovery too, and only the STRIKING is gated.** Otherwise a
player digging up the fossil while a warden is already stood on station would be greeted by an
arrival note for an arrival that happened minutes ago. Measured: it stays silent.
- **The debt band is the pallor machinery with its sign flipped** — same radii, same falloff,
white instead of grey. Pallor is the corners closing into darkness because you are poor; this
is the corners washing out under institutional light because you are being LOOKED AT. Two
steps (0.65 at SEALED, 1.0 at REFUSED) rather than a ramp: with only two rungs above the
threshold, a ramp would just be a dimmer.
VERIFIED (real module, real 640×360 canvas, synthetic timebase, GL pixel reads; audio through
`OfflineAudioContext` with a Hann-windowed DFT, plus the real `AudioFX` instrumented):
- **Order 1, the arithmetic, pinned**: melt ×20 → units 24.0 / total 0.560. `repaired` → units
22.8 / tally 19 / total 0.546. `mirrored` → units 22.8 / tally 19 / total 0.546. Delta 1.2000
both, which is melt's potency exactly. Byte-equal stats, not "close".
- **Order 1, the placards are two documents**: both hospital white (region luminance 227.7 and
226.6 against the corrupted feed's 70.2), MAD **20.97** between them inside the placard, 2.66
everywhere else. Contact sheet screenshotted: MIRRORED reads as a plan, CORRECTED as a receipt.
- **Order 2, byte-identical, five ways.** Against a control run at an identical feed time:
every frame during a 5-second hold is one frame; release is byte-identical to the moment the
hold began AND to the control at the same feed time; the first frame after release is
byte-identical to the control's next frame. 5 s of audit cost the feed exactly 0.0000 s.
Nested holds: two on, one released → clock still stopped; last released → clock resumes.
Snapshot-only path (a save loaded mid-audit, zero events): froze and released identically.
- **Order 3, spectrally**: single partial at 440.00 Hz. Harmonics 28 at **132 to 163 dB**
(the double-precision floor). The other two triad voices and the shimmer octave at 110 to
132 dB — it really is one voice. Skirt 3.2/13/37.3/55.6/73.7/97.6/133.8 dB at
±1/2/5/10/20/50/200 Hz, **symmetric to 0.1 dB and exactly the Hann window's own response**;
sweeping 60 Hz12 kHz, the ONLY points above 60 dB are four inside ±10 Hz of the partial.
Zero sidebands, and the symmetry is the proof (AM/FM would be asymmetric or discrete).
Measured pitch in four consecutive windows: 440.00, 440.00, 440.00, 440.00 — **wander 0.000 Hz**.
Two renders bit-identical: generated, not recorded, which is the whole argument.
- **Order 3, gating, on the REAL module** (counted every `createOscillator` and its type/pitch):
notice pre-relic → **zero nodes created at all**; post-relic → exactly one `sine@440` and
nothing else; four more notices inside the gap → zero; after the gap → one. Hammered with
146,669,332 notices across 6 s of audio time → **exactly 5 tones, the theoretical maximum**.
It cannot become continuous. A warden already on station at the moment of discovery → zero
(the `square@440` in that window is FKTRY FM's lead; a no-warden control produced it too).
- **Order 4**: bands 0/1/2 render **byte-identical** — the sky wears nothing below SEALED.
Corner luminance 134.8 → 158.0 (SEALED) → 170.5 (REFUSED); corner saturation 0.453 → 0.259 →
0.201; **centre luminance 79.1 at every band** (a vignette, not a wash). The snapshot path and
the `debtBand` event path produce the same hash. On a corrupt sky the eleven params are
byte-identical at band 0 and band 4 — the band never touches the corruption.
- **Budget**: CPU rolling 0.0160.027 ms/frame worst case (2000 entities, total 1.0, strain 0.997
+ fever 0.849 + pallor 0.99 + sterile 0.993 + mosquito 0.95 + mite 0.95 + brownout + placard +
scram + a hold). Wall-clock with a forced `readPixels` sync: 0.495 ms — but a STERILE frame
measures 0.488 ms on the same instrument, so ~0.49 ms is the sync itself and the v9 work is
below the noise floor. Round 6 (no v9) measured 0.510 on the same rig, i.e. indistinguishable.
BLOCKED/BROKEN: nothing in my lane.
- `npm test` has **2 red in `src/sim/reference.test.ts`** ("still shipping melt at 20,000 ticks",
"keeps the demuxer alive"). NOT mine and structurally cannot be: SIM has uncommitted
`constants.ts` / `index.ts` / `reference.ts` (+ a `zzscratch.test.ts`) in the shared tree
landing the Correction, and `src/sim` imports nothing from `src/screen` or `src/audio`
(grepped, rather than bare-stashing — round-4's lesson). My 96 are green; tsc is clean
repo-wide as of writing. Mid-round tsc showed ~25 SIM/RENDER errors that cleared as they landed.
- HARNESS GOTCHAS, both mine not the product's, both cost me time:
(a) **Vite HMR full-reloads every lane's dev tab whenever ANY lane saves a file**, so no
console global survives between two tool calls in a five-lane tree. Every verification here is
a single self-contained call for that reason. Recommend it to the other lanes.
(b) The browser pane is shared: my first screenshot photographed RENDER's tab on :8152.
Pass the tabId explicitly.
(c) Measuring at exactly feed t=10.000 s made bands 0/1/2 hash differently — `floor()` on a
float landing on a whole-second edge flips a **timecode digit**. Same family as round 3's
clock-rewind false alarm. Re-measured off the boundary; byte-identical. Worth knowing for
anyone pinning pixels: never sample on a second boundary.
CONTRACT REQUEST: **pin the `CorrectionUnit.phase` vocabulary, or give arrival its own event.**
The compliance tone's second trigger (warden arrival) and stasis's save-load fallback both work
by string-matching `phase === 'audit'` / `'stasis'` against a vocabulary the contract documents
in a comment as "sim-defined, e.g. …". If SIM lands `'auditing'` or `'hold'`, the tone silently
never fires for arrivals and a mid-audit save never freezes — no error, no red test, just a
feature that quietly isn't there. Either (a) promote the phase strings to a union type in
`contracts.ts`, or (b) add `{ kind:'arrived'; unit; id; tick }` and I will drop the string match
entirely. (b) is cleaner — arrival is an edge and edges should be events. Notices are unaffected
either way; they already work.
COORDINATION FOR SIM (not a request, a heads-up): a `mirrored` event takes exactly ONE unit off
the ledger, same as `repaired`, per order 1. If a gunship's sector theft is meant to cost more
than a mite's, emit N `mirrored` events rather than expecting THE SCREEN to scale one — the
ledger only counts units, and scaling on my side would be exactly the fork order 1 forbade.
(N events would also stamp N placards, which queue at 2; say the word if you want a count field
on the placard instead, that is a small change.)
PROPOSAL: the sterile vignette is currently the only thing the debt band does to the sky, and it
is deliberately the faintest reading up there. If band 4 REFUSED is meant to be the end of the
world, the substrate has one more move available for free: `eras.ts` already knows how to age
the base feed, and a REFUSED factory could have its clean programming replaced by the fortress
skin regardless of what it has actually ratified — The Correction showing you the format it
intends to leave you on. One line, no new machinery, but it is a fiction call and therefore
yours. Not building it.
NEXT (not self-assigning): `growthMask` now has four customers and stasis is the first thing on
the sky with a *rectangle* rather than a seed point — the `stasis` event carries a world-space
rect I currently ignore. Mapping that rect onto a region of the sky (the audited sector holding
still while the rest of the feed keeps moving) is the natural next step and would need a second
mask primitive: rectangular, screen-projected, hard-edged where growthMask is ragged. That is a
real feature, not a polish pass, so I have left it alone.

View File

@ -0,0 +1,90 @@
/**
* THE COMPLIANCE TONE's sparseness, pinned without a speaker.
*
* The synthesis is verified spectrally in the dev build (a single partial, no sidebands a
* node test has no AudioContext to render into). What CAN be pinned here is the property that
* actually protects the design: the tone must fire ONCE per warden arrival. A tone that struck
* every frame a warden stood on station would be a continuous tone, and a continuous "only
* clean sound" would demote the ratification sting it is one voice of.
*/
import { describe, expect, it } from 'vitest';
import type { CorrectionUnit } from '../contracts';
import { arrivedWardens, WARDEN_ARRIVAL_PHASE } from './compliance';
import { COMPLIANCE_HZ, RATIFY_TRIAD } from './sfx';
function unit(
id: number,
phase: string,
kind: CorrectionUnit['kind'] = 'checksum-warden',
): CorrectionUnit {
return { id, kind, pos: { x: 0, y: 0 }, phase, progress: 0 };
}
describe('the tone is one voice of the ratification triad, not a new sound', () => {
it('is literally a member of the triad', () => {
expect(RATIFY_TRIAD).toContain(COMPLIANCE_HZ);
});
it('is A4 — the pitch the whole industry agreed to calibrate against', () => {
expect(COMPLIANCE_HZ).toBe(440);
});
});
describe('a warden arrival strikes exactly once', () => {
it('strikes when a warden reaches its station', () => {
const { struck, next } = arrivedWardens(new Set(), [unit(7, WARDEN_ARRIVAL_PHASE)]);
expect(struck).toEqual([7]);
expect(next.has(7)).toBe(true);
});
it('does NOT strike again while the warden stands there working', () => {
let on: ReadonlySet<number> = new Set();
let strikes = 0;
for (let frame = 0; frame < 200; frame++) {
const r = arrivedWardens(on, [unit(7, WARDEN_ARRIVAL_PHASE)]);
strikes += r.struck.length;
on = r.next;
}
expect(strikes).toBe(1);
});
it('says nothing at all while the warden is still approaching', () => {
const { struck, next } = arrivedWardens(new Set(), [unit(7, 'approach')]);
expect(struck).toEqual([]);
expect(next.size).toBe(0);
});
it('strikes again for a SECOND visit — that is a second arrival', () => {
let on: ReadonlySet<number> = new Set();
on = arrivedWardens(on, [unit(7, WARDEN_ARRIVAL_PHASE)]).next;
on = arrivedWardens(on, [unit(7, 'leaving')]).next;
expect(on.size).toBe(0);
const back = arrivedWardens(on, [unit(7, WARDEN_ARRIVAL_PHASE)]);
expect(back.struck).toEqual([7]);
});
it('ignores gunships and auditors — this is the WARDEN\'s note', () => {
const { struck } = arrivedWardens(new Set(), [
unit(1, WARDEN_ARRIVAL_PHASE, 'redundancy-gunship'),
unit(2, WARDEN_ARRIVAL_PHASE, 'hash-auditor'),
]);
expect(struck).toEqual([]);
});
it('is inert when The Correction is not in the snapshot at all', () => {
const { struck, next } = arrivedWardens(new Set([7]), undefined);
expect(struck).toEqual([]);
expect(next.size).toBe(0); // and it forgets, so a later return counts as an arrival
});
it('two wardens landing together are two ids, and the caller strikes one note', () => {
// The tracker reports honestly; sparseness is enforced at the call site (one strike per
// frame, plus a min-gap). Pinning the shape here so a future refactor cannot quietly turn
// a burst of arrivals into a chord.
const { struck } = arrivedWardens(new Set(), [
unit(1, WARDEN_ARRIVAL_PHASE),
unit(2, WARDEN_ARRIVAL_PHASE),
]);
expect(struck).toHaveLength(2);
});
});

View File

@ -0,0 +1,41 @@
/**
* THE COMPLIANCE TONE's trigger logic, kept pure so it can be pinned without a speaker.
*
* The tone itself lives in sfx.ts with its siblings; what lives here is the *when*. Two things
* strike it: a `notice` event (an edge the compositor gets that for free) and a checksum
* warden ARRIVING on station, which is not an edge at all. Arrival is only visible as a phase
* transition inside `snapshot.correction.units`, so somebody has to remember last frame's
* phases, and that somebody should be testable.
*
* Sparseness is the whole design constraint: one note per arrival, never a note per frame while
* the warden stands there working.
*/
import type { CorrectionUnit } from '../contracts';
/**
* The sim-defined phase a warden is in once it has reached its target and started work.
* Contracts v9 documents the vocabulary as 'approach' | 'audit' | 'tether' | 'prescan' |
* 'stasis' | 'leaving' 'audit' is the warden's arrival.
*/
export const WARDEN_ARRIVAL_PHASE = 'audit';
/**
* Which wardens have just arrived, given who was already on station.
*
* Returns the ids that newly reached the arrival phase plus the set to carry into next frame.
* A warden that leaves and comes back strikes again that is a second arrival, and the player
* should hear it. A warden merely *standing* in the audit phase strikes nothing.
*/
export function arrivedWardens(
onStation: ReadonlySet<number>,
units: ReadonlyArray<CorrectionUnit> | undefined,
): { struck: number[]; next: Set<number> } {
const next = new Set<number>();
const struck: number[] = [];
for (const u of units ?? []) {
if (u.kind !== 'checksum-warden' || u.phase !== WARDEN_ARRIVAL_PHASE) continue;
next.add(u.id);
if (!onStation.has(u.id)) struck.push(u.id);
}
return { struck, next };
}

View File

@ -20,8 +20,9 @@ import { createAmbience, type Ambience } from './ambience';
import { createRadio, type Radio, type RadioEngine } from './radio'; import { createRadio, type Radio, type RadioEngine } from './radio';
import type { StationDef } from './stations'; import type { StationDef } from './stations';
import { import {
brownoutHit, brownoutRecover, faxChatter, klaxon, ratifySting, tapeChunk, thunk, brownoutHit, brownoutRecover, complianceTone, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
} from './sfx'; } from './sfx';
import { arrivedWardens } from './compliance';
import stationData from '../../data/stations.json'; import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json'; import seamData from '../../data/seams.json';
@ -43,7 +44,7 @@ function isRealSeam(x: number, y: number): boolean {
} }
/** Sounds that must not machine-gun when the sim emits a burst in one tick. */ /** Sounds that must not machine-gun when the sim emits a burst in one tick. */
const MIN_GAP = { thunk: 0.03, chunk: 0.05 }; const MIN_GAP = { thunk: 0.03, chunk: 0.05, compliance: 1.2 };
/** The live radio handle for getRadio(). Module-level set by build(), which runs on the /** The live radio handle for getRadio(). Module-level set by build(), which runs on the
* first user gesture so the control surface exists in PRODUCTION, not just behind the * first user gesture so the control surface exists in PRODUCTION, not just behind the
@ -61,7 +62,10 @@ export function createAudioFX(): AudioFX {
let discovered = false; let discovered = false;
let lastThunk = 0; let lastThunk = 0;
let lastChunk = 0; let lastChunk = 0;
let lastCompliance = -Infinity;
let variant = 0; let variant = 0;
/** Wardens currently on station, so an arrival strikes once and standing there strikes never. */
let wardensOnStation: ReadonlySet<number> = new Set<number>();
function now(): number { function now(): number {
return ctx ? ctx.currentTime : 0; return ctx ? ctx.currentTime : 0;
@ -77,6 +81,21 @@ export function createAudioFX(): AudioFX {
ratifySting(ctx, master.input, now() + 0.02); ratifySting(ctx, master.input, now() + 0.02);
} }
/**
* Strike THE COMPLIANCE TONE. Gated on `discovered` exactly as tapeChunk is the pre-relic
* silence guarantee is structural, not a volume setting, and The Correction does not get to
* be the exception. The min-gap is insurance only: this sound is meant to be rare by
* CONSTRUCTION (one note per notice, one per warden arrival), and the gap exists so that a
* sim burst in a single tick can never turn the rarity into a chord.
*/
function strikeCompliance() {
if (!discovered || !ctx || !master) return;
const t = now() + 0.005;
if (t - lastCompliance < MIN_GAP.compliance) return;
complianceTone(ctx, master.input, t);
lastCompliance = t;
}
function handle(e: SimEvent) { function handle(e: SimEvent) {
if (!ctx || !master) return; if (!ctx || !master) return;
const dest = master.input; const dest = master.input;
@ -108,6 +127,10 @@ export function createAudioFX(): AudioFX {
lastChunk = t; lastChunk = t;
} }
break; break;
case 'notice':
// The fax arrives before the action, always (contracts v9). One note, then paperwork.
strikeCompliance();
break;
case 'relicFound': case 'relicFound':
openTheBand(); openTheBand();
break; break;
@ -154,7 +177,8 @@ export function createAudioFX(): AudioFX {
const d = master.input; const d = master.input;
const t = now() + 0.02; const t = now() + 0.02;
({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover, ({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover,
klaxon, ratify: ratifySting, chunk: tapeChunk } as any)[name]?.(ctx, d, t); klaxon, ratify: ratifySting, chunk: tapeChunk,
compliance: complianceTone } as any)[name]?.(ctx, d, t);
}, },
}; };
} }
@ -191,6 +215,16 @@ export function createAudioFX(): AudioFX {
radio.update(t, era); radio.update(t, era);
// Warden arrival is not an edge event — it is a phase transition inside the snapshot — so
// the tracker runs every frame. It runs REGARDLESS of `discovered` on purpose: if the
// player digs up the fossil while a warden is already stood on station, that warden is
// not a new arrival and must not announce itself. Only the striking is gated.
if (snap) {
const { struck, next } = arrivedWardens(wardensOnStation, snap.correction?.units);
wardensOnStation = next;
if (struck.length) strikeCompliance(); // ONE note, however many arrived
}
if (discovered && snap) { if (discovered && snap) {
const { gen, draw } = snap.bandwidth; const { gen, draw } = snap.bandwidth;
const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw))); const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw)));

View File

@ -150,6 +150,18 @@ export function klaxon(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
} }
} }
/**
* The ratification triad: F4A4C5. Exported because THE COMPLIANCE TONE is literally one
* voice of it, and the two must never drift apart into "two nice sounds".
*/
export const RATIFY_TRIAD = [349.23, 440.0, 523.25] as const;
/**
* The one voice The Correction speaks with: A4, the middle of its own triad and the pitch
* the entire industry agreed to calibrate against. Of course that is the note it picked.
*/
export const COMPLIANCE_HZ = RATIFY_TRIAD[1];
/** /**
* RATIFICATION STING The Correction resolves; you don't. * RATIFICATION STING The Correction resolves; you don't.
* A clean major triad (FAC) on sines with a soft bell attack. It is the ONLY consonant, * A clean major triad (FAC) on sines with a soft bell attack. It is the ONLY consonant,
@ -160,7 +172,7 @@ export function ratifySting(ctx: BaseAudioContext, dest: AudioNode, t0: number)
const bus = ctx.createGain(); const bus = ctx.createGain();
bus.gain.value = 0.3; bus.gain.value = 0.3;
bus.connect(dest); bus.connect(dest);
const triad = [349.23, 440.0, 523.25]; // F4 A4 C5 const triad = RATIFY_TRIAD; // F4 A4 C5
triad.forEach((f, i) => { triad.forEach((f, i) => {
const o = ctx.createOscillator(); const o = ctx.createOscillator();
o.type = 'sine'; o.type = 'sine';
@ -180,6 +192,37 @@ export function ratifySting(ctx: BaseAudioContext, dest: AudioNode, t0: number)
shimmer.stop(t0 + 1.1); shimmer.stop(t0 + 1.1);
} }
/**
* THE COMPLIANCE TONE The Correction never speaks. It faxes. This is the only noise it makes.
*
* ONE voice of the ratification triad, standing alone: A4, struck on a fixed grid. The frequency
* is SET, never ramped. No detune, no hash01, no vibrato, no LFO, no noise layer, no filter
* sweep, no second oscillator to beat against not one analog imperfection anywhere in the
* signal path, because every other sound in this game has at least one and they are all the
* player's. You cannot RECORD a tone with no imperfection in it; you can only generate one.
* That is the house law making its own argument, and it is why the enemy's sound is the only
* one here that could not have come off tape.
*
* SPARSE BY CONSTRUCTION: one note per event, nothing loops it, and it is never sustained.
* The ratification sting's rarity is its punchline; a second "clean sound" heard continuously
* would demote both. If this is ever heard often, something upstream is wrong, not this.
*
* Layer 3 by gating: like tapeChunk, it belongs to the discovered world, so the pre-relic
* silence guarantee (measured at exactly 0.0 peak / 0.0 RMS) is untouched by its existence.
*/
export function complianceTone(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = COMPLIANCE_HZ; // set once, for all time
// The envelope is the only thing in here that moves, and it moves slowly on purpose: a
// percussive attack would splatter sidebands either side of the partial, and the claim this
// sound is making is precisely that it has none. Struck like a tuning fork, left to decay.
const g = envGain(ctx, t0, 0.14, 0.05, 1.5);
o.connect(g).connect(dest);
o.start(t0);
o.stop(t0 + 1.7);
}
/** TAPE CHUNK — a shipment lands. Layer 3, but it lives here with its siblings. */ /** TAPE CHUNK — a shipment lands. Layer 3, but it lives here with its siblings. */
export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) { export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2; const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2;

View File

@ -20,6 +20,13 @@ export interface BaseState {
era: Era; era: Era;
/** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */ /** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */
chyron: string; chyron: string;
/**
* v9 stasis: a hash auditor is holding a sector still while it reads it. The transport legend
* flips PLAY HOLD and the timecode simply stops (the caller freezes `timeMs`). This is
* the camcorder's OWN vocabulary for a deliberate pause nothing here is damage, and none of
* the brownout's freeze/dropout/judder language is involved.
*/
held?: boolean;
} }
export interface BaseFeed { export interface BaseFeed {
@ -117,7 +124,7 @@ export function createBaseFeed(): BaseFeed {
// camcorder overlay // camcorder overlay
g.fillStyle = skin.ink; g.fillStyle = skin.ink;
g.font = "bold 22px 'Courier New', monospace"; g.font = "bold 22px 'Courier New', monospace";
g.fillText('PLAY ▶', 20, BASE_H * 0.86); g.fillText(state.held ? 'HOLD ‖' : 'PLAY ▶', 20, BASE_H * 0.86);
g.fillText(timecode(state.timeMs), 20, BASE_H * 0.95); g.fillText(timecode(state.timeMs), 20, BASE_H * 0.95);
g.font = "bold 18px 'Courier New', monospace"; g.font = "bold 18px 'Courier New', monospace";
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95); g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
@ -164,7 +171,7 @@ export function createBaseFeed(): BaseFeed {
canvas, canvas,
update(state: BaseState) { update(state: BaseState) {
// Cache key of every visible input; repaint only when one changes. // Cache key of every visible input; repaint only when one changes.
const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}`; const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}|${state.held ? 'H' : 'P'}`;
if (key === last) return false; if (key === last) return false;
last = key; last = key;
paint(state); paint(state);

View File

@ -165,6 +165,20 @@ describe('repairs ease the ledger by one unit of the item (The Correction, v6)',
expect(heavy).toBeGreaterThan(light); expect(heavy).toBeGreaterThan(light);
}); });
it('a gunship mirror costs EXACTLY what a mite repair costs (one decrement path, v9)', () => {
// `repaired` and `mirrored` both route through correct() in index.ts, so the arithmetic is
// the same subtraction by construction. This pins the thing that would break if anyone ever
// forked them: a sector theft and a unit theft must move the ledger identically, because
// the ledger only knows units. The gunship's difference is its PLACARD, not its price.
const before = resolveLedger(tally({ 'chroma-slurry': 12 }));
const afterOne = resolveLedger(tally({ 'chroma-slurry': 11 }));
const afterTwo = resolveLedger(tally({ 'chroma-slurry': 10 }));
const first = before.units - afterOne.units;
const second = afterOne.units - afterTwo.units;
expect(first).toBeCloseTo(second, 10);
expect(first).toBeCloseTo(corruptionFor('chroma-slurry').potency, 10);
});
it('a repair never drives corruption below sterile (theft floors at clean)', () => { it('a repair never drives corruption below sterile (theft floors at clean)', () => {
// ship one, correct one → back to the NOTHING IS WRONG floor, never past it. // ship one, correct one → back to the NOTHING IS WRONG floor, never past it.
const { units, total, targets } = resolveLedger(tally({ melt: 0 })); const { units, total, targets } = resolveLedger(tally({ melt: 0 }));

View File

@ -30,15 +30,15 @@ uniform float t;
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze; uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
uniform float u_block, u_ring, u_moire; uniform float u_block, u_ring, u_moire;
uniform float u_drift, u_brownout, u_flash; uniform float u_drift, u_brownout, u_flash;
uniform float u_strain, u_fever, u_pallor; uniform float u_strain, u_fever, u_pallor, u_sterile;
uniform vec2 u_moireSeed; uniform vec2 u_moireSeed;
uniform float u_wildlife; uniform float u_wildlife;
uniform vec2 u_wildlifeSeed; uniform vec2 u_wildlifeSeed;
uniform float u_mite; uniform float u_mite;
uniform vec2 u_miteSeed; uniform vec2 u_miteSeed;
uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex, u_repairTex; uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex, u_placardTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card, u_title; uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card, u_title;
uniform float u_repair, u_repairScale; uniform float u_placard, u_placardScale;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); } float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){ float vnoise(vec2 p){
@ -228,6 +228,17 @@ void main(){
col *= 1.0 - vig*u_pallor*0.75; col *= 1.0 - vig*u_pallor*0.75;
} }
// ---- debt band: PARITY DEBT at SEALED (band 3) or worse. Literally the pallor machinery
// above with its sign flipped — same radii, same falloff, white instead of grey. Pallor is
// the corners closing into darkness because you are poor; this is the corners washing out
// under institutional light because you are being LOOKED AT. Faintest reading on the sky by
// design: it is a standing condition, not an event, and it must never out-shout the artifacts.
if(u_sterile > 0.001){
vec2 sd = vUv - 0.5; sd.x *= 1.78;
float svig = smoothstep(0.34, 0.95, length(sd));
col = mix(col, vec3(0.95,0.97,0.96), svig*u_sterile*0.42);
}
// ---- mite sterility: The Correction's hospital-white cleanliness creeping outward from heavy // ---- mite sterility: The Correction's hospital-white cleanliness creeping outward from heavy
// parity-mite activity. Same growth-mask template as moire/wildlife, but the INVERSE of // parity-mite activity. Same growth-mask template as moire/wildlife, but the INVERSE of
// corruption — it desaturates and washes the region toward clinical white with a faint // corruption — it desaturates and washes the region toward clinical white with a faint
@ -257,14 +268,16 @@ void main(){
} }
} }
// ---- repair placard: The Correction signs one unit off the ledger. Small, perfect, screen // ---- the Correction's placard: it signs one unit off the ledger (a mite's repair) or takes
// space (uncorrupted), parked low-right so it never fights the shipment stamp for the centre. // a whole sector (a gunship's mirror). ONE slot, two painters — same faction, same voice, so a
if(u_repair > 0.001){ // burst of Corrections queues instead of overlapping. Small, perfect, screen space
vec2 halfSize = vec2(0.150, 0.082)*u_repairScale; // (uncorrupted), parked low-right so it never fights the shipment stamp for the centre.
if(u_placard > 0.001){
vec2 halfSize = vec2(0.150, 0.082)*u_placardScale;
vec2 ruv = (vUv - vec2(0.80, 0.80))/(2.0*halfSize) + 0.5; vec2 ruv = (vUv - vec2(0.80, 0.80))/(2.0*halfSize) + 0.5;
if(ruv.x > 0.0 && ruv.x < 1.0 && ruv.y > 0.0 && ruv.y < 1.0){ if(ruv.x > 0.0 && ruv.x < 1.0 && ruv.y > 0.0 && ruv.y < 1.0){
vec4 rp = texture2D(u_repairTex, ruv); vec4 rp = texture2D(u_placardTex, ruv);
col = mix(col, rp.rgb, rp.a*u_repair); col = mix(col, rp.rgb, rp.a*u_placard);
} }
} }
@ -297,10 +310,10 @@ void main(){
}`; }`;
type UniformKey = type UniformKey =
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed' | Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'sterile' | 'moireSeed'
| 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed' | 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed'
| 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'repairTex' | 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'placardTex'
| 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'repair' | 'repairScale'; | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'placard' | 'placardScale';
export interface DrawState { export interface DrawState {
params: ParamSet; params: ParamSet;
@ -315,12 +328,17 @@ export interface DrawState {
era: Era; era: Era;
/** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */ /** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */
chyron: string; chyron: string;
/** v9: a hash auditor is holding the feed. Flips the transport legend to HOLD; the CALLER
* freezes `timeSec`, which is what actually stops the picture. */
held: boolean;
/** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */ /** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */
strain: number; strain: number;
/** 0..1 feverish shimmer from aggregate machine heat */ /** 0..1 feverish shimmer from aggregate machine heat */
fever: number; fever: number;
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */ /** 0..1 pallor from an empty reserve — chronic, unlike tremor */
pallor: number; pallor: number;
/** 0..1 sterile white vignette from the Correction's debt band (>=3 SEALED) */
sterile: number;
/** where the moire crystal grows from, in screen uv */ /** where the moire crystal grows from, in screen uv */
moireSeed: [number, number]; moireSeed: [number, number];
/** 0..1 regional mosquito shimmer amount (wildlife swarms) */ /** 0..1 regional mosquito shimmer amount (wildlife swarms) */
@ -349,12 +367,12 @@ export interface DrawState {
scramY: number; scramY: number;
/** set when the stamp canvas has been repainted and needs re-upload */ /** set when the stamp canvas has been repainted and needs re-upload */
stampDirty: boolean; stampDirty: boolean;
/** 0..1 opacity of the repair placard */ /** 0..1 opacity of the Correction's placard (mite repair OR gunship mirror — one slot) */
repair: number; placard: number;
/** repair placard size multiplier (slight overshoot on impact, like the stamp) */ /** placard size multiplier (slight overshoot on impact, like the stamp) */
repairScale: number; placardScale: number;
/** set when the repair canvas has been repainted and needs re-upload */ /** set when the placard canvas has been repainted and needs re-upload */
repairDirty: boolean; placardDirty: boolean;
} }
export interface GlitchStack { export interface GlitchStack {
@ -418,13 +436,13 @@ export function createGlitchStack(
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
overlays.paintCard('reel', ''); overlays.paintCard('reel', '');
overlays.paintTitle(); overlays.paintTitle();
overlays.paintRepair(''); // allocate the repair placard texture up front overlays.paintRepair(''); // allocate the Correction's placard texture up front
const baseTex = makeTex(0, base.canvas); const baseTex = makeTex(0, base.canvas);
const stampTex = makeTex(1, overlays.stampCanvas); const stampTex = makeTex(1, overlays.stampCanvas);
makeTex(2, overlays.scramCanvas); makeTex(2, overlays.scramCanvas);
const cardTex = makeTex(3, overlays.cardCanvas); const cardTex = makeTex(3, overlays.cardCanvas);
const titleTex = makeTex(4, overlays.titleCanvas); const titleTex = makeTex(4, overlays.titleCanvas);
const repairTex = makeTex(5, overlays.repairCanvas); const placardTex = makeTex(5, overlays.placardCanvas);
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>; const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
uni.t = gl.getUniformLocation(prog, 't'); uni.t = gl.getUniformLocation(prog, 't');
@ -434,6 +452,7 @@ export function createGlitchStack(
uni.strain = gl.getUniformLocation(prog, 'u_strain'); uni.strain = gl.getUniformLocation(prog, 'u_strain');
uni.fever = gl.getUniformLocation(prog, 'u_fever'); uni.fever = gl.getUniformLocation(prog, 'u_fever');
uni.pallor = gl.getUniformLocation(prog, 'u_pallor'); uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
uni.sterile = gl.getUniformLocation(prog, 'u_sterile');
uni.card = gl.getUniformLocation(prog, 'u_card'); uni.card = gl.getUniformLocation(prog, 'u_card');
uni.title = gl.getUniformLocation(prog, 'u_title'); uni.title = gl.getUniformLocation(prog, 'u_title');
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed'); uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
@ -445,8 +464,8 @@ export function createGlitchStack(
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale'); uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
uni.scram = gl.getUniformLocation(prog, 'u_scram'); uni.scram = gl.getUniformLocation(prog, 'u_scram');
uni.scramY = gl.getUniformLocation(prog, 'u_scramY'); uni.scramY = gl.getUniformLocation(prog, 'u_scramY');
uni.repair = gl.getUniformLocation(prog, 'u_repair'); uni.placard = gl.getUniformLocation(prog, 'u_placard');
uni.repairScale = gl.getUniformLocation(prog, 'u_repairScale'); uni.placardScale = gl.getUniformLocation(prog, 'u_placardScale');
for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`); for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);
gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0); gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0);
@ -454,19 +473,19 @@ export function createGlitchStack(
gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2); gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2);
gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3); gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3);
gl.uniform1i(gl.getUniformLocation(prog, 'u_titleTex'), 4); gl.uniform1i(gl.getUniformLocation(prog, 'u_titleTex'), 4);
gl.uniform1i(gl.getUniformLocation(prog, 'u_repairTex'), 5); gl.uniform1i(gl.getUniformLocation(prog, 'u_placardTex'), 5);
gl.viewport(0, 0, canvas.width, canvas.height); gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(uni.drift, 1); gl.uniform1f(uni.drift, 1);
return { return {
draw({ params, timeSec, drift, brownout, flash, era, chyron, strain, fever, pallor, draw({ params, timeSec, drift, brownout, flash, era, chyron, held, strain, fever, pallor, sterile,
moireSeed, wildlife, wildlifeSeed, mite, miteSeed, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
stampAmt, stampScale, scram, scramY, stampDirty, stampAmt, stampScale, scram, scramY, stampDirty,
repair, repairScale, repairDirty, placard, placardScale, placardDirty,
card, cardDirty, title, titleDirty }) { card, cardDirty, title, titleDirty }) {
// Re-upload the clean feed only when it actually changed (timecode tick / era / chyron). // Re-upload the clean feed only when it actually changed (timecode tick / era / chyron).
if (base.update({ timeMs: timeSec * 1000, era, chyron })) { if (base.update({ timeMs: timeSec * 1000, era, chyron, held })) {
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, baseTex); gl.bindTexture(gl.TEXTURE_2D, baseTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
@ -486,10 +505,10 @@ export function createGlitchStack(
gl.bindTexture(gl.TEXTURE_2D, titleTex); gl.bindTexture(gl.TEXTURE_2D, titleTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas);
} }
if (repairDirty) { if (placardDirty) {
gl.activeTexture(gl.TEXTURE0 + 5); gl.activeTexture(gl.TEXTURE0 + 5);
gl.bindTexture(gl.TEXTURE_2D, repairTex); gl.bindTexture(gl.TEXTURE_2D, placardTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.repairCanvas); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.placardCanvas);
} }
gl.uniform1f(uni.t, timeSec); gl.uniform1f(uni.t, timeSec);
gl.uniform1f(uni.drift, drift); gl.uniform1f(uni.drift, drift);
@ -498,6 +517,7 @@ export function createGlitchStack(
gl.uniform1f(uni.strain, strain); gl.uniform1f(uni.strain, strain);
gl.uniform1f(uni.fever, fever); gl.uniform1f(uni.fever, fever);
gl.uniform1f(uni.pallor, pallor); gl.uniform1f(uni.pallor, pallor);
gl.uniform1f(uni.sterile, sterile);
gl.uniform1f(uni.card, card); gl.uniform1f(uni.card, card);
gl.uniform1f(uni.title, title); gl.uniform1f(uni.title, title);
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]); gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
@ -509,8 +529,8 @@ export function createGlitchStack(
gl.uniform1f(uni.stampScale, stampScale); gl.uniform1f(uni.stampScale, stampScale);
gl.uniform1f(uni.scram, scram ? 1 : 0); gl.uniform1f(uni.scram, scram ? 1 : 0);
gl.uniform1f(uni.scramY, scramY); gl.uniform1f(uni.scramY, scramY);
gl.uniform1f(uni.repair, repair); gl.uniform1f(uni.placard, placard);
gl.uniform1f(uni.repairScale, repairScale); gl.uniform1f(uni.placardScale, placardScale);
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]); for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
gl.drawArrays(gl.TRIANGLES, 0, 3); gl.drawArrays(gl.TRIANGLES, 0, 3);
}, },

View File

@ -15,7 +15,7 @@ import { createGlitchStack, type GlitchStack } from './glitchStack';
import { createOverlays } from './overlays'; import { createOverlays } from './overlays';
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap'; import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS, ease, zeroParams, type ParamSet } from './params'; import { PARAMS, ease, zeroParams, type ParamSet } from './params';
import { computeStrain } from './strain'; import { computeStrain, sterileForBand } from './strain';
import { freshness } from './boredom'; import { freshness } from './boredom';
import { ERA_ORDER, highestEra, type Era } from './eras'; import { ERA_ORDER, highestEra, type Era } from './eras';
import { getSubtitle } from '../audio/subtitleBus'; import { getSubtitle } from '../audio/subtitleBus';
@ -31,8 +31,8 @@ const WOBBLE_DEPTH = 0.12;
const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, 0.05]; const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, 0.05];
/** Seconds a shipment stamp stays on the feed. */ /** Seconds a shipment stamp stays on the feed. */
const STAMP_LIFE = 0.4; const STAMP_LIFE = 0.4;
/** The repair placard lingers a touch longer than a shipment stamp — a signature, read at leisure. */ /** The Correction's placard lingers a touch longer than a shipment stamp — read at leisure. */
const REPAIR_LIFE = 0.55; const PLACARD_LIFE = 0.55;
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */ /** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
const STAMP_QUEUE_MAX = 2; const STAMP_QUEUE_MAX = 2;
/** Round 3: one frame was a flinch, not a legend. Three frames is readable. */ /** Round 3: one frame was a flinch, not a legend. Three frames is readable. */
@ -55,7 +55,12 @@ const TITLE_FADE = 0.6;
const FLASH_SECONDS = 0.5; const FLASH_SECONDS = 0.5;
interface Stamp { name: string; count: number; color: string; age: number } interface Stamp { name: string; count: number; color: string; age: number }
interface Repair { name: string; age: number } /**
* One entry in The Correction's placard queue. `kind` picks the painter, NOT the arithmetic:
* a mite's repair and a gunship's mirror take exactly the same unit off exactly the same
* ledger, and they share one overlay slot so the faction speaks with one voice.
*/
interface Placard { name: string; kind: 'repair' | 'mirror'; age: number }
interface Swarm { pos: { x: number; y: number }; size: number } interface Swarm { pos: { x: number; y: number }; size: number }
/** /**
@ -123,15 +128,35 @@ export function createScreenFX(): ScreenFX {
const stampQueue: Stamp[] = []; const stampQueue: Stamp[] = [];
let stamp: Stamp | null = null; let stamp: Stamp | null = null;
let stampDirty = false; let stampDirty = false;
const repairQueue: Repair[] = []; const placardQueue: Placard[] = [];
let repair: Repair | null = null; let placard: Placard | null = null;
let repairDirty = false; let placardDirty = false;
let scramFrames = 0; let scramFrames = 0;
let scramY = 0.5; let scramY = 0.5;
let strain = 0; let strain = 0;
let fever = 0; let fever = 0;
let pallor = 0; let pallor = 0;
let sterile = 0;
/** Debt band from `debtBand` events — the fallback when no snapshot.correction is present. */
let eventBand = 0;
/**
* Live hash-auditor holds, keyed by the event's `lockHash`. A set rather than a flag so
* overlapping audits nest correctly: two auditors both holding means one release cannot
* restart the clock under the other one's feet.
*/
const stasisHolds = new Set<string>();
/**
* THE SKY'S OWN CLOCK. Everything the picture animates on reads this, not wall time: the
* timecode, the drift, the shader's `t`, the mature-corruption wobble. A hash auditor's
* stasis simply stops it accumulating the feed is not damaged, it is administered and
* releasing resumes from the stopped value, so the sky comes back exactly where it left
* rather than jumping forward by however long the audit took.
*/
let feedSec = 0;
/** Last resolved values of the two Correction readings, for the DEV stats readout only. */
let heldNow = false;
let bandNow = 0;
let moireSeed: [number, number] = [0.5, 0.44]; let moireSeed: [number, number] = [0.5, 0.44];
let moireSeeded = false; let moireSeeded = false;
@ -192,6 +217,27 @@ export function createScreenFX(): ScreenFX {
while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift(); while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift();
} }
/**
* THE CORRECTION TAKES ONE UNIT. The single decrement path a parity mite's `repaired` and a
* redundancy gunship's `mirrored` both come through here, because they are the same theft at
* two scales and forking them would be how the two slowly stopped agreeing.
*
* This is THEFT, not decay: the LEDGER drops by exactly that item's per-unit contribution, a
* real permanent removal unlike boredom, which only fades the display and leaves the tally
* intact to snap back. We deliberately do NOT refresh the boredom clock either way: a factory
* being quietly eaten is not a factory that is producing.
*/
function correct(item: string, kind: 'repair' | 'mirror') {
const cur = tally.get(item) ?? 0;
if (cur > 0) {
tally.set(item, cur - 1);
ledgerDirty = true;
}
const def = items.get(item);
placardQueue.push({ name: def?.name ?? item.toUpperCase(), kind, age: 0 });
while (placardQueue.length > STAMP_QUEUE_MAX) placardQueue.shift();
}
let liveCanvas: HTMLCanvasElement | null = null; let liveCanvas: HTMLCanvasElement | null = null;
const api: ScreenFX = { const api: ScreenFX = {
@ -226,15 +272,19 @@ export function createScreenFX(): ScreenFX {
if (on) mites.set(id, { pos, size }); if (on) mites.set(id, { pos, size });
else mites.delete(id); else mites.delete(id);
}, },
// Both drive the ONE decrement path, so the debug hook can never disagree with the
// event handler about what a Correction costs.
repair: (item: string, count = 1) => { repair: (item: string, count = 1) => {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) correct(item, 'repair');
const cur = tally.get(item) ?? 0;
if (cur > 0) { tally.set(item, cur - 1); ledgerDirty = true; }
const def = items.get(item);
repairQueue.push({ name: def?.name ?? item.toUpperCase(), age: 0 });
while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift();
}
}, },
mirror: (item: string, count = 1) => {
for (let i = 0; i < count; i++) correct(item, 'mirror');
},
stasis: (on: boolean, lockHash = 'dev') => {
if (on) stasisHolds.add(lockHash);
else stasisHolds.delete(lockHash);
},
band: (n: number) => { eventBand = n; },
reset: () => { reset: () => {
tally.clear(); tally.clear();
ledgerDirty = true; ledgerDirty = true;
@ -246,6 +296,12 @@ export function createScreenFX(): ScreenFX {
strain = 0; strain = 0;
fever = 0; fever = 0;
pallor = 0; pallor = 0;
sterile = 0;
eventBand = 0;
bandNow = 0;
heldNow = false;
stasisHolds.clear();
feedSec = 0;
cardAge = -1; cardAge = -1;
flashAge = -1; flashAge = -1;
lastShipMs = 0; lastShipMs = 0;
@ -255,8 +311,8 @@ export function createScreenFX(): ScreenFX {
wildlife = 0; wildlife = 0;
mites.clear(); mites.clear();
mite = 0; mite = 0;
repair = null; placard = null;
repairQueue.length = 0; placardQueue.length = 0;
for (const p of PARAMS) eased[p] = 0; for (const p of PARAMS) eased[p] = 0;
}, },
research: (tech: string) => { research: (tech: string) => {
@ -274,9 +330,14 @@ export function createScreenFX(): ScreenFX {
strain: +strain.toFixed(3), strain: +strain.toFixed(3),
fever: +fever.toFixed(3), fever: +fever.toFixed(3),
pallor: +pallor.toFixed(3), pallor: +pallor.toFixed(3),
sterile: +sterile.toFixed(3),
band: bandNow,
held: heldNow,
holds: stasisHolds.size,
feedSec: +feedSec.toFixed(4),
wildlife: +wildlife.toFixed(3), wildlife: +wildlife.toFixed(3),
mite: +mite.toFixed(3), mite: +mite.toFixed(3),
repair: repair ? 1 : 0, placard: placard ? placard.kind : null,
card: cardAge >= 0 ? 1 : 0, card: cardAge >= 0 ? 1 : 0,
title: +titleOpacity(titleAge).toFixed(3), title: +titleOpacity(titleAge).toFixed(3),
flash: +flashEnvelope(flashAge).toFixed(3), flash: +flashEnvelope(flashAge).toFixed(3),
@ -320,19 +381,20 @@ export function createScreenFX(): ScreenFX {
if (e.on === 'spawned') mites.set(e.id, { pos: e.pos, size: 0.6 }); if (e.on === 'spawned') mites.set(e.id, { pos: e.pos, size: 0.6 });
else mites.delete(e.id); else mites.delete(e.id);
} else if (e.kind === 'repaired') { } else if (e.kind === 'repaired') {
// The Correction corrected one unit of your work. This is THEFT, not decay: ease the // A parity mite corrected one unit of your work, and signs it with the green tick.
// LEDGER down by exactly that item's per-unit contribution (a real, permanent removal — correct(e.item, 'repair');
// unlike boredom, which only fades the display and leaves the tally intact). Then let } else if (e.kind === 'mirrored') {
// it sign the job with a small green-tick placard. We deliberately do NOT refresh the // A redundancy gunship re-encoded a whole sector. Same theft, same ledger, same
// boredom clock: a factory being quietly eaten is not a factory that is producing. // decrement — a different placard, because a sector is not a unit.
const cur = tally.get(e.item) ?? 0; correct(e.item, 'mirror');
if (cur > 0) { } else if (e.kind === 'stasis') {
tally.set(e.item, cur - 1); // A hash auditor is holding a rectangle of the factory still while it reads it.
ledgerDirty = true; // Keyed by lockHash so nested holds release independently. Stasis auto-releases by
} // contract, so the sky can trust it will be handed its clock back.
const def = items.get(e.item); if (e.on) stasisHolds.add(e.lockHash);
repairQueue.push({ name: def?.name ?? e.item.toUpperCase(), age: 0 }); else stasisHolds.delete(e.lockHash);
while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift(); } else if (e.kind === 'debtBand') {
eventBand = e.band;
} }
} }
}, },
@ -347,6 +409,20 @@ export function createScreenFX(): ScreenFX {
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000)); const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000));
lastMs = timeMs; lastMs = timeMs;
// STASIS. Events are the truth here — this is an edge, and main.ts drains every frame —
// while the snapshot corroborates for the case an edge can never cover: a save loaded
// mid-audit, where the hold is a standing fact and no event will ever fire for it.
let held = stasisHolds.size > 0;
if (!held && snap?.correction) {
for (const u of snap.correction.units) {
if (u.phase === 'stasis') { held = true; break; }
}
}
// The sky's clock stops. Nothing else does: the ledger still books shipments, stamps
// still land, the strain still reads. The picture is held, the paperwork is not.
if (!held) feedSec += dt;
heldNow = held;
if (ledgerDirty) recompute(); if (ledgerDirty) recompute();
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU); brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
@ -357,6 +433,13 @@ export function createScreenFX(): ScreenFX {
fever = ease(fever, target.fever, dt, FEVER_TAU); fever = ease(fever, target.fever, dt, FEVER_TAU);
pallor = ease(pallor, target.pallor, dt, PALLOR_TAU); pallor = ease(pallor, target.pallor, dt, PALLOR_TAU);
// The debt band. Snapshot is truth when The Correction exists at all; the `debtBand` edge
// covers the no-snapshot path. Eased on the PALLOR tau because it is the same kind of
// reading: chronic, a standing condition, never an event.
const band = snap?.correction ? snap.correction.band : eventBand;
bandNow = band;
sterile = ease(sterile, sterileForBand(band), dt, PALLOR_TAU);
// The substrate ages with the highest ratified era. Corruption stays era-agnostic — // The substrate ages with the highest ratified era. Corruption stays era-agnostic —
// only what it is painted ONTO changes. // only what it is painted ONTO changes.
era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era); era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era);
@ -408,7 +491,7 @@ export function createScreenFX(): ScreenFX {
if (worstMite) miteSeed = seedFromPos(worstMite.pos); if (worstMite) miteSeed = seedFromPos(worstMite.pos);
mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0); mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0);
const timeSec = timeMs / 1000; const timeSec = feedSec;
const displayTotal = total * fresh; const displayTotal = total * fresh;
const wobble = const wobble =
displayTotal > WOBBLE_THRESHOLD displayTotal > WOBBLE_THRESHOLD
@ -444,21 +527,23 @@ export function createScreenFX(): ScreenFX {
if (stamp.age >= STAMP_LIFE) stamp = null; if (stamp.age >= STAMP_LIFE) stamp = null;
} }
// Repair placard lifecycle — its own slot so it never fights the shipment stamp. Same // The Correction's placard lifecycle — its own slot so it never fights the shipment stamp,
// and ONE slot for both painters so repairs and mirrors queue rather than overlap. Same
// punch-in-hold-fade shape, held a touch longer because it is a signature to be read. // punch-in-hold-fade shape, held a touch longer because it is a signature to be read.
if (!repair && repairQueue.length) { if (!placard && placardQueue.length) {
repair = repairQueue.shift()!; placard = placardQueue.shift()!;
overlays.paintRepair(repair.name); if (placard.kind === 'mirror') overlays.paintMirror(placard.name);
repairDirty = true; else overlays.paintRepair(placard.name);
placardDirty = true;
} }
let repairAmt = 0; let placardAmt = 0;
let repairScale = 1; let placardScale = 1;
if (repair) { if (placard) {
const a = repair.age / REPAIR_LIFE; const a = placard.age / PLACARD_LIFE;
repairAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a)); placardAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a));
repairScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot placardScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot
repair.age += dt; placard.age += dt;
if (repair.age >= REPAIR_LIFE) repair = null; if (placard.age >= PLACARD_LIFE) placard = null;
} }
// The card holds at full opacity and then cuts — no fade. The corruption crashes back in // The card holds at full opacity and then cuts — no fade. The corruption crashes back in
@ -485,15 +570,15 @@ export function createScreenFX(): ScreenFX {
const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG'); const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG');
stack.draw({ stack.draw({
params: out, timeSec, drift: 1, brownout, flash, era, chyron, params: out, timeSec, drift: 1, brownout, flash, era, chyron, held,
strain, fever, pallor, moireSeed, wildlife, wildlifeSeed, mite, miteSeed, strain, fever, pallor, sterile, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty, stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
repair: repairAmt, repairScale, repairDirty, placard: placardAmt, placardScale, placardDirty,
card, cardDirty, title, titleDirty, card, cardDirty, title, titleDirty,
}); });
if (scramFrames > 0) scramFrames--; if (scramFrames > 0) scramFrames--;
stampDirty = false; stampDirty = false;
repairDirty = false; placardDirty = false;
cardDirty = false; cardDirty = false;
titleDirty = false; titleDirty = false;

View File

@ -12,9 +12,15 @@ export const SCRAM_W = 512;
export const SCRAM_H = 64; export const SCRAM_H = 64;
export const CARD_W = 640; export const CARD_W = 640;
export const CARD_H = 360; export const CARD_H = 360;
/** The repair placard is small on purpose — it's a signature, not a broadcast. */ /**
export const REPAIR_W = 224; * THE CORRECTION'S PLACARD SLOT. One canvas, one shader unit, two painters: `paintRepair` for a
export const REPAIR_H = 112; * parity mite's correction and `paintMirror` for a redundancy gunship's sector theft. They share
* the slot deliberately both are the same faction signing the same ledger, and the sharing is
* what keeps a burst of Corrections reading as one voice instead of two competing overlays.
* Small on purpose: a signature, not a broadcast.
*/
export const PLACARD_W = 224;
export const PLACARD_H = 112;
/** /**
* The eras as the codex names them (§1). The card announces the STANDARD, not the tech id * The eras as the codex names them (§1). The card announces the STANDARD, not the tech id
@ -37,7 +43,8 @@ export interface Overlays {
scramCanvas: HTMLCanvasElement; scramCanvas: HTMLCanvasElement;
cardCanvas: HTMLCanvasElement; cardCanvas: HTMLCanvasElement;
titleCanvas: HTMLCanvasElement; titleCanvas: HTMLCanvasElement;
repairCanvas: HTMLCanvasElement; /** The Correction's placard slot — repaint it with paintRepair OR paintMirror. */
placardCanvas: HTMLCanvasElement;
/** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */ /** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */
paintStamp(name: string, count: number, color: string): void; paintStamp(name: string, count: number, color: string): void;
/** Repaint the ratification card. The Correction's uniform: hospital white, green tick. */ /** Repaint the ratification card. The Correction's uniform: hospital white, green tick. */
@ -45,11 +52,18 @@ export interface Overlays {
/** Paint the boot title card. Alpha channel matters — it dissolves over the feed. */ /** Paint the boot title card. Alpha channel matters — it dissolves over the feed. */
paintTitle(): void; paintTitle(): void;
/** /**
* Repaint the repair placard The Correction signing one unit of your work off the ledger. * Repaint the placard as a MITE REPAIR The Correction signing one unit of your work off the
* Small, perfect (no crooked stamp), hospital white with the faction green tick: deliberately * ledger. Small, perfect (no crooked stamp), hospital white with the faction green tick:
* the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft, made legible. * deliberately the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft.
*/ */
paintRepair(name: string): void; paintRepair(name: string): void;
/**
* Repaint the placard as a GUNSHIP MIRROR a whole sector re-encoded out from under you.
* Same hospital white, but ARCHITECTURAL where the mite's is clerical: no green tick, a
* drafted sector outline with registration marks instead. A mite corrects a unit; a gunship
* redraws the map, and the placard should read as a plan rather than a signature.
*/
paintMirror(name: string): void;
} }
function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
@ -105,17 +119,17 @@ export function createOverlays(): Overlays {
titleCanvas.height = CARD_H; titleCanvas.height = CARD_H;
const tg = titleCanvas.getContext('2d')!; const tg = titleCanvas.getContext('2d')!;
const repairCanvas = document.createElement('canvas'); const placardCanvas = document.createElement('canvas');
repairCanvas.width = REPAIR_W; placardCanvas.width = PLACARD_W;
repairCanvas.height = REPAIR_H; placardCanvas.height = PLACARD_H;
const rg = repairCanvas.getContext('2d')!; const rg = placardCanvas.getContext('2d')!;
return { return {
stampCanvas, stampCanvas,
scramCanvas, scramCanvas,
cardCanvas, cardCanvas,
titleCanvas, titleCanvas,
repairCanvas, placardCanvas,
paintTitle() { paintTitle() {
// The game finally says its own name. A CRT burn-in: chunky phosphor letters with a // The game finally says its own name. A CRT burn-in: chunky phosphor letters with a
@ -240,15 +254,15 @@ export function createOverlays(): Overlays {
// The Correction's uniform (codex §8): hospital white, green tick, and the placid // The Correction's uniform (codex §8): hospital white, green tick, and the placid
// confidence of a compliance stamp. NOT rotated — where the shipment stamp is thumped // confidence of a compliance stamp. NOT rotated — where the shipment stamp is thumped
// on by a tired hand, this is machined on square. That contrast is the whole read. // on by a tired hand, this is machined on square. That contrast is the whole read.
rg.clearRect(0, 0, REPAIR_W, REPAIR_H); rg.clearRect(0, 0, PLACARD_W, PLACARD_H);
// clean white placard with a thin green rule // clean white placard with a thin green rule
rg.fillStyle = '#f2f5f2'; rg.fillStyle = '#f2f5f2';
roundRect(rg, 6, 6, REPAIR_W - 12, REPAIR_H - 12, 5); roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
rg.fill(); rg.fill();
rg.strokeStyle = '#2f7d3f'; rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 3; rg.lineWidth = 3;
roundRect(rg, 6, 6, REPAIR_W - 12, REPAIR_H - 12, 5); roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
rg.stroke(); rg.stroke();
// the green tick — the faction signature // the green tick — the faction signature
@ -271,7 +285,7 @@ export function createOverlays(): Overlays {
rg.fillStyle = '#5c6b5c'; rg.fillStyle = '#5c6b5c';
let size = 15; let size = 15;
rg.font = `${size}px 'Courier New', monospace`; rg.font = `${size}px 'Courier New', monospace`;
while (rg.measureText(name).width > REPAIR_W - 40 && size > 8) { while (rg.measureText(name).width > PLACARD_W - 40 && size > 8) {
size -= 1; size -= 1;
rg.font = `${size}px 'Courier New', monospace`; rg.font = `${size}px 'Courier New', monospace`;
} }
@ -281,5 +295,81 @@ export function createOverlays(): Overlays {
rg.font = "9px 'Courier New', monospace"; rg.font = "9px 'Courier New', monospace";
rg.fillText('THE CORRECTION · NO ACTION REQUIRED', 26, 94); rg.fillText('THE CORRECTION · NO ACTION REQUIRED', 26, 94);
}, },
paintMirror(name: string) {
// A REDUNDANCY GUNSHIP has re-encoded a sector. Same faction, same hospital white — but
// the mite's placard is CLERICAL (a tick, a signature) and this one is ARCHITECTURAL: a
// drafted sector rectangle with registration marks, the way a plan shows you the block of
// your city that has been compulsorily purchased. No tick anywhere; a tick means a person
// checked something, and nobody checked this.
rg.clearRect(0, 0, PLACARD_W, PLACARD_H);
rg.fillStyle = '#f2f5f2';
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
rg.fill();
// graphite double rule — drawing-office, not clinic
rg.strokeStyle = '#5c6b5c';
rg.lineWidth = 2;
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
rg.stroke();
rg.lineWidth = 1;
rg.strokeStyle = '#b9c4b9';
roundRect(rg, 12, 12, PLACARD_W - 24, PLACARD_H - 24, 3);
rg.stroke();
// the sector diagram: an outlined rect with corner registration ticks and a centre cross.
const bx = 24;
const by = 30;
const bw = 44;
const bh = 44;
rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 2;
rg.strokeRect(bx, by, bw, bh);
rg.strokeStyle = '#12160f';
rg.lineWidth = 1;
const tick = 6;
for (const [cx, cy, sx, sy] of [
[bx, by, -1, -1], [bx + bw, by, 1, -1], [bx, by + bh, -1, 1], [bx + bw, by + bh, 1, 1],
] as const) {
rg.beginPath();
rg.moveTo(cx, cy);
rg.lineTo(cx + sx * tick, cy);
rg.moveTo(cx, cy);
rg.lineTo(cx, cy + sy * tick);
rg.stroke();
}
rg.strokeStyle = '#8a998a';
rg.beginPath();
rg.moveTo(bx + bw / 2, by + 8);
rg.lineTo(bx + bw / 2, by + bh - 8);
rg.moveTo(bx + 8, by + bh / 2);
rg.lineTo(bx + bw - 8, by + bh / 2);
rg.stroke();
// the mirror itself: the sector's own diagonal, doubled
rg.strokeStyle = '#2f7d3f';
rg.beginPath();
rg.moveTo(bx + 4, by + bh - 4);
rg.lineTo(bx + bw - 4, by + 4);
rg.stroke();
rg.textBaseline = 'middle';
rg.fillStyle = '#12160f';
rg.font = "bold 19px 'Courier New', monospace";
rg.fillText('MIRRORED', 82, 42);
// the item taken out of the sector — shrink long codex names to fit beside the diagram
rg.fillStyle = '#5c6b5c';
let msize = 14;
rg.font = `${msize}px 'Courier New', monospace`;
while (rg.measureText(name).width > PLACARD_W - 96 && msize > 8) {
msize -= 1;
rg.font = `${msize}px 'Courier New', monospace`;
}
rg.fillText(name, 82, 66);
rg.fillStyle = '#8a998a';
rg.font = "9px 'Courier New', monospace";
rg.fillText('THE CORRECTION · SECTOR RE-ENCODED', 24, 94);
},
}; };
} }

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { EntityState, SimSnapshot } from '../contracts'; import type { EntityState, SimSnapshot } from '../contracts';
import { computeStrain, STRAIN_HORIZON_SECONDS } from './strain'; import { computeStrain, sterileForBand, STERILE_BAND, STRAIN_HORIZON_SECONDS } from './strain';
// v4 fixture hygiene: commissionQueue and research both harden in v5, so they are populated // v4 fixture hygiene: commissionQueue and research both harden in v5, so they are populated
// here now rather than left to break the day the contract tightens. // here now rather than left to break the day the contract tightens.
@ -144,3 +144,33 @@ describe('fever reads the machines that actually run hot', () => {
expect(f).toBe(1); expect(f).toBe(1);
}); });
}); });
describe('the debt band reads as light (v9)', () => {
it('shows NOTHING below SEALED — a factory in good standing wears no mark', () => {
// The whole reading is worthless if it is ever faintly on. Bands 0-2 must be exactly zero,
// not "nearly zero": this is the same guarantee as the NOTHING IS WRONG era above.
for (const band of [0, 1, 2]) expect(sterileForBand(band)).toBe(0);
});
it('is absent when The Correction has never been heard from at all', () => {
expect(sterileForBand(undefined)).toBe(0);
expect(sterileForBand(null)).toBe(0);
});
it('appears at SEALED and gets worse at REFUSED', () => {
expect(sterileForBand(STERILE_BAND)).toBeGreaterThan(0);
expect(sterileForBand(4)).toBeGreaterThan(sterileForBand(3));
expect(sterileForBand(4)).toBe(1);
});
it('never exceeds the 0..1 the shader uniform expects', () => {
for (const band of [-1, 0, 1, 2, 3, 4, 99]) {
expect(sterileForBand(band)).toBeGreaterThanOrEqual(0);
expect(sterileForBand(band)).toBeLessThanOrEqual(1);
}
});
it('is a step, not a ramp — SEALED and REFUSED are two rooms', () => {
expect(sterileForBand(4) - sterileForBand(3)).toBeGreaterThan(0.2);
});
});

View File

@ -84,3 +84,26 @@ export function computeStrain(snap?: SimSnapshot): Strain {
return { tremor, fever, pallor }; return { tremor, fever, pallor };
} }
/**
* The rung at which The Correction's paperwork reaches the sky.
* Contracts v9 ladder: 0 NOMINAL, 1 OBSERVED, 2 AUDITED, 3 SEALED, 4 REFUSED.
*/
export const STERILE_BAND = 3;
/**
* The debt band, read as light (v9). Below SEALED the sky wears nothing at all: a factory in
* good standing must look exactly like a factory nobody has ever heard of, or the reading means
* nothing when it does appear.
*
* Deliberately NOT folded into computeStrain: the band arrives both as a `debtBand` edge event
* and in `snapshot.correction`, so the compositor resolves the source of truth first and then
* asks this for the amount. Keeping it a one-line pure function is what makes "gone below band
* 3" a unit test instead of a promise.
*/
export function sterileForBand(band: number | undefined | null): number {
if (band == null || band < STERILE_BAND) return 0;
// Two steps, not a ramp: the last rung of the ladder should feel like a different room, and
// there are only two rungs above the threshold to distinguish.
return band >= 4 ? 1 : 0.65;
}