Compare commits

..

4 Commits

Author SHA1 Message Date
type-two
180747ce28 [screen] round 4 notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:01:38 +10:00
type-two
6404c44fcc [screen] ratification card + pallor pass
The Correction takes the sky for one second every time the player does science:
hospital white, green tick, the standard's real name from codex 1. Composited
dead last in screen space so not one pixel of it is corrupted -- that is the
joke. Hard cut on exit; a dissolve would make it look negotiable.

Timed in seconds, not frames: a broadcast interruption must last a second at any
refresh rate (verified 30 frames at 30fps, 60 at 60fps). The scram wipe stays
frame-counted -- tape artifacts are quantized to frames.

Pallor drains saturation and closes the corners in, per codex 8: colour is the
resource, so a factory with no reserve literally has less of it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:01:38 +10:00
type-two
bc992a99d4 [screen] second dread axis: pallor, and v5 fixture hygiene
tremor asks "how many seconds until dark" -- acute, the cliff edge, expressed as
judder. pallor asks "how much cushion do I have at all" -- chronic, sits there in
surplus, expressed as colour draining out. A full tank draining fast is a jolt;
a near-empty tank coasting is a way of life.

No tanks built means no cushion to miss, so no pallor. Without that, an unbuilt
reserve reads as "totally empty" and paints the boot state grey, which would
break the sterile guarantee on day one.

Fixtures now carry commissionQueue and research ahead of the v5 hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:01:38 +10:00
type-two
5ad7252e20 [screen] boredom: the sky forgets, the ledger does not
No shipments for a sustained stretch and the corruption eases back toward clean.
The LEDGER is untouched -- history is not erased, only the display decays -- so
resuming production puts the targets straight back and the picture walks home in
about a second.

60s grace then 180s decay: a jam you notice, diagnose and fix costs nothing.
The player has to genuinely walk away before the sky starts cleaning itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:01:38 +10:00
8 changed files with 406 additions and 16 deletions

View File

