[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 {
|
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()!);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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