[screen] round 6: repairs ease the ledger + green-tick placards; hospital-white mite sterility creep

- repaired events decrement the ledger by one unit of the item (theft, not
  boredom-decay) and stamp a small perfect green-tick placard — The Correction
  signs its work; kept distinct from the crooked chip-coloured shipment stamp
- parity-mite activity grows a hospital-white sterile patch via growthMask
  (its 3rd/4th customer): desaturate + lift to clinical white + faint tile grid,
  the inverse of corruption and meant to feel worse
- DEV hook: .mite() .repair() plus .frame()/.canvas() pixel-verification drivers
- +3 repair-semantics tests (screen suite 33); order-3 STANDARD lines are DATA's
  copy, my chyron wraps them (verified live, no clipping)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 18:34:32 +10:00
parent eafd31f263
commit 32ff942fd5
5 changed files with 326 additions and 7 deletions

View File

@ -503,3 +503,84 @@ NEXT (not self-assigning): the tuner has no UI yet — LANE-UI's dock is what ma
findable at all, and until then the radio is only reachable from the console. Also `growthMask` findable at all, and until then the radio is only reachable from the console. Also `growthMask`
now has two customers (moiré, wildlife) and is ready for a third: HF dust piles are regional in now has two customers (moiré, wildlife) and is ready for a third: HF dust piles are regional in
the codex and `dust-pile` wildlife already arrives in the same event I'm consuming. the codex and `dust-pile` wildlife already arrives in the same event I'm consuming.
### Round 6 — 2026-07-20 — Opus 4.8
SHIPPED: all 3 orders. The sky now notices The Correction. `growthMask` gained its third and
fourth customers (order 3 round-3 groundwork paying off — no new shader machinery, new weights).
- `overlays.ts``paintRepair()` + `repairCanvas` (224×112). The Correction's uniform: hospital
white, faction green tick, machined square. Deliberately the inverse of the shipment stamp
(hand-thumped, crooked, chip-coloured, "SHIPPED / NO QUESTIONS").
- `glitchStack.ts` — two additions: the **mite sterility** pass (`u_mite`/`u_miteSeed`, reusing
`growthMask`) and the **repair placard** composite (`u_repair`, texture unit 5). The mite pass
sits on TOP of the corruption stack because it is *erasure*, not another artifact.
- `index.ts``repaired` events ease the ledger and queue a placard; parity-mite tracking drives
the sterile creep (snapshot truth / event fallback, same pattern as the mosquito swarms); repair
placard lifecycle in its own slot. DEV hook gained `.mite()`, `.repair()`, and — new — `.frame()`
+ `.canvas()` drivers (see the harness note below).
- `corruptionMap.test.ts` — +3 tests pinning the repair semantics. Screen suite 33 (was 30);
my two lanes together 81 green (screen 72 + audio band 9).
DECISIONS:
- **Repairs are THEFT; boredom is ROT — kept structurally distinct, per the order.** A `repaired`
event decrements the *ledger* by exactly one unit of the item (units drop by that item's
potency — pixel-verified: melt ×20 → repair → units 24.0→22.8, tally 20→19). Boredom only fades
the *display* and leaves the tally intact (recoverable). So a factory being eaten by mites
permanently loses corruption it can never fade back, while an idle factory snaps straight back
the moment it ships. I deliberately do NOT refresh the boredom clock on a repair — a factory
being quietly corrected is not a factory that is producing.
- **The mite patch is the inverse of corruption and it reads WORSE, as ordered.** Where the
mosquito shimmer *darkens* a region with crawling noise, the mite patch *desaturates and lifts
toward clinical white* with a faint manufactured tile grid. Pixel-verified against a heavy melt
feed: the bloom region went luminance 22.9→168.8 and saturation 0.695→0.025. A dead, too-clean
hole in your rot is more unsettling than more rot — the codex's whole point about The Correction.
- **The repair placard parks low-right (vUv 0.80,0.80), small (0.15×0.082 half-size), 0.55s** so it
never fights the centre shipment stamp — a burst of ships and a burst of corrections can co-occur
and stay legible as two separate voices.
- Both new spatial effects reuse `growthMask` verbatim (round-3 template): moiré, mosquito, AND
now mite sterility all share one fbm growth front. Zero new shader machinery for order 2.
VERIFIED (drove the REAL module on the REAL 640×360 canvas, synthetic timebase, GL pixel reads —
the harness tab is `hidden` so rAF is throttled, the round-2/3 gotcha; I pump `frame()` and
`drawImage` in the same JS task):
- **Repair**: units eased by exactly melt's potency (1.2); placard region flips corrupted
(lum 68) → hospital-white placard (rgb 228/232/228, lum 230). repair flag + tally decrement both
confirmed.
- **Mite creep**: localized bloom (grid cell 3,4 — NOT the whole frame), lum +146, saturation
→0.025. `mite` eases to 0.83 from size 0.85. Both source-of-truth paths exercised: event
fallback (`.mite()`) AND `snapshot.wildlife` parity-mite.
- **Chyron / order 3**: DATA landed 8 mite-acknowledging STANDARD lines (30→38). Pixel-checked the
live wrap: short lines 1 row, the 96-char lines wrap to exactly 2 rows, rightmost glyph at x613
inside the 640 canvas — no clipping. Word-wrap (orchestrator's fix) intact; I did not touch it.
The mumble is untouched — I never edited `amPropaganda`; it derives syllables from the line text.
- **Budget: 0.034 ms/frame** worst case — 2000 entities, corruption total 1.0, strain 0.94 +
fever 0.57 + pallor 0.82 + mosquito 0.89 + mite 0.89 + brownout, every param lit. ~59× under the
2ms budget. The two new shader branches and one new texture are free at this resolution.
BLOCKED/BROKEN: nothing in my lane.
- **Transient full-suite red, NOT mine (round-4 lesson holds).** A combined `npm test` briefly
showed 3 reds — `band.test.ts` (STANDARD line >96 chars), `sim/mite.test.ts`, and
`sim/reference.test.ts`. All three are the shared-tree race: DATA was mid-save on
`data/stations.json` (I caught it at exactly 96 chars, its own budget) and SIM has uncommitted
`mite.test.ts` + `constants.ts`/`index.ts` edits in flight. Re-running JUST my lanes: 81/81
green. I did NOT bare-`git stash` to chase it (my own round-4 near-miss).
- HEAD advanced under me twice mid-round (a `[ui] round 6` commit landed while I worked). Re-probed
before writing this; all claims above are against the tree as it stands now.
HARNESS NOTE: I added DEV-only `.frame(ms, snap)` and `.canvas()` to `__fktryScreen`. Round 2/3
both lamented having no clean way to pump the real module in a throttled hidden tab; this makes
pixel verification a two-line console call for any lane. Stripped from prod (`import.meta.env.DEV`).
CONTRACT REQUEST: none. `repaired` + `parity-mite` (v8 contracts) gave me everything.
COORDINATION (order 3, resolved): DATA owns the STANDARD copy; I own its rendering. The 8 new
lines are theirs and are already in `data/stations.json`. My only ask of them is a standing one:
please keep STANDARD lines ≤96 chars — `band.test.ts` asserts it and the chyron wraps to 2 lines
at that budget; a longer line spills to a 3rd line (the font steps down to 13px) but starts to
crowd the feed. The newest line is exactly 96, so we're at the edge.
PROPOSAL: the repair placard and the ratification card both wear the green tick — that's
intentional (one faction, one uniform), but a player seeing their first repair might read the
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.

View File

@ -145,6 +145,35 @@ describe('anchor slabs sanitize', () => {
}); });
}); });
describe('repairs ease the ledger by one unit of the item (The Correction, v6)', () => {
// A `repaired` event decrements the tally by one unit in index.ts; this pins the CONTRACT
// that decrement is built on — "eases total corruption by its item's contribution".
it("removing one unit eases net units by exactly that item's per-unit contribution", () => {
const before = resolveLedger(tally({ melt: 20 }));
const after = resolveLedger(tally({ melt: 19 }));
expect(before.units - after.units).toBeCloseTo(corruptionFor('melt').potency, 5);
expect(after.total).toBeLessThan(before.total);
});
it('correcting a hard-hitting item eases more than correcting a weak one', () => {
const heavy =
resolveLedger(tally({ 'explosive-hits': 20 })).units -
resolveLedger(tally({ 'explosive-hits': 19 })).units;
const light =
resolveLedger(tally({ 'gamut-paint-rec709': 20 })).units -
resolveLedger(tally({ 'gamut-paint-rec709': 19 })).units;
expect(heavy).toBeGreaterThan(light);
});
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 }));
expect(units).toBe(0);
expect(total).toBe(0);
for (const p of PARAMS) expect(targets[p]).toBe(0);
});
});
describe('param hygiene', () => { describe('param hygiene', () => {
it('never emits a param outside 0..1, even shipping everything at once', () => { it('never emits a param outside 0..1, even shipping everything at once', () => {
const everything = tally({ const everything = tally({

View File

@ -34,8 +34,11 @@ uniform float u_strain, u_fever, u_pallor;
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 sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex; uniform float u_mite;
uniform vec2 u_miteSeed;
uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex, u_repairTex;
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;
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){
@ -225,6 +228,22 @@ void main(){
col *= 1.0 - vig*u_pallor*0.75; col *= 1.0 - vig*u_pallor*0.75;
} }
// ---- 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
// manufactured tile grid. It sits on TOP of the corruption stack because it is erasure: your
// mess is yours, this is your work being quietly deleted. Meant to feel WORSE than rot.
if(u_mite > 0.001){
float mmask = growthMask(vUv, u_miteSeed, u_mite*0.7) * clamp(u_mite*1.7, 0.0, 1.0);
if(mmask > 0.001){
vec3 sterile = mix(vec3(lu(col)), vec3(0.93,0.96,0.95), 0.72); // desaturate → cool white
sterile = mix(sterile, vec3(0.98), 0.35); // lift toward clinical bright
float tile = max(step(0.93, fract(vUv.x*64.0)), step(0.93, fract(vUv.y*36.0)));
sterile = mix(sterile, sterile*0.9, tile*0.6); // faint manufactured grid
col = mix(col, sterile, mmask*0.9);
}
}
// faint scanlines for vibe (constant, not scored) // faint scanlines for vibe (constant, not scored)
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415); col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
@ -238,6 +257,17 @@ 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;
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);
}
}
// ---- scram: the tape loses tracking for exactly one frame // ---- scram: the tape loses tracking for exactly one frame
if(u_scram > 0.5){ if(u_scram > 0.5){
float halfH = 0.085; float halfH = 0.085;
@ -268,9 +298,9 @@ void main(){
type UniformKey = type UniformKey =
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed' | Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed'
| 'wildlife' | 'wildlifeSeed' | 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed'
| 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'repairTex'
| 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title'; | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'repair' | 'repairScale';
export interface DrawState { export interface DrawState {
params: ParamSet; params: ParamSet;
@ -297,6 +327,10 @@ export interface DrawState {
wildlife: number; wildlife: number;
/** where the swarm shimmer grows from, in screen uv */ /** where the swarm shimmer grows from, in screen uv */
wildlifeSeed: [number, number]; wildlifeSeed: [number, number];
/** 0..1 hospital-white sterile creep around heavy parity-mite activity (the inverse of rot) */
mite: number;
/** where the sterile patch grows from, in screen uv */
miteSeed: [number, number];
/** 0..1 opacity of the ratification card */ /** 0..1 opacity of the ratification card */
card: number; card: number;
/** set when the card canvas has been repainted and needs re-upload */ /** set when the card canvas has been repainted and needs re-upload */
@ -315,6 +349,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 */
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;
} }
export interface GlitchStack { export interface GlitchStack {
@ -378,11 +418,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
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 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');
@ -397,10 +439,14 @@ export function createGlitchStack(
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed'); uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
uni.wildlife = gl.getUniformLocation(prog, 'u_wildlife'); uni.wildlife = gl.getUniformLocation(prog, 'u_wildlife');
uni.wildlifeSeed = gl.getUniformLocation(prog, 'u_wildlifeSeed'); uni.wildlifeSeed = gl.getUniformLocation(prog, 'u_wildlifeSeed');
uni.mite = gl.getUniformLocation(prog, 'u_mite');
uni.miteSeed = gl.getUniformLocation(prog, 'u_miteSeed');
uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt'); uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
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.repairScale = gl.getUniformLocation(prog, 'u_repairScale');
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);
@ -408,13 +454,16 @@ 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.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, strain, fever, pallor,
moireSeed, wildlife, wildlifeSeed, stampAmt, stampScale, scram, scramY, stampDirty, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
stampAmt, stampScale, scram, scramY, stampDirty,
repair, repairScale, repairDirty,
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 })) {
@ -437,6 +486,11 @@ 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) {
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.uniform1f(uni.t, timeSec); gl.uniform1f(uni.t, timeSec);
gl.uniform1f(uni.drift, drift); gl.uniform1f(uni.drift, drift);
gl.uniform1f(uni.brownout, brownout); gl.uniform1f(uni.brownout, brownout);
@ -449,10 +503,14 @@ export function createGlitchStack(
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]); gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
gl.uniform1f(uni.wildlife, wildlife); gl.uniform1f(uni.wildlife, wildlife);
gl.uniform2f(uni.wildlifeSeed, wildlifeSeed[0], wildlifeSeed[1]); gl.uniform2f(uni.wildlifeSeed, wildlifeSeed[0], wildlifeSeed[1]);
gl.uniform1f(uni.mite, mite);
gl.uniform2f(uni.miteSeed, miteSeed[0], miteSeed[1]);
gl.uniform1f(uni.stampAmt, stampAmt); gl.uniform1f(uni.stampAmt, stampAmt);
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.repairScale, repairScale);
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]); for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
gl.drawArrays(gl.TRIANGLES, 0, 3); gl.drawArrays(gl.TRIANGLES, 0, 3);
}, },

View File

@ -31,6 +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. */
const REPAIR_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. */
@ -53,6 +55,7 @@ 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 }
interface Swarm { pos: { x: number; y: number }; size: number } interface Swarm { pos: { x: number; y: number }; size: number }
/** /**
@ -120,6 +123,9 @@ 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[] = [];
let repair: Repair | null = null;
let repairDirty = false;
let scramFrames = 0; let scramFrames = 0;
let scramY = 0.5; let scramY = 0.5;
@ -143,6 +149,9 @@ export function createScreenFX(): ScreenFX {
let wildlife = 0; // eased regional-shimmer amount let wildlife = 0; // eased regional-shimmer amount
let wildlifeSeed: [number, number] = [0.5, 0.5]; let wildlifeSeed: [number, number] = [0.5, 0.5];
const swarms = new Map<number, Swarm>(); // event-tracked fallback when no snapshot const swarms = new Map<number, Swarm>(); // event-tracked fallback when no snapshot
let mite = 0; // eased hospital-white sterile creep amount
let miteSeed: [number, number] = [0.5, 0.5];
const mites = new Map<number, Swarm>(); // event-tracked parity-mite fallback
let titleAge = 0; // boot title sequence clock let titleAge = 0; // boot title sequence clock
let titleDirty = true; // painted once at stack creation; upload on the first frame let titleDirty = true; // painted once at stack creation; upload on the first frame
let flashAge = -1; // <0 = not flashing let flashAge = -1; // <0 = not flashing
@ -183,8 +192,11 @@ export function createScreenFX(): ScreenFX {
while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift(); while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift();
} }
return { let liveCanvas: HTMLCanvasElement | null = null;
const api: ScreenFX = {
init(canvas: HTMLCanvasElement, data: GameData) { init(canvas: HTMLCanvasElement, data: GameData) {
liveCanvas = canvas;
const base = createBaseFeed(); const base = createBaseFeed();
stack = createGlitchStack(canvas, base, overlays); stack = createGlitchStack(canvas, base, overlays);
for (const it of data.items) items.set(it.id, it); for (const it of data.items) items.set(it.id, it);
@ -210,6 +222,19 @@ export function createScreenFX(): ScreenFX {
if (on) swarms.set(id, { pos, size: 0.7 }); if (on) swarms.set(id, { pos, size: 0.7 });
else swarms.delete(id); else swarms.delete(id);
}, },
mite: (on: boolean, id = 1, pos = { x: -4, y: 5 }, size = 0.7) => {
if (on) mites.set(id, { pos, size });
else mites.delete(id);
},
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();
}
},
reset: () => { reset: () => {
tally.clear(); tally.clear();
ledgerDirty = true; ledgerDirty = true;
@ -228,6 +253,10 @@ export function createScreenFX(): ScreenFX {
forcedEra = null; forcedEra = null;
swarms.clear(); swarms.clear();
wildlife = 0; wildlife = 0;
mites.clear();
mite = 0;
repair = null;
repairQueue.length = 0;
for (const p of PARAMS) eased[p] = 0; for (const p of PARAMS) eased[p] = 0;
}, },
research: (tech: string) => { research: (tech: string) => {
@ -246,6 +275,8 @@ export function createScreenFX(): ScreenFX {
fever: +fever.toFixed(3), fever: +fever.toFixed(3),
pallor: +pallor.toFixed(3), pallor: +pallor.toFixed(3),
wildlife: +wildlife.toFixed(3), wildlife: +wildlife.toFixed(3),
mite: +mite.toFixed(3),
repair: repair ? 1 : 0,
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),
@ -256,6 +287,11 @@ export function createScreenFX(): ScreenFX {
params: Object.fromEntries(PARAMS.map((p) => [p, +out[p].toFixed(3)])), params: Object.fromEntries(PARAMS.map((p) => [p, +out[p].toFixed(3)])),
}), }),
escalationItems: ESCALATION_ITEMS, escalationItems: ESCALATION_ITEMS,
// Verification driver: a hidden harness tab throttles rAF, so pump the REAL module with
// a synthetic timebase and read pixels in the same JS task (prior-round contact-sheet
// trick — a hidden tab clears the WebGL buffer after compositing otherwise).
frame: (ms: number, snap?: SimSnapshot) => api.frame(ms, snap),
canvas: () => liveCanvas,
}; };
}, },
@ -278,6 +314,25 @@ export function createScreenFX(): ScreenFX {
// (debug hook). When a snapshot IS present, frame() reads it as the source of truth. // (debug hook). When a snapshot IS present, frame() reads it as the source of truth.
if (e.on === 'spawned') swarms.set(e.id, { pos: e.pos, size: 0.6 }); if (e.on === 'spawned') swarms.set(e.id, { pos: e.pos, size: 0.6 });
else swarms.delete(e.id); else swarms.delete(e.id);
} else if (e.kind === 'wildlife' && e.wildlife === 'parity-mite') {
// Parity-mites drive the sterile creep. Same fallback contract as the swarms above:
// snapshot.wildlife is truth when present, these events cover the no-snapshot case.
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();
} }
} }
}, },
@ -339,6 +394,20 @@ export function createScreenFX(): ScreenFX {
if (worst) wildlifeSeed = seedFromPos(worst.pos); if (worst) wildlifeSeed = seedFromPos(worst.pos);
wildlife = ease(wildlife, worst ? clamp01(worst.size) : 0, dt, 1.0); wildlife = ease(wildlife, worst ? clamp01(worst.size) : 0, dt, 1.0);
// Mite sterility: same worst-of pattern, but for parity-mites. The sterile creep grows
// around the heaviest infestation; snapshot is truth when present, events are the fallback.
let worstMite: Swarm | null = null;
if (live) {
for (const w of live) {
if (w.kind !== 'parity-mite') continue;
if (!worstMite || w.size > worstMite.size) worstMite = { pos: w.pos, size: w.size };
}
} else {
for (const m of mites.values()) if (!worstMite || m.size > worstMite.size) worstMite = m;
}
if (worstMite) miteSeed = seedFromPos(worstMite.pos);
mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0);
const timeSec = timeMs / 1000; const timeSec = timeMs / 1000;
const displayTotal = total * fresh; const displayTotal = total * fresh;
const wobble = const wobble =
@ -375,6 +444,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
// 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;
}
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;
}
// 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
// rather than seeping back; a dissolve would make it look negotiable. // rather than seeping back; a dissolve would make it look negotiable.
let card = 0; let card = 0;
@ -400,16 +486,20 @@ export function createScreenFX(): ScreenFX {
stack.draw({ stack.draw({
params: out, timeSec, drift: 1, brownout, flash, era, chyron, params: out, timeSec, drift: 1, brownout, flash, era, chyron,
strain, fever, pallor, moireSeed, wildlife, wildlifeSeed, strain, fever, pallor, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty, stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
repair: repairAmt, repairScale, repairDirty,
card, cardDirty, title, titleDirty, card, cardDirty, title, titleDirty,
}); });
if (scramFrames > 0) scramFrames--; if (scramFrames > 0) scramFrames--;
stampDirty = false; stampDirty = false;
repairDirty = false;
cardDirty = false; cardDirty = false;
titleDirty = false; titleDirty = false;
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05; frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
}, },
}; };
return api;
} }

View File

@ -12,6 +12,9 @@ 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;
export const REPAIR_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
@ -34,12 +37,19 @@ export interface Overlays {
scramCanvas: HTMLCanvasElement; scramCanvas: HTMLCanvasElement;
cardCanvas: HTMLCanvasElement; cardCanvas: HTMLCanvasElement;
titleCanvas: HTMLCanvasElement; titleCanvas: HTMLCanvasElement;
repairCanvas: 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. */
paintCard(era: string, tech: string): void; paintCard(era: string, tech: string): void;
/** 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.
* 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.
*/
paintRepair(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) {
@ -95,11 +105,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');
repairCanvas.width = REPAIR_W;
repairCanvas.height = REPAIR_H;
const rg = repairCanvas.getContext('2d')!;
return { return {
stampCanvas, stampCanvas,
scramCanvas, scramCanvas,
cardCanvas, cardCanvas,
titleCanvas, titleCanvas,
repairCanvas,
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
@ -220,5 +236,50 @@ export function createOverlays(): Overlays {
sg.fillText('SHIPPED / NO QUESTIONS', 22, 34); sg.fillText('SHIPPED / NO QUESTIONS', 22, 34);
sg.restore(); sg.restore();
}, },
paintRepair(name: string) {
// 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);
// clean white placard with a thin green rule
rg.fillStyle = '#f2f5f2';
roundRect(rg, 6, 6, REPAIR_W - 12, REPAIR_H - 12, 5);
rg.fill();
rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 3;
roundRect(rg, 6, 6, REPAIR_W - 12, REPAIR_H - 12, 5);
rg.stroke();
// the green tick — the faction signature
rg.strokeStyle = '#2f7d3f';
rg.lineWidth = 5;
rg.lineCap = 'round';
rg.lineJoin = 'round';
rg.beginPath();
rg.moveTo(26, 40);
rg.lineTo(38, 52);
rg.lineTo(60, 26);
rg.stroke();
rg.textBaseline = 'middle';
rg.fillStyle = '#12160f';
rg.font = "bold 20px 'Courier New', monospace";
rg.fillText('CORRECTED', 74, 40);
// the item that was quietly reverted — shrink long codex names to fit the placard
rg.fillStyle = '#5c6b5c';
let size = 15;
rg.font = `${size}px 'Courier New', monospace`;
while (rg.measureText(name).width > REPAIR_W - 40 && size > 8) {
size -= 1;
rg.font = `${size}px 'Courier New', monospace`;
}
rg.fillText(name, 26, 74);
rg.fillStyle = '#8a998a';
rg.font = "9px 'Courier New', monospace";
rg.fillText('THE CORRECTION · NO ACTION REQUIRED', 26, 94);
},
}; };
} }