From de5a1b57707180074993b54fd9663636ddd0e741 Mon Sep 17 00:00:00 2001 From: type-two Date: Wed, 29 Jul 2026 12:38:54 +1000 Subject: [PATCH] [screen] round 7: the gunship's theft, the auditor's hold, and one note at 440.000 Hz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fktry/lanes/LANE-SCREEN.md | 153 +++++++++++++++++++++ fktry/src/audio/compliance.test.ts | 90 ++++++++++++ fktry/src/audio/compliance.ts | 41 ++++++ fktry/src/audio/index.ts | 40 +++++- fktry/src/audio/sfx.ts | 45 +++++- fktry/src/screen/baseTexture.ts | 11 +- fktry/src/screen/corruptionMap.test.ts | 14 ++ fktry/src/screen/glitchStack.ts | 82 ++++++----- fktry/src/screen/index.ts | 181 ++++++++++++++++++------- fktry/src/screen/overlays.ts | 122 ++++++++++++++--- fktry/src/screen/strain.test.ts | 32 ++++- fktry/src/screen/strain.ts | 23 ++++ 12 files changed, 732 insertions(+), 102 deletions(-) create mode 100644 fktry/src/audio/compliance.test.ts create mode 100644 fktry/src/audio/compliance.ts diff --git a/fktry/lanes/LANE-SCREEN.md b/fktry/lanes/LANE-SCREEN.md index a7440d9..f269cee 100644 --- a/fktry/lanes/LANE-SCREEN.md +++ b/fktry/lanes/LANE-SCREEN.md @@ -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 2–8 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 Hz–12 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.016–0.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. diff --git a/fktry/src/audio/compliance.test.ts b/fktry/src/audio/compliance.test.ts new file mode 100644 index 0000000..2f8700b --- /dev/null +++ b/fktry/src/audio/compliance.test.ts @@ -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 = 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 = 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); + }); +}); diff --git a/fktry/src/audio/compliance.ts b/fktry/src/audio/compliance.ts new file mode 100644 index 0000000..96723b6 --- /dev/null +++ b/fktry/src/audio/compliance.ts @@ -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, + units: ReadonlyArray | undefined, +): { struck: number[]; next: Set } { + const next = new Set(); + 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 }; +} diff --git a/fktry/src/audio/index.ts b/fktry/src/audio/index.ts index e85f8c4..a7fa252 100644 --- a/fktry/src/audio/index.ts +++ b/fktry/src/audio/index.ts @@ -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 = new Set(); 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))); diff --git a/fktry/src/audio/sfx.ts b/fktry/src/audio/sfx.ts index 35c9518..51ea9ef 100644 --- a/fktry/src/audio/sfx.ts +++ b/fktry/src/audio/sfx.ts @@ -150,6 +150,18 @@ export function klaxon(ctx: BaseAudioContext, dest: AudioNode, t0: number) { } } +/** + * The ratification triad: F4–A4–C5. 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 (F–A–C) 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; diff --git a/fktry/src/screen/baseTexture.ts b/fktry/src/screen/baseTexture.ts index c789ee1..3af1d2d 100644 --- a/fktry/src/screen/baseTexture.ts +++ b/fktry/src/screen/baseTexture.ts @@ -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); diff --git a/fktry/src/screen/corruptionMap.test.ts b/fktry/src/screen/corruptionMap.test.ts index b93882e..e9ce3f6 100644 --- a/fktry/src/screen/corruptionMap.test.ts +++ b/fktry/src/screen/corruptionMap.test.ts @@ -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 })); diff --git a/fktry/src/screen/glitchStack.ts b/fktry/src/screen/glitchStack.ts index 76426d9..89f2ab5 100644 --- a/fktry/src/screen/glitchStack.ts +++ b/fktry/src/screen/glitchStack.ts @@ -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; 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); }, diff --git a/fktry/src/screen/index.ts b/fktry/src/screen/index.ts index 9794783..cfa7cd5 100644 --- a/fktry/src/screen/index.ts +++ b/fktry/src/screen/index.ts @@ -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(); + /** + * 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; diff --git a/fktry/src/screen/overlays.ts b/fktry/src/screen/overlays.ts index 4d9b436..8ee2c82 100644 --- a/fktry/src/screen/overlays.ts +++ b/fktry/src/screen/overlays.ts @@ -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); + }, }; } diff --git a/fktry/src/screen/strain.test.ts b/fktry/src/screen/strain.test.ts index c0abf24..4728ce0 100644 --- a/fktry/src/screen/strain.test.ts +++ b/fktry/src/screen/strain.test.ts @@ -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); + }); +}); diff --git a/fktry/src/screen/strain.ts b/fktry/src/screen/strain.ts index 53c12b9..6c73388 100644 --- a/fktry/src/screen/strain.ts +++ b/fktry/src/screen/strain.ts @@ -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; +}