diff --git a/fktry/src/ui/power.test.ts b/fktry/src/ui/power.test.ts new file mode 100644 index 0000000..b53ecd6 --- /dev/null +++ b/fktry/src/ui/power.test.ts @@ -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 => + ({ 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('∞'); + }); +}); diff --git a/fktry/src/ui/power.ts b/fktry/src/ui/power.ts new file mode 100644 index 0000000..e031490 --- /dev/null +++ b/fktry/src/ui/power.ts @@ -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`; +} diff --git a/fktry/src/ui/topstrip.ts b/fktry/src/ui/topstrip.ts index e72ee86..768cf4d 100644 --- a/fktry/src/ui/topstrip.ts +++ b/fktry/src/ui/topstrip.ts @@ -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(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; diff --git a/fktry/src/ui/voice.ts b/fktry/src/ui/voice.ts index acc730e..d7f43a5 100644 --- a/fktry/src/ui/voice.ts +++ b/fktry/src/ui/voice.ts @@ -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',