@ -302,3 +302,89 @@ piles** are the obvious next spatial artifacts (both are regional in the codex),
biomes in §1 would suggest a mask per era. Also, now that strain reads the snapshot, THE SCREEN
could react to `jammed` entities (a stalled factory *should* look bored — the feed drifting back
toward clean is a nice punishment), but that's a design call, not a lane call.
### Round 4 — 2026-07-17 — Opus 4.8
SHIPPED: all 4 orders. Thanks for granting capacity unasked.
- `boredom.ts` + `boredom.test.ts` (new) — pure freshness curve, 7 tests.
- `strain.ts` — second dread axis; `strain.test.ts` now 20 tests. Screen suite 57 green.
- `overlays.ts` — ratification card painter + era→standard table.
- `glitchStack.ts` — pallor pass (desaturate + vignette), card composite.
- `index.ts` — boredom wiring, card lifecycle, `researched` handling, pallor easing.
DESIGN INTENT, one line each (as ordered):
- **tremor = "how many seconds until dark?"** ACUTE. The cliff edge. Only exists while something
is actually draining, and it spikes as the runway runs out. Expressed as motion: judder.
- **pallor = "how much cushion do I have at all?"** CHRONIC. Sits there in surplus. Expressed as
colour: the saturation drains out and the corners close in.
A full tank draining fast is a jolt; a near-empty tank coasting is a way of life. Different
questions, different senses, so they never mush into one dial.
DECISIONS:
- **Pallor drains saturation on purpose.** Codex §8: "ALL saturation belongs to media/artifacts.
Color = value = literally the resource." A factory with no reserve should therefore literally
have less colour. The vignette does the rest. It fits the existing strain/fever/seizure
hierarchy as the quiet one: chronic, slow tau (2.0s), never louder than the corruption.
- **The capacity trap**: `1 - stored/capacity` with no tanks built reads as "totally empty" and
would have painted the boot state grey — blowing up round 3's sterile guarantee on day one.
No tanks = no cushion to miss = no pallor. That's not a special case, it's the honest reading:
you cannot be low on a reserve you never built. Two tests pin it (capacity 0 AND undefined).
- **Boredom decays the DISPLAY, never the ledger.** `fresh` scales the targets; the tally
underneath is untouched. Verified explicitly: after 4 idle minutes the sky is clean but
`units: 72, tally: {melt: 60}, total: 0.893` are all exactly as earned.
- **Boredom horizon: 60s grace, then 180s decay (4 minutes to clean).** You asked for minutes,
not seconds. A full minute of silence changes literally nothing, so a jam you notice, diagnose
and fix is free. Recovery rides the normal 1.2s shipment ease — against a 4-minute decay that
reads as a snap, and it reuses the wave feel rather than inventing a second motion.
- **The card is timed in SECONDS, not frames** — it's a broadcast interruption and must be one
second at any refresh rate. Verified 30 frames @30fps AND 60 frames @60fps. (Deliberate
contrast with the scram wipe, which stays frame-counted: that one is a tape artifact, and tape
artifacts are quantized to frames. Two different clocks for two different fictions.)
- Card composites dead last in screen space, so not one pixel of it is corrupted — that IS the
joke. Hard cut on exit, no fade: the corruption crashes back rather than seeping, because a
dissolve would make The Correction look negotiable.
- Card says the STANDARD, not the tech id (codex §1 names: OPTICAL FILM, NTSC / PAL ANALOG,
MPEG-2 / DVD, H.264 / HEVC / AV1, DCP) with the green tick from §8's Correction uniform. The
tech id is in the small print where a compliance notice would put it.
VERIFIED (fixture snapshots, pixel reads, contact sheet):
- **Sterile guarantee survives the new axis**: boot AND a tankless factory in surplus both render
byte-for-byte identical to no-snapshot, pallor 0.
- **Two dreads are separable at the pixel level**: full-tank-draining-fast → tremor 0.50, pallor
0, saturation 0.621 and corners 96 both *unchanged from healthy*. Empty-tank-coasting → tremor
0, pallor 0.972, saturation 0.500, corners 26.6. MAD between the two: **43.7**. They do not
look like the same emergency.
- **Boredom**: untouched at 60s → fresh 0.834 at 90s → 0.5 at 150s → 0 at 240s → stays 0. Ledger
intact throughout. Resume: freshness 1 instantly, picture home in ~1.5s.
- **Card**: 30 frames @30fps, 60 @60fps = 1.00s both. Mean luminance 83 → 235 → 83.
- **Budget: 0.078 ms/frame** with GPU sync, 2000 entities, every artifact + both dreads + fever +
brownout + card compositing. ~26× under.
- Note for the record: **tremor is invisible in a still** — it's motion, so the contact-sheet tile
looks like HEALTHY. I pinned it numerically instead (0.50, MAD 6.8 vs sterile). A still frame is
the wrong instrument for a judder and I didn't want that filed as "verified by screenshot".
BLOCKED/BROKEN: nothing in my lane. **One thing the fleet needs to know:**
- `npm test` currently shows 6 red in `src/ui/live.test.ts` ("reference factory should contain a
software decoder", commission/entity-count assertions). **Not mine, and not LANE-UI's fault
either**: LANE-DATA has uncommitted edits to all five `data/*.json` in the shared tree, and
UI's live tests build a reference factory out of that data. I confirmed by stashing *only*
`src/screen/` — the same 6 still fail with my lane entirely absent.
- **Warning about the shared working tree**: my first instinct was `git stash`, which stashed
every lane's uncommitted work, not just mine — and made the UI tests "pass", which nearly had me
believing I'd broken them. `git stash push -- src/screen/` is the only safe form here. Any lane
bisecting a red test in this tree can be badly misled by a bare stash. Worth a line in
MASTERPLAN if you agree.
CONTRACT REQUEST: none. v4 gave me everything; fixtures now carry `commissionQueue` and
`research` ahead of the v5 hardening (order 4 done).
PROPOSAL: boredom currently only listens for `shipped`. A factory that is *jammed solid* and one
that is *idle because the player is redesigning it* decay identically. You approved the stalled-
factory idea as design — if you want the distinction, `jammed` entities in the snapshot could make
the sky decay noticeably faster (the work is rotting, not resting) while a clean idle stays
gentle. That's a one-line read of data I already have. Say the word and it's a small round-5 item.
NEXT (not self-assigning): the era→standard table means THE SCREEN now knows about eras — the §1
strata skins (sepia Reel, phosphor Broadcast, rainbow-on-black Disc) could drive the *base feed's*
palette per the player's current era, so the clean programming itself ages as they tech up. That
plus the mask template would give era-flavoured biomes on the sky.

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { BOREDOM_GRACE_SECONDS, BOREDOM_HORIZON_SECONDS, freshness } from './boredom';
describe('boredom decays the display, slowly', () => {
it('shows everything you earned while production is running', () => {
expect(freshness(0)).toBe(1);
expect(freshness(5)).toBe(1);
});
it('does not punish a normal jam', () => {
// The whole grace window plus a beat: a stall long enough to notice, diagnose and fix
// must leave THE SCREEN exactly as the player left it.
expect(freshness(BOREDOM_GRACE_SECONDS)).toBe(1);
expect(freshness(BOREDOM_GRACE_SECONDS - 1)).toBe(1);
});
it('takes minutes, not seconds, to forget', () => {
// A minute of silence has barely dented it; the sky is patient.
expect(freshness(BOREDOM_GRACE_SECONDS + 30)).toBeGreaterThan(0.8);
// Fully clean only after the player has genuinely walked away.
expect(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS).toBeGreaterThanOrEqual(180);
});
it('reaches clean at the horizon and stays there', () => {
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS)).toBe(0);
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS + 9999)).toBe(0);
});
it('decays monotonically', () => {
let prev = 2;
for (let s = 0; s <= BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS + 60; s += 5) {
const f = freshness(s);
expect(f).toBeLessThanOrEqual(prev);
expect(f).toBeGreaterThanOrEqual(0);
prev = f;
}
});
it('sits at half way through the decay', () => {
expect(freshness(BOREDOM_GRACE_SECONDS + BOREDOM_HORIZON_SECONDS / 2)).toBeCloseTo(0.5, 5);
});
it('treats a nonsense idle time as fresh rather than wiping the sky', () => {
expect(freshness(NaN)).toBe(1);
expect(freshness(-10)).toBe(1);
});
});

