[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
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.
### 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 type { StationDef } from './stations';
import {
brownoutHit, brownoutRecover, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
brownoutHit, brownoutRecover, complianceTone, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
} from './sfx';
import { arrivedWardens } from './compliance';
import stationData from '../../data/stations.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. */
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
* 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 lastThunk = 0;
let lastChunk = 0;
let lastCompliance = -Infinity;
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 {
return ctx ? ctx.currentTime : 0;
@ -77,6 +81,21 @@ export function createAudioFX(): AudioFX {
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) {
if (!ctx || !master) return;
const dest = master.input;
@ -108,6 +127,10 @@ export function createAudioFX(): AudioFX {
lastChunk = t;
}
break;
case 'notice':
// The fax arrives before the action, always (contracts v9). One note, then paperwork.
strikeCompliance();
break;
case 'relicFound':
openTheBand();
break;
@ -154,7 +177,8 @@ export function createAudioFX(): AudioFX {
const d = master.input;
const t = now() + 0.02;
({ 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);
// 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) {
const { gen, draw } = snap.bandwidth;
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.
* 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();
bus.gain.value = 0.3;
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) => {
const o = ctx.createOscillator();
o.type = 'sine';
@ -180,6 +192,37 @@ export function ratifySting(ctx: BaseAudioContext, dest: AudioNode, t0: number)
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. */
export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2;

View File

@ -20,6 +20,13 @@ export interface BaseState {
era: Era;
/** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */
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 {
@ -117,7 +124,7 @@ export function createBaseFeed(): BaseFeed {
// camcorder overlay
g.fillStyle = skin.ink;
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.font = "bold 18px 'Courier New', monospace";
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
@ -164,7 +171,7 @@ export function createBaseFeed(): BaseFeed {
canvas,
update(state: BaseState) {
// 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;
last = key;
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);
});
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)', () => {
// ship one, correct one → back to the NOTHING IS WRONG floor, never past it.
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_block, u_ring, u_moire;
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 float u_wildlife;
uniform vec2 u_wildlifeSeed;
uniform float u_mite;
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_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 vnoise(vec2 p){
@ -228,6 +228,17 @@ void main(){
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
// 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
@ -257,14 +268,16 @@ void main(){
}
}
// ---- repair placard: The Correction signs one unit off the ledger. Small, perfect, screen
// space (uncorrupted), parked low-right so it never fights the shipment stamp for the centre.
if(u_repair > 0.001){
vec2 halfSize = vec2(0.150, 0.082)*u_repairScale;
// ---- the Correction's placard: it signs one unit off the ledger (a mite's repair) or takes
// a whole sector (a gunship's mirror). ONE slot, two painters — same faction, same voice, so a
// burst of Corrections queues instead of overlapping. Small, perfect, screen space
// (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;
if(ruv.x > 0.0 && ruv.x < 1.0 && ruv.y > 0.0 && ruv.y < 1.0){
vec4 rp = texture2D(u_repairTex, ruv);
col = mix(col, rp.rgb, rp.a*u_repair);
vec4 rp = texture2D(u_placardTex, ruv);
col = mix(col, rp.rgb, rp.a*u_placard);
}
}
@ -297,10 +310,10 @@ void main(){
}`;
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'
| 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'repairTex'
| 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'repair' | 'repairScale';
| 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'placardTex'
| 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'placard' | 'placardScale';
export interface DrawState {
params: ParamSet;
@ -315,12 +328,17 @@ export interface DrawState {
era: Era;
/** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */
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) */
strain: number;
/** 0..1 feverish shimmer from aggregate machine heat */
fever: number;
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */
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 */
moireSeed: [number, number];
/** 0..1 regional mosquito shimmer amount (wildlife swarms) */
@ -349,12 +367,12 @@ export interface DrawState {
scramY: number;
/** set when the stamp canvas has been repainted and needs re-upload */
stampDirty: boolean;
/** 0..1 opacity of the repair placard */
repair: number;
/** repair placard size multiplier (slight overshoot on impact, like the stamp) */
repairScale: number;
/** set when the repair canvas has been repainted and needs re-upload */
repairDirty: boolean;
/** 0..1 opacity of the Correction's placard (mite repair OR gunship mirror — one slot) */
placard: number;
/** placard size multiplier (slight overshoot on impact, like the stamp) */
placardScale: number;
/** set when the placard canvas has been repainted and needs re-upload */
placardDirty: boolean;
}
export interface GlitchStack {
@ -418,13 +436,13 @@ export function createGlitchStack(
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
overlays.paintCard('reel', '');
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 stampTex = makeTex(1, overlays.stampCanvas);
makeTex(2, overlays.scramCanvas);
const cardTex = makeTex(3, overlays.cardCanvas);
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>;
uni.t = gl.getUniformLocation(prog, 't');
@ -434,6 +452,7 @@ export function createGlitchStack(
uni.strain = gl.getUniformLocation(prog, 'u_strain');
uni.fever = gl.getUniformLocation(prog, 'u_fever');
uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
uni.sterile = gl.getUniformLocation(prog, 'u_sterile');
uni.card = gl.getUniformLocation(prog, 'u_card');
uni.title = gl.getUniformLocation(prog, 'u_title');
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
@ -445,8 +464,8 @@ export function createGlitchStack(
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
uni.scram = gl.getUniformLocation(prog, 'u_scram');
uni.scramY = gl.getUniformLocation(prog, 'u_scramY');
uni.repair = gl.getUniformLocation(prog, 'u_repair');
uni.repairScale = gl.getUniformLocation(prog, 'u_repairScale');
uni.placard = gl.getUniformLocation(prog, 'u_placard');
uni.placardScale = gl.getUniformLocation(prog, 'u_placardScale');
for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);
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_cardTex'), 3);
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.uniform1f(uni.drift, 1);
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,
stampAmt, stampScale, scram, scramY, stampDirty,
repair, repairScale, repairDirty,
placard, placardScale, placardDirty,
card, cardDirty, title, titleDirty }) {
// 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.bindTexture(gl.TEXTURE_2D, baseTex);
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.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas);
}
if (repairDirty) {
if (placardDirty) {
gl.activeTexture(gl.TEXTURE0 + 5);
gl.bindTexture(gl.TEXTURE_2D, repairTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.repairCanvas);
gl.bindTexture(gl.TEXTURE_2D, placardTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.placardCanvas);
}
gl.uniform1f(uni.t, timeSec);
gl.uniform1f(uni.drift, drift);
@ -498,6 +517,7 @@ export function createGlitchStack(
gl.uniform1f(uni.strain, strain);
gl.uniform1f(uni.fever, fever);
gl.uniform1f(uni.pallor, pallor);
gl.uniform1f(uni.sterile, sterile);
gl.uniform1f(uni.card, card);
gl.uniform1f(uni.title, title);
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
@ -509,8 +529,8 @@ export function createGlitchStack(
gl.uniform1f(uni.stampScale, stampScale);
gl.uniform1f(uni.scram, scram ? 1 : 0);
gl.uniform1f(uni.scramY, scramY);
gl.uniform1f(uni.repair, repair);
gl.uniform1f(uni.repairScale, repairScale);
gl.uniform1f(uni.placard, placard);
gl.uniform1f(uni.placardScale, placardScale);
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},

View File

@ -15,7 +15,7 @@ import { createGlitchStack, type GlitchStack } from './glitchStack';
import { createOverlays } from './overlays';
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
import { computeStrain } from './strain';
import { computeStrain, sterileForBand } from './strain';
import { freshness } from './boredom';
import { ERA_ORDER, highestEra, type Era } from './eras';
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];
/** Seconds a shipment stamp stays on the feed. */
const STAMP_LIFE = 0.4;
/** The repair placard lingers a touch longer than a shipment stamp — a signature, read at leisure. */
const REPAIR_LIFE = 0.55;
/** The Correction's placard lingers a touch longer than a shipment stamp — read at leisure. */
const PLACARD_LIFE = 0.55;
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
const STAMP_QUEUE_MAX = 2;
/** 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;
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 }
/**
@ -123,15 +128,35 @@ export function createScreenFX(): ScreenFX {
const stampQueue: Stamp[] = [];
let stamp: Stamp | null = null;
let stampDirty = false;
const repairQueue: Repair[] = [];
let repair: Repair | null = null;
let repairDirty = false;
const placardQueue: Placard[] = [];
let placard: Placard | null = null;
let placardDirty = false;
let scramFrames = 0;
let scramY = 0.5;
let strain = 0;
let fever = 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 moireSeeded = false;
@ -192,6 +217,27 @@ export function createScreenFX(): ScreenFX {
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;
const api: ScreenFX = {
@ -226,15 +272,19 @@ export function createScreenFX(): ScreenFX {
if (on) mites.set(id, { pos, size });
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) => {
for (let i = 0; i < count; i++) {
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();
}
for (let i = 0; i < count; i++) correct(item, 'repair');
},
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: () => {
tally.clear();
ledgerDirty = true;
@ -246,6 +296,12 @@ export function createScreenFX(): ScreenFX {
strain = 0;
fever = 0;
pallor = 0;
sterile = 0;
eventBand = 0;
bandNow = 0;
heldNow = false;
stasisHolds.clear();
feedSec = 0;
cardAge = -1;
flashAge = -1;
lastShipMs = 0;
@ -255,8 +311,8 @@ export function createScreenFX(): ScreenFX {
wildlife = 0;
mites.clear();
mite = 0;
repair = null;
repairQueue.length = 0;
placard = null;
placardQueue.length = 0;
for (const p of PARAMS) eased[p] = 0;
},
research: (tech: string) => {
@ -274,9 +330,14 @@ export function createScreenFX(): ScreenFX {
strain: +strain.toFixed(3),
fever: +fever.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),
mite: +mite.toFixed(3),
repair: repair ? 1 : 0,
placard: placard ? placard.kind : null,
card: cardAge >= 0 ? 1 : 0,
title: +titleOpacity(titleAge).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 });
else mites.delete(e.id);
} else if (e.kind === 'repaired') {
// The Correction corrected one unit of your work. This is THEFT, not decay: ease the
// LEDGER down by exactly that item's per-unit contribution (a real, permanent removal —
// unlike boredom, which only fades the display and leaves the tally intact). Then let
// it sign the job with a small green-tick placard. We deliberately do NOT refresh the
// boredom clock: a factory being quietly eaten is not a factory that is producing.
const cur = tally.get(e.item) ?? 0;
if (cur > 0) {
tally.set(e.item, cur - 1);
ledgerDirty = true;
}
const def = items.get(e.item);
repairQueue.push({ name: def?.name ?? e.item.toUpperCase(), age: 0 });
while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift();
// A parity mite corrected one unit of your work, and signs it with the green tick.
correct(e.item, 'repair');
} else if (e.kind === 'mirrored') {
// A redundancy gunship re-encoded a whole sector. Same theft, same ledger, same
// decrement — a different placard, because a sector is not a unit.
correct(e.item, 'mirror');
} else if (e.kind === 'stasis') {
// A hash auditor is holding a rectangle of the factory still while it reads it.
// 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.
if (e.on) stasisHolds.add(e.lockHash);
else stasisHolds.delete(e.lockHash);
} 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));
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();
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
@ -357,6 +433,13 @@ export function createScreenFX(): ScreenFX {
fever = ease(fever, target.fever, dt, FEVER_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 —
// only what it is painted ONTO changes.
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);
mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0);
const timeSec = timeMs / 1000;
const timeSec = feedSec;
const displayTotal = total * fresh;
const wobble =
displayTotal > WOBBLE_THRESHOLD
@ -444,21 +527,23 @@ export function createScreenFX(): ScreenFX {
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.
if (!repair && repairQueue.length) {
repair = repairQueue.shift()!;
overlays.paintRepair(repair.name);
repairDirty = true;
if (!placard && placardQueue.length) {
placard = placardQueue.shift()!;
if (placard.kind === 'mirror') overlays.paintMirror(placard.name);
else overlays.paintRepair(placard.name);
placardDirty = true;
}
let repairAmt = 0;
let repairScale = 1;
if (repair) {
const a = repair.age / REPAIR_LIFE;
repairAmt = 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
repair.age += dt;
if (repair.age >= REPAIR_LIFE) repair = null;
let placardAmt = 0;
let placardScale = 1;
if (placard) {
const a = placard.age / PLACARD_LIFE;
placardAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a));
placardScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot
placard.age += dt;
if (placard.age >= PLACARD_LIFE) placard = null;
}
// 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');
stack.draw({
params: out, timeSec, drift: 1, brownout, flash, era, chyron,
strain, fever, pallor, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
params: out, timeSec, drift: 1, brownout, flash, era, chyron, held,
strain, fever, pallor, sterile, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
repair: repairAmt, repairScale, repairDirty,
placard: placardAmt, placardScale, placardDirty,
card, cardDirty, title, titleDirty,
});
if (scramFrames > 0) scramFrames--;
stampDirty = false;
repairDirty = false;
placardDirty = false;
cardDirty = false;
titleDirty = false;

View File

@ -12,9 +12,15 @@ export const SCRAM_W = 512;
export const SCRAM_H = 64;
export const CARD_W = 640;
export const CARD_H = 360;
/** The repair placard is small on purpose — it's a signature, not a broadcast. */
export const REPAIR_W = 224;
export const REPAIR_H = 112;
/**
* THE CORRECTION'S PLACARD SLOT. One canvas, one shader unit, two painters: `paintRepair` for a
* 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
@ -37,7 +43,8 @@ export interface Overlays {
scramCanvas: HTMLCanvasElement;
cardCanvas: 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. */
paintStamp(name: string, count: number, color: string): void;
/** 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. */
paintTitle(): void;
/**
* Repaint the repair placard The Correction signing one unit of your work off the ledger.
* Small, perfect (no crooked stamp), hospital white with the faction green tick: deliberately
* the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft, made legible.
* Repaint the placard as a MITE REPAIR The Correction signing one unit of your work off the
* ledger. Small, perfect (no crooked stamp), hospital white with the faction green tick:
* deliberately the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft.
*/
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) {
@ -105,17 +119,17 @@ export function createOverlays(): Overlays {
titleCanvas.height = CARD_H;
const tg = titleCanvas.getContext('2d')!;
const repairCanvas = document.createElement('canvas');
repairCanvas.width = REPAIR_W;
repairCanvas.height = REPAIR_H;
const rg = repairCanvas.getContext('2d')!;
const placardCanvas = document.createElement('canvas');
placardCanvas.width = PLACARD_W;
placardCanvas.height = PLACARD_H;
const rg = placardCanvas.getContext('2d')!;
return {
stampCanvas,
scramCanvas,
cardCanvas,
titleCanvas,
repairCanvas,
placardCanvas,
paintTitle() {
// 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
// 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.
rg.clearRect(0, 0, REPAIR_W, REPAIR_H);
rg.clearRect(0, 0, PLACARD_W, PLACARD_H);
// clean white placard with a thin green rule
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.strokeStyle = '#2f7d3f';
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();
// the green tick — the faction signature
@ -271,7 +285,7 @@ export function createOverlays(): Overlays {
rg.fillStyle = '#5c6b5c';
let size = 15;
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;
rg.font = `${size}px 'Courier New', monospace`;
}
@ -281,5 +295,81 @@ export function createOverlays(): Overlays {
rg.font = "9px 'Courier New', monospace";
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 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
// 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);
});
});
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 };
}
/**
* 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;
}