diff --git a/fktry/src/screen/strain.test.ts b/fktry/src/screen/strain.test.ts index 1a7832d..c0abf24 100644 --- a/fktry/src/screen/strain.test.ts +++ b/fktry/src/screen/strain.test.ts @@ -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, entities: Partial[] = []): SimSnapshot { return { tick: 0, @@ -14,22 +16,32 @@ function snap(bandwidth: Partial, entities: Partial { 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. diff --git a/fktry/src/screen/strain.ts b/fktry/src/screen/strain.ts index 0e5ee70..53c12b9 100644 --- a/fktry/src/screen/strain.ts +++ b/fktry/src/screen/strain.ts @@ -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 }; }