[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:
parent
acb1d88038
commit
e24100c26e
@ -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()!);
|
||||
|
||||
@ -58,6 +58,7 @@ const JAM_TEXT: Record<string, string> = {
|
||||
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;
|
||||
|
||||
124
fktry/src/ui/wildlife.test.ts
Normal file
124
fktry/src/ui/wildlife.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user