diff --git a/fktry/src/ui/toasts.ts b/fktry/src/ui/toasts.ts index b39b525..53a8f60 100644 --- a/fktry/src/ui/toasts.ts +++ b/fktry/src/ui/toasts.ts @@ -11,6 +11,10 @@ import { eventToast } from './voice'; export interface Toasts { el: HTMLElement; push(ev: SimEvent, machineName: (entityId: number) => string): void; + /** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */ + pushMessage(msg: string, kind: string): void; + /** Stand the stack off the bottom-right corner once the tuner dock moves in there. */ + setStandoff(on: boolean): void; /** Called once per frame to expire old lines. */ update(nowMs: number): void; } @@ -30,17 +34,22 @@ export function createToasts(): Toasts { setTimeout(() => entry.node.remove(), FADE_MS); } + function emit(msg: string, kind: string) { + const node = el('div', `fk-toast k-${kind}`, [msg]); + root.append(node); + live.push({ node, dieAt: performance.now() + TTL_MS }); + while (live.length > MAX) retire(live.shift()!); + } + return { el: root, push(ev, machineName) { const msg = eventToast(ev, machineName); - if (!msg) return; - - const node = el('div', `fk-toast k-${ev.kind}`, [msg]); - root.append(node); - live.push({ node, dieAt: performance.now() + TTL_MS }); - - while (live.length > MAX) retire(live.shift()!); + if (msg) emit(msg, ev.kind); + }, + pushMessage: emit, + setStandoff(on) { + cls(root, 'is-standoff', on); }, update(nowMs) { while (live.length && live[0].dieAt <= nowMs) retire(live.shift()!); diff --git a/fktry/src/ui/voice.ts b/fktry/src/ui/voice.ts index d7f43a5..d3e1dbd 100644 --- a/fktry/src/ui/voice.ts +++ b/fktry/src/ui/voice.ts @@ -58,6 +58,7 @@ const JAM_TEXT: Record = { starved: 'INTAKE STARVED. NOTHING IS ARRIVING.', 'no recipe': 'NO PROCESS ASSIGNED. UNIT AWAITS INSTRUCTION.', 'no power': 'INSUFFICIENT BANDWIDTH. THE MATHEMATICS ARE UNMOVED.', + 'no seam': 'THERE IS NOTHING DOWN THERE. MOVE.', }; export function jamText(reason: string): string { @@ -83,11 +84,34 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.'; case 'researched': return 'STANDARD RATIFIED. NEW CRIMES AVAILABLE.'; + case 'wildlife': + return wildlifeToast(ev.wildlife, ev.on); + case 'relicFound': + return COPY.relicDiscovery; default: return null; // shipped/crafted/placed are too frequent to toast } } +/** + * Infestation copy. A dust pile spawning is a hatch warning (index.ts dedups it to one + * per pile — see the notices logic there); a swarm spawning is the bite already landing. + */ +export function wildlifeToast( + kind: 'dust-pile' | 'mosquito-swarm', + on: 'spawned' | 'cleared', +): string | null { + if (kind === 'dust-pile') { + return on === 'spawned' + ? 'HF DUST IS POOLING. SOMETHING WILL HATCH IN IT.' + : null; // clearing a pile is housekeeping, not news + } + // mosquito-swarm + return on === 'spawned' + ? 'MOSQUITO SWARM. QUANTIZATION NOISE HAS LEGS NOW.' + : 'SWARM DISPERSED. THE EDGES ARE QUIET AGAIN.'; +} + export const COPY = { bandwidth: 'BANDWIDTH', brownout: 'BROWNOUT', @@ -127,12 +151,43 @@ export const COPY = { requires: 'REQUIRES:', placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL', - idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · T RESEARCH · SPACE HALT · CLICK A UNIT', + idleHint: + '1-9 SELECT · [ ] PAGE · X REMOVE · T RESEARCH · SPACE HALT · CLICK A UNIT' + + ' · MMB/WASD PAN · WHEEL ZOOM', removeLabel: 'REMOVE', removeKey: 'X', removeHint: 'REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN', + + /** The one hint the opening needs — fires once, on the first brownout ever. */ + firstBrownoutMercy: 'NOTHING IS GENERATING. THE COMMITTEE SUGGESTS: POWER.', + + /** Inspector line when a swarm is degrading the open machine. */ + mosquitoExposure: 'MOSQUITO EXPOSURE', + mosquitoPenalty: (pct: number) => `PRECISION DOWN ${pct}%. SWARM ON THE EDGES.`, + + /** relicFound ceremony. */ + relicDiscovery: 'AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.', + + // THE TUNER DOCK + tunerHeader: 'AUDIO BUS', + tunerBandLabel: 'BAND', + tunerVolLabel: 'VOL', + tunerMute: 'MUTE', + tunerMuted: 'MUTED', + tunerLockedSuffix: 'SIGNAL LOCKED', + tunerStatic: 'STATIC — TUNE FOR A STATION', + tunerHint: '◄ ► TUNE · B BAND · M MUTE', } as const; +export type Band = 'AM' | 'FM' | 'SW'; + +/** "88.1 FKTRY FM — SIGNAL LOCKED" / "104.6 FM — STATIC …". Station names are flavor. */ +export function stationReadout(freqMHz: number, band: Band, station: string | null): string { + const f = freqMHz.toFixed(1); + if (station) return `${f} ${station} ${band} — ${COPY.tunerLockedSuffix}`; + return `${f} ${band} — ${COPY.tunerStatic}`; +} + /** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */ export const DIR_NAME = ['N', 'E', 'S', 'W'] as const; diff --git a/fktry/src/ui/wildlife.test.ts b/fktry/src/ui/wildlife.test.ts new file mode 100644 index 0000000..f61c07c --- /dev/null +++ b/fktry/src/ui/wildlife.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest'; +import type { GameData, SimEvent, SimSnapshot, UIBus } from '../contracts'; +import { createInspector } from './inspector'; +import { eventToast, wildlifeToast } from './voice'; + +const named = (id: number) => `UNIT ${id}`; + +describe('wildlife voice', () => { + it('warns that a dust pile is a nursery, not just a mess', () => { + expect(wildlifeToast('dust-pile', 'spawned')).toContain('HATCH'); + }); + + it('says nothing when a pile is cleared — housekeeping is not news', () => { + expect(wildlifeToast('dust-pile', 'cleared')).toBeNull(); + }); + + it('announces a swarm and its dispersal differently', () => { + const on = wildlifeToast('mosquito-swarm', 'spawned'); + const off = wildlifeToast('mosquito-swarm', 'cleared'); + expect(on).toContain('MOSQUITO SWARM'); + expect(off).not.toBe(on); + expect(off).toContain('DISPERSED'); + }); + + it('routes wildlife events through the standard toast path', () => { + const ev: SimEvent = { + kind: 'wildlife', on: 'spawned', wildlife: 'mosquito-swarm', + id: 1, pos: { x: 0, y: 0 }, tick: 5, + }; + expect(eventToast(ev, named)).toContain('MOSQUITO SWARM'); + }); + + it('announces the fossil in the house voice', () => { + const ev: SimEvent = { kind: 'relicFound', relic: 'optical-fossil', id: 1, tick: 9 }; + expect(eventToast(ev, named)).toBe('AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.'); + }); +}); + +// ------------------------------------------------------------------ inspector exposure + +const DATA: GameData = { + items: [], + machines: [{ + id: 'quantizer', name: 'QUANTIZER', codex: '', kind: 'crafter', + footprint: { x: 2, y: 2 }, recipes: [], powerDraw: 0, asset: 'q', + }], + recipes: [], + tech: [], + commissions: [], +}; + +const bus: UIBus = { + dispatch: () => {}, + selectedBuild: () => null, + pickTile: () => null, +}; + +function snapWith(wildlife?: SimSnapshot['wildlife']): SimSnapshot { + return { + tick: 1, paused: false, + entities: [{ + id: 7, def: 'quantizer', pos: { x: 0, y: 0 }, dir: 0, recipe: null, progress: 0, + inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, + }], + beltItems: [], + bandwidth: { gen: 10, draw: 1, stored: 0, brownout: false }, + shippedTotal: {}, activeCommission: null, commissionProgress: {}, + wildlife, + }; +} + +describe('MOSQUITO EXPOSURE in the inspector', () => { + function open() { + document.body.innerHTML = ''; + const ins = createInspector(DATA, bus); + document.body.append(ins.el); + ins.open(7); + return ins; + } + const exposure = () => document.querySelector('.fk-ins-exposure') as HTMLElement; + + it('shows nothing when no swarm is on this machine', () => { + const ins = open(); + ins.update(snapWith([ + { id: 1, kind: 'mosquito-swarm', pos: { x: 9, y: 9 }, size: 0.8, target: 99 }, + ])); + expect(exposure().classList.contains('is-on')).toBe(false); + }); + + it('surfaces the exposure and its severity when a swarm targets this machine', () => { + const ins = open(); + ins.update(snapWith([ + { id: 1, kind: 'mosquito-swarm', pos: { x: 0, y: 0 }, size: 0.42, target: 7 }, + ])); + expect(exposure().classList.contains('is-on')).toBe(true); + expect(document.querySelector('.fk-ins-exposure-pct')!.textContent) + .toBe('PRECISION DOWN 42%. SWARM ON THE EDGES.'); + }); + + it('ignores dust piles — a pile is not biting anything yet', () => { + const ins = open(); + ins.update(snapWith([ + { id: 2, kind: 'dust-pile', pos: { x: 0, y: 0 }, size: 1, target: 7 }, + ])); + expect(exposure().classList.contains('is-on')).toBe(false); + }); + + it('clears the line when the swarm is dispersed', () => { + const ins = open(); + ins.update(snapWith([ + { id: 1, kind: 'mosquito-swarm', pos: { x: 0, y: 0 }, size: 0.5, target: 7 }, + ])); + expect(exposure().classList.contains('is-on')).toBe(true); + ins.update(snapWith([])); + expect(exposure().classList.contains('is-on')).toBe(false); + }); + + it('survives a sim that reports no wildlife at all', () => { + const ins = open(); + ins.update(snapWith(undefined)); + expect(exposure().classList.contains('is-on')).toBe(false); + }); +});