[ui] fix: BROWNOUT was lying about a covered deficit

The banner keyed on `out || ratio > 1`, so any time draw exceeded generation
the panel screamed BROWNOUT — even while the buffer tanks were quietly
covering it, the sim's flag was false and THE SCREEN was calm. That state is
the factory working as designed, not failing. Only the HUD was panicking.

BROWNOUT is now the sim's flag and nothing else. A covered deficit gets its
own amber, steady, non-flashing state: "ON RESERVE · 34s", where the seconds
are stored/(draw-gen) — real arithmetic, because v3 ruled stored is
bandwidth-SECONDS and gen/draw are per-second. Same maths SCREEN uses.

Four states (ok / tight / reserve / brownout) now live in a pure power.ts and
are pinned by tests, including the exact case that lied: gen 100, draw 134,
stored 1000, brownout false -> reserve, not brownout.

Also uses v4 bandwidth.capacity to turn the buffer readout into a fuel gauge
(0/900) rather than a bare number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:13:23 +10:00
parent c97dd80a9b
commit 3cec62368a
4 changed files with 169 additions and 12 deletions

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import type { SimSnapshot } from '../contracts';
import { formatReserve, powerState, TIGHT_AT } from './power';
type BW = SimSnapshot['bandwidth'];
const bw = (over: Partial<BW> = {}): BW =>
({ gen: 100, draw: 50, stored: 0, brownout: false, ...over });
describe('powerState', () => {
it('is ok with headroom', () => {
expect(powerState(bw()).state).toBe('ok');
});
it('goes tight at 85% of generation, before anything is wrong', () => {
expect(powerState(bw({ draw: 84 })).state).toBe('ok');
expect(powerState(bw({ draw: TIGHT_AT * 100 })).state).toBe('tight');
expect(powerState(bw({ draw: 99 })).state).toBe('tight');
});
// ---- the round-3 bug, pinned ------------------------------------------------
it('a covered deficit is ON RESERVE, not a brownout', () => {
// draw exceeds gen, tanks have charge, and the sim says no brownout.
// The old code keyed on `out || ratio > 1` and screamed BROWNOUT at this.
const p = powerState(bw({ gen: 100, draw: 134, stored: 1000, brownout: false }));
expect(p.state).toBe('reserve');
expect(p.state).not.toBe('brownout');
});
it('reports seconds of reserve left, because stored is bandwidth-SECONDS', () => {
// 1000 bandwidth-seconds stored, bleeding 34/sec => ~29s
const p = powerState(bw({ gen: 100, draw: 134, stored: 1000, brownout: false }));
expect(p.reserveSeconds).toBeCloseTo(1000 / 34, 5);
});
it('only the sim gets to say BROWNOUT', () => {
// Same covered-deficit numbers, but the sim has raised the flag: believe the flag.
expect(powerState(bw({ gen: 100, draw: 134, stored: 1000, brownout: true })).state)
.toBe('brownout');
// ...and a brownout at nominal draw is still a brownout.
expect(powerState(bw({ gen: 100, draw: 1, brownout: true })).state).toBe('brownout');
});
it('treats an exhausted reserve honestly rather than rounding it up', () => {
const p = powerState(bw({ gen: 10, draw: 20, stored: 0, brownout: false }));
expect(p.state).toBe('reserve');
expect(p.reserveSeconds).toBe(0); // about to become the sim's problem
});
it('handles drawing with no generation at all', () => {
const p = powerState(bw({ gen: 0, draw: 4, stored: 40, brownout: false }));
expect(p.state).toBe('reserve');
expect(p.ratio).toBe(Infinity);
expect(p.reserveSeconds).toBe(10);
});
it('is ok on an empty floor — no generation, no draw', () => {
const p = powerState(bw({ gen: 0, draw: 0 }));
expect(p.state).toBe('ok');
expect(p.ratio).toBe(0);
});
it('exactly break-even is not a deficit', () => {
expect(powerState(bw({ gen: 100, draw: 100, stored: 5 })).state).toBe('tight');
});
});
describe('formatReserve', () => {
it('floors, because a countdown that rounds up lies at the end', () => {
expect(formatReserve(29.9)).toBe('29s');
expect(formatReserve(0.4)).toBe('0s');
});
it('caps absurd numbers instead of printing a wall of digits', () => {
expect(formatReserve(100000)).toBe('999s');
expect(formatReserve(Infinity)).toBe('∞');
});
});

