[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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 18:20:50 +10:00
parent 08761e8eeb
commit 4803b6a1e7
3 changed files with 176 additions and 7 deletions

View File

@ -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[];

View File

@ -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<SimSnapshot['bandwidth']>, entities: Partial<EntityState>[] = []): 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);
});
});

View File

@ -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 };
}