[screen] second dread axis: pallor, and v5 fixture hygiene
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>
This commit is contained in:
parent
5ad7252e20
commit
bc992a99d4
@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { EntityState, SimSnapshot } from '../contracts';
|
import type { EntityState, SimSnapshot } from '../contracts';
|
||||||
import { computeStrain, STRAIN_HORIZON_SECONDS } from './strain';
|
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<SimSnapshot['bandwidth']>, entities: Partial<EntityState>[] = []): SimSnapshot {
|
function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<EntityState>[] = []): SimSnapshot {
|
||||||
return {
|
return {
|
||||||
tick: 0,
|
tick: 0,
|
||||||
@ -14,22 +16,32 @@ function snap(bandwidth: Partial<SimSnapshot['bandwidth']>, entities: Partial<En
|
|||||||
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false, ...bandwidth },
|
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false, ...bandwidth },
|
||||||
shippedTotal: {},
|
shippedTotal: {},
|
||||||
activeCommission: null,
|
activeCommission: null,
|
||||||
|
commissionQueue: [],
|
||||||
commissionProgress: {},
|
commissionProgress: {},
|
||||||
|
research: { active: null, progress: {}, unlocked: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('the NOTHING IS WRONG era stays sterile', () => {
|
describe('the NOTHING IS WRONG era stays sterile', () => {
|
||||||
it('is inert with no snapshot at all', () => {
|
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)', () => {
|
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.
|
// 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', () => {
|
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', () => {
|
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', () => {
|
describe('fever reads the machines that actually run hot', () => {
|
||||||
it('is not diluted by a sea of cold belts', () => {
|
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.
|
// One decoder at 0.9 surrounded by 50 cold belts is a fever, not a rounding error.
|
||||||
|
|||||||
@ -10,14 +10,28 @@
|
|||||||
*/
|
*/
|
||||||
import type { SimSnapshot } from '../contracts';
|
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 {
|
export interface Strain {
|
||||||
/** 0..1 tremor/desync judder as the bandwidth reserve runs out */
|
/** 0..1 tremor/desync judder as the bandwidth reserve runs out */
|
||||||
tremor: number;
|
tremor: number;
|
||||||
/** 0..1 slow feverish shimmer from aggregate machine heat */
|
/** 0..1 slow feverish shimmer from aggregate machine heat */
|
||||||
fever: number;
|
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
|
* 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.
|
// --- tremor: seconds of reserve remaining at the current deficit.
|
||||||
// Per the v3 units ruling, `stored` is bandwidth-SECONDS while gen/draw are bandwidth per
|
// 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".
|
// 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;
|
const deficit = draw - gen;
|
||||||
let tremor = 0;
|
let tremor = 0;
|
||||||
if (deficit > 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
|
// 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.
|
// 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.
|
// --- 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.
|
// Averaging across every entity would drown a glowing decoder in a sea of cold belts.
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
@ -62,5 +82,5 @@ export function computeStrain(snap?: SimSnapshot): Strain {
|
|||||||
}
|
}
|
||||||
const fever = hot > 0 ? clamp01(sum / hot) : 0;
|
const fever = hot > 0 ? clamp01(sum / hot) : 0;
|
||||||
|
|
||||||
return { tremor, fever };
|
return { tremor, fever, pallor };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user