/** * Signal strain — the continuous half of THE SCREEN's vocabulary. * * Events give edges (brownout on/off); the snapshot gives quantities. Strain is what the * factory feels BEFORE the seizure: the reserve draining toward empty, the decoders running * hot. Strain is weather; the corruption ledger is climate. It stays deliberately subordinate. * * Pure and snapshot-shaped on purpose: no DOM, no GL, so the "healthy factory stays sterile" * guarantee is a unit test rather than a promise. */ 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 }; /** * How much runway counts as calm. Reserve deeper than this reads as healthy; dread ramps up * as the factory's remaining seconds fall toward zero. */ export const STRAIN_HORIZON_SECONDS = 8; /** Machines idling at a trace of heat are not a fever. */ const HEAT_FLOOR = 0.01; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); /** * Read-only peek at the snapshot (contracts v3). The snapshot is never retained: everything * needed collapses to two scalars here and now. */ export function computeStrain(snap?: SimSnapshot): Strain { if (!snap) return NO_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 deficit = draw - gen; let tremor = 0; if (deficit > 0) { const secondsLeft = Math.max(0, stored) / deficit; tremor = 1 - clamp01(secondsLeft / STRAIN_HORIZON_SECONDS); } // 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; let hot = 0; for (const e of snap.entities) { if (e.heat > HEAT_FLOOR) { sum += e.heat; hot++; } } const fever = hot > 0 ? clamp01(sum / hot) : 0; return { tremor, fever, pallor }; }