[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:
parent
c97dd80a9b
commit
3cec62368a
77
fktry/src/ui/power.test.ts
Normal file
77
fktry/src/ui/power.test.ts
Normal 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
59
fktry/src/ui/power.ts
Normal 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`;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user