[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:
parent
3acd605e2e
commit
de5a1b5770
@ -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.
|
||||
|
||||
90
fktry/src/audio/compliance.test.ts
Normal file
90
fktry/src/audio/compliance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
fktry/src/audio/compliance.ts
Normal file
41
fktry/src/audio/compliance.ts
Normal 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 };
|
||||
}
|
||||
@ -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)));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user