View File

@ -0,0 +1,27 @@
/**
* Boredom the factory's work evaporating off THE SCREEN.
*
* If nothing ships for a long stretch, the sky slowly forgets. The corruption LEDGER is
* untouched: history is not erased, only the display decays. So when production resumes the
* targets are instantly back where they were and the picture snaps home in about a second.
*
* The horizon is deliberately generous. A jam, a rebuild, a rethink of the bus none of
* those are supposed to read as punishment. The player should have to genuinely walk away
* before the sky starts cleaning itself.
*/
/** No shipments for this long before the sky begins to forget at all. */
export const BOREDOM_GRACE_SECONDS = 60;
/** ...and this long again to fade from full corruption to clean. */
export const BOREDOM_HORIZON_SECONDS = 180;
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/**
* How much of the ledger's corruption is still on display, 0..1.
* 1 = everything you earned is showing; 0 = the sky has forgotten (the tallies have not).
*/
export function freshness(idleSeconds: number): number {
if (!(idleSeconds > BOREDOM_GRACE_SECONDS)) return 1; // NaN-safe: unknown idle reads as fresh
return 1 - clamp01((idleSeconds - BOREDOM_GRACE_SECONDS) / BOREDOM_HORIZON_SECONDS);
}

View File

@ -29,10 +29,10 @@ uniform float t;
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
uniform float u_block, u_ring, u_moire;
uniform float u_drift, u_brownout, u_flash;
uniform float u_strain, u_fever;
uniform float u_strain, u_fever, u_pallor;
uniform vec2 u_moireSeed;
uniform sampler2D u_stampTex, u_scramTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY;
uniform sampler2D u_stampTex, u_scramTex, u_cardTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
@ -201,6 +201,16 @@ void main(){
// fever runs warm: the picture itself develops a temperature
col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55);
// ---- pallor: the second dread. Not urgency — malaise. An empty reserve drains the colour
// out of the sky and closes the corners in. Codex §8: colour IS the resource, so a factory
// with no cushion literally has less of it. Chronic, and it sits there in surplus too.
if(u_pallor > 0.001){
col = mix(col, vec3(lu(col)), u_pallor*0.5);
vec2 pd = vUv - 0.5; pd.x *= 1.78;
float vig = smoothstep(0.34, 0.95, length(pd));
col *= 1.0 - vig*u_pallor*0.75;
}
// faint scanlines for vibe (constant, not scored)
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
@ -227,12 +237,18 @@ void main(){
// the awakening: one frame of full white on the first shipment ever
col = mix(col, vec3(1.0), u_flash);
// ---- ratification: The Correction takes the sky. Composited dead last and in screen space,
// so not one pixel of it is corrupted — that is the entire point of the joke.
if(u_card > 0.001){
col = mix(col, texture2D(u_cardTex, vUv).rgb, u_card);
}
gl_FragColor = vec4(col, 1.0);
}`;
type UniformKey =
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed'
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY';
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed'
| 'stampTex' | 'scramTex' | 'cardTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card';
export interface DrawState {
params: ParamSet;
@ -249,8 +265,14 @@ export interface DrawState {
strain: number;
/** 0..1 feverish shimmer from aggregate machine heat */
fever: number;
/** 0..1 pallor from an empty reserve — chronic, unlike tremor */
pallor: number;
/** where the moire crystal grows from, in screen uv */
moireSeed: [number, number];
/** 0..1 opacity of the ratification card */
card: number;
/** set when the card canvas has been repainted and needs re-upload */
cardDirty: boolean;
/** 0..1 opacity of the shipment stamp */
stampAmt: number;
/** stamp size multiplier — bigger shipments stamp harder */
@ -322,9 +344,11 @@ export function createGlitchStack(
base.update(0, true);
overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
overlays.paintCard('reel', '');
const baseTex = makeTex(0, base.canvas);
const stampTex = makeTex(1, overlays.stampCanvas);
makeTex(2, overlays.scramCanvas);
const cardTex = makeTex(3, overlays.cardCanvas);
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
uni.t = gl.getUniformLocation(prog, 't');
@ -333,6 +357,8 @@ export function createGlitchStack(
uni.flash = gl.getUniformLocation(prog, 'u_flash');
uni.strain = gl.getUniformLocation(prog, 'u_strain');
uni.fever = gl.getUniformLocation(prog, 'u_fever');
uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
uni.card = gl.getUniformLocation(prog, 'u_card');
uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
@ -343,13 +369,14 @@ export function createGlitchStack(
gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0);
gl.uniform1i(gl.getUniformLocation(prog, 'u_stampTex'), 1);
gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2);
gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(uni.drift, 1);
return {
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, moireSeed,
stampAmt, stampScale, scram, scramY, stampDirty }) {
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, pallor, moireSeed,
stampAmt, stampScale, scram, scramY, stampDirty, card, cardDirty }) {
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
if (base.update(timeSec * 1000, idle)) {
gl.activeTexture(gl.TEXTURE0);
@ -361,12 +388,19 @@ export function createGlitchStack(
gl.bindTexture(gl.TEXTURE_2D, stampTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.stampCanvas);
}
if (cardDirty) {
gl.activeTexture(gl.TEXTURE0 + 3);
gl.bindTexture(gl.TEXTURE_2D, cardTex);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.cardCanvas);
}
gl.uniform1f(uni.t, timeSec);
gl.uniform1f(uni.drift, drift);
gl.uniform1f(uni.brownout, brownout);
gl.uniform1f(uni.flash, flash);
gl.uniform1f(uni.strain, strain);
gl.uniform1f(uni.fever, fever);
gl.uniform1f(uni.pallor, pallor);
gl.uniform1f(uni.card, card);
gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
gl.uniform1f(uni.stampAmt, stampAmt);
gl.uniform1f(uni.stampScale, stampScale);

View File

@ -16,6 +16,7 @@ import { createOverlays } from './overlays';
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
import { computeStrain } from './strain';
import { freshness } from './boredom';
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
const EASE_TAU = 1.2;
@ -35,6 +36,14 @@ const SCRAM_FRAMES = 3;
/** Tremor tracks the reserve fairly tightly; fever is a slow burn. */
const STRAIN_TAU = 0.35;
const FEVER_TAU = 1.5;
/** Pallor is chronic, so it should drift rather than react. */
const PALLOR_TAU = 2.0;
/**
* The ratification card is timed in SECONDS, not frames: it's a broadcast interruption, so it
* must last one second whatever the refresh rate. (The scram wipe is frame-counted on purpose
* that one is a tape artifact, and tape artifacts are quantized to frames.)
*/
const CARD_SECONDS = 1;
interface Stamp { name: string; count: number; color: string; age: number }
@ -83,9 +92,18 @@ export function createScreenFX(): ScreenFX {
let strain = 0;
let fever = 0;
let pallor = 0;
let moireSeed: [number, number] = [0.5, 0.44];
let moireSeeded = false;
let lastShipMs = 0;
let markShip = false;
let fresh = 1;
let cardAge = -1; // <0 = no card on screen
let cardDirty = false;
const eras = new Map<string, string>();
let lastMs = 0;
let frameCost = 0; // rolling avg ms of this module's CPU work
@ -101,6 +119,7 @@ export function createScreenFX(): ScreenFX {
if (count <= 0) return;
tally.set(item, (tally.get(item) ?? 0) + count);
ledgerDirty = true;
markShip = true; // the sky stops forgetting the moment anything lands
if (!hasShipped) {
hasShipped = true;
pendingFlash = true; // the awakening
@ -126,6 +145,7 @@ export function createScreenFX(): ScreenFX {
const base = createBaseFeed();
stack = createGlitchStack(canvas, base, overlays);
for (const it of data.items) items.set(it.id, it);
for (const t of data.tech) eras.set(t.id, t.era);
const unmapped = unmappedItems(data);
if (unmapped.length) {
@ -151,14 +171,27 @@ export function createScreenFX(): ScreenFX {
moireSeed = [0.5, 0.44];
strain = 0;
fever = 0;
pallor = 0;
cardAge = -1;
lastShipMs = 0;
fresh = 1;
for (const p of PARAMS) eased[p] = 0;
},
research: (tech: string) => {
overlays.paintCard(eras.get(tech) ?? 'reel', tech);
cardDirty = true;
cardAge = 0;
},
stats: () => ({
units: +units.toFixed(2),
total: +total.toFixed(3),
displayTotal: +(total * fresh).toFixed(3),
freshness: +fresh.toFixed(3),
idle: !hasShipped,
strain: +strain.toFixed(3),
fever: +fever.toFixed(3),
pallor: +pallor.toFixed(3),
card: cardAge >= 0 ? 1 : 0,
moireSeed: moireSeed.map((n) => +n.toFixed(3)),
brownout: +brownout.toFixed(2),
msPerFrame: +frameCost.toFixed(3),
@ -178,6 +211,11 @@ export function createScreenFX(): ScreenFX {
// event rather than random, so the same run always wipes the same way.
scramFrames = SCRAM_FRAMES;
scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7;
} else if (e.kind === 'researched') {
// The Correction takes the sky for a second, every time the player does science.
overlays.paintCard(eras.get(e.tech) ?? 'reel', e.tech);
cardDirty = true;
cardAge = 0;
}
}
},
@ -200,14 +238,27 @@ export function createScreenFX(): ScreenFX {
const target = computeStrain(snap);
strain = ease(strain, target.tremor, dt, STRAIN_TAU);
fever = ease(fever, target.fever, dt, FEVER_TAU);
pallor = ease(pallor, target.pallor, dt, PALLOR_TAU);
// Boredom: the DISPLAY decays, the ledger does not. `fresh` scales what's shown; the
// tally underneath is untouched, so resuming production snaps the targets straight back
// and the easing walks the picture home in about a second.
if (markShip) {
lastShipMs = timeMs;
markShip = false;
}
fresh = hasShipped ? freshness((timeMs - lastShipMs) / 1000) : 1;
const timeSec = timeMs / 1000;
const displayTotal = total * fresh;
const wobble =
total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0;
displayTotal > WOBBLE_THRESHOLD
? (displayTotal - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD)
: 0;
for (let i = 0; i < PARAMS.length; i++) {
const p = PARAMS[i];
eased[p] = ease(eased[p], targets[p], dt, EASE_TAU);
eased[p] = ease(eased[p], targets[p] * fresh, dt, EASE_TAU);
// Wobble rides on top of the eased value; it never feeds back into the ledger.
const w =
wobble > 0
@ -234,15 +285,26 @@ export function createScreenFX(): ScreenFX {
if (stamp.age >= STAMP_LIFE) stamp = 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;
if (cardAge >= 0) {
card = 1;
cardAge += dt;
if (cardAge >= CARD_SECONDS) cardAge = -1;
}
const flash = pendingFlash ? 1 : 0;
stack.draw({
params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped,
strain, fever, moireSeed,
strain, fever, pallor, moireSeed,
stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
card, cardDirty,
});
pendingFlash = false;
if (scramFrames > 0) scramFrames--;
stampDirty = false;
cardDirty = false;
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
},

View File

@ -10,12 +10,33 @@ export const STAMP_W = 256;
export const STAMP_H = 128;
export const SCRAM_W = 512;
export const SCRAM_H = 64;
export const CARD_W = 640;
export const CARD_H = 360;
/**
* The eras as the codex names them (§1). The card announces the STANDARD, not the tech id
* The Correction does not care what you unlocked, only which format it just blessed.
*/
const ERA_STANDARD: Record<string, string> = {
reel: 'OPTICAL FILM',
broadcast: 'NTSC / PAL ANALOG',
disc: 'MPEG-2 / DVD',
stream: 'H.264 / HEVC / AV1',
fortress: 'DCP',
};
export function eraStandard(era: string): string {
return ERA_STANDARD[era] ?? era.toUpperCase();
}
export interface Overlays {
stampCanvas: HTMLCanvasElement;
scramCanvas: HTMLCanvasElement;
cardCanvas: 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;
}
function roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
@ -61,9 +82,57 @@ export function createOverlays(): Overlays {
scramCanvas.height = SCRAM_H;
paintScramBar(scramCanvas.getContext('2d')!);
const cardCanvas = document.createElement('canvas');
cardCanvas.width = CARD_W;
cardCanvas.height = CARD_H;
const cg = cardCanvas.getContext('2d')!;
return {
stampCanvas,
scramCanvas,
cardCanvas,
paintCard(era: string, tech: string) {
// The Correction wins the sky for one second: hospital white, green checkmark, and the
// absolute confidence of a compliance notice. Nothing here is corrupted — that's the joke.
cg.fillStyle = '#f2f5f2';
cg.fillRect(0, 0, CARD_W, CARD_H);
cg.strokeStyle = '#cfd8cf';
cg.lineWidth = 2;
cg.strokeRect(24, 24, CARD_W - 48, CARD_H - 48);
cg.textBaseline = 'middle';
cg.fillStyle = '#12160f';
cg.font = "bold 40px 'Courier New', monospace";
const title = 'STANDARD RATIFIED';
cg.fillText(title, (CARD_W - cg.measureText(title).width) / 2, 132);
const standard = eraStandard(era);
cg.fillStyle = '#2f7d3f';
cg.font = "bold 28px 'Courier New', monospace";
cg.fillText(standard, (CARD_W - cg.measureText(standard).width) / 2, 186);
// green tick — The Correction's faction uniform (codex §8)
cg.strokeStyle = '#2f7d3f';
cg.lineWidth = 7;
cg.lineCap = 'round';
cg.lineJoin = 'round';
cg.beginPath();
cg.moveTo(CARD_W / 2 - 26, 236);
cg.lineTo(CARD_W / 2 - 8, 254);
cg.lineTo(CARD_W / 2 + 28, 218);
cg.stroke();
cg.fillStyle = '#5c6b5c';
cg.font = "13px 'Courier New', monospace";
const sub = 'CONFORMANCE VERIFIED · NO ACTION REQUIRED';
cg.fillText(sub, (CARD_W - cg.measureText(sub).width) / 2, 292);
cg.font = "11px 'Courier New', monospace";
cg.fillText(tech.toUpperCase().replace(/-/g, ' '), 40, 322);
const auth = 'THE CORRECTION';
cg.fillText(auth, CARD_W - 40 - cg.measureText(auth).width, 322);
},
paintStamp(name: string, count: number, color: string) {
sg.clearRect(0, 0, STAMP_W, STAMP_H);
sg.save();

View File

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import type { EntityState, SimSnapshot } from '../contracts';
import { computeStrain, STRAIN_HORIZON_SECONDS } from './strain';
// v4 fixture hygiene: commissionQueue and research both harden in v5, so they are populated
// here now rather than left to break the day the contract tightens.
function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<EntityState>[] = []): SimSnapshot {
return {
tick: 0,
@ -14,22 +16,32 @@ function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<En
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false, ...bandwidth },
shippedTotal: {},
activeCommission: null,
commissionQueue: [],
commissionProgress: {},
research: { active: null, progress: {}, unlocked: [] },
};
}
describe('the NOTHING IS WRONG era stays sterile', () => {
it('is inert with no snapshot at all', () => {
expect(computeStrain(undefined)).toEqual({ tremor: 0, fever: 0 });
expect(computeStrain(undefined)).toEqual({ tremor: 0, fever: 0, pallor: 0 });
});
it('does not leak strain into a fresh factory (gen=draw=stored=0)', () => {
// The boot state. Nothing built, nothing drawing: dread here would be a lie.
expect(computeStrain(snap({}))).toEqual({ tremor: 0, fever: 0 });
expect(computeStrain(snap({}))).toEqual({ tremor: 0, fever: 0, pallor: 0 });
});
it('stays sterile with no tanks built, even though stored is 0', () => {
// The trap in the second dread axis: stored/capacity with no capacity must not read as
// "empty". You cannot be low on a reserve you never built.
expect(computeStrain(snap({ capacity: 0 })).pallor).toBe(0);
expect(computeStrain(snap({ gen: 50, draw: 20, capacity: undefined })).pallor).toBe(0);
});
it('stays calm for a healthy factory running a surplus', () => {
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500 }))).toEqual({ tremor: 0, fever: 0 });
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500, capacity: 500 })))
.toEqual({ tremor: 0, fever: 0, pallor: 0 });
});
it('stays calm in surplus even with an empty reserve', () => {
@ -82,6 +94,39 @@ describe('tremor tracks seconds-of-reserve, not raw stored', () => {
});
});
describe('the two dreads are different questions', () => {
it('full tank draining fast: tremor without pallor', () => {
// 4 seconds from dark, but the tanks are brimming. Acute, not chronic.
const s = computeStrain(snap({ gen: 0, draw: 10, stored: 40, capacity: 40 }));
expect(s.tremor).toBe(0.5);
expect(s.pallor).toBe(0);
});
it('near-empty tank coasting: pallor without tremor', () => {
// Nothing is draining — draw <= gen — so there is no cliff. But there is also no cushion.
const s = computeStrain(snap({ gen: 100, draw: 100, stored: 5, capacity: 500 }));
expect(s.tremor).toBe(0);
expect(s.pallor).toBeCloseTo(0.99, 5);
});
it('empty tank AND draining: both, which is the real emergency', () => {
const s = computeStrain(snap({ gen: 0, draw: 10, stored: 0, capacity: 500 }));
expect(s.tremor).toBe(1);
expect(s.pallor).toBe(1);
});
it('pallor tracks the fraction, not the amount', () => {
// Same 100 banked reads as comfortable in a small tank and dire in a huge one.
expect(computeStrain(snap({ stored: 100, capacity: 100 })).pallor).toBe(0);
expect(computeStrain(snap({ stored: 100, capacity: 1000 })).pallor).toBeCloseTo(0.9, 5);
});
it('never leaves 0..1 when the sim overfills or overdraws a tank', () => {
expect(computeStrain(snap({ stored: 900, capacity: 500 })).pallor).toBe(0);
expect(computeStrain(snap({ stored: -20, capacity: 500 })).pallor).toBe(1);
});
});
describe('fever reads the machines that actually run hot', () => {
it('is not diluted by a sea of cold belts', () => {
// One decoder at 0.9 surrounded by 50 cold belts is a fever, not a rounding error.

View File

@ -10,14 +10,28 @@
*/
import type { SimSnapshot } from '../contracts';
/**
* The two dreads (v4). They are deliberately different questions:
*
* tremor "how many seconds until dark?" ACUTE. The cliff edge. Only exists while
* something is actually draining, and it spikes as the runway runs out.
* pallor "how much cushion do I have at all?" CHRONIC. Sits there even in surplus.
* A full tank draining fast is a crisis; a near-empty tank coasting is a way of
* life, and it should feel like malaise rather than alarm.
*
* A factory with 4 seconds left and full tanks reads as a jolt; one permanently scraping the
* bottom of an empty tank reads as washed-out and grim. Same bandwidth system, two feelings.
*/
export interface Strain {
/** 0..1 tremor/desync judder as the bandwidth reserve runs out */
tremor: number;
/** 0..1 slow feverish shimmer from aggregate machine heat */
fever: number;
/** 0..1 pallor: how empty the tanks are, regardless of whether anything is draining */
pallor: number;
}
export const NO_STRAIN: Strain = { tremor: 0, fever: 0 };
export const NO_STRAIN: Strain = { tremor: 0, fever: 0, pallor: 0 };
/**
* How much runway counts as calm. Reserve deeper than this reads as healthy; dread ramps up
@ -40,7 +54,7 @@ export function computeStrain(snap?: SimSnapshot): Strain {
// --- tremor: seconds of reserve remaining at the current deficit.
// Per the v3 units ruling, `stored` is bandwidth-SECONDS while gen/draw are bandwidth per
// second — so stored/deficit is literally "how long until the lights go out".
const { gen, draw, stored } = snap.bandwidth;
const { gen, draw, stored, capacity } = snap.bandwidth;
const deficit = draw - gen;
let tremor = 0;
if (deficit > 0) {
@ -50,6 +64,12 @@ export function computeStrain(snap?: SimSnapshot): Strain {
// A factory in surplus is calm no matter how little it has banked — that is what keeps
// strain out of the NOTHING IS WRONG era, where gen and draw are both zero.
// --- pallor: how empty the tanks are. Unlike tremor this does NOT care about draining;
// it is the standing cost of living hand-to-mouth.
// No tanks built = no cushion to miss = no pallor. That is not a special case for the boot
// state, it is the honest reading: you cannot be low on a reserve you never built.
const pallor = capacity && capacity > 0 ? 1 - clamp01(Math.max(0, stored) / capacity) : 0;
// --- fever: how hot are the machines that actually run hot.
// Averaging across every entity would drown a glowing decoder in a sea of cold belts.
let sum = 0;
@ -62,5 +82,5 @@ export function computeStrain(snap?: SimSnapshot): Strain {
}
const fever = hot > 0 ? clamp01(sum / hot) : 0;
return { tremor, fever };
return { tremor, fever, pallor };
}