[ui] wildlife, the no-seam jam, and the opening's one mercy hint

Voice: dust piles read as nurseries rather than mess ("SOMETHING WILL HATCH IN
IT"), swarms announce and disperse differently, and SIM's new 'no seam' jam
finally gets the line it deserves — "THERE IS NOTHING DOWN THERE. MOVE."

The inspector grows a MOSQUITO EXPOSURE row, read from snap.wildlife by target,
so a machine that is mysteriously slow says why where the player is already
looking. It shows the swarm's severity, not a throughput promise — SIM owns the
real penalty and doesn't expose a number.

Toasts gain pushMessage for lines that aren't raw sim events. First among them:
once per session, on the very first brownout, "NOTHING IS GENERATING. THE
COMMITTEE SUGGESTS: POWER." The alarm alone taught nothing; this is the one
hint the opening needs, and it is emphatically not a nag — hatch warnings are
deduped per pile for the same reason.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 14:11:59 +10:00
parent acb1d88038
commit e24100c26e
3 changed files with 196 additions and 8 deletions

View File

@ -11,6 +11,10 @@ import { eventToast } from './voice';
export interface Toasts { export interface Toasts {
el: HTMLElement; el: HTMLElement;
push(ev: SimEvent, machineName: (entityId: number) => string): void; 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. */ /** Called once per frame to expire old lines. */
update(nowMs: number): void; update(nowMs: number): void;
} }
@ -30,17 +34,22 @@ export function createToasts(): Toasts {
setTimeout(() => entry.node.remove(), FADE_MS); 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 { return {
el: root, el: root,
push(ev, machineName) { push(ev, machineName) {
const msg = eventToast(ev, machineName); const msg = eventToast(ev, machineName);
if (!msg) return; if (msg) emit(msg, ev.kind);
},
const node = el('div', `fk-toast k-${ev.kind}`, [msg]); pushMessage: emit,
root.append(node); setStandoff(on) {
live.push({ node, dieAt: performance.now() + TTL_MS }); cls(root, 'is-standoff', on);
while (live.length > MAX) retire(live.shift()!);
}, },
update(nowMs) { update(nowMs) {
while (live.length && live[0].dieAt <= nowMs) retire(live.shift()!); while (live.length && live[0].dieAt <= nowMs) retire(live.shift()!);

View File

@ -58,6 +58,7 @@ const JAM_TEXT: Record<string, string> = {
starved: 'INTAKE STARVED. NOTHING IS ARRIVING.', starved: 'INTAKE STARVED. NOTHING IS ARRIVING.',
'no recipe': 'NO PROCESS ASSIGNED. UNIT AWAITS INSTRUCTION.', 'no recipe': 'NO PROCESS ASSIGNED. UNIT AWAITS INSTRUCTION.',
'no power': 'INSUFFICIENT BANDWIDTH. THE MATHEMATICS ARE UNMOVED.', 'no power': 'INSUFFICIENT BANDWIDTH. THE MATHEMATICS ARE UNMOVED.',
'no seam': 'THERE IS NOTHING DOWN THERE. MOVE.',
}; };
export function jamText(reason: string): string { 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.'; return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.';
case 'researched': case 'researched':
return 'STANDARD RATIFIED. NEW CRIMES AVAILABLE.'; return 'STANDARD RATIFIED. NEW CRIMES AVAILABLE.';
case 'wildlife':
return wildlifeToast(ev.wildlife, ev.on);
case 'relicFound':
return COPY.relicDiscovery;
default: default:
return null; // shipped/crafted/placed are too frequent to toast 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 = { export const COPY = {
bandwidth: 'BANDWIDTH', bandwidth: 'BANDWIDTH',
brownout: 'BROWNOUT', brownout: 'BROWNOUT',
@ -127,12 +151,43 @@ export const COPY = {
requires: 'REQUIRES:', requires: 'REQUIRES:',
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL', 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', removeLabel: 'REMOVE',
removeKey: 'X', removeKey: 'X',
removeHint: 'REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN', 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; } 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. */ /** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */
export const DIR_NAME = ['N', 'E', 'S', 'W'] as const; export const DIR_NAME = ['N', 'E', 'S', 'W'] as const;

View File

@ -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);
});
});