Compare commits

..

No commits in common. "180747ce28c4a93841fc6bb013332dc8c3152464" and "83e3ad21072784cb0f885a3c6fa44df343f64cd1" have entirely different histories.

8 changed files with 16 additions and 406 deletions

View File

@ -302,89 +302,3 @@ 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

@ -1,47 +0,0 @@
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

@ -1,27 +0,0 @@
/**
* 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, u_pallor;
uniform float u_strain, u_fever;
uniform vec2 u_moireSeed;
uniform sampler2D u_stampTex, u_scramTex, u_cardTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card;
uniform sampler2D u_stampTex, u_scramTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
@ -201,16 +201,6 @@ 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);
@ -237,18 +227,12 @@ 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' | 'pallor' | 'moireSeed'
| 'stampTex' | 'scramTex' | 'cardTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card';
| Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed'
| 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY';
export interface DrawState {
params: ParamSet;
@ -265,14 +249,8 @@ 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 */
@ -344,11 +322,9 @@ 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');
@ -357,8 +333,6 @@ 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');
@ -369,14 +343,13 @@ 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, pallor, moireSeed,
stampAmt, stampScale, scram, scramY, stampDirty, card, cardDirty }) {
draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, moireSeed,
stampAmt, stampScale, scram, scramY, stampDirty }) {
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
if (base.update(timeSec * 1000, idle)) {
gl.activeTexture(gl.TEXTURE0);
@ -388,19 +361,12 @@ 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,7 +16,6 @@ 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;
@ -36,14 +35,6 @@ 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 }
@ -92,18 +83,9 @@ 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
@ -119,7 +101,6 @@ 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
@ -145,7 +126,6 @@ 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) {
@ -171,27 +151,14 @@ 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),
@ -211,11 +178,6 @@ 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;
}
}
},
@ -238,27 +200,14 @@ 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 =
displayTotal > WOBBLE_THRESHOLD
? (displayTotal - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD)
: 0;
total > WOBBLE_THRESHOLD ? (total - 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] * fresh, dt, EASE_TAU);
eased[p] = ease(eased[p], targets[p], dt, EASE_TAU);
// Wobble rides on top of the eased value; it never feeds back into the ledger.
const w =
wobble > 0
@ -285,26 +234,15 @@ 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, pallor, moireSeed,
strain, fever, 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,33 +10,12 @@ 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) {
@ -82,57 +61,9 @@ 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,8 +2,6 @@ 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,
@ -16,32 +14,22 @@ 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, pallor: 0 });
expect(computeStrain(undefined)).toEqual({ tremor: 0, fever: 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, 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);
expect(computeStrain(snap({}))).toEqual({ tremor: 0, fever: 0 });
});
it('stays calm for a healthy factory running a surplus', () => {
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500, capacity: 500 })))
.toEqual({ tremor: 0, fever: 0, pallor: 0 });
expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500 }))).toEqual({ tremor: 0, fever: 0 });
});
it('stays calm in surplus even with an empty reserve', () => {
@ -94,39 +82,6 @@ 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,28 +10,14 @@
*/
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, pallor: 0 };
export const NO_STRAIN: Strain = { tremor: 0, fever: 0 };
/**
* How much runway counts as calm. Reserve deeper than this reads as healthy; dread ramps up
@ -54,7 +40,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, capacity } = snap.bandwidth;
const { gen, draw, stored } = snap.bandwidth;
const deficit = draw - gen;
let tremor = 0;
if (deficit > 0) {
@ -64,12 +50,6 @@ 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;
@ -82,5 +62,5 @@ export function computeStrain(snap?: SimSnapshot): Strain {
}
const fever = hot > 0 ? clamp01(sum / hot) : 0;
return { tremor, fever, pallor };
return { tremor, fever };
}