59
fktry/src/ui/power.ts Normal file
View File

@ -0,0 +1,59 @@
/**
* LANE-UI reading the bandwidth economy (pure; see power.test.ts).
*
* Four states, and the distinction between the middle two is the whole point:
*
* ok draw comfortably under generation
* tight draw 85% of generation nothing is wrong yet, but there's no headroom
* reserve draw EXCEEDS generation and the buffer tanks are covering the difference.
* This is the factory working as designed. Round 1-3 painted it as BROWNOUT,
* which was a lie: the sim's flag was false, THE SCREEN was calm, and only the
* HUD was screaming. Survival is not failure.
* brownout the sim says so. That flag is the only truth here the tanks are dry and
* the sector is a still life.
*
* Units (contracts v3 ruling): `gen`/`draw` are bandwidth per SECOND, `stored` is
* bandwidth-SECONDS. So `stored / (draw - gen)` is literally seconds of reserve left
* the same arithmetic LANE-SCREEN drives its dread with.
*/
import type { SimSnapshot } from '../contracts';
export type PowerState = 'ok' | 'tight' | 'reserve' | 'brownout';
/** Draw at or above this share of generation is "tight" — a warning, not a fault. */
export const TIGHT_AT = 0.85;
export interface PowerReadout {
state: PowerState;
/** draw/gen, clamped for the meter fill. Infinity when drawing with no generation. */
ratio: number;
/** Seconds of reserve left; only meaningful in the 'reserve' state. */
reserveSeconds: number | null;
}
export function powerState(bw: SimSnapshot['bandwidth']): PowerReadout {
const { gen, draw, stored, brownout } = bw;
// With no generation at all, any draw is already past budget.
const ratio = gen > 0 ? draw / gen : draw > 0 ? Infinity : 0;
// The sim owns this word. If it says brownout, it's a brownout, whatever the maths say.
if (brownout) return { state: 'brownout', ratio, reserveSeconds: 0 };
const deficit = draw - gen;
if (deficit > 0) {
// Covered deficit: the tanks are doing exactly what tanks are for.
const seconds = deficit > 0 ? stored / deficit : Infinity;
return { state: 'reserve', ratio, reserveSeconds: Math.max(0, seconds) };
}
if (ratio >= TIGHT_AT) return { state: 'tight', ratio, reserveSeconds: null };
return { state: 'ok', ratio, reserveSeconds: null };
}
/** "34s" — floored, because a countdown that rounds up lies at the end. */
export function formatReserve(seconds: number): string {
if (!Number.isFinite(seconds)) return '∞';
if (seconds >= 999) return '999s';
return `${Math.floor(seconds)}s`;
}

View File

