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>
87 lines
3.6 KiB
TypeScript
87 lines
3.6 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|