[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
|
small placard as "another commission done" rather than "something took my work". If that lands
|
||||||
ambiguous in playtest, the fix is a one-word change to the placard legend ("CORRECTED" is already
|
ambiguous in playtest, the fix is a one-word change to the placard legend ("CORRECTED" is already
|
||||||
doing the work, but "REVERTED" or "RECLAIMED" is colder). Flagging, not pre-emptively changing.
|
doing the work, but "REVERTED" or "RECLAIMED" is colder). Flagging, not pre-emptively changing.
|
||||||
|
|
||||||
|
### Round 7 (Phase 1) — 2026-07-29 — Opus 5
|
||||||
|
|
||||||
|
SHIPPED: all 4 orders. The Correction can now reach the sky, and it still can't sing.
|
||||||
|
- `overlays.ts` — the repair placard slot generalised into **the Correction's placard slot**
|
||||||
|
(`PLACARD_W/H`, `placardCanvas`) with two painters: `paintRepair` (unchanged) and the new
|
||||||
|
`paintMirror`. Renamed `repair*` → `placard*` through `glitchStack.ts` and `index.ts` so the
|
||||||
|
sharing is legible in the code rather than only in a comment. Contained to my lane (grepped).
|
||||||
|
- `index.ts` — `correct(item, kind)`: the ONE decrement path, called by `repaired` and
|
||||||
|
`mirrored` alike; the feed clock + stasis holds; the debt band; DEV `.mirror()`, `.stasis()`,
|
||||||
|
`.band()`, and `feedSec`/`held`/`holds`/`sterile`/`band`/`placard` in `.stats()`.
|
||||||
|
- `glitchStack.ts` — `u_sterile` pass; `held` on `DrawState`.
|
||||||
|
- `baseTexture.ts` — `held` flips the transport legend and joins the repaint cache key.
|
||||||
|
- `strain.ts` — `sterileForBand()` + `STERILE_BAND`, pure and tested.
|
||||||
|
- `sfx.ts` — `RATIFY_TRIAD` and `COMPLIANCE_HZ` exported; `complianceTone()`.
|
||||||
|
- `compliance.ts` + `compliance.test.ts` (new) — the warden-arrival tracker, pure.
|
||||||
|
- Tests: screen 78 (was 72), audio 18 (was 9) = **96 green** across my two lanes.
|
||||||
|
|
||||||
|
DECISIONS:
|
||||||
|
- **The mirror shares the decrement path AND the overlay slot.** Order 1 said reuse, don't fork;
|
||||||
|
I took that past the arithmetic. One `correct()`, one placard canvas, one queue. So a repair
|
||||||
|
and a mirror landing together QUEUE rather than overlap — which is right: it is one faction
|
||||||
|
filing two forms, and two Correction placards fighting for the same corner would read as a bug.
|
||||||
|
The kind picks the painter and nothing else. There is a test pinning that a sector theft and a
|
||||||
|
unit theft move the ledger by exactly the same amount, which is the thing a fork would break.
|
||||||
|
- **The mirror placard is ARCHITECTURAL, the mite's is CLERICAL.** No green tick on the mirror —
|
||||||
|
a tick means a person checked something, and nobody checked this. It gets a drafted sector
|
||||||
|
rectangle with corner registration marks, a crosshair and the sector's doubled diagonal, in
|
||||||
|
graphite with the faction green reserved for the outline. Same hospital white, same authority,
|
||||||
|
different department: a mite signs a receipt, a gunship redraws the map.
|
||||||
|
- **Stasis is a CLOCK, not an effect.** I introduced `feedSec` — the sky's own clock — and moved
|
||||||
|
the shader's `t`, the timecode, the drift and the mature-corruption wobble onto it. Stasis just
|
||||||
|
stops it accumulating. That is why the freeze is exact rather than approximately exact: there
|
||||||
|
is no "restore the frozen state" code to get wrong, because nothing ever left. It also means
|
||||||
|
the picture holds *completely* (the corruption stops crawling too), which is what "administered"
|
||||||
|
should look like — a hold, not a stutter. None of the brownout's freeze/dropout/judder
|
||||||
|
vocabulary is touched; measured `brownout 0`, `freeze 0` throughout a hold.
|
||||||
|
- **Holds are keyed by `lockHash`, in a Set.** Two auditors holding at once means one releasing
|
||||||
|
cannot restart the clock under the other's feet. INTEGRATION NOTE FOR SIM: the `on:false`
|
||||||
|
event must carry the SAME `lockHash` as the `on:true` that opened it, or that hold never lifts
|
||||||
|
from the event path (the snapshot phase fallback would still rescue it).
|
||||||
|
- **The transport legend flips `PLAY ▶` → `HOLD ‖` while held. This is my addition, past the
|
||||||
|
literal order** — flag it if you disagree. A totally still picture with no tell reads as a
|
||||||
|
crashed rAF, not as an administered pause, and `HOLD ‖` is the camcorder's OWN word for a
|
||||||
|
deliberate stop. It costs one glyph, it is sterile, and it is not seizure vocabulary. It is
|
||||||
|
also the only pixel difference a hold makes: measured at 855 px in one box at (21,290,79,20).
|
||||||
|
- **THE COMPLIANCE TONE is A4 = 440.000 Hz**, the middle voice of the ratify triad — and the
|
||||||
|
pitch the entire industry agreed to calibrate against. Of course that is the note it picked.
|
||||||
|
`frequency.value` is SET, never ramped; one sine, no detune, no `hash01`, no LFO, no filter,
|
||||||
|
no noise layer, no second oscillator to beat against. The envelope is the only moving part and
|
||||||
|
it moves slowly (50 ms attack) precisely so the spectrum stays a single partial. Peak 0.14
|
||||||
|
against the full sting's 0.216: one voice must sit UNDER the triad or the sting stops being
|
||||||
|
the bigger event.
|
||||||
|
- **Sparseness is enforced in three places, not one**: gated on `discovered`, one strike per
|
||||||
|
frame however many wardens arrive, and a 1.2 s floor. The tracker reports every arrival
|
||||||
|
honestly and the call site is what refuses to make a chord out of them.
|
||||||
|
- **The warden tracker runs pre-discovery too, and only the STRIKING is gated.** Otherwise a
|
||||||
|
player digging up the fossil while a warden is already stood on station would be greeted by an
|
||||||
|
arrival note for an arrival that happened minutes ago. Measured: it stays silent.
|
||||||
|
- **The debt band is the pallor machinery with its sign flipped** — same radii, same falloff,
|
||||||
|
white instead of grey. Pallor is the corners closing into darkness because you are poor; this
|
||||||
|
is the corners washing out under institutional light because you are being LOOKED AT. Two
|
||||||
|
steps (0.65 at SEALED, 1.0 at REFUSED) rather than a ramp: with only two rungs above the
|
||||||
|
threshold, a ramp would just be a dimmer.
|
||||||
|
|
||||||
|
VERIFIED (real module, real 640×360 canvas, synthetic timebase, GL pixel reads; audio through
|
||||||
|
`OfflineAudioContext` with a Hann-windowed DFT, plus the real `AudioFX` instrumented):
|
||||||
|
- **Order 1, the arithmetic, pinned**: melt ×20 → units 24.0 / total 0.560. `repaired` → units
|
||||||
|
22.8 / tally 19 / total 0.546. `mirrored` → units 22.8 / tally 19 / total 0.546. Delta 1.2000
|
||||||
|
both, which is melt's potency exactly. Byte-equal stats, not "close".
|
||||||
|
- **Order 1, the placards are two documents**: both hospital white (region luminance 227.7 and
|
||||||
|
226.6 against the corrupted feed's 70.2), MAD **20.97** between them inside the placard, 2.66
|
||||||
|
everywhere else. Contact sheet screenshotted: MIRRORED reads as a plan, CORRECTED as a receipt.
|
||||||
|
- **Order 2, byte-identical, five ways.** Against a control run at an identical feed time:
|
||||||
|
every frame during a 5-second hold is one frame; release is byte-identical to the moment the
|
||||||
|
hold began AND to the control at the same feed time; the first frame after release is
|
||||||
|
byte-identical to the control's next frame. 5 s of audit cost the feed exactly 0.0000 s.
|
||||||
|
Nested holds: two on, one released → clock still stopped; last released → clock resumes.
|
||||||
|
Snapshot-only path (a save loaded mid-audit, zero events): froze and released identically.
|
||||||
|
- **Order 3, spectrally**: single partial at 440.00 Hz. Harmonics 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 { createRadio, type Radio, type RadioEngine } from './radio';
|
||||||
import type { StationDef } from './stations';
|
import type { StationDef } from './stations';
|
||||||
import {
|
import {
|
||||||
brownoutHit, brownoutRecover, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
|
brownoutHit, brownoutRecover, complianceTone, faxChatter, klaxon, ratifySting, tapeChunk, thunk,
|
||||||
} from './sfx';
|
} from './sfx';
|
||||||
|
import { arrivedWardens } from './compliance';
|
||||||
|
|
||||||
import stationData from '../../data/stations.json';
|
import stationData from '../../data/stations.json';
|
||||||
import seamData from '../../data/seams.json';
|
import seamData from '../../data/seams.json';
|
||||||
@ -43,7 +44,7 @@ function isRealSeam(x: number, y: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sounds that must not machine-gun when the sim emits a burst in one tick. */
|
/** Sounds that must not machine-gun when the sim emits a burst in one tick. */
|
||||||
const MIN_GAP = { thunk: 0.03, chunk: 0.05 };
|
const MIN_GAP = { thunk: 0.03, chunk: 0.05, compliance: 1.2 };
|
||||||
|
|
||||||
/** The live radio handle for getRadio(). Module-level — set by build(), which runs on the
|
/** The live radio handle for getRadio(). Module-level — set by build(), which runs on the
|
||||||
* first user gesture — so the control surface exists in PRODUCTION, not just behind the
|
* first user gesture — so the control surface exists in PRODUCTION, not just behind the
|
||||||
@ -61,7 +62,10 @@ export function createAudioFX(): AudioFX {
|
|||||||
let discovered = false;
|
let discovered = false;
|
||||||
let lastThunk = 0;
|
let lastThunk = 0;
|
||||||
let lastChunk = 0;
|
let lastChunk = 0;
|
||||||
|
let lastCompliance = -Infinity;
|
||||||
let variant = 0;
|
let variant = 0;
|
||||||
|
/** Wardens currently on station, so an arrival strikes once and standing there strikes never. */
|
||||||
|
let wardensOnStation: ReadonlySet<number> = new Set<number>();
|
||||||
|
|
||||||
function now(): number {
|
function now(): number {
|
||||||
return ctx ? ctx.currentTime : 0;
|
return ctx ? ctx.currentTime : 0;
|
||||||
@ -77,6 +81,21 @@ export function createAudioFX(): AudioFX {
|
|||||||
ratifySting(ctx, master.input, now() + 0.02);
|
ratifySting(ctx, master.input, now() + 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strike THE COMPLIANCE TONE. Gated on `discovered` exactly as tapeChunk is — the pre-relic
|
||||||
|
* silence guarantee is structural, not a volume setting, and The Correction does not get to
|
||||||
|
* be the exception. The min-gap is insurance only: this sound is meant to be rare by
|
||||||
|
* CONSTRUCTION (one note per notice, one per warden arrival), and the gap exists so that a
|
||||||
|
* sim burst in a single tick can never turn the rarity into a chord.
|
||||||
|
*/
|
||||||
|
function strikeCompliance() {
|
||||||
|
if (!discovered || !ctx || !master) return;
|
||||||
|
const t = now() + 0.005;
|
||||||
|
if (t - lastCompliance < MIN_GAP.compliance) return;
|
||||||
|
complianceTone(ctx, master.input, t);
|
||||||
|
lastCompliance = t;
|
||||||
|
}
|
||||||
|
|
||||||
function handle(e: SimEvent) {
|
function handle(e: SimEvent) {
|
||||||
if (!ctx || !master) return;
|
if (!ctx || !master) return;
|
||||||
const dest = master.input;
|
const dest = master.input;
|
||||||
@ -108,6 +127,10 @@ export function createAudioFX(): AudioFX {
|
|||||||
lastChunk = t;
|
lastChunk = t;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'notice':
|
||||||
|
// The fax arrives before the action, always (contracts v9). One note, then paperwork.
|
||||||
|
strikeCompliance();
|
||||||
|
break;
|
||||||
case 'relicFound':
|
case 'relicFound':
|
||||||
openTheBand();
|
openTheBand();
|
||||||
break;
|
break;
|
||||||
@ -154,7 +177,8 @@ export function createAudioFX(): AudioFX {
|
|||||||
const d = master.input;
|
const d = master.input;
|
||||||
const t = now() + 0.02;
|
const t = now() + 0.02;
|
||||||
({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover,
|
({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover,
|
||||||
klaxon, ratify: ratifySting, chunk: tapeChunk } as any)[name]?.(ctx, d, t);
|
klaxon, ratify: ratifySting, chunk: tapeChunk,
|
||||||
|
compliance: complianceTone } as any)[name]?.(ctx, d, t);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -191,6 +215,16 @@ export function createAudioFX(): AudioFX {
|
|||||||
|
|
||||||
radio.update(t, era);
|
radio.update(t, era);
|
||||||
|
|
||||||
|
// Warden arrival is not an edge event — it is a phase transition inside the snapshot — so
|
||||||
|
// the tracker runs every frame. It runs REGARDLESS of `discovered` on purpose: if the
|
||||||
|
// player digs up the fossil while a warden is already stood on station, that warden is
|
||||||
|
// not a new arrival and must not announce itself. Only the striking is gated.
|
||||||
|
if (snap) {
|
||||||
|
const { struck, next } = arrivedWardens(wardensOnStation, snap.correction?.units);
|
||||||
|
wardensOnStation = next;
|
||||||
|
if (struck.length) strikeCompliance(); // ONE note, however many arrived
|
||||||
|
}
|
||||||
|
|
||||||
if (discovered && snap) {
|
if (discovered && snap) {
|
||||||
const { gen, draw } = snap.bandwidth;
|
const { gen, draw } = snap.bandwidth;
|
||||||
const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw)));
|
const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw)));
|
||||||
|
|||||||
@ -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.
|
* 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,
|
* 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();
|
const bus = ctx.createGain();
|
||||||
bus.gain.value = 0.3;
|
bus.gain.value = 0.3;
|
||||||
bus.connect(dest);
|
bus.connect(dest);
|
||||||
const triad = [349.23, 440.0, 523.25]; // F4 A4 C5
|
const triad = RATIFY_TRIAD; // F4 A4 C5
|
||||||
triad.forEach((f, i) => {
|
triad.forEach((f, i) => {
|
||||||
const o = ctx.createOscillator();
|
const o = ctx.createOscillator();
|
||||||
o.type = 'sine';
|
o.type = 'sine';
|
||||||
@ -180,6 +192,37 @@ export function ratifySting(ctx: BaseAudioContext, dest: AudioNode, t0: number)
|
|||||||
shimmer.stop(t0 + 1.1);
|
shimmer.stop(t0 + 1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE COMPLIANCE TONE — The Correction never speaks. It faxes. This is the only noise it makes.
|
||||||
|
*
|
||||||
|
* ONE voice of the ratification triad, standing alone: A4, struck on a fixed grid. The frequency
|
||||||
|
* is SET, never ramped. No detune, no hash01, no vibrato, no LFO, no noise layer, no filter
|
||||||
|
* sweep, no second oscillator to beat against — not one analog imperfection anywhere in the
|
||||||
|
* signal path, because every other sound in this game has at least one and they are all the
|
||||||
|
* player's. You cannot RECORD a tone with no imperfection in it; you can only generate one.
|
||||||
|
* That is the house law making its own argument, and it is why the enemy's sound is the only
|
||||||
|
* one here that could not have come off tape.
|
||||||
|
*
|
||||||
|
* SPARSE BY CONSTRUCTION: one note per event, nothing loops it, and it is never sustained.
|
||||||
|
* The ratification sting's rarity is its punchline; a second "clean sound" heard continuously
|
||||||
|
* would demote both. If this is ever heard often, something upstream is wrong, not this.
|
||||||
|
*
|
||||||
|
* Layer 3 by gating: like tapeChunk, it belongs to the discovered world, so the pre-relic
|
||||||
|
* silence guarantee (measured at exactly 0.0 peak / 0.0 RMS) is untouched by its existence.
|
||||||
|
*/
|
||||||
|
export function complianceTone(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
|
||||||
|
const o = ctx.createOscillator();
|
||||||
|
o.type = 'sine';
|
||||||
|
o.frequency.value = COMPLIANCE_HZ; // set once, for all time
|
||||||
|
// The envelope is the only thing in here that moves, and it moves slowly on purpose: a
|
||||||
|
// percussive attack would splatter sidebands either side of the partial, and the claim this
|
||||||
|
// sound is making is precisely that it has none. Struck like a tuning fork, left to decay.
|
||||||
|
const g = envGain(ctx, t0, 0.14, 0.05, 1.5);
|
||||||
|
o.connect(g).connect(dest);
|
||||||
|
o.start(t0);
|
||||||
|
o.stop(t0 + 1.7);
|
||||||
|
}
|
||||||
|
|
||||||
/** TAPE CHUNK — a shipment lands. Layer 3, but it lives here with its siblings. */
|
/** TAPE CHUNK — a shipment lands. Layer 3, but it lives here with its siblings. */
|
||||||
export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
|
export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
|
||||||
const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2;
|
const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2;
|
||||||
|
|||||||
@ -20,6 +20,13 @@ export interface BaseState {
|
|||||||
era: Era;
|
era: Era;
|
||||||
/** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */
|
/** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */
|
||||||
chyron: string;
|
chyron: string;
|
||||||
|
/**
|
||||||
|
* v9 stasis: a hash auditor is holding a sector still while it reads it. The transport legend
|
||||||
|
* flips PLAY ▶ → HOLD ‖ and the timecode simply stops (the caller freezes `timeMs`). This is
|
||||||
|
* the camcorder's OWN vocabulary for a deliberate pause — nothing here is damage, and none of
|
||||||
|
* the brownout's freeze/dropout/judder language is involved.
|
||||||
|
*/
|
||||||
|
held?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseFeed {
|
export interface BaseFeed {
|
||||||
@ -117,7 +124,7 @@ export function createBaseFeed(): BaseFeed {
|
|||||||
// camcorder overlay
|
// camcorder overlay
|
||||||
g.fillStyle = skin.ink;
|
g.fillStyle = skin.ink;
|
||||||
g.font = "bold 22px 'Courier New', monospace";
|
g.font = "bold 22px 'Courier New', monospace";
|
||||||
g.fillText('PLAY ▶', 20, BASE_H * 0.86);
|
g.fillText(state.held ? 'HOLD ‖' : 'PLAY ▶', 20, BASE_H * 0.86);
|
||||||
g.fillText(timecode(state.timeMs), 20, BASE_H * 0.95);
|
g.fillText(timecode(state.timeMs), 20, BASE_H * 0.95);
|
||||||
g.font = "bold 18px 'Courier New', monospace";
|
g.font = "bold 18px 'Courier New', monospace";
|
||||||
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
|
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
|
||||||
@ -164,7 +171,7 @@ export function createBaseFeed(): BaseFeed {
|
|||||||
canvas,
|
canvas,
|
||||||
update(state: BaseState) {
|
update(state: BaseState) {
|
||||||
// Cache key of every visible input; repaint only when one changes.
|
// Cache key of every visible input; repaint only when one changes.
|
||||||
const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}`;
|
const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}|${state.held ? 'H' : 'P'}`;
|
||||||
if (key === last) return false;
|
if (key === last) return false;
|
||||||
last = key;
|
last = key;
|
||||||
paint(state);
|
paint(state);
|
||||||
|
|||||||
@ -165,6 +165,20 @@ describe('repairs ease the ledger by one unit of the item (The Correction, v6)',
|
|||||||
expect(heavy).toBeGreaterThan(light);
|
expect(heavy).toBeGreaterThan(light);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a gunship mirror costs EXACTLY what a mite repair costs (one decrement path, v9)', () => {
|
||||||
|
// `repaired` and `mirrored` both route through correct() in index.ts, so the arithmetic is
|
||||||
|
// the same subtraction by construction. This pins the thing that would break if anyone ever
|
||||||
|
// forked them: a sector theft and a unit theft must move the ledger identically, because
|
||||||
|
// the ledger only knows units. The gunship's difference is its PLACARD, not its price.
|
||||||
|
const before = resolveLedger(tally({ 'chroma-slurry': 12 }));
|
||||||
|
const afterOne = resolveLedger(tally({ 'chroma-slurry': 11 }));
|
||||||
|
const afterTwo = resolveLedger(tally({ 'chroma-slurry': 10 }));
|
||||||
|
const first = before.units - afterOne.units;
|
||||||
|
const second = afterOne.units - afterTwo.units;
|
||||||
|
expect(first).toBeCloseTo(second, 10);
|
||||||
|
expect(first).toBeCloseTo(corruptionFor('chroma-slurry').potency, 10);
|
||||||
|
});
|
||||||
|
|
||||||
it('a repair never drives corruption below sterile (theft floors at clean)', () => {
|
it('a repair never drives corruption below sterile (theft floors at clean)', () => {
|
||||||
// ship one, correct one → back to the NOTHING IS WRONG floor, never past it.
|
// ship one, correct one → back to the NOTHING IS WRONG floor, never past it.
|
||||||
const { units, total, targets } = resolveLedger(tally({ melt: 0 }));
|
const { units, total, targets } = resolveLedger(tally({ melt: 0 }));
|
||||||
|
|||||||
@ -30,15 +30,15 @@ uniform float t;
|
|||||||
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
|
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
|
||||||
uniform float u_block, u_ring, u_moire;
|
uniform float u_block, u_ring, u_moire;
|
||||||
uniform float u_drift, u_brownout, u_flash;
|
uniform float u_drift, u_brownout, u_flash;
|
||||||
uniform float u_strain, u_fever, u_pallor;
|
uniform float u_strain, u_fever, u_pallor, u_sterile;
|
||||||
uniform vec2 u_moireSeed;
|
uniform vec2 u_moireSeed;
|
||||||
uniform float u_wildlife;
|
uniform float u_wildlife;
|
||||||
uniform vec2 u_wildlifeSeed;
|
uniform vec2 u_wildlifeSeed;
|
||||||
uniform float u_mite;
|
uniform float u_mite;
|
||||||
uniform vec2 u_miteSeed;
|
uniform vec2 u_miteSeed;
|
||||||
uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex, u_repairTex;
|
uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex, u_placardTex;
|
||||||
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card, u_title;
|
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card, u_title;
|
||||||
uniform float u_repair, u_repairScale;
|
uniform float u_placard, u_placardScale;
|
||||||
|
|
||||||
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
|
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
|
||||||
float vnoise(vec2 p){
|
float vnoise(vec2 p){
|
||||||
@ -228,6 +228,17 @@ void main(){
|
|||||||
col *= 1.0 - vig*u_pallor*0.75;
|
col *= 1.0 - vig*u_pallor*0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- debt band: PARITY DEBT at SEALED (band 3) or worse. Literally the pallor machinery
|
||||||
|
// above with its sign flipped — same radii, same falloff, white instead of grey. Pallor is
|
||||||
|
// the corners closing into darkness because you are poor; this is the corners washing out
|
||||||
|
// under institutional light because you are being LOOKED AT. Faintest reading on the sky by
|
||||||
|
// design: it is a standing condition, not an event, and it must never out-shout the artifacts.
|
||||||
|
if(u_sterile > 0.001){
|
||||||
|
vec2 sd = vUv - 0.5; sd.x *= 1.78;
|
||||||
|
float svig = smoothstep(0.34, 0.95, length(sd));
|
||||||
|
col = mix(col, vec3(0.95,0.97,0.96), svig*u_sterile*0.42);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- mite sterility: The Correction's hospital-white cleanliness creeping outward from heavy
|
// ---- mite sterility: The Correction's hospital-white cleanliness creeping outward from heavy
|
||||||
// parity-mite activity. Same growth-mask template as moire/wildlife, but the INVERSE of
|
// parity-mite activity. Same growth-mask template as moire/wildlife, but the INVERSE of
|
||||||
// corruption — it desaturates and washes the region toward clinical white with a faint
|
// corruption — it desaturates and washes the region toward clinical white with a faint
|
||||||
@ -257,14 +268,16 @@ void main(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- repair placard: The Correction signs one unit off the ledger. Small, perfect, screen
|
// ---- the Correction's placard: it signs one unit off the ledger (a mite's repair) or takes
|
||||||
// space (uncorrupted), parked low-right so it never fights the shipment stamp for the centre.
|
// a whole sector (a gunship's mirror). ONE slot, two painters — same faction, same voice, so a
|
||||||
if(u_repair > 0.001){
|
// burst of Corrections queues instead of overlapping. Small, perfect, screen space
|
||||||
vec2 halfSize = vec2(0.150, 0.082)*u_repairScale;
|
// (uncorrupted), parked low-right so it never fights the shipment stamp for the centre.
|
||||||
|
if(u_placard > 0.001){
|
||||||
|
vec2 halfSize = vec2(0.150, 0.082)*u_placardScale;
|
||||||
vec2 ruv = (vUv - vec2(0.80, 0.80))/(2.0*halfSize) + 0.5;
|
vec2 ruv = (vUv - vec2(0.80, 0.80))/(2.0*halfSize) + 0.5;
|
||||||
if(ruv.x > 0.0 && ruv.x < 1.0 && ruv.y > 0.0 && ruv.y < 1.0){
|
if(ruv.x > 0.0 && ruv.x < 1.0 && ruv.y > 0.0 && ruv.y < 1.0){
|
||||||
vec4 rp = texture2D(u_repairTex, ruv);
|
vec4 rp = texture2D(u_placardTex, ruv);
|
||||||
col = mix(col, rp.rgb, rp.a*u_repair);
|
col = mix(col, rp.rgb, rp.a*u_placard);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,10 +310,10 @@ void main(){
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
type UniformKey =
|
type UniformKey =
|
||||||
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed'
|
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'sterile' | 'moireSeed'
|
||||||
| 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed'
|
| 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed'
|
||||||
| 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'repairTex'
|
| 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'placardTex'
|
||||||
| 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'repair' | 'repairScale';
|
| 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'placard' | 'placardScale';
|
||||||
|
|
||||||
export interface DrawState {
|
export interface DrawState {
|
||||||
params: ParamSet;
|
params: ParamSet;
|
||||||
@ -315,12 +328,17 @@ export interface DrawState {
|
|||||||
era: Era;
|
era: Era;
|
||||||
/** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */
|
/** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */
|
||||||
chyron: string;
|
chyron: string;
|
||||||
|
/** v9: a hash auditor is holding the feed. Flips the transport legend to HOLD; the CALLER
|
||||||
|
* freezes `timeSec`, which is what actually stops the picture. */
|
||||||
|
held: boolean;
|
||||||
/** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */
|
/** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */
|
||||||
strain: number;
|
strain: number;
|
||||||
/** 0..1 feverish shimmer from aggregate machine heat */
|
/** 0..1 feverish shimmer from aggregate machine heat */
|
||||||
fever: number;
|
fever: number;
|
||||||
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */
|
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */
|
||||||
pallor: number;
|
pallor: number;
|
||||||
|
/** 0..1 sterile white vignette from the Correction's debt band (>=3 SEALED) */
|
||||||
|
sterile: number;
|
||||||
/** where the moire crystal grows from, in screen uv */
|
/** where the moire crystal grows from, in screen uv */
|
||||||
moireSeed: [number, number];
|
moireSeed: [number, number];
|
||||||
/** 0..1 regional mosquito shimmer amount (wildlife swarms) */
|
/** 0..1 regional mosquito shimmer amount (wildlife swarms) */
|
||||||
@ -349,12 +367,12 @@ export interface DrawState {
|
|||||||
scramY: number;
|
scramY: number;
|
||||||
/** set when the stamp canvas has been repainted and needs re-upload */
|
/** set when the stamp canvas has been repainted and needs re-upload */
|
||||||
stampDirty: boolean;
|
stampDirty: boolean;
|
||||||
/** 0..1 opacity of the repair placard */
|
/** 0..1 opacity of the Correction's placard (mite repair OR gunship mirror — one slot) */
|
||||||
repair: number;
|
placard: number;
|
||||||
/** repair placard size multiplier (slight overshoot on impact, like the stamp) */
|
/** placard size multiplier (slight overshoot on impact, like the stamp) */
|
||||||
repairScale: number;
|
placardScale: number;
|
||||||
/** set when the repair canvas has been repainted and needs re-upload */
|
/** set when the placard canvas has been repainted and needs re-upload */
|
||||||
repairDirty: boolean;
|
placardDirty: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlitchStack {
|
export interface GlitchStack {
|
||||||
@ -418,13 +436,13 @@ export function createGlitchStack(
|
|||||||
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
|
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
|
||||||
overlays.paintCard('reel', '');
|
overlays.paintCard('reel', '');
|
||||||
overlays.paintTitle();
|
overlays.paintTitle();
|
||||||
overlays.paintRepair(''); // allocate the repair placard texture up front
|
overlays.paintRepair(''); // allocate the Correction's placard texture up front
|
||||||
const baseTex = makeTex(0, base.canvas);
|
const baseTex = makeTex(0, base.canvas);
|
||||||
const stampTex = makeTex(1, overlays.stampCanvas);
|
const stampTex = makeTex(1, overlays.stampCanvas);
|
||||||
makeTex(2, overlays.scramCanvas);
|
makeTex(2, overlays.scramCanvas);
|
||||||
const cardTex = makeTex(3, overlays.cardCanvas);
|
const cardTex = makeTex(3, overlays.cardCanvas);
|
||||||
const titleTex = makeTex(4, overlays.titleCanvas);
|
const titleTex = makeTex(4, overlays.titleCanvas);
|
||||||
const repairTex = makeTex(5, overlays.repairCanvas);
|
const placardTex = makeTex(5, overlays.placardCanvas);
|
||||||
|
|
||||||
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
|
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
|
||||||
uni.t = gl.getUniformLocation(prog, 't');
|
uni.t = gl.getUniformLocation(prog, 't');
|
||||||
@ -434,6 +452,7 @@ export function createGlitchStack(
|
|||||||
uni.strain = gl.getUniformLocation(prog, 'u_strain');
|
uni.strain = gl.getUniformLocation(prog, 'u_strain');
|
||||||
uni.fever = gl.getUniformLocation(prog, 'u_fever');
|
uni.fever = gl.getUniformLocation(prog, 'u_fever');
|
||||||
uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
|
uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
|
||||||
|
uni.sterile = gl.getUniformLocation(prog, 'u_sterile');
|
||||||
uni.card = gl.getUniformLocation(prog, 'u_card');
|
uni.card = gl.getUniformLocation(prog, 'u_card');
|
||||||
uni.title = gl.getUniformLocation(prog, 'u_title');
|
uni.title = gl.getUniformLocation(prog, 'u_title');
|
||||||
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
|
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
|
||||||
@ -445,8 +464,8 @@ export function createGlitchStack(
|
|||||||
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
|
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
|
||||||
uni.scram = gl.getUniformLocation(prog, 'u_scram');
|
uni.scram = gl.getUniformLocation(prog, 'u_scram');
|
||||||
uni.scramY = gl.getUniformLocation(prog, 'u_scramY');
|
uni.scramY = gl.getUniformLocation(prog, 'u_scramY');
|
||||||
uni.repair = gl.getUniformLocation(prog, 'u_repair');
|
uni.placard = gl.getUniformLocation(prog, 'u_placard');
|
||||||
uni.repairScale = gl.getUniformLocation(prog, 'u_repairScale');
|
uni.placardScale = gl.getUniformLocation(prog, 'u_placardScale');
|
||||||
for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);
|
for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);
|
||||||
|
|
||||||
gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0);
|
gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0);
|
||||||
@ -454,19 +473,19 @@ export function createGlitchStack(
|
|||||||
gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2);
|
gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2);
|
||||||
gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3);
|
gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3);
|
||||||
gl.uniform1i(gl.getUniformLocation(prog, 'u_titleTex'), 4);
|
gl.uniform1i(gl.getUniformLocation(prog, 'u_titleTex'), 4);
|
||||||
gl.uniform1i(gl.getUniformLocation(prog, 'u_repairTex'), 5);
|
gl.uniform1i(gl.getUniformLocation(prog, 'u_placardTex'), 5);
|
||||||
|
|
||||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
gl.uniform1f(uni.drift, 1);
|
gl.uniform1f(uni.drift, 1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
draw({ params, timeSec, drift, brownout, flash, era, chyron, strain, fever, pallor,
|
draw({ params, timeSec, drift, brownout, flash, era, chyron, held, strain, fever, pallor, sterile,
|
||||||
moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
|
moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
|
||||||
stampAmt, stampScale, scram, scramY, stampDirty,
|
stampAmt, stampScale, scram, scramY, stampDirty,
|
||||||
repair, repairScale, repairDirty,
|
placard, placardScale, placardDirty,
|
||||||
card, cardDirty, title, titleDirty }) {
|
card, cardDirty, title, titleDirty }) {
|
||||||
// Re-upload the clean feed only when it actually changed (timecode tick / era / chyron).
|
// Re-upload the clean feed only when it actually changed (timecode tick / era / chyron).
|
||||||
if (base.update({ timeMs: timeSec * 1000, era, chyron })) {
|
if (base.update({ timeMs: timeSec * 1000, era, chyron, held })) {
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, baseTex);
|
gl.bindTexture(gl.TEXTURE_2D, baseTex);
|
||||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
|
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
|
||||||
@ -486,10 +505,10 @@ export function createGlitchStack(
|
|||||||
gl.bindTexture(gl.TEXTURE_2D, titleTex);
|
gl.bindTexture(gl.TEXTURE_2D, titleTex);
|
||||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas);
|
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas);
|
||||||
}
|
}
|
||||||
if (repairDirty) {
|
if (placardDirty) {
|
||||||
gl.activeTexture(gl.TEXTURE0 + 5);
|
gl.activeTexture(gl.TEXTURE0 + 5);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, repairTex);
|
gl.bindTexture(gl.TEXTURE_2D, placardTex);
|
||||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.repairCanvas);
|
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.placardCanvas);
|
||||||
}
|
}
|
||||||
gl.uniform1f(uni.t, timeSec);
|
gl.uniform1f(uni.t, timeSec);
|
||||||
gl.uniform1f(uni.drift, drift);
|
gl.uniform1f(uni.drift, drift);
|
||||||
@ -498,6 +517,7 @@ export function createGlitchStack(
|
|||||||
gl.uniform1f(uni.strain, strain);
|
gl.uniform1f(uni.strain, strain);
|
||||||
gl.uniform1f(uni.fever, fever);
|
gl.uniform1f(uni.fever, fever);
|
||||||
gl.uniform1f(uni.pallor, pallor);
|
gl.uniform1f(uni.pallor, pallor);
|
||||||
|
gl.uniform1f(uni.sterile, sterile);
|
||||||
gl.uniform1f(uni.card, card);
|
gl.uniform1f(uni.card, card);
|
||||||
gl.uniform1f(uni.title, title);
|
gl.uniform1f(uni.title, title);
|
||||||
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
|
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
|
||||||
@ -509,8 +529,8 @@ export function createGlitchStack(
|
|||||||
gl.uniform1f(uni.stampScale, stampScale);
|
gl.uniform1f(uni.stampScale, stampScale);
|
||||||
gl.uniform1f(uni.scram, scram ? 1 : 0);
|
gl.uniform1f(uni.scram, scram ? 1 : 0);
|
||||||
gl.uniform1f(uni.scramY, scramY);
|
gl.uniform1f(uni.scramY, scramY);
|
||||||
gl.uniform1f(uni.repair, repair);
|
gl.uniform1f(uni.placard, placard);
|
||||||
gl.uniform1f(uni.repairScale, repairScale);
|
gl.uniform1f(uni.placardScale, placardScale);
|
||||||
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
|
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { createGlitchStack, type GlitchStack } from './glitchStack';
|
|||||||
import { createOverlays } from './overlays';
|
import { createOverlays } from './overlays';
|
||||||
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
|
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
|
||||||
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
|
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
|
||||||
import { computeStrain } from './strain';
|
import { computeStrain, sterileForBand } from './strain';
|
||||||
import { freshness } from './boredom';
|
import { freshness } from './boredom';
|
||||||
import { ERA_ORDER, highestEra, type Era } from './eras';
|
import { ERA_ORDER, highestEra, type Era } from './eras';
|
||||||
import { getSubtitle } from '../audio/subtitleBus';
|
import { getSubtitle } from '../audio/subtitleBus';
|
||||||
@ -31,8 +31,8 @@ const WOBBLE_DEPTH = 0.12;
|
|||||||
const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, 0.05];
|
const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, 0.05];
|
||||||
/** Seconds a shipment stamp stays on the feed. */
|
/** Seconds a shipment stamp stays on the feed. */
|
||||||
const STAMP_LIFE = 0.4;
|
const STAMP_LIFE = 0.4;
|
||||||
/** The repair placard lingers a touch longer than a shipment stamp — a signature, read at leisure. */
|
/** The Correction's placard lingers a touch longer than a shipment stamp — read at leisure. */
|
||||||
const REPAIR_LIFE = 0.55;
|
const PLACARD_LIFE = 0.55;
|
||||||
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
|
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
|
||||||
const STAMP_QUEUE_MAX = 2;
|
const STAMP_QUEUE_MAX = 2;
|
||||||
/** Round 3: one frame was a flinch, not a legend. Three frames is readable. */
|
/** Round 3: one frame was a flinch, not a legend. Three frames is readable. */
|
||||||
@ -55,7 +55,12 @@ const TITLE_FADE = 0.6;
|
|||||||
const FLASH_SECONDS = 0.5;
|
const FLASH_SECONDS = 0.5;
|
||||||
|
|
||||||
interface Stamp { name: string; count: number; color: string; age: number }
|
interface Stamp { name: string; count: number; color: string; age: number }
|
||||||
interface Repair { name: string; age: number }
|
/**
|
||||||
|
* One entry in The Correction's placard queue. `kind` picks the painter, NOT the arithmetic:
|
||||||
|
* a mite's repair and a gunship's mirror take exactly the same unit off exactly the same
|
||||||
|
* ledger, and they share one overlay slot so the faction speaks with one voice.
|
||||||
|
*/
|
||||||
|
interface Placard { name: string; kind: 'repair' | 'mirror'; age: number }
|
||||||
interface Swarm { pos: { x: number; y: number }; size: number }
|
interface Swarm { pos: { x: number; y: number }; size: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -123,15 +128,35 @@ export function createScreenFX(): ScreenFX {
|
|||||||
const stampQueue: Stamp[] = [];
|
const stampQueue: Stamp[] = [];
|
||||||
let stamp: Stamp | null = null;
|
let stamp: Stamp | null = null;
|
||||||
let stampDirty = false;
|
let stampDirty = false;
|
||||||
const repairQueue: Repair[] = [];
|
const placardQueue: Placard[] = [];
|
||||||
let repair: Repair | null = null;
|
let placard: Placard | null = null;
|
||||||
let repairDirty = false;
|
let placardDirty = false;
|
||||||
let scramFrames = 0;
|
let scramFrames = 0;
|
||||||
let scramY = 0.5;
|
let scramY = 0.5;
|
||||||
|
|
||||||
let strain = 0;
|
let strain = 0;
|
||||||
let fever = 0;
|
let fever = 0;
|
||||||
let pallor = 0;
|
let pallor = 0;
|
||||||
|
let sterile = 0;
|
||||||
|
/** Debt band from `debtBand` events — the fallback when no snapshot.correction is present. */
|
||||||
|
let eventBand = 0;
|
||||||
|
/**
|
||||||
|
* Live hash-auditor holds, keyed by the event's `lockHash`. A set rather than a flag so
|
||||||
|
* overlapping audits nest correctly: two auditors both holding means one release cannot
|
||||||
|
* restart the clock under the other one's feet.
|
||||||
|
*/
|
||||||
|
const stasisHolds = new Set<string>();
|
||||||
|
/**
|
||||||
|
* THE SKY'S OWN CLOCK. Everything the picture animates on reads this, not wall time: the
|
||||||
|
* timecode, the drift, the shader's `t`, the mature-corruption wobble. A hash auditor's
|
||||||
|
* stasis simply stops it accumulating — the feed is not damaged, it is administered — and
|
||||||
|
* releasing resumes from the stopped value, so the sky comes back exactly where it left
|
||||||
|
* rather than jumping forward by however long the audit took.
|
||||||
|
*/
|
||||||
|
let feedSec = 0;
|
||||||
|
/** Last resolved values of the two Correction readings, for the DEV stats readout only. */
|
||||||
|
let heldNow = false;
|
||||||
|
let bandNow = 0;
|
||||||
let moireSeed: [number, number] = [0.5, 0.44];
|
let moireSeed: [number, number] = [0.5, 0.44];
|
||||||
let moireSeeded = false;
|
let moireSeeded = false;
|
||||||
|
|
||||||
@ -192,6 +217,27 @@ export function createScreenFX(): ScreenFX {
|
|||||||
while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift();
|
while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE CORRECTION TAKES ONE UNIT. The single decrement path — a parity mite's `repaired` and a
|
||||||
|
* redundancy gunship's `mirrored` both come through here, because they are the same theft at
|
||||||
|
* two scales and forking them would be how the two slowly stopped agreeing.
|
||||||
|
*
|
||||||
|
* This is THEFT, not decay: the LEDGER drops by exactly that item's per-unit contribution, a
|
||||||
|
* real permanent removal — unlike boredom, which only fades the display and leaves the tally
|
||||||
|
* intact to snap back. We deliberately do NOT refresh the boredom clock either way: a factory
|
||||||
|
* being quietly eaten is not a factory that is producing.
|
||||||
|
*/
|
||||||
|
function correct(item: string, kind: 'repair' | 'mirror') {
|
||||||
|
const cur = tally.get(item) ?? 0;
|
||||||
|
if (cur > 0) {
|
||||||
|
tally.set(item, cur - 1);
|
||||||
|
ledgerDirty = true;
|
||||||
|
}
|
||||||
|
const def = items.get(item);
|
||||||
|
placardQueue.push({ name: def?.name ?? item.toUpperCase(), kind, age: 0 });
|
||||||
|
while (placardQueue.length > STAMP_QUEUE_MAX) placardQueue.shift();
|
||||||
|
}
|
||||||
|
|
||||||
let liveCanvas: HTMLCanvasElement | null = null;
|
let liveCanvas: HTMLCanvasElement | null = null;
|
||||||
|
|
||||||
const api: ScreenFX = {
|
const api: ScreenFX = {
|
||||||
@ -226,15 +272,19 @@ export function createScreenFX(): ScreenFX {
|
|||||||
if (on) mites.set(id, { pos, size });
|
if (on) mites.set(id, { pos, size });
|
||||||
else mites.delete(id);
|
else mites.delete(id);
|
||||||
},
|
},
|
||||||
|
// Both drive the ONE decrement path, so the debug hook can never disagree with the
|
||||||
|
// event handler about what a Correction costs.
|
||||||
repair: (item: string, count = 1) => {
|
repair: (item: string, count = 1) => {
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) correct(item, 'repair');
|
||||||
const cur = tally.get(item) ?? 0;
|
|
||||||
if (cur > 0) { tally.set(item, cur - 1); ledgerDirty = true; }
|
|
||||||
const def = items.get(item);
|
|
||||||
repairQueue.push({ name: def?.name ?? item.toUpperCase(), age: 0 });
|
|
||||||
while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
mirror: (item: string, count = 1) => {
|
||||||
|
for (let i = 0; i < count; i++) correct(item, 'mirror');
|
||||||
|
},
|
||||||
|
stasis: (on: boolean, lockHash = 'dev') => {
|
||||||
|
if (on) stasisHolds.add(lockHash);
|
||||||
|
else stasisHolds.delete(lockHash);
|
||||||
|
},
|
||||||
|
band: (n: number) => { eventBand = n; },
|
||||||
reset: () => {
|
reset: () => {
|
||||||
tally.clear();
|
tally.clear();
|
||||||
ledgerDirty = true;
|
ledgerDirty = true;
|
||||||
@ -246,6 +296,12 @@ export function createScreenFX(): ScreenFX {
|
|||||||
strain = 0;
|
strain = 0;
|
||||||
fever = 0;
|
fever = 0;
|
||||||
pallor = 0;
|
pallor = 0;
|
||||||
|
sterile = 0;
|
||||||
|
eventBand = 0;
|
||||||
|
bandNow = 0;
|
||||||
|
heldNow = false;
|
||||||
|
stasisHolds.clear();
|
||||||
|
feedSec = 0;
|
||||||
cardAge = -1;
|
cardAge = -1;
|
||||||
flashAge = -1;
|
flashAge = -1;
|
||||||
lastShipMs = 0;
|
lastShipMs = 0;
|
||||||
@ -255,8 +311,8 @@ export function createScreenFX(): ScreenFX {
|
|||||||
wildlife = 0;
|
wildlife = 0;
|
||||||
mites.clear();
|
mites.clear();
|
||||||
mite = 0;
|
mite = 0;
|
||||||
repair = null;
|
placard = null;
|
||||||
repairQueue.length = 0;
|
placardQueue.length = 0;
|
||||||
for (const p of PARAMS) eased[p] = 0;
|
for (const p of PARAMS) eased[p] = 0;
|
||||||
},
|
},
|
||||||
research: (tech: string) => {
|
research: (tech: string) => {
|
||||||
@ -274,9 +330,14 @@ export function createScreenFX(): ScreenFX {
|
|||||||
strain: +strain.toFixed(3),
|
strain: +strain.toFixed(3),
|
||||||
fever: +fever.toFixed(3),
|
fever: +fever.toFixed(3),
|
||||||
pallor: +pallor.toFixed(3),
|
pallor: +pallor.toFixed(3),
|
||||||
|
sterile: +sterile.toFixed(3),
|
||||||
|
band: bandNow,
|
||||||
|
held: heldNow,
|
||||||
|
holds: stasisHolds.size,
|
||||||
|
feedSec: +feedSec.toFixed(4),
|
||||||
wildlife: +wildlife.toFixed(3),
|
wildlife: +wildlife.toFixed(3),
|
||||||
mite: +mite.toFixed(3),
|
mite: +mite.toFixed(3),
|
||||||
repair: repair ? 1 : 0,
|
placard: placard ? placard.kind : null,
|
||||||
card: cardAge >= 0 ? 1 : 0,
|
card: cardAge >= 0 ? 1 : 0,
|
||||||
title: +titleOpacity(titleAge).toFixed(3),
|
title: +titleOpacity(titleAge).toFixed(3),
|
||||||
flash: +flashEnvelope(flashAge).toFixed(3),
|
flash: +flashEnvelope(flashAge).toFixed(3),
|
||||||
@ -320,19 +381,20 @@ export function createScreenFX(): ScreenFX {
|
|||||||
if (e.on === 'spawned') mites.set(e.id, { pos: e.pos, size: 0.6 });
|
if (e.on === 'spawned') mites.set(e.id, { pos: e.pos, size: 0.6 });
|
||||||
else mites.delete(e.id);
|
else mites.delete(e.id);
|
||||||
} else if (e.kind === 'repaired') {
|
} else if (e.kind === 'repaired') {
|
||||||
// The Correction corrected one unit of your work. This is THEFT, not decay: ease the
|
// A parity mite corrected one unit of your work, and signs it with the green tick.
|
||||||
// LEDGER down by exactly that item's per-unit contribution (a real, permanent removal —
|
correct(e.item, 'repair');
|
||||||
// unlike boredom, which only fades the display and leaves the tally intact). Then let
|
} else if (e.kind === 'mirrored') {
|
||||||
// it sign the job with a small green-tick placard. We deliberately do NOT refresh the
|
// A redundancy gunship re-encoded a whole sector. Same theft, same ledger, same
|
||||||
// boredom clock: a factory being quietly eaten is not a factory that is producing.
|
// decrement — a different placard, because a sector is not a unit.
|
||||||
const cur = tally.get(e.item) ?? 0;
|
correct(e.item, 'mirror');
|
||||||
if (cur > 0) {
|
} else if (e.kind === 'stasis') {
|
||||||
tally.set(e.item, cur - 1);
|
// A hash auditor is holding a rectangle of the factory still while it reads it.
|
||||||
ledgerDirty = true;
|
// Keyed by lockHash so nested holds release independently. Stasis auto-releases by
|
||||||
}
|
// contract, so the sky can trust it will be handed its clock back.
|
||||||
const def = items.get(e.item);
|
if (e.on) stasisHolds.add(e.lockHash);
|
||||||
repairQueue.push({ name: def?.name ?? e.item.toUpperCase(), age: 0 });
|
else stasisHolds.delete(e.lockHash);
|
||||||
while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift();
|
} else if (e.kind === 'debtBand') {
|
||||||
|
eventBand = e.band;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -347,6 +409,20 @@ export function createScreenFX(): ScreenFX {
|
|||||||
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000));
|
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000));
|
||||||
lastMs = timeMs;
|
lastMs = timeMs;
|
||||||
|
|
||||||
|
// STASIS. Events are the truth here — this is an edge, and main.ts drains every frame —
|
||||||
|
// while the snapshot corroborates for the case an edge can never cover: a save loaded
|
||||||
|
// mid-audit, where the hold is a standing fact and no event will ever fire for it.
|
||||||
|
let held = stasisHolds.size > 0;
|
||||||
|
if (!held && snap?.correction) {
|
||||||
|
for (const u of snap.correction.units) {
|
||||||
|
if (u.phase === 'stasis') { held = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The sky's clock stops. Nothing else does: the ledger still books shipments, stamps
|
||||||
|
// still land, the strain still reads. The picture is held, the paperwork is not.
|
||||||
|
if (!held) feedSec += dt;
|
||||||
|
heldNow = held;
|
||||||
|
|
||||||
if (ledgerDirty) recompute();
|
if (ledgerDirty) recompute();
|
||||||
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
|
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
|
||||||
|
|
||||||
@ -357,6 +433,13 @@ export function createScreenFX(): ScreenFX {
|
|||||||
fever = ease(fever, target.fever, dt, FEVER_TAU);
|
fever = ease(fever, target.fever, dt, FEVER_TAU);
|
||||||
pallor = ease(pallor, target.pallor, dt, PALLOR_TAU);
|
pallor = ease(pallor, target.pallor, dt, PALLOR_TAU);
|
||||||
|
|
||||||
|
// The debt band. Snapshot is truth when The Correction exists at all; the `debtBand` edge
|
||||||
|
// covers the no-snapshot path. Eased on the PALLOR tau because it is the same kind of
|
||||||
|
// reading: chronic, a standing condition, never an event.
|
||||||
|
const band = snap?.correction ? snap.correction.band : eventBand;
|
||||||
|
bandNow = band;
|
||||||
|
sterile = ease(sterile, sterileForBand(band), dt, PALLOR_TAU);
|
||||||
|
|
||||||
// The substrate ages with the highest ratified era. Corruption stays era-agnostic —
|
// The substrate ages with the highest ratified era. Corruption stays era-agnostic —
|
||||||
// only what it is painted ONTO changes.
|
// only what it is painted ONTO changes.
|
||||||
era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era);
|
era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era);
|
||||||
@ -408,7 +491,7 @@ export function createScreenFX(): ScreenFX {
|
|||||||
if (worstMite) miteSeed = seedFromPos(worstMite.pos);
|
if (worstMite) miteSeed = seedFromPos(worstMite.pos);
|
||||||
mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0);
|
mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0);
|
||||||
|
|
||||||
const timeSec = timeMs / 1000;
|
const timeSec = feedSec;
|
||||||
const displayTotal = total * fresh;
|
const displayTotal = total * fresh;
|
||||||
const wobble =
|
const wobble =
|
||||||
displayTotal > WOBBLE_THRESHOLD
|
displayTotal > WOBBLE_THRESHOLD
|
||||||
@ -444,21 +527,23 @@ export function createScreenFX(): ScreenFX {
|
|||||||
if (stamp.age >= STAMP_LIFE) stamp = null;
|
if (stamp.age >= STAMP_LIFE) stamp = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repair placard lifecycle — its own slot so it never fights the shipment stamp. Same
|
// The Correction's placard lifecycle — its own slot so it never fights the shipment stamp,
|
||||||
|
// and ONE slot for both painters so repairs and mirrors queue rather than overlap. Same
|
||||||
// punch-in-hold-fade shape, held a touch longer because it is a signature to be read.
|
// punch-in-hold-fade shape, held a touch longer because it is a signature to be read.
|
||||||
if (!repair && repairQueue.length) {
|
if (!placard && placardQueue.length) {
|
||||||
repair = repairQueue.shift()!;
|
placard = placardQueue.shift()!;
|
||||||
overlays.paintRepair(repair.name);
|
if (placard.kind === 'mirror') overlays.paintMirror(placard.name);
|
||||||
repairDirty = true;
|
else overlays.paintRepair(placard.name);
|
||||||
|
placardDirty = true;
|
||||||
}
|
}
|
||||||
let repairAmt = 0;
|
let placardAmt = 0;
|
||||||
let repairScale = 1;
|
let placardScale = 1;
|
||||||
if (repair) {
|
if (placard) {
|
||||||
const a = repair.age / REPAIR_LIFE;
|
const a = placard.age / PLACARD_LIFE;
|
||||||
repairAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a));
|
placardAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a));
|
||||||
repairScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot
|
placardScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot
|
||||||
repair.age += dt;
|
placard.age += dt;
|
||||||
if (repair.age >= REPAIR_LIFE) repair = null;
|
if (placard.age >= PLACARD_LIFE) placard = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The card holds at full opacity and then cuts — no fade. The corruption crashes back in
|
// The card holds at full opacity and then cuts — no fade. The corruption crashes back in
|
||||||
@ -485,15 +570,15 @@ export function createScreenFX(): ScreenFX {
|
|||||||
const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG');
|
const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG');
|
||||||
|
|
||||||
stack.draw({
|
stack.draw({
|
||||||
params: out, timeSec, drift: 1, brownout, flash, era, chyron,
|
params: out, timeSec, drift: 1, brownout, flash, era, chyron, held,
|
||||||
strain, fever, pallor, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
|
strain, fever, pallor, sterile, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
|
||||||
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
|
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
|
||||||
repair: repairAmt, repairScale, repairDirty,
|
placard: placardAmt, placardScale, placardDirty,
|
||||||
card, cardDirty, title, titleDirty,
|
card, cardDirty, title, titleDirty,
|
||||||
});
|
});
|
||||||
if (scramFrames > 0) scramFrames--;
|
if (scramFrames > 0) scramFrames--;
|
||||||
stampDirty = false;
|
stampDirty = false;
|
||||||
repairDirty = false;
|
placardDirty = false;
|
||||||
cardDirty = false;
|
cardDirty = false;
|
||||||
titleDirty = false;
|
titleDirty = false;
|
||||||
|
|
||||||
|
|||||||
@ -12,9 +12,15 @@ export const SCRAM_W = 512;
|
|||||||
export const SCRAM_H = 64;
|
export const SCRAM_H = 64;
|
||||||
export const CARD_W = 640;
|
export const CARD_W = 640;
|
||||||
export const CARD_H = 360;
|
export const CARD_H = 360;
|
||||||
/** The repair placard is small on purpose — it's a signature, not a broadcast. */
|
/**
|
||||||
export const REPAIR_W = 224;
|
* THE CORRECTION'S PLACARD SLOT. One canvas, one shader unit, two painters: `paintRepair` for a
|
||||||
export const REPAIR_H = 112;
|
* parity mite's correction and `paintMirror` for a redundancy gunship's sector theft. They share
|
||||||
|
* the slot deliberately — both are the same faction signing the same ledger, and the sharing is
|
||||||
|
* what keeps a burst of Corrections reading as one voice instead of two competing overlays.
|
||||||
|
* Small on purpose: a signature, not a broadcast.
|
||||||
|
*/
|
||||||
|
export const PLACARD_W = 224;
|
||||||
|
export const PLACARD_H = 112;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The eras as the codex names them (§1). The card announces the STANDARD, not the tech id —
|
* The eras as the codex names them (§1). The card announces the STANDARD, not the tech id —
|
||||||
@ -37,7 +43,8 @@ export interface Overlays {
|
|||||||
scramCanvas: HTMLCanvasElement;
|
scramCanvas: HTMLCanvasElement;
|
||||||
cardCanvas: HTMLCanvasElement;
|
cardCanvas: HTMLCanvasElement;
|
||||||
titleCanvas: HTMLCanvasElement;
|
titleCanvas: HTMLCanvasElement;
|
||||||
repairCanvas: HTMLCanvasElement;
|
/** The Correction's placard slot — repaint it with paintRepair OR paintMirror. */
|
||||||
|
placardCanvas: HTMLCanvasElement;
|
||||||
/** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */
|
/** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */
|
||||||
paintStamp(name: string, count: number, color: string): void;
|
paintStamp(name: string, count: number, color: string): void;
|
||||||
/** Repaint the ratification card. The Correction's uniform: hospital white, green tick. */
|
/** Repaint the ratification card. The Correction's uniform: hospital white, green tick. */
|
||||||
@ -45,11 +52,18 @@ export interface Overlays {
|
|||||||
/** Paint the boot title card. Alpha channel matters — it dissolves over the feed. */
|
/** Paint the boot title card. Alpha channel matters — it dissolves over the feed. */
|
||||||
paintTitle(): void;
|
paintTitle(): void;
|
||||||
/**
|
/**
|
||||||
* Repaint the repair placard — The Correction signing one unit of your work off the ledger.
|
* Repaint the placard as a MITE REPAIR — The Correction signing one unit of your work off the
|
||||||
* Small, perfect (no crooked stamp), hospital white with the faction green tick: deliberately
|
* ledger. Small, perfect (no crooked stamp), hospital white with the faction green tick:
|
||||||
* the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft, made legible.
|
* deliberately the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft.
|
||||||
*/
|
*/
|
||||||
paintRepair(name: string): void;
|
paintRepair(name: string): void;
|
||||||
|
/**
|
||||||
|
* Repaint the placard as a GUNSHIP MIRROR — a whole sector re-encoded out from under you.
|
||||||
|
* Same hospital white, but ARCHITECTURAL where the mite's is clerical: no green tick, a
|
||||||
|
* drafted sector outline with registration marks instead. A mite corrects a unit; a gunship
|
||||||
|
* redraws the map, and the placard should read as a plan rather than a signature.
|
||||||
|
*/
|
||||||
|
paintMirror(name: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
@ -105,17 +119,17 @@ export function createOverlays(): Overlays {
|
|||||||
titleCanvas.height = CARD_H;
|
titleCanvas.height = CARD_H;
|
||||||
const tg = titleCanvas.getContext('2d')!;
|
const tg = titleCanvas.getContext('2d')!;
|
||||||
|
|
||||||
const repairCanvas = document.createElement('canvas');
|
const placardCanvas = document.createElement('canvas');
|
||||||
repairCanvas.width = REPAIR_W;
|
placardCanvas.width = PLACARD_W;
|
||||||
repairCanvas.height = REPAIR_H;
|
placardCanvas.height = PLACARD_H;
|
||||||
const rg = repairCanvas.getContext('2d')!;
|
const rg = placardCanvas.getContext('2d')!;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stampCanvas,
|
stampCanvas,
|
||||||
scramCanvas,
|
scramCanvas,
|
||||||
cardCanvas,
|
cardCanvas,
|
||||||
titleCanvas,
|
titleCanvas,
|
||||||
repairCanvas,
|
placardCanvas,
|
||||||
|
|
||||||
paintTitle() {
|
paintTitle() {
|
||||||
// The game finally says its own name. A CRT burn-in: chunky phosphor letters with a
|
// The game finally says its own name. A CRT burn-in: chunky phosphor letters with a
|
||||||
@ -240,15 +254,15 @@ export function createOverlays(): Overlays {
|
|||||||
// The Correction's uniform (codex §8): hospital white, green tick, and the placid
|
// The Correction's uniform (codex §8): hospital white, green tick, and the placid
|
||||||
// confidence of a compliance stamp. NOT rotated — where the shipment stamp is thumped
|
// confidence of a compliance stamp. NOT rotated — where the shipment stamp is thumped
|
||||||
// on by a tired hand, this is machined on square. That contrast is the whole read.
|
// on by a tired hand, this is machined on square. That contrast is the whole read.
|
||||||
rg.clearRect(0, 0, REPAIR_W, REPAIR_H);
|
rg.clearRect(0, 0, PLACARD_W, PLACARD_H);
|
||||||
|
|
||||||
// clean white placard with a thin green rule
|
// clean white placard with a thin green rule
|
||||||
rg.fillStyle = '#f2f5f2';
|
rg.fillStyle = '#f2f5f2';
|
||||||
roundRect(rg, 6, 6, REPAIR_W - 12, REPAIR_H - 12, 5);
|
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
|
||||||
rg.fill();
|
rg.fill();
|
||||||
rg.strokeStyle = '#2f7d3f';
|
rg.strokeStyle = '#2f7d3f';
|
||||||
rg.lineWidth = 3;
|
rg.lineWidth = 3;
|
||||||
roundRect(rg, 6, 6, REPAIR_W - 12, REPAIR_H - 12, 5);
|
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
|
||||||
rg.stroke();
|
rg.stroke();
|
||||||
|
|
||||||
// the green tick — the faction signature
|
// the green tick — the faction signature
|
||||||
@ -271,7 +285,7 @@ export function createOverlays(): Overlays {
|
|||||||
rg.fillStyle = '#5c6b5c';
|
rg.fillStyle = '#5c6b5c';
|
||||||
let size = 15;
|
let size = 15;
|
||||||
rg.font = `${size}px 'Courier New', monospace`;
|
rg.font = `${size}px 'Courier New', monospace`;
|
||||||
while (rg.measureText(name).width > REPAIR_W - 40 && size > 8) {
|
while (rg.measureText(name).width > PLACARD_W - 40 && size > 8) {
|
||||||
size -= 1;
|
size -= 1;
|
||||||
rg.font = `${size}px 'Courier New', monospace`;
|
rg.font = `${size}px 'Courier New', monospace`;
|
||||||
}
|
}
|
||||||
@ -281,5 +295,81 @@ export function createOverlays(): Overlays {
|
|||||||
rg.font = "9px 'Courier New', monospace";
|
rg.font = "9px 'Courier New', monospace";
|
||||||
rg.fillText('THE CORRECTION · NO ACTION REQUIRED', 26, 94);
|
rg.fillText('THE CORRECTION · NO ACTION REQUIRED', 26, 94);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
paintMirror(name: string) {
|
||||||
|
// A REDUNDANCY GUNSHIP has re-encoded a sector. Same faction, same hospital white — but
|
||||||
|
// the mite's placard is CLERICAL (a tick, a signature) and this one is ARCHITECTURAL: a
|
||||||
|
// drafted sector rectangle with registration marks, the way a plan shows you the block of
|
||||||
|
// your city that has been compulsorily purchased. No tick anywhere; a tick means a person
|
||||||
|
// checked something, and nobody checked this.
|
||||||
|
rg.clearRect(0, 0, PLACARD_W, PLACARD_H);
|
||||||
|
|
||||||
|
rg.fillStyle = '#f2f5f2';
|
||||||
|
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
|
||||||
|
rg.fill();
|
||||||
|
// graphite double rule — drawing-office, not clinic
|
||||||
|
rg.strokeStyle = '#5c6b5c';
|
||||||
|
rg.lineWidth = 2;
|
||||||
|
roundRect(rg, 6, 6, PLACARD_W - 12, PLACARD_H - 12, 5);
|
||||||
|
rg.stroke();
|
||||||
|
rg.lineWidth = 1;
|
||||||
|
rg.strokeStyle = '#b9c4b9';
|
||||||
|
roundRect(rg, 12, 12, PLACARD_W - 24, PLACARD_H - 24, 3);
|
||||||
|
rg.stroke();
|
||||||
|
|
||||||
|
// the sector diagram: an outlined rect with corner registration ticks and a centre cross.
|
||||||
|
const bx = 24;
|
||||||
|
const by = 30;
|
||||||
|
const bw = 44;
|
||||||
|
const bh = 44;
|
||||||
|
rg.strokeStyle = '#2f7d3f';
|
||||||
|
rg.lineWidth = 2;
|
||||||
|
rg.strokeRect(bx, by, bw, bh);
|
||||||
|
rg.strokeStyle = '#12160f';
|
||||||
|
rg.lineWidth = 1;
|
||||||
|
const tick = 6;
|
||||||
|
for (const [cx, cy, sx, sy] of [
|
||||||
|
[bx, by, -1, -1], [bx + bw, by, 1, -1], [bx, by + bh, -1, 1], [bx + bw, by + bh, 1, 1],
|
||||||
|
] as const) {
|
||||||
|
rg.beginPath();
|
||||||
|
rg.moveTo(cx, cy);
|
||||||
|
rg.lineTo(cx + sx * tick, cy);
|
||||||
|
rg.moveTo(cx, cy);
|
||||||
|
rg.lineTo(cx, cy + sy * tick);
|
||||||
|
rg.stroke();
|
||||||
|
}
|
||||||
|
rg.strokeStyle = '#8a998a';
|
||||||
|
rg.beginPath();
|
||||||
|
rg.moveTo(bx + bw / 2, by + 8);
|
||||||
|
rg.lineTo(bx + bw / 2, by + bh - 8);
|
||||||
|
rg.moveTo(bx + 8, by + bh / 2);
|
||||||
|
rg.lineTo(bx + bw - 8, by + bh / 2);
|
||||||
|
rg.stroke();
|
||||||
|
// the mirror itself: the sector's own diagonal, doubled
|
||||||
|
rg.strokeStyle = '#2f7d3f';
|
||||||
|
rg.beginPath();
|
||||||
|
rg.moveTo(bx + 4, by + bh - 4);
|
||||||
|
rg.lineTo(bx + bw - 4, by + 4);
|
||||||
|
rg.stroke();
|
||||||
|
|
||||||
|
rg.textBaseline = 'middle';
|
||||||
|
rg.fillStyle = '#12160f';
|
||||||
|
rg.font = "bold 19px 'Courier New', monospace";
|
||||||
|
rg.fillText('MIRRORED', 82, 42);
|
||||||
|
|
||||||
|
// the item taken out of the sector — shrink long codex names to fit beside the diagram
|
||||||
|
rg.fillStyle = '#5c6b5c';
|
||||||
|
let msize = 14;
|
||||||
|
rg.font = `${msize}px 'Courier New', monospace`;
|
||||||
|
while (rg.measureText(name).width > PLACARD_W - 96 && msize > 8) {
|
||||||
|
msize -= 1;
|
||||||
|
rg.font = `${msize}px 'Courier New', monospace`;
|
||||||
|
}
|
||||||
|
rg.fillText(name, 82, 66);
|
||||||
|
|
||||||
|
rg.fillStyle = '#8a998a';
|
||||||
|
rg.font = "9px 'Courier New', monospace";
|
||||||
|
rg.fillText('THE CORRECTION · SECTOR RE-ENCODED', 24, 94);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { EntityState, SimSnapshot } from '../contracts';
|
import type { EntityState, SimSnapshot } from '../contracts';
|
||||||
import { computeStrain, STRAIN_HORIZON_SECONDS } from './strain';
|
import { computeStrain, sterileForBand, STERILE_BAND, STRAIN_HORIZON_SECONDS } from './strain';
|
||||||
|
|
||||||
// v4 fixture hygiene: commissionQueue and research both harden in v5, so they are populated
|
// v4 fixture hygiene: commissionQueue and research both harden in v5, so they are populated
|
||||||
// here now rather than left to break the day the contract tightens.
|
// here now rather than left to break the day the contract tightens.
|
||||||
@ -144,3 +144,33 @@ describe('fever reads the machines that actually run hot', () => {
|
|||||||
expect(f).toBe(1);
|
expect(f).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the debt band reads as light (v9)', () => {
|
||||||
|
it('shows NOTHING below SEALED — a factory in good standing wears no mark', () => {
|
||||||
|
// The whole reading is worthless if it is ever faintly on. Bands 0-2 must be exactly zero,
|
||||||
|
// not "nearly zero": this is the same guarantee as the NOTHING IS WRONG era above.
|
||||||
|
for (const band of [0, 1, 2]) expect(sterileForBand(band)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is absent when The Correction has never been heard from at all', () => {
|
||||||
|
expect(sterileForBand(undefined)).toBe(0);
|
||||||
|
expect(sterileForBand(null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appears at SEALED and gets worse at REFUSED', () => {
|
||||||
|
expect(sterileForBand(STERILE_BAND)).toBeGreaterThan(0);
|
||||||
|
expect(sterileForBand(4)).toBeGreaterThan(sterileForBand(3));
|
||||||
|
expect(sterileForBand(4)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never exceeds the 0..1 the shader uniform expects', () => {
|
||||||
|
for (const band of [-1, 0, 1, 2, 3, 4, 99]) {
|
||||||
|
expect(sterileForBand(band)).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(sterileForBand(band)).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a step, not a ramp — SEALED and REFUSED are two rooms', () => {
|
||||||
|
expect(sterileForBand(4) - sterileForBand(3)).toBeGreaterThan(0.2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -84,3 +84,26 @@ export function computeStrain(snap?: SimSnapshot): Strain {
|
|||||||
|
|
||||||
return { tremor, fever, pallor };
|
return { tremor, fever, pallor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rung at which The Correction's paperwork reaches the sky.
|
||||||
|
* Contracts v9 ladder: 0 NOMINAL, 1 OBSERVED, 2 AUDITED, 3 SEALED, 4 REFUSED.
|
||||||
|
*/
|
||||||
|
export const STERILE_BAND = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The debt band, read as light (v9). Below SEALED the sky wears nothing at all: a factory in
|
||||||
|
* good standing must look exactly like a factory nobody has ever heard of, or the reading means
|
||||||
|
* nothing when it does appear.
|
||||||
|
*
|
||||||
|
* Deliberately NOT folded into computeStrain: the band arrives both as a `debtBand` edge event
|
||||||
|
* and in `snapshot.correction`, so the compositor resolves the source of truth first and then
|
||||||
|
* asks this for the amount. Keeping it a one-line pure function is what makes "gone below band
|
||||||
|
* 3" a unit test instead of a promise.
|
||||||
|
*/
|
||||||
|
export function sterileForBand(band: number | undefined | null): number {
|
||||||
|
if (band == null || band < STERILE_BAND) return 0;
|
||||||
|
// Two steps, not a ramp: the last rung of the ladder should feel like a different room, and
|
||||||
|
// there are only two rungs above the threshold to distinguish.
|
||||||
|
return band >= 4 ? 1 : 0.65;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user