@ -7,6 +7,7 @@
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
import { createChipRow, type ChipRow } from './chips';
import { cls, el, panel, style, text } from './dom';
import { formatReserve, powerState } from './power';
import { COPY } from './voice';
export interface TopStrip {
@ -14,9 +15,6 @@ export interface TopStrip {
update(snap: SimSnapshot): void;
}
/** Draw above this fraction of generation is "tight" — amber, before it's a fault. */
const TIGHT_AT = 0.85;
function stat(label: string, valueCls?: string): { row: HTMLElement; v: HTMLElement } {
const v = el('span', valueCls ? `fk-stat-v ${valueCls}` : 'fk-stat-v');
const row = el('div', 'fk-stat', [el('span', undefined, [label]), v]);
@ -40,6 +38,10 @@ export function createTopStrip(data: GameData): TopStrip {
brownout.id = 'fk-brownout';
brownout.textContent = COPY.brownout;
// Amber, not red: the factory is surviving on stored charge, not failing.
const reserve = el('div');
reserve.id = 'fk-reserve';
const buffer = stat('BUFFER', 'fk-buf');
const shipped = stat(COPY.shipped);
const run = stat(COPY.tick);
@ -48,24 +50,31 @@ export function createTopStrip(data: GameData): TopStrip {
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
const chips: ChipRow = createChipRow(items);
root.append(head, nums, bar, brownout, el('div', 'fk-rule'),
root.append(head, nums, bar, brownout, reserve, el('div', 'fk-rule'),
buffer.row, shipped.row, run.row, chips.el);
return {
el: root,
update(snap) {
const { gen, draw, stored, brownout: out } = snap.bandwidth;
const { gen, draw, stored, capacity } = snap.bandwidth;
text(drawEl, `${draw.toFixed(0)} DRAW`);
text(genEl, `${gen.toFixed(0)} GEN`);
// With no generators at all, any draw is already over budget.
const ratio = gen > 0 ? draw / gen : draw > 0 ? Infinity : 0;
style(fill, 'width', `${Math.min(1, ratio) * 100}%`);
cls(root, 'is-tight', !out && ratio >= TIGHT_AT);
cls(root, 'is-brownout', out || ratio > 1);
// BROWNOUT is the sim's flag and nothing else. A covered deficit is the tanks
// doing their job, and it gets its own amber word — see power.ts.
const p = powerState(snap.bandwidth);
style(fill, 'width', `${Math.min(1, p.ratio) * 100}%`);
cls(root, 'is-tight', p.state === 'tight');
cls(root, 'is-reserve', p.state === 'reserve');
cls(root, 'is-brownout', p.state === 'brownout');
text(buffer.v, stored.toFixed(0));
if (p.state === 'reserve') {
text(reserve, `${COPY.onReserve} · ${formatReserve(p.reserveSeconds!)}`);
}
// Capacity (v4) turns the buffer readout into a fuel gauge rather than a number.
text(buffer.v, capacity ? `${stored.toFixed(0)}/${capacity.toFixed(0)}` : stored.toFixed(0));
let total = 0;
for (const n of Object.values(snap.shippedTotal)) total += n;

View File

@ -81,6 +81,8 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
return 'FAX SENT. THE COLLECTOR DOES NOT SAY THANK YOU.';
case 'removed':
return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.';
case 'researched':
return 'STANDARD RATIFIED. NEW CRIMES AVAILABLE.';
default:
return null; // shipped/crafted/placed are too frequent to toast
}
@ -89,6 +91,8 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
export const COPY = {
bandwidth: 'BANDWIDTH',
brownout: 'BROWNOUT',
/** Deficit covered by the tanks. Not a fault — the machine is doing its job. */
onReserve: 'ON RESERVE',
shipped: 'SHIPPED',
paused: 'HALTED',
running: 'RUNNING',
@ -114,8 +118,16 @@ export const COPY = {
/** Shown against shipments The Correction quietly buys back (M3 fiction). */
sanitising: 'SANITISING',
techHeader: 'STANDARDS COMMITTEE',
techSubheader: 'MEDIA ARCHAEOLOGY — RESEARCH DOWN, DERIVE UP',
techActive: 'IN COMMITTEE',
techDone: 'RATIFIED',
/** Shown when the sim reports no research state at all. */
researchOffline: 'RESEARCH OFFLINE. THE COMMITTEE HAS NOT CONVENED.',
requires: 'REQUIRES:',
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · SPACE HALT · CLICK A UNIT TO INSPECT',
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · T RESEARCH · SPACE HALT · CLICK A UNIT',
removeLabel: 'REMOVE',
removeKey: 'X',