From 4803b6a1e702a2e6643ddbe6ec71c3831fc51c12 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 18:20:50 +1000 Subject: [PATCH] [screen] signal strain: dread measured in seconds of reserve Per the v3 units ruling, stored is bandwidth-seconds and draw-gen is bandwidth per second, so stored/(draw-gen) is literally how long until the lights go out. Tremor ramps over the last 8 seconds. That reads correct at every factory size for free: 1000/s with 4000 banked feels exactly as doomed as 10/s with 40. A factory in surplus is always calm, which is also what keeps the boot state (gen=draw=stored=0) sterile by construction rather than by special case. Fever averages only the machines that run hot, so one glowing decoder is not drowned by a sea of cold belts. Pure and snapshot-shaped so the sterile guarantee is a test, not a promise. Co-Authored-By: Claude Opus 4.8 --- .../validate.test.ts} | 16 +-- fktry/src/screen/strain.test.ts | 101 ++++++++++++++++++ fktry/src/screen/strain.ts | 66 ++++++++++++ 3 files changed, 176 insertions(+), 7 deletions(-) rename fktry/{src/sim/data-validate.test.ts => data/validate.test.ts} (97%) create mode 100644 fktry/src/screen/strain.test.ts create mode 100644 fktry/src/screen/strain.ts diff --git a/fktry/src/sim/data-validate.test.ts b/fktry/data/validate.test.ts similarity index 97% rename from fktry/src/sim/data-validate.test.ts rename to fktry/data/validate.test.ts index fcda2ba..a9f99a7 100644 --- a/fktry/src/sim/data-validate.test.ts +++ b/fktry/data/validate.test.ts @@ -1,17 +1,19 @@ /** - * LANE-DATA territory (the one file this lane owns inside src/sim/). + * LANE-DATA territory. Lives beside the JSON it guards (round 3: moved out of + * src/sim/, which returns fully to LANE-SIM). + * * Validates data/*.json against the contracts. It is the handshake that keeps five * parallel lanes from drowning in dangling ids: if a lane references a machine, * recipe, item or tech that data doesn't define, this test names it. */ import { describe, expect, it } from 'vitest'; -import type { CommissionDef, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../contracts'; +import type { CommissionDef, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../src/contracts'; -import itemsJson from '../../data/items.json'; -import machinesJson from '../../data/machines.json'; -import recipesJson from '../../data/recipes.json'; -import techJson from '../../data/tech.json'; -import commissionsJson from '../../data/commissions.json'; +import itemsJson from './items.json'; +import machinesJson from './machines.json'; +import recipesJson from './recipes.json'; +import techJson from './tech.json'; +import commissionsJson from './commissions.json'; const items = itemsJson as unknown as ItemDef[]; const machines = machinesJson as unknown as MachineDef[]; diff --git a/fktry/src/screen/strain.test.ts b/fktry/src/screen/strain.test.ts new file mode 100644 index 0000000..1a7832d --- /dev/null +++ b/fktry/src/screen/strain.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import type { EntityState, SimSnapshot } from '../contracts'; +import { computeStrain, STRAIN_HORIZON_SECONDS } from './strain'; + +function snap(bandwidth: Partial, entities: Partial[] = []): SimSnapshot { + return { + tick: 0, + paused: false, + entities: entities.map((e, i) => ({ + id: i, def: 'x', pos: { x: 0, y: 0 }, dir: 0, recipe: null, progress: 0, + inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, ...e, + })), + beltItems: [], + bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false, ...bandwidth }, + shippedTotal: {}, + activeCommission: null, + commissionProgress: {}, + }; +} + +describe('the NOTHING IS WRONG era stays sterile', () => { + it('is inert with no snapshot at all', () => { + 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 }); + }); + + it('stays calm for a healthy factory running a surplus', () => { + expect(computeStrain(snap({ gen: 100, draw: 40, stored: 500 }))).toEqual({ tremor: 0, fever: 0 }); + }); + + it('stays calm in surplus even with an empty reserve', () => { + // Nothing is draining, so there is nothing to dread — banked reserve is irrelevant. + expect(computeStrain(snap({ gen: 100, draw: 100, stored: 0 })).tremor).toBe(0); + }); + + it('stays calm with cold machines', () => { + expect(computeStrain(snap({ gen: 10, draw: 5 }, [{ heat: 0 }, { heat: 0 }])).fever).toBe(0); + }); +}); + +describe('tremor tracks seconds-of-reserve, not raw stored', () => { + it('is silent while the reserve outlasts the horizon', () => { + // deficit 10/s, 200 banked = 20s of runway: further out than the horizon. + expect(computeStrain(snap({ gen: 0, draw: 10, stored: 200 })).tremor).toBe(0); + }); + + it('is total when the reserve is gone and the draw continues', () => { + expect(computeStrain(snap({ gen: 0, draw: 10, stored: 0 })).tremor).toBe(1); + }); + + it('sits halfway at half the horizon', () => { + const stored = 10 * (STRAIN_HORIZON_SECONDS / 2); // deficit 10/s -> horizon/2 seconds left + expect(computeStrain(snap({ gen: 0, draw: 10, stored })).tremor).toBeCloseTo(0.5, 5); + }); + + it('rises monotonically as the reserve drains', () => { + let prev = -1; + for (let stored = 200; stored >= 0; stored -= 10) { + const t = computeStrain(snap({ gen: 0, draw: 10, stored })).tremor; + expect(t).toBeGreaterThanOrEqual(prev); + prev = t; + } + expect(prev).toBe(1); + }); + + it('reads the same dread from a big factory as a small one at equal runway', () => { + // A megafactory bleeding 1000/s with 4000 banked is in exactly as much trouble as a + // shack bleeding 10/s with 40 banked. Seconds, not units, is the honest measure. + const big = computeStrain(snap({ gen: 0, draw: 1000, stored: 4000 })).tremor; + const small = computeStrain(snap({ gen: 0, draw: 10, stored: 40 })).tremor; + expect(big).toBeCloseTo(small, 5); + }); + + it('never leaves 0..1, even absurdly overdrawn', () => { + const t = computeStrain(snap({ gen: 0, draw: 1e9, stored: -50 })).tremor; + expect(t).toBeGreaterThanOrEqual(0); + expect(t).toBeLessThanOrEqual(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. + const entities = [{ heat: 0.9 }, ...Array.from({ length: 50 }, () => ({ heat: 0 }))]; + expect(computeStrain(snap({}), ).fever).toBe(0); + expect(computeStrain(snap({}, entities)).fever).toBeCloseTo(0.9, 5); + }); + + it('averages across the hot machines', () => { + expect(computeStrain(snap({}, [{ heat: 0.4 }, { heat: 0.8 }])).fever).toBeCloseTo(0.6, 5); + }); + + it('stays within 0..1 for a fully scrammed factory', () => { + const f = computeStrain(snap({}, [{ heat: 1, scrammed: true }, { heat: 1 }])).fever; + expect(f).toBe(1); + }); +}); diff --git a/fktry/src/screen/strain.ts b/fktry/src/screen/strain.ts new file mode 100644 index 0000000..0e5ee70 --- /dev/null +++ b/fktry/src/screen/strain.ts @@ -0,0 +1,66 @@ +/** + * 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'; + +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; +} + +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 + * 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 } = 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. + + // --- 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 }; +}