From 32ff942fd525915daa040252dd73947da9fe83b3 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 18:34:32 +1000 Subject: [PATCH] [screen] round 6: repairs ease the ledger + green-tick placards; hospital-white mite sterility creep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- fktry/lanes/LANE-SCREEN.md | 81 ++++++++++++++++++++++ fktry/src/screen/corruptionMap.test.ts | 29 ++++++++ fktry/src/screen/glitchStack.ts | 68 +++++++++++++++++-- fktry/src/screen/index.ts | 94 +++++++++++++++++++++++++- fktry/src/screen/overlays.ts | 61 +++++++++++++++++ 5 files changed, 326 insertions(+), 7 deletions(-) diff --git a/fktry/lanes/LANE-SCREEN.md b/fktry/lanes/LANE-SCREEN.md index e533eae..6455142 100644 --- a/fktry/lanes/LANE-SCREEN.md +++ b/fktry/lanes/LANE-SCREEN.md @@ -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` 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. + +### 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. diff --git a/fktry/src/screen/corruptionMap.test.ts b/fktry/src/screen/corruptionMap.test.ts index b72cbbd..b93882e 100644 --- a/fktry/src/screen/corruptionMap.test.ts +++ b/fktry/src/screen/corruptionMap.test.ts @@ -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', () => { it('never emits a param outside 0..1, even shipping everything at once', () => { const everything = tally({ diff --git a/fktry/src/screen/glitchStack.ts b/fktry/src/screen/glitchStack.ts index ebb76d1..76426d9 100644 --- a/fktry/src/screen/glitchStack.ts +++ b/fktry/src/screen/glitchStack.ts @@ -34,8 +34,11 @@ uniform float u_strain, u_fever, u_pallor; uniform vec2 u_moireSeed; uniform float u_wildlife; 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_repair, u_repairScale; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); } float vnoise(vec2 p){ @@ -225,6 +228,22 @@ void main(){ 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) 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 if(u_scram > 0.5){ float halfH = 0.085; @@ -268,9 +298,9 @@ void main(){ type UniformKey = | Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed' - | 'wildlife' | 'wildlifeSeed' - | 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' - | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title'; + | 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed' + | 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'repairTex' + | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'repair' | 'repairScale'; export interface DrawState { params: ParamSet; @@ -297,6 +327,10 @@ export interface DrawState { wildlife: number; /** where the swarm shimmer grows from, in screen uv */ 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 */ card: number; /** set when the card canvas has been repainted and needs re-upload */ @@ -315,6 +349,12 @@ export interface DrawState { scramY: number; /** set when the stamp canvas has been repainted and needs re-upload */ stampDirty: boolean; + /** 0..1 opacity of the repair placard */ + repair: number; + /** repair placard size multiplier (slight overshoot on impact, like the stamp) */ + repairScale: number; + /** set when the repair canvas has been repainted and needs re-upload */ + repairDirty: boolean; } export interface GlitchStack { @@ -378,11 +418,13 @@ export function createGlitchStack( overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front overlays.paintCard('reel', ''); overlays.paintTitle(); + overlays.paintRepair(''); // allocate the repair placard texture up front const baseTex = makeTex(0, base.canvas); const stampTex = makeTex(1, overlays.stampCanvas); makeTex(2, overlays.scramCanvas); const cardTex = makeTex(3, overlays.cardCanvas); const titleTex = makeTex(4, overlays.titleCanvas); + const repairTex = makeTex(5, overlays.repairCanvas); const uni = {} as Record; uni.t = gl.getUniformLocation(prog, 't'); @@ -397,10 +439,14 @@ export function createGlitchStack( uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed'); uni.wildlife = gl.getUniformLocation(prog, 'u_wildlife'); 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.stampScale = gl.getUniformLocation(prog, 'u_stampScale'); uni.scram = gl.getUniformLocation(prog, 'u_scram'); uni.scramY = gl.getUniformLocation(prog, 'u_scramY'); + uni.repair = gl.getUniformLocation(prog, 'u_repair'); + uni.repairScale = gl.getUniformLocation(prog, 'u_repairScale'); for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`); 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_cardTex'), 3); 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.uniform1f(uni.drift, 1); return { 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 }) { // Re-upload the clean feed only when it actually changed (timecode tick / era / chyron). if (base.update({ timeMs: timeSec * 1000, era, chyron })) { @@ -437,6 +486,11 @@ export function createGlitchStack( gl.bindTexture(gl.TEXTURE_2D, titleTex); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas); } + if (repairDirty) { + 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.drift, drift); gl.uniform1f(uni.brownout, brownout); @@ -449,10 +503,14 @@ export function createGlitchStack( gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]); gl.uniform1f(uni.wildlife, wildlife); 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.stampScale, stampScale); gl.uniform1f(uni.scram, scram ? 1 : 0); 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]); gl.drawArrays(gl.TRIANGLES, 0, 3); }, diff --git a/fktry/src/screen/index.ts b/fktry/src/screen/index.ts index 91423f4..9794783 100644 --- a/fktry/src/screen/index.ts +++ b/fktry/src/screen/index.ts @@ -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]; /** Seconds a shipment stamp stays on the feed. */ const STAMP_LIFE = 0.4; +/** The repair placard lingers a touch longer than a shipment stamp — a signature, read at leisure. */ +const REPAIR_LIFE = 0.55; /** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */ const STAMP_QUEUE_MAX = 2; /** Round 3: one frame was a flinch, not a legend. Three frames is readable. */ @@ -53,6 +55,7 @@ const TITLE_FADE = 0.6; const FLASH_SECONDS = 0.5; interface Stamp { name: string; count: number; color: string; age: number } +interface Repair { name: string; age: number } interface Swarm { pos: { x: number; y: number }; size: number } /** @@ -120,6 +123,9 @@ export function createScreenFX(): ScreenFX { const stampQueue: Stamp[] = []; let stamp: Stamp | null = null; let stampDirty = false; + const repairQueue: Repair[] = []; + let repair: Repair | null = null; + let repairDirty = false; let scramFrames = 0; let scramY = 0.5; @@ -143,6 +149,9 @@ export function createScreenFX(): ScreenFX { let wildlife = 0; // eased regional-shimmer amount let wildlifeSeed: [number, number] = [0.5, 0.5]; const swarms = new Map(); // 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(); // event-tracked parity-mite fallback let titleAge = 0; // boot title sequence clock let titleDirty = true; // painted once at stack creation; upload on the first frame let flashAge = -1; // <0 = not flashing @@ -183,8 +192,11 @@ export function createScreenFX(): ScreenFX { while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift(); } - return { + let liveCanvas: HTMLCanvasElement | null = null; + + const api: ScreenFX = { init(canvas: HTMLCanvasElement, data: GameData) { + liveCanvas = canvas; const base = createBaseFeed(); stack = createGlitchStack(canvas, base, overlays); 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 }); 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: () => { tally.clear(); ledgerDirty = true; @@ -228,6 +253,10 @@ export function createScreenFX(): ScreenFX { forcedEra = null; swarms.clear(); wildlife = 0; + mites.clear(); + mite = 0; + repair = null; + repairQueue.length = 0; for (const p of PARAMS) eased[p] = 0; }, research: (tech: string) => { @@ -246,6 +275,8 @@ export function createScreenFX(): ScreenFX { fever: +fever.toFixed(3), pallor: +pallor.toFixed(3), wildlife: +wildlife.toFixed(3), + mite: +mite.toFixed(3), + repair: repair ? 1 : 0, card: cardAge >= 0 ? 1 : 0, title: +titleOpacity(titleAge).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)])), }), 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. if (e.on === 'spawned') swarms.set(e.id, { pos: e.pos, size: 0.6 }); 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); 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 displayTotal = total * fresh; const wobble = @@ -375,6 +444,23 @@ export function createScreenFX(): ScreenFX { if (stamp.age >= STAMP_LIFE) stamp = null; } + // Repair placard lifecycle — its own slot so it never fights the shipment stamp. Same + // 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 // rather than seeping back; a dissolve would make it look negotiable. let card = 0; @@ -400,16 +486,20 @@ export function createScreenFX(): ScreenFX { stack.draw({ 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, + repair: repairAmt, repairScale, repairDirty, card, cardDirty, title, titleDirty, }); if (scramFrames > 0) scramFrames--; stampDirty = false; + repairDirty = false; cardDirty = false; titleDirty = false; frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05; }, }; + + return api; } diff --git a/fktry/src/screen/overlays.ts b/fktry/src/screen/overlays.ts index 2e662f8..4d9b436 100644 --- a/fktry/src/screen/overlays.ts +++ b/fktry/src/screen/overlays.ts @@ -12,6 +12,9 @@ export const SCRAM_W = 512; export const SCRAM_H = 64; export const CARD_W = 640; export const CARD_H = 360; +/** The repair placard is small on purpose — it's a signature, not a broadcast. */ +export const REPAIR_W = 224; +export const REPAIR_H = 112; /** * The 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; cardCanvas: HTMLCanvasElement; titleCanvas: HTMLCanvasElement; + repairCanvas: HTMLCanvasElement; /** Repaint the stamp for a landed shipment. OSHA-placard deadpan, in the item's chip colour. */ paintStamp(name: string, count: number, color: string): void; /** Repaint the ratification card. The Correction's uniform: hospital white, green tick. */ paintCard(era: string, tech: string): void; /** Paint the boot title card. Alpha channel matters — it dissolves over the feed. */ paintTitle(): void; + /** + * Repaint the repair placard — The Correction signing one unit of your work off the ledger. + * Small, perfect (no crooked stamp), hospital white with the faction green tick: deliberately + * the opposite of the hand-thumped, chip-coloured shipment stamp. Rot vs theft, made legible. + */ + paintRepair(name: string): void; } 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; const tg = titleCanvas.getContext('2d')!; + const repairCanvas = document.createElement('canvas'); + repairCanvas.width = REPAIR_W; + repairCanvas.height = REPAIR_H; + const rg = repairCanvas.getContext('2d')!; + return { stampCanvas, scramCanvas, cardCanvas, titleCanvas, + repairCanvas, paintTitle() { // 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.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); + }, }; }