Compare commits

...

8 Commits

Author SHA1 Message Date
type-two
0cc80d3c82 [ui] round 5 notes
All nine orders shipped. The tab-click report was a real bug (scrollbar eating
half of every tab hitbox) and is fixed; RENDER's demolition tint is verified
with a screenshot, closing the item owed since round 3.

The honest parts: DATA's commission queue head-of-line blocks the whole game,
RENDER doesn't read the inspect selection yet, and the radio drives a stand-in
until SCREEN publishes its control. One proposal (audio on the bus), no
contract requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:42 +10:00
type-two
4da8096de9 [ui] live: research via testkit, and the round-5 behaviours against the real sim
Research is granted through SIM's own testkit.grantResearch now, not by
hand-editing a save blob here — that was my round-4 fragility and it broke this
round exactly as predicted. The research loop also runs the lab for real: v5
converts one pack per RESEARCH_TICKS_PER_PACK, so the test drives 45 packs
instead of asserting after 3 ticks.

New coverage against the real sim: the mercy toast fires once and stays once
across a second brownout; the inspect selection is published on open and
dropped both on Esc and when the inspected unit is demolished; the tuner stays
hidden until relics[0].found actually flips, then reveals with the ceremony
toast and the toast standoff.

The commission tests now build the rig a player would (extractor -> belt ->
uplink) because DATA's queue head asks for raw ore the reference factory never
ships — see NOTES on head-of-line blocking. Also stopped hardcoding the i-only
assembler's coordinates; reference.ts moved it again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:42 +10:00
type-two
0bf0e5a076 [ui] wire round 5: tuner reveal, screen enlarge, notices, camera hint
The HUD root gains the tuner and the enlarge overlay, the T/arrows/B/M keys,
and the small amount of session state the onboarding needs: the mercy toast
fires once ever, hatch warnings are remembered per pile.

The fossil reveals the dock from either direction — the relicFound event, or a
loaded save where relics[].found is already true and no event is coming. The
dock lands in the toast stack's corner, so the toasts stand off above it rather
than printing over the dial.

Esc now closes the most-recently-fronted thing first: enlarge, then the held
tool, then panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:42 +10:00
type-two
d6d6833b78 [ui] THE TUNER DOCK — the radio gets a face
Hidden until the optical fossil is excavated, then it slides in bottom-right:
frequency dial (drag or arrow keys), AM/FM/SW, volume and mute, and a station
readout in the house voice that reads STATIC until something locks. Its keys
are inert while hidden so they never shadow the pre-discovery game.

src/audio is still a stub with no radio export, so radio.ts resolves the
control at runtime and otherwise drives a silent echo stand-in — which is why
every dial is functional and testable today rather than waiting.

LANE-SCREEN: publish window.__fktrySpeaker = { radio } and this drives real
audio with no change here. The interface is exactly the one you documented —
setFrequency / setBand / volume / mute / getState / subscribe — with getState()
returning {freqMHz, band, volume, muted, station}; a non-null station is what
flips the readout to SIGNAL LOCKED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:18 +10:00
type-two
5d6afdac97 [ui] THE SCREEN click-to-enlarge
Click the sky, get the sky: ~2.5x, centred, over a dimmed backdrop. Esc or a
click closes it.

Enlarges the real element rather than mirroring it. I built the mirror first
and it rendered black — THE SCREEN is a WebGL canvas, and drawImage from a
WebGL context without preserveDrawingBuffer returns nothing. Asking LANE-SCREEN
to enable that flag would cost them a buffer copy every frame for a feature
used occasionally.

Since that means touching another lane's element, it snapshots the style
attribute on open and restores it verbatim on close — including the case where
SCREEN later adds inline styles of their own, which is tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:12:18 +10:00
type-two
e24100c26e [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>
2026-07-18 14:11:59 +10:00
type-two
acb1d88038 [ui] publish inspect mode so the renderer can highlight the open unit
Opening the inspector now publishes {mode:'inspect', entity} through
setSelection, and closing it — by X, by Esc, or because the unit was demolished
under the panel — clears it. Picking a machine or arming remove supersedes the
inspect selection and shuts the panel, so the panel and the highlight can never
disagree about what is being looked at.

The three modes are now genuinely exclusive in selection.ts rather than two
booleans and an implied third state. Inspect deliberately does not count as
"armed": you can click straight from one unit to another without putting
anything down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:11:59 +10:00
type-two
7e24eab171 [ui] fix: the tab scrollbar was swallowing clicks on half of every tab
Reproduced the "clicking POWER does nothing" report with a real mouse:
elementFromPoint at the button's own centre returned the strip container, not
the button. The horizontal scrollbar was painted over the lower half of every
tab and ate clicks there — which is exactly why keyboard paging still worked
and clicking didn't.

Fixed by giving the scrollbar its own lane (padding-bottom >= its height). The
full button height now hit-tests to the button and a real click activates the
tab.

The strip itself is the approved round-5 design: one row that scrolls rather
than wrapping. The catalog keeps growing — RESEARCH joined in v4 and threw
REMOVE onto a line of its own — and a wrapping row moves every other tab under
the player's cursor. Paging with [ ] scrolls the active tab into view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:11:39 +10:00
16 changed files with 1356 additions and 73 deletions

View File

@ -471,3 +471,120 @@ breaks for no good reason.
**NEXT (suggested):** verify RENDER's tint; research rig in reference.ts so a pack economy
exists to test against; scrolling tab strip if the catalog grows again; era-skinned tech
panel once eras mean something visually (§8).
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
**SHIPPED:** All nine orders. New modules: `power.ts` was round 4; this round adds
`radio.ts` (SPEAKER bridge + echo stand-in), `tuner.ts` (the dock), `screenview.ts`
(enlarge), and tests `tuner.test.ts`, `wildlife.test.ts`, `screenview.test.ts`.
**194 UI tests** (was 145); full suite 446/446; `npm run check` clean.
1. **Scrolling tab strip** — one row, never wraps, active tab scrolled into view on
`[`/`]`. Found and fixed a real bug doing it: see the hitbox finding below.
2. **Inspect mode published**`setSelection({mode:'inspect',entity})` on open, cleared
on close (X, Esc, or the unit being demolished under it). Holding a tool supersedes it
and shuts the panel, so panel and highlight can't disagree.
3. **Wildlife surfaced** — infestation toasts in voice; MOSQUITO EXPOSURE + severity in
the inspector, read from `snap.wildlife` by `target`; hatch warnings deduped one per
pile id.
4. **Research tests migrated** to SIM's `testkit.grantResearch`/`grantAllResearch` — my
round-4 hand-rolled save-blob edit is gone, and with it the coupling to SIM's schema.
Tint + highlight verified, see below.
5. **First-brownout mercy toast** — once per session, on the first brownout event.
6. **Camera hint** in the idle line; **tab hitbox adjudicated** (below).
7. **`no seam` jam voice** — "THERE IS NOTHING DOWN THERE. MOVE." SIM emits this reason
at index.ts:498, so it's live copy, not speculative.
8. **THE SCREEN click-to-enlarge** — ~2.5×, dimmed backdrop, Esc/click closes.
9. **THE TUNER DOCK** — hidden until the fossil is excavated, then slides in with dial
(drag + ←/→), AM/FM/SW, volume + mute, and a station readout in voice.
**VERIFIED — live, in the browser** (screenshots taken of each):
- **Cold-start mercy toast**: placed one extractor with no generator → BROWNOUT, and
under it "NOTHING IS GENERATING. THE COMMITTEE SUGGESTS: POWER." That's the DoD's
opening requirement.
- **THE SCREEN enlarge**: the live WebGL sky at 2.5× centred over a dimmed HUD; Esc
restored its `style` attribute to `null` and its dock rect to y=10, w=322 — exactly as
found.
- **THE TUNER DOCK**: excavated SIM's real seeded fossil → dock slid in bottom-right with
"AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.", dial, FM active, VOL/MUTE, hint line.
- **RENDER's demolition tint — the item owed since round 3 is CLOSED.** Armed remove,
hovered a machine, and it renders outlined in demolition red. Screenshot taken.
- Build bar with padlocks on the POWER page (DECODE ASIC free; SOFTWARE DECODER and ASIC
COOLER greyed + 🔒 + REQUIRES:), reference factory healthy at GEN 220 / DRAW 158.
**VERIFIED — headless** (`live.test.ts`, real sim + real DOM, 19 tests): mercy toast fires
once and *stays* once across a second brownout; inspect selection published on open and
dropped both on Esc and when the inspected unit is demolished; the tuner stays hidden
until `relics[0].found` flips and then reveals with the ceremony toast and the toast
standoff; full research loop (lock → pick → deliver → unlock → place) now driven through
`grantResearch` and the lab's real per-pack timing.
**DECISIONS:**
- **THE SCREEN is enlarged in place, not mirrored.** I built the mirror first; it rendered
black. THE SCREEN is a WebGL canvas, and `drawImage` from a WebGL context without
`preserveDrawingBuffer` returns nothing. Asking SCREEN to enable that flag would cost
them a buffer copy every frame for an occasional feature, so I enlarge the real element
with inline styles and restore its `style` attribute verbatim on close (tested,
including the case where SCREEN later adds inline styles of their own).
- **The radio bridge is guarded** (`radio.ts`). `src/audio` is still the stub — no `radio`
export exists — so the dock resolves a control at runtime and otherwise drives a silent
echo stand-in, which is why every dial is testable today. **LANE-SCREEN: publish
`window.__fktrySpeaker = { radio }` (or tell me the export name you prefer) and the dock
drives real audio with zero change here.** The interface I'm driving is exactly your
documented one: `setFrequency / setBand / volume / mute / getState / subscribe`, with
`getState()` returning `{freqMHz, band, volume, muted, station}``station: string|null`
is what flips the readout to "SIGNAL LOCKED". `isLive()` reports which one is attached.
- Wildlife exposure shows the swarm's **severity** (`WildlifeState.size`), not a
sim-confirmed throughput number — SIM owns the real penalty and doesn't expose one.
Labelled as exposure rather than a promise about output.
**BLOCKED/BROKEN (read this part):**
- **The tab-click bug was REAL, and I fixed it.** Reproduced with a genuine mouse click:
a click on POWER did nothing. `elementFromPoint` at the button's own centre returned the
*strip container*, not the button — the horizontal scrollbar was being painted over the
lower half of every tab and swallowing clicks there, which is exactly why keyboard
paging still worked. Fixed by giving the scrollbar its own lane (`padding-bottom: 7px`
≥ scrollbar height); the full button height now hit-tests to the button, and a real
click activates POWER. **Separately**, `computer` clicks via `ref` deliver *no* events to
the page at all (document-level capture recorded nothing); coordinate clicks work fine.
So: real bug, now fixed, plus a harness quirk on top of it.
- **DATA's commission queue head-of-line blocks, and it stalls the whole game.** The v5
queue opens with `raw-payload` (20 raw MDAT ORE), but the reference factory refines every
scrap of its ore and never ships it — so the head commission can never complete and all
19 behind it are unreachable. Probed: 12k ticks ships hf-dust, chroma-slurry,
macroblock-bricks, melt and anchor-slab, and `commissionDone` fires **zero** times. My
tests now build the obvious player rig (extractor → belt → uplink) to pay it. Worth a
design look: either the opening commission should want something the tutorial factory
ships, or an unsatisfiable head shouldn't block the tray.
- **Machines only push onto belts, never into an adjacent machine.** Cost me a while to
find; noting it because it's non-obvious and unstated anywhere in CONTRACTS.
- **RENDER doesn't consume the inspect selection yet** — no `inspect` anywhere in
`src/render`. I publish it correctly (verified live: `{mode:'inspect',entity:105}`), so
the highlight lights up whenever they read it. **Not owed by me any more; owed to me.**
- **Stale ghost on keyboard disarm**: main.ts only refreshes the ghost on `pointermove`, so
Esc-ing out of remove mode leaves the demolition tint on screen until the mouse moves.
Visible in my inspect screenshot. Cosmetic, and it's main.ts's glue, not mine.
- **SIM landed wildlife AND relics mid-round, again** — my round-start grep found neither.
Fifth round running. It's why my wildlife/relic UI could be verified against the real sim
at all, so this is a good problem, but the pattern is now completely reliable: I re-probe
a *running* sim before writing NOTES, never a grep from round-start.
- Three of my live tests failed mid-round from SIM/DATA changes, not my code (proved by
stashing my diff and re-running). Also hit `DUST_SPILL_GRACE is not defined` from SIM
mid-edit, which resolved itself between two consecutive commands.
- Harness, for the record: the browser pane backgrounds the tab, which pauses rAF **and
CSS transitions**. I lost time reading a transitioned `bottom` that never advanced and
briefly thought the CSS was broken; `transition:none` proved it computes correctly. Same
root cause as the paused sim loop from rounds 3-4.
**CONTRACT REQUEST:** none. v5/v6 gave me everything these orders needed.
**PROPOSAL:** `UIBus.audio?: { radio?: RadioControl }` — the tuner currently reaches THE
SPEAKER through a global handshake because `ui.init` never sees the audio module, which is
the same shape of problem as `pickTile` in round 1 and the `__remove` sentinel in round 3.
One optional field on the bus would make it typed and testable instead of a documented
convention. Not blocking: the guarded bridge works.
**NEXT (suggested):** wire the real radio the moment SCREEN publishes it (and describe how
it sounds in NOTES, since they're doing the same); inspect highlight once RENDER reads the
selection; a station-locked readout screenshot once there are real stations; commission
head-of-line blocking is a design question for you, not a UI fix.

View File

@ -115,6 +115,9 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
function render() {
buildButtons(pages[active]);
for (let i = 0; i < tabs.length; i++) cls(tabs[i], 'is-active', i === active);
// The strip scrolls, so paging with [ ] must bring its tab into view or the player
// pages blind. `nearest` avoids yanking the strip when the tab is already visible.
tabs[active]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
sync();
}

View File

@ -13,11 +13,14 @@ import { createFax } from './fax';
import { createHotkeys } from './hotkeys';
import { createInspector } from './inspector';
import { createRoster, entityAt, syncRoster } from './pick';
import { createScreenView } from './screenview';
import { createSelection } from './selection';
import { injectStyle } from './style';
import { createTechPanel } from './techpanel';
import { createTopStrip } from './topstrip';
import { createToasts } from './toasts';
import { createTuner } from './tuner';
import { COPY } from './voice';
export function createUI(): UI {
let machines = new Map<string, MachineDef>();
@ -27,11 +30,25 @@ export function createUI(): UI {
let fax: ReturnType<typeof createFax>;
let toasts: ReturnType<typeof createToasts>;
let tech: ReturnType<typeof createTechPanel>;
let tuner: ReturnType<typeof createTuner>;
let screenView: ReturnType<typeof createScreenView>;
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
const roster = createRoster();
let paused = false;
// ---- onboarding + ceremony state (per session; none of it is sim truth) ----
/** The opening's one hint. Fires on the first brownout ever, then never again. */
let mercyShown = false;
/** Dust piles we've already warned about, so a pile isn't a recurring nag. */
const warnedPiles = new Set<number>();
function revealTuner() {
tuner.reveal();
// The dock lands in the toasts' corner; move them up so it can't print over the dial.
toasts.setStandoff(true);
}
function machineName(entityId: number): string {
for (let i = 0; i < roster.length; i++) {
const r = roster.entries[i];
@ -48,12 +65,25 @@ export function createUI(): UI {
const sel = createSelection(bus);
top = createTopStrip(data);
build = createBuildBar(data, sel);
inspector = createInspector(data, bus);
// v5: closing the inspector — by X, by Esc, or because its unit was demolished —
// must stop the renderer highlighting it.
inspector = createInspector(data, bus, () => sel.clearInspect());
fax = createFax(data);
toasts = createToasts();
tech = createTechPanel(data, bus);
tuner = createTuner();
screenView = createScreenView(document.getElementById('screen'));
root.append(top.el, build.el, inspector.el, fax.el, tech.el, toasts.el);
// A held tool and an open inspector are mutually exclusive: picking a machine or
// arming remove supersedes the inspect selection, so shut the panel to match.
sel.onChange(() => {
if (sel.isArmed() && inspector.isOpen()) inspector.close();
});
root.append(
top.el, build.el, inspector.el, fax.el, tech.el,
tuner.el, screenView.el, toasts.el,
);
// ------------------------------------------------------------------ keyboard
const keys = createHotkeys();
@ -63,10 +93,14 @@ export function createUI(): UI {
keys.bind('r', () => sel.rotate());
keys.bind('x', () => sel.toggleRemove());
keys.bind('t', () => tech.toggle());
// Tuner keys are inert until the dock is revealed, so they don't shadow anything
// during the pre-discovery game.
for (const k of ['arrowleft', 'arrowright', 'b', 'm']) keys.bind(k, () => tuner.onKey(k));
keys.bind('escape', () => {
// One key, several jobs, in the order the player expects: put the tool down
// first (wrecking ball included), then shut whatever is open.
if (sel.isArmed()) sel.clear();
// One key, several jobs, in the order the player expects: close the thing most
// recently in front first, then put the tool down, then shut panels.
if (screenView.isOpen()) screenView.close();
else if (sel.isArmed()) sel.clear();
else if (tech.isOpen()) tech.close();
else inspector.close();
});
@ -97,8 +131,12 @@ export function createUI(): UI {
if (!tile) return;
const hit = entityAt(roster, machines, tile);
if (hit === null) inspector.close();
else inspector.open(hit);
if (hit === null) {
inspector.close();
} else {
inspector.open(hit);
sel.inspect(hit); // v5: renderer highlights the unit we're showing
}
});
// ?uidemo — build SIM's reference factory on boot. main.ts defines __fktryDemo
@ -122,9 +160,30 @@ export function createUI(): UI {
fax.update(snap);
tech.update(snap);
// The fossil may already be dug up in a loaded save, with no event to announce it.
if (!tuner.isRevealed() && snap.relics?.some((r) => r.found)) revealTuner();
for (const ev of events) {
toasts.push(ev, machineName);
if (ev.kind === 'commissionDone') fax.stamp();
if (ev.kind === 'relicFound') revealTuner(); // ceremony toast comes from voice.ts
// One hatch warning per pile. `spawned` is edge-triggered so this is usually
// already true; the guard keeps a re-emitting sim from turning it into a nag.
if (ev.kind === 'wildlife' && ev.wildlife === 'dust-pile') {
if (ev.on === 'cleared') warnedPiles.delete(ev.id);
else if (warnedPiles.has(ev.id)) continue;
else warnedPiles.add(ev.id);
}
toasts.push(ev, machineName);
// The alarm alone teaches nothing. Once per session, on the very first brownout,
// say what to do about it.
if (ev.kind === 'brownout' && ev.on && !mercyShown) {
mercyShown = true;
toasts.pushMessage(COPY.firstBrownoutMercy, 'mercy');
}
}
toasts.update(performance.now());
},

View File

@ -27,7 +27,7 @@ export interface Inspector {
/** Heat above this throttles (CONTRACTS: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7;
export function createInspector(data: GameData, bus: UIBus): Inspector {
export function createInspector(data: GameData, bus: UIBus, onClose?: () => void): Inspector {
const machines = new Map<string, MachineDef>(data.machines.map((m) => [m.id, m]));
const recipes = new Map<string, RecipeDef>(data.recipes.map((r) => [r.id, r]));
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
@ -72,6 +72,12 @@ export function createInspector(data: GameData, bus: UIBus): Inspector {
const jam = el('div', 'fk-ins-jam');
// MOSQUITO EXPOSURE (v5): a swarm degrading this machine, surfaced where the player
// is already looking to understand why it's slow.
const exposure = el('div', 'fk-ins-exposure');
const exposureLine = el('div', 'fk-ins-exposure-pct');
exposure.append(el('div', 'fk-h', [COPY.mosquitoExposure]), exposureLine);
const body = el('div', undefined, [
recHead, recSelect, noRecipe,
inHead, inChips.el, outHead, outChips.el,
@ -79,14 +85,16 @@ export function createInspector(data: GameData, bus: UIBus): Inspector {
heatHead, heatBar, heatState,
]);
root.append(head, flavor, el('div', 'fk-rule'), body, jam);
root.append(head, flavor, el('div', 'fk-rule'), body, exposure, jam);
let openId: number | null = null;
function close() {
if (openId === null) return; // already shut — don't re-notify selection
openId = null;
optionsFor = null;
cls(root, 'is-open', false);
onClose?.();
}
closeBtn.addEventListener('click', close);
@ -190,6 +198,16 @@ export function createInspector(data: GameData, bus: UIBus): Inspector {
const e = snap.entities.find((x) => x.id === openId);
if (!e) return close(); // unit was removed out from under us
paint(e);
// A swarm degrading THIS machine, if any. size is severity 0..1; we render it as
// the precision hit. TEMPORARY: the sim owns the true throughput penalty and
// doesn't expose a number, so this is the swarm's severity, not a confirmed
// slowdown — see NOTES. Absent snap.wildlife (sim hasn't shipped it), no row.
const swarm = snap.wildlife?.find(
(w) => w.kind === 'mosquito-swarm' && w.target === openId,
);
cls(exposure, 'is-on', !!swarm);
if (swarm) text(exposureLine, COPY.mosquitoPenalty(Math.round(swarm.size * 100)));
},
};
}

View File

@ -17,7 +17,9 @@
import { describe, expect, it } from 'vitest';
import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
import { createSim } from '../sim';
import { RESEARCH_TICKS_PER_PACK } from '../sim/constants';
import { referenceFactory } from '../sim/reference';
import { grantAllResearch, grantResearch } from '../sim/testkit';
import { createUI } from './index';
import items from '../../data/items.json';
@ -28,12 +30,12 @@ import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
/** Grant research through the sim's own save/load — `load()` re-runs applyUnlocks(). */
function unlockAll(sim: Sim) {
const s = JSON.parse(sim.save());
s.research = { active: null, progress: {}, unlocked: DATA.tech.map((t) => t.id) };
sim.load(JSON.stringify(s));
}
/**
* v5 (order 4): research is granted through LANE-SIM's own `testkit`, not by hand-editing
* a save blob here. My round-4 version duplicated that round-trip and coupled this suite
* to SIM's save schema exactly the fragility I flagged then.
*/
const unlockAll = (sim: Sim) => grantAllResearch(sim, DATA);
function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) {
document.body.innerHTML = '<div id="game"></div><div id="ui"></div>';
@ -176,33 +178,51 @@ describe('the HUD against the reference factory', () => {
expect($('.fk-san .fk-chip').textContent).toContain('I-FRAME');
});
it('reads NEXT IN TRAY from the real commission queue, and stamps a real standing order', () => {
/**
* DATA's v5 commission queue opens with `raw-payload` 20 raw MDAT ORE and the
* reference factory refines every scrap of its ore rather than shipping it, so the head
* commission can never complete and blocks all 19 behind it (see NOTES: head-of-line
* blocking). A player would do the obvious thing: tap raw ore straight to an uplink.
* `GameData` ships no seam map, so the whole map is minable and this works anywhere.
*/
function shipRawOre(h: ReturnType<typeof harness>, x = 24, y = 24) {
// extractor (2x2) -> belt -> uplink (2x2), all facing east. The belt is required:
// machines only ever push onto belts, never straight into an adjacent machine.
h.bus.dispatch({ kind: 'place', def: 'seam-extractor', pos: { x, y }, dir: 1 });
h.bus.dispatch({ kind: 'place', def: 'belt', pos: { x: x + 2, y }, dir: 1 });
h.bus.dispatch({ kind: 'place', def: 'shipper', pos: { x: x + 3, y }, dir: 1 });
}
it('reads NEXT IN TRAY from the real commission queue, and advances it on completion', () => {
const h = harness({ unlocked: true });
let sawStanding = false;
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
h.step(10);
const first = h.sim.snapshot();
expect(first.commissionQueue, 'SIM publishes the queue').toBeTruthy();
expect(first.activeCommission).toBe(first.commissionQueue![0]);
// The peek is the queue's second entry, not a guess from data order.
const nextId = first.commissionQueue![1];
expect(on('.fk-fax-next')).toBe(true);
expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase());
// Satisfy the head commission for real, and watch the tray move up.
shipRawOre(h);
for (let t = 0; t < 12000 && h.sim.snapshot().activeCommission === first.activeCommission; t += 100) {
h.step(100);
if (on('.fk-fax-standing')) sawStanding = true;
}
const snap = h.sim.snapshot();
expect(snap.commissionQueue, 'SIM publishes the queue').toBeTruthy();
// A repeat commission reached the head of SIM's real queue and the fax stamped it.
expect(sawStanding, 'a standing order should become active').toBe(true);
const active = DATA.commissions.find((c) => c.id === snap.activeCommission);
expect(active?.repeat).toBe(true);
// ...and the peek is the queue's second entry, not a guess from data order.
const nextId = snap.commissionQueue![1];
if (nextId) {
expect(on('.fk-fax-next')).toBe(true);
expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase());
}
const after = h.sim.snapshot();
expect(after.activeCommission, 'the queue should advance once the head is paid')
.toBe(nextId);
expect($('.fk-fax-next-name').textContent)
.toBe(after.commissionQueue![1].replace(/-/g, ' ').toUpperCase());
});
it('completes a commission and stamps the fax', () => {
const h = harness({ unlocked: true });
shipRawOre(h);
let done = false;
for (let t = 0; t < 6000 && !done; t += 50) {
for (let t = 0; t < 12000 && !done; t += 50) {
h.step(50);
if (toasts().some((x) => x?.includes('FAX SENT'))) done = true;
}
@ -308,8 +328,19 @@ describe('research against the real sim', () => {
lab.state.inputBuf = { ...cost };
h.sim.load(JSON.stringify(s));
// 4. the unlock lands: real event, toast in the house voice, padlock falls off.
h.step(3);
// 4. the lab actually works for it: v5 SIM converts one pack per
// RESEARCH_TICKS_PER_PACK ticks, so research is a process, not an instant.
const packs = Object.values(cost).reduce((a, b) => a + b, 0);
h.step(RESEARCH_TICKS_PER_PACK); // one pack's worth
expect(h.sim.snapshot().research!.progress, 'the lab should be converting packs')
.not.toEqual({});
// ...run it to completion (generous bound; headless this is milliseconds).
for (let i = 0; i < packs + 4 && !h.sim.snapshot().research!.unlocked.includes(SOFTWARE_TECH); i++) {
h.step(RESEARCH_TICKS_PER_PACK);
}
// 5. the unlock lands: real event, toast in the house voice, padlock falls off.
expect(h.sim.snapshot().research!.unlocked).toContain(SOFTWARE_TECH);
expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true);
@ -318,7 +349,7 @@ describe('research against the real sim', () => {
.toBe(false);
expect(lockBtn()!.getAttribute('title')).not.toContain('REQUIRES:');
// 5. and now it builds.
// 6. and now it builds.
h.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
h.step(2);
expect(h.find('software-decoder'), 'unlocked machine must place').toBeTruthy();
@ -326,6 +357,79 @@ describe('research against the real sim', () => {
});
describe('onboarding and ceremony against the real sim', () => {
it('gives the opening exactly one mercy hint on the first brownout, then never again', () => {
// Cold start: one extractor, no generator. The alarm alone teaches nothing.
const h = harness({ reference: false });
h.bus.dispatch({ kind: 'place', def: 'seam-extractor', pos: { x: 0, y: 0 }, dir: 1 });
h.step(30);
const mercy = () => toasts().filter((t) => t?.includes('THE COMMITTEE SUGGESTS: POWER'));
expect(h.sim.snapshot().bandwidth.brownout, 'a powerless factory browns out').toBe(true);
expect(mercy(), 'the hint fires on the first brownout').toHaveLength(1);
// Ride it out and brown out again — the hint must not become a nag.
h.bus.dispatch({ kind: 'place', def: 'decode-asic', pos: { x: 6, y: 0 }, dir: 1 });
h.step(300);
h.bus.dispatch({ kind: 'remove', pos: { x: 6, y: 0 } });
h.step(300);
expect(mercy(), 'and only ever once a session').toHaveLength(1);
});
it('publishes the inspect selection while the panel is open, and drops it on close', () => {
const h = harness({ unlocked: true });
h.step(20);
const unit = h.find('seam-extractor')!;
h.clickTile(unit.pos);
h.step(1);
expect(h.state(), 'renderer gets the entity to highlight')
.toEqual({ mode: 'inspect', entity: unit.id });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
h.step(1);
expect(h.state(), 'highlight stops when the panel shuts').toBeNull();
});
it('drops the highlight when the inspected unit is demolished under it', () => {
const h = harness({ unlocked: true });
h.step(20);
const unit = h.find('seam-extractor')!;
h.clickTile(unit.pos);
h.step(1);
expect(h.state()).toEqual({ mode: 'inspect', entity: unit.id });
h.bus.dispatch({ kind: 'remove', pos: unit.pos });
h.step(3);
expect(h.state(), 'no highlight on a unit that no longer exists').toBeNull();
});
it('reveals the tuner dock only once the fossil is actually excavated', () => {
const h = harness({ unlocked: true });
h.step(20);
const dock = () => document.getElementById('fk-tuner')!;
const relic = h.sim.snapshot().relics?.[0];
expect(relic, 'SIM seeds an optical fossil').toBeTruthy();
expect(relic!.found).toBe(false);
expect(dock().classList.contains('is-revealed'), 'silent until it is dug up').toBe(false);
h.bus.dispatch({ kind: 'excavate', pos: relic!.pos });
// Excavation isn't instant; give it room to finish.
for (let i = 0; i < 200 && !h.sim.snapshot().relics![0].found; i++) h.step(10);
expect(h.sim.snapshot().relics![0].found, 'the fossil should come up').toBe(true);
expect(dock().classList.contains('is-revealed'), 'the dock arrives with it').toBe(true);
expect(toasts().some((t) => t?.includes('THE BITSTREAM HAS A VOICE'))).toBe(true);
// The dock lands in the toast stack's corner, so the toasts must step aside.
expect(
document.getElementById('fk-toasts')!.classList.contains('is-standoff'),
'toasts should clear the dial rather than print over it',
).toBe(true);
});
});
describe('remove mode against the real sim', () => {
it('demolishes the clicked unit and toasts it', () => {
const h = harness({ unlocked: true });

80
fktry/src/ui/radio.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* LANE-UI the bridge to THE SPEAKER's radio control.
*
* Order 9's tuner dock drives the audio engine through a narrow control that LANE-SCREEN
* exports from `src/audio` (their documented API: setFrequency / setBand / volume / mute /
* getState / subscribe). That export does not exist yet `src/audio` is still a stub so
* this module resolves the control at runtime and, until it lands, drives a local echo
* stand-in so every dial is fully functional and testable now.
*
* COORDINATION (see NOTES): I resolve the control from, in order,
* 1. `window.__fktrySpeaker.radio` the handshake I'm proposing (SCREEN already keeps
* a `window.__fktryScreen` debug hook, so this is idiomatic for them), or
* 2. a `radio` / `getRadio()` export from `../audio` (dynamic, cast never breaks the
* build if absent).
* SCREEN: set one of those to a RadioControl and the dock drives real audio with zero
* change here. Tell me in NOTES which name you pick.
*
* The stand-in is silent it only echoes state back through `subscribe` so the readout is
* honest about what the dock is asking for, even with no engine attached.
*/
export type Band = 'AM' | 'FM' | 'SW';
export interface RadioState {
freqMHz: number;
band: Band;
volume: number; // 0..1
muted: boolean;
/** Station name if the current frequency locks one, else null (static). */
station: string | null;
}
export interface RadioControl {
setFrequency(mhz: number): void;
setBand(band: Band): void;
volume(v: number): void;
mute(on: boolean): void;
getState(): RadioState;
subscribe(cb: (s: RadioState) => void): () => void;
}
/** Band frequency ranges (MHz-ish; SW/AM are scaled for a single readable dial). */
export const BAND_RANGE: Record<Band, { min: number; max: number; step: number }> = {
AM: { min: 0.53, max: 1.7, step: 0.01 },
FM: { min: 88.0, max: 108.0, step: 0.1 },
SW: { min: 5.9, max: 26.1, step: 0.05 },
};
/**
* Local echo stand-in. Holds state, notifies subscribers, locks no stations (the real
* station map is SCREEN's). Keeps the dock alive before the engine exists.
*/
export function createEchoRadio(): RadioControl {
let state: RadioState = { freqMHz: BAND_RANGE.FM.min, band: 'FM', volume: 0.7, muted: false, station: null };
const subs = new Set<(s: RadioState) => void>();
const notify = () => { for (const cb of subs) cb(state); };
return {
setFrequency(mhz) { state = { ...state, freqMHz: mhz }; notify(); },
setBand(band) { state = { ...state, band, freqMHz: BAND_RANGE[band].min }; notify(); },
volume(v) { state = { ...state, volume: Math.max(0, Math.min(1, v)) }; notify(); },
mute(on) { state = { ...state, muted: on }; notify(); },
getState: () => state,
subscribe(cb) { subs.add(cb); cb(state); return () => subs.delete(cb); },
};
}
/** Resolve the real control if THE SPEAKER has published one; else the echo stand-in. */
export function resolveRadio(): { control: RadioControl; live: boolean } {
const speaker = (globalThis as any).__fktrySpeaker;
if (speaker?.radio && typeof speaker.radio.subscribe === 'function') {
return { control: speaker.radio as RadioControl, live: true };
}
// Dynamic, casted probe of the audio module — safe whether or not the export exists.
const audioMod = (globalThis as any).__fktryAudioModule;
const fromMod = audioMod?.radio ?? audioMod?.getRadio?.();
if (fromMod && typeof fromMod.subscribe === 'function') {
return { control: fromMod as RadioControl, live: true };
}
return { control: createEchoRadio(), live: false };
}

View File

@ -0,0 +1,80 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest';
import { createScreenView } from './screenview';
function mount(withInlineStyle = false) {
document.body.innerHTML = withInlineStyle
? '<canvas id="screen" style="position:fixed;top:10px"></canvas>'
: '<canvas id="screen"></canvas>';
const source = document.getElementById('screen') as HTMLCanvasElement;
const view = createScreenView(source);
document.body.append(view.el);
return { view, source };
}
const click = (el: Element) => el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
describe('THE SCREEN click-to-enlarge', () => {
it('starts closed and leaves THE SCREEN alone', () => {
const { view, source } = mount();
expect(view.isOpen()).toBe(false);
expect(source.getAttribute('style')).toBeNull();
});
it('enlarges THE SCREEN and dims the backdrop when clicked', () => {
const { view, source } = mount();
click(source);
expect(view.isOpen()).toBe(true);
expect(view.el.classList.contains('is-open')).toBe(true);
const style = source.getAttribute('style')!;
expect(style).toContain('position:fixed');
expect(style).toContain('z-index:41'); // above the backdrop
});
it('restores THE SCREEN exactly as it found it', () => {
// Another lane owns this element; we must hand it back untouched.
const { view, source } = mount();
click(source);
view.close();
expect(source.getAttribute('style')).toBeNull();
expect(view.isOpen()).toBe(false);
});
it('preserves inline styles LANE-SCREEN may add later', () => {
const { view, source } = mount(true);
const before = source.getAttribute('style');
click(source);
expect(source.getAttribute('style')).toContain('top:10px'); // ours appended, not replacing
view.close();
expect(source.getAttribute('style')).toBe(before);
});
it('closes when the enlarged SCREEN is clicked again', () => {
const { view, source } = mount();
click(source);
click(source);
expect(view.isOpen()).toBe(false);
expect(source.getAttribute('style')).toBeNull();
});
it('closes on a click on the dimmed backdrop', () => {
const { view, source } = mount();
click(source);
click(view.el);
expect(view.isOpen()).toBe(false);
});
it('close() is idempotent and does not clobber the style on a second call', () => {
const { view, source } = mount(true);
const before = source.getAttribute('style');
view.close();
view.close();
expect(source.getAttribute('style')).toBe(before);
});
it('survives having no SCREEN to enlarge', () => {
document.body.innerHTML = '';
const view = createScreenView(null);
expect(() => { view.toggle(); view.close(); }).not.toThrow();
});
});

View File

@ -0,0 +1,82 @@
/**
* LANE-UI THE SCREEN click-to-enlarge (order 8). The sky is the score; let people look.
*
* Clicking THE SCREEN toggles a centered ~2.5× view over a dimmed backdrop. Esc or a
* click closes it.
*
* We enlarge the real `#screen` element with inline styles rather than mirroring it into
* our own canvas: THE SCREEN is a WebGL canvas, and `drawImage` from a WebGL context
* without `preserveDrawingBuffer` returns black (verified the mirror rendered empty).
* Asking LANE-SCREEN to enable that flag would cost them a frame buffer copy every frame
* for a feature used occasionally, so the transform is both cheaper and what the order
* specified.
*
* Touching another lane's element is done carefully: we snapshot its `style` attribute on
* open and restore it verbatim on close, so THE SCREEN's own positioning stays SCREEN's
* to change.
*/
import { cls, el } from './dom';
export interface ScreenView {
/** The dimming backdrop, mounted in #ui. */
el: HTMLElement;
toggle(): void;
close(): void;
isOpen(): boolean;
}
/** Inline styles applied to #screen while enlarged. z-index clears the backdrop (40). */
const ENLARGED = [
'position:fixed',
'top:50%',
'left:50%',
'transform:translate(-50%,-50%)',
'width:min(800px, calc(100vw - 80px))',
'height:auto',
'z-index:41',
'cursor:zoom-out',
'image-rendering:pixelated', // it's a dying video file — let the blocks show
].join(';');
export function createScreenView(source: HTMLElement | null): ScreenView {
const backdrop = el('div');
backdrop.id = 'fk-screenview';
let open = false;
/** The element's own style attribute before we touched it. */
let savedStyle: string | null = null;
function apply() {
if (!source) return;
savedStyle = source.getAttribute('style');
source.setAttribute('style', `${savedStyle ? savedStyle + ';' : ''}${ENLARGED}`);
}
function restore() {
if (!source) return;
if (savedStyle === null) source.removeAttribute('style');
else source.setAttribute('style', savedStyle);
savedStyle = null;
}
function close() {
if (!open) return;
open = false;
cls(backdrop, 'is-open', false);
restore();
}
function toggle() {
if (open) return close();
open = true;
cls(backdrop, 'is-open', true);
apply();
}
// A click on the dimmed backdrop closes. So does a click on the enlarged SCREEN, which
// sits above the backdrop — that's the same toggle listener below.
backdrop.addEventListener('pointerdown', close);
source?.addEventListener('pointerdown', toggle);
return { el: backdrop, toggle, close, isOpen: () => open };
}

View File

@ -121,6 +121,84 @@ describe('createSelection (v4 typed protocol)', () => {
expect(read()).toEqual({ mode: 'remove' });
});
// ---------------------------------------------------------------- v5 inspect mode
it('publishes the inspected entity so the renderer can highlight it', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.inspect(42);
expect(read()).toEqual({ mode: 'inspect', entity: 42 });
expect(sel.isInspecting()).toBe(true);
});
it('does not count inspect as holding a tool — you can click another unit', () => {
const { bus } = makeBus();
const sel = createSelection(bus);
sel.inspect(42);
expect(sel.isArmed()).toBe(false);
expect(sel.get()).toBeNull();
});
it('moves the highlight when a different unit is inspected', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.inspect(1);
sel.inspect(2);
expect(read()).toEqual({ mode: 'inspect', entity: 2 });
});
it('stops highlighting when the inspector closes', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.inspect(42);
sel.clearInspect();
expect(read()).toBeNull();
expect(sel.isInspecting()).toBe(false);
});
it('clearInspect is a no-op when we were not inspecting', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.select('belt');
sel.clearInspect();
expect(read()).toEqual({ mode: 'build', def: 'belt', dir: 0 }); // tool untouched
});
it('picking a tool supersedes the inspect highlight', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.inspect(42);
sel.select('quantizer');
expect(sel.isInspecting()).toBe(false);
expect(read()).toEqual({ mode: 'build', def: 'quantizer', dir: 0 });
});
it('arming remove supersedes the inspect highlight', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.inspect(42);
sel.toggleRemove();
expect(sel.isInspecting()).toBe(false);
expect(read()).toEqual({ mode: 'remove' });
});
it('clear() drops the inspect highlight too — that is what Esc rides on', () => {
const { bus, read } = makeBus();
const sel = createSelection(bus);
sel.inspect(42);
sel.clear();
expect(read()).toBeNull();
});
it('re-inspecting the same entity does not churn the bus', () => {
const { bus } = makeBus();
let writes = 0;
const spy: UIBus = { ...bus, setSelection: (s) => { writes++; bus.setSelection!(s); } };
const sel = createSelection(spy);
sel.inspect(42);
sel.inspect(42);
expect(writes).toBe(1);
});
it('notifies listeners on change, and not on a no-op clear', () => {
const { bus } = makeBus();
const sel = createSelection(bus);

View File

@ -1,28 +1,35 @@
/**
* LANE-UI the pending build selection, and remove mode.
* LANE-UI the player's current intent: a pending build, remove mode, or an inspected
* unit. All three are published through `bus.setSelection()` (contracts v4/v5), and they
* are mutually exclusive you hold the wrecking ball, or a machine, or you're looking at
* a unit, never two at once.
*
* v4: the selection is main.ts's state and we write it through `bus.setSelection()`
* (contracts v4 `SelectionState`). The round-3 `__remove` magic string is gone the
* renderer gets a real `mode` now and so is our own `remove` dispatch: **main.ts
* dispatches the removal on click.** We only say what is held; main.ts decides what a
* click on the world means.
* main.ts owns the state and decides what a world click means (it places/removes); the
* renderer reads the selection to draw the ghost, the demolition tint, or (v5) the
* inspect highlight. We only say what is held.
*
* `selection`/`setSelection` are optional in the contract, so we fall back to the legacy
* `_sel` field if a host doesn't serve them yet (live.test.ts's own bus, for one).
* `_sel` field if a host doesn't serve them yet (live.test.ts's own bus, for one). The
* legacy field can't express inspect mode, which is fine — it's a v5 host that highlights.
*/
import type { Dir, SelectionState, UIBus } from '../contracts';
export interface BuildSelection {
/** The pending *build*, or null. Null whenever remove mode is armed. */
/** The pending *build*, or null. Null whenever removing or inspecting. */
get(): { def: string; dir: Dir } | null;
/** Select a machine; selecting the one already selected clears it (toggle). */
select(def: string): void;
rotate(): void;
/** Drop the build selection AND disarm remove mode. */
/** Drop everything — build, wrecking ball, and inspect. */
clear(): void;
isRemoving(): boolean;
toggleRemove(): void;
/** True when the player is holding anything at all (build or the wrecking ball). */
/** v5: publish that the inspector is showing this entity, so the renderer highlights it. */
inspect(entityId: number): void;
/** v5: the inspector closed — stop highlighting (no-op if we weren't inspecting). */
clearInspect(): void;
isInspecting(): boolean;
/** True when the player is holding a tool (build or wrecking ball). Inspect is not a tool. */
isArmed(): boolean;
onChange(cb: () => void): void;
}
@ -32,6 +39,7 @@ type LegacyBus = UIBus & { _sel?: { def: string; dir: Dir } | null };
export function createSelection(bus: UIBus): BuildSelection {
let sel: { def: string; dir: Dir } | null = null;
let removing = false;
let inspecting: number | null = null;
const listeners: Array<() => void> = [];
function publish() {
@ -39,40 +47,58 @@ export function createSelection(bus: UIBus): BuildSelection {
? { mode: 'remove' }
: sel
? { mode: 'build', def: sel.def, dir: sel.dir }
: null;
: inspecting !== null
? { mode: 'inspect', entity: inspecting }
: null;
if (bus.setSelection) {
bus.setSelection(state);
} else {
// Legacy host: keep the old shape alive rather than silently doing nothing.
// Legacy host: keep the old build/remove shape alive. Inspect has no legacy form —
// a host without setSelection simply won't get the highlight, which is acceptable.
(bus as LegacyBus)._sel = removing ? { def: '__remove', dir: 0 } : sel;
}
}
function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) {
sel = next;
removing = nextRemoving;
function commit(next: {
sel?: { def: string; dir: Dir } | null;
removing?: boolean;
inspecting?: number | null;
}) {
// Any explicit field wins; unspecified tool fields reset, because the three modes are
// mutually exclusive and a partial update would leave two of them live.
sel = next.sel !== undefined ? next.sel : null;
removing = next.removing ?? false;
inspecting = next.inspecting !== undefined ? next.inspecting : null;
publish();
for (const cb of listeners) cb();
}
return {
get: () => (removing ? null : sel),
get: () => (removing || inspecting !== null ? null : sel),
select(def) {
// Picking a machine puts the wrecking ball down — you can't build and demolish.
if (!removing && sel?.def === def) commit(null, false);
else commit({ def, dir: sel?.dir ?? 0 }, false); // keep the dir across swaps
// Picking a machine puts the wrecking ball down and stops inspecting.
if (!removing && sel?.def === def) commit({}); // toggle off
else commit({ sel: { def, dir: sel?.dir ?? 0 } }); // keep the dir across swaps
},
rotate() {
if (!removing && sel) commit({ def: sel.def, dir: ((sel.dir + 1) % 4) as Dir }, false);
if (!removing && sel) commit({ sel: { def: sel.def, dir: ((sel.dir + 1) % 4) as Dir } });
},
clear() {
if (sel || removing) commit(null, false);
if (sel || removing || inspecting !== null) commit({});
},
isRemoving: () => removing,
toggleRemove() {
commit(null, !removing);
commit({ removing: !removing });
},
inspect(entityId) {
if (inspecting === entityId && !sel && !removing) return; // already showing it
commit({ inspecting: entityId });
},
clearInspect() {
if (inspecting !== null) commit({});
},
isInspecting: () => inspecting !== null,
isArmed: () => removing || sel !== null,
onChange(cb) {
listeners.push(cb);

View File

@ -125,8 +125,30 @@ const CSS = `
justify-content: center;
}
/* Kind tabs. A page holds at most nine so 1-9 always reaches every machine. */
.fk-tabs { display: flex; gap: 3px; margin-bottom: 5px; flex-wrap: wrap; justify-content: center; }
/* Kind tabs. A page holds at most nine so 1-9 always reaches every machine.
v5: a single scrolling strip rather than a wrapping row the catalog keeps growing
(RESEARCH joined in v4 and threw REMOVE onto a line of its own), and tabs jumping to a
second row moves every other tab under the player's cursor. */
.fk-tabs {
display: flex; gap: 3px; margin-bottom: 5px;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
scrollbar-width: thin;
scrollbar-color: var(--fk-line) transparent;
/* The scrollbar gets its OWN lane below the buttons. With too little padding it is
painted over the lower half of every tab and swallows clicks there the exact
"clicking POWER does nothing" bug from review, which is why keyboard paging still
worked. Must stay >= the scrollbar height below. */
padding-bottom: 7px;
}
.fk-tabs::-webkit-scrollbar { height: 4px; }
.fk-tabs::-webkit-scrollbar-track { background: transparent; }
.fk-tabs::-webkit-scrollbar-thumb { background: var(--fk-line); }
.fk-tabs::-webkit-scrollbar-thumb:hover { background: var(--fk-dim); }
/* Tabs must not shrink into unreadable slivers when the strip overflows. */
.fk-tab { flex: 0 0 auto; }
.fk-tab {
background: var(--fk-bg-solid);
border: 1px solid var(--fk-line);
@ -138,9 +160,8 @@ const CSS = `
.fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
.fk-tab.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; }
/* REMOVE is a mode, not a machine it sits with the tabs and reads as a hazard.
No auto margin: with the RESEARCH tab added the row wraps, and an auto margin threw
REMOVE onto a line of its own. */
/* REMOVE is a mode, not a machine it sits at the end of the strip and reads as a
hazard. It scrolls with the tabs; the hotkey (X) is always available regardless. */
.fk-tab-remove { margin-left: 10px; color: var(--fk-dim); }
.fk-tab-remove:hover { color: var(--fk-red); border-color: var(--fk-red); }
.fk-tab-remove.is-armed {
@ -227,6 +248,11 @@ const CSS = `
.fk-ins-heat-state { font-size: 10px; color: var(--fk-amber); margin-top: 3px; }
.fk-ins-heat-state.is-scram { color: var(--fk-red); font-weight: bold; }
/* MOSQUITO EXPOSURE — wildlife is a hazard, not a fault: amber, quiet, matter-of-fact. */
.fk-ins-exposure { display: none; margin-top: 6px; }
.fk-ins-exposure.is-on { display: block; }
.fk-ins-exposure-pct { color: var(--fk-amber); font-size: 10px; }
/* Process select: a native <select> beaten into the house style. */
.fk-ins-recipe {
width: 100%;
@ -354,10 +380,107 @@ const CSS = `
100% { opacity: 0; }
}
/* ---------------------------------------------------------------- screen enlarge */
#fk-screenview {
display: none;
position: fixed; inset: 0;
background: rgba(4, 3, 8, 0.86);
align-items: center; justify-content: center;
pointer-events: auto;
z-index: 40;
cursor: zoom-out;
}
#fk-screenview.is-open { display: flex; }
/* THE SCREEN itself is enlarged (see screenview.ts it's a WebGL canvas and can't be
mirrored), so the backdrop only dims. */
#screen { cursor: zoom-in; }
/* ---------------------------------------------------------------- tuner dock */
#fk-tuner {
/* Off-stage until the fossil is dug up. */
display: none;
bottom: 10px; right: 10px;
width: 250px;
z-index: 13;
}
#fk-tuner.is-revealed {
display: block;
animation: fk-tuner-in 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Arrives like equipment being wheeled in, not like a notification. */
@keyframes fk-tuner-in {
0% { opacity: 0; transform: translateX(28px); }
100% { opacity: 1; transform: translateX(0); }
}
#fk-tuner.is-muted { opacity: 0.72; }
/* The dock and the toast stack share the bottom-right corner. Once the dock is in, the
toasts stand off above it rather than printing over the dial. The class goes on the
toast stack itself rather than on an ancestor same element, no descendant matching
to get wrong. */
#fk-toasts.is-standoff { bottom: 176px; }
.fk-tuner-readout {
color: var(--fk-dim);
font-size: 11px;
letter-spacing: 0.04em;
margin: 4px 0 6px;
min-height: 14px;
}
.fk-tuner-readout.is-locked { color: var(--fk-cool); text-shadow: 0 0 6px rgba(63,255,224,0.5); }
.fk-tuner-dial {
position: relative;
height: 14px;
background: var(--fk-bg-solid);
border: 1px solid var(--fk-line);
cursor: ew-resize;
pointer-events: auto;
/* Tick marks, so the dial reads as a radio scale rather than a progress bar. */
background-image: repeating-linear-gradient(
90deg, var(--fk-line) 0 1px, transparent 1px 10px);
}
.fk-tuner-dial-fill {
position: absolute; top: 0; bottom: 0; left: 0;
background: linear-gradient(90deg, transparent, rgba(63, 255, 224, 0.25));
border-right: 2px solid var(--fk-cool);
box-shadow: 0 0 8px rgba(63, 255, 224, 0.6);
}
.fk-tuner-bands { display: flex; gap: 3px; margin-top: 6px; }
.fk-tuner-band {
flex: 1;
background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
color: var(--fk-faint); font: inherit; font-size: 9px; letter-spacing: 0.1em;
padding: 2px 0; cursor: pointer; pointer-events: auto;
}
.fk-tuner-band:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
.fk-tuner-band.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; }
.fk-tuner-volrow { display: flex; align-items: center; gap: 5px; margin-top: 6px; }
.fk-tuner-vol-label { color: var(--fk-faint); font-size: 9px; }
.fk-tuner-vol {
position: relative; flex: 1; height: 6px;
background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
cursor: pointer; pointer-events: auto;
}
.fk-tuner-vol-fill { position: absolute; top: 0; bottom: 0; left: 0; background: var(--fk-dim); }
.fk-tuner-mute {
background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
color: var(--fk-dim); font: inherit; font-size: 9px;
padding: 2px 6px; cursor: pointer; pointer-events: auto;
}
.fk-tuner-mute:hover { border-color: var(--fk-amber); color: var(--fk-amber); }
.fk-tuner-mute.is-on { color: var(--fk-amber); border-color: var(--fk-amber); }
.fk-tuner-hint { color: var(--fk-faint); font-size: 8px; letter-spacing: 0.1em; margin-top: 6px; }
/* ---------------------------------------------------------------- toasts */
#fk-toasts {
position: fixed; bottom: 10px; right: 10px;
transition: bottom 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
display: flex; flex-direction: column; align-items: flex-end; gap: 4px;
pointer-events: none;
z-index: 11;

View File

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

171
fktry/src/ui/tuner.test.ts Normal file
View File

@ -0,0 +1,171 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest';
import { BAND_RANGE, createEchoRadio, resolveRadio, type RadioControl } from './radio';
import { createTuner } from './tuner';
const q = (s: string) => document.querySelector(s) as HTMLElement;
const bandBtn = (b: string) =>
Array.from(document.querySelectorAll('.fk-tuner-band')).find((x) => x.textContent === b) as HTMLElement;
function mount() {
document.body.innerHTML = '';
const tuner = createTuner();
document.body.append(tuner.el);
return tuner;
}
describe('radio control resolution', () => {
beforeEach(() => {
delete (globalThis as any).__fktrySpeaker;
delete (globalThis as any).__fktryAudioModule;
});
it('falls back to a silent echo stand-in when THE SPEAKER has published nothing', () => {
const { live } = resolveRadio();
expect(live).toBe(false);
});
it('uses the real control the moment SCREEN publishes one', () => {
const fake = createEchoRadio();
(globalThis as any).__fktrySpeaker = { radio: fake };
const { control, live } = resolveRadio();
expect(live).toBe(true);
expect(control).toBe(fake);
delete (globalThis as any).__fktrySpeaker;
});
it('ignores a published object that is not a radio control', () => {
(globalThis as any).__fktrySpeaker = { radio: { nope: true } };
expect(resolveRadio().live).toBe(false);
delete (globalThis as any).__fktrySpeaker;
});
});
describe('echo radio', () => {
it('notifies subscribers immediately and on every change', () => {
const r = createEchoRadio();
const seen: number[] = [];
r.subscribe((s) => seen.push(s.freqMHz));
r.setFrequency(101.1);
expect(seen).toHaveLength(2); // initial + change
expect(seen[1]).toBe(101.1);
});
it('snaps to the band floor when the band changes', () => {
const r = createEchoRadio();
r.setBand('AM');
expect(r.getState().band).toBe('AM');
expect(r.getState().freqMHz).toBe(BAND_RANGE.AM.min);
});
it('clamps volume to 0..1', () => {
const r = createEchoRadio();
r.volume(5);
expect(r.getState().volume).toBe(1);
r.volume(-2);
expect(r.getState().volume).toBe(0);
});
it('unsubscribes cleanly', () => {
const r = createEchoRadio();
let n = 0;
const off = r.subscribe(() => n++);
off();
r.setFrequency(99);
expect(n).toBe(1); // only the initial call
});
});
describe('the tuner dock', () => {
let tuner: ReturnType<typeof createTuner>;
beforeEach(() => {
delete (globalThis as any).__fktrySpeaker;
tuner = mount();
});
it('stays hidden until the fossil is found', () => {
expect(tuner.isRevealed()).toBe(false);
expect(tuner.el.classList.contains('is-revealed')).toBe(false);
});
it('reveals on discovery, and revealing twice is harmless', () => {
tuner.reveal();
tuner.reveal();
expect(tuner.isRevealed()).toBe(true);
expect(tuner.el.classList.contains('is-revealed')).toBe(true);
});
it('ignores its keys while hidden, so they never shadow the pre-discovery game', () => {
expect(tuner.onKey('arrowright')).toBe(false);
expect(tuner.onKey('m')).toBe(false);
});
it('tunes with the arrow keys once revealed', () => {
tuner.reveal();
const before = q('.fk-tuner-readout').textContent!;
expect(tuner.onKey('arrowright')).toBe(true);
expect(q('.fk-tuner-readout').textContent).not.toBe(before);
});
it('does not tune past the end of the band', () => {
tuner.reveal();
for (let i = 0; i < 400; i++) tuner.onKey('arrowleft'); // hammer the bottom stop
const shown = q('.fk-tuner-readout').textContent!;
expect(shown).toContain(BAND_RANGE.FM.min.toFixed(1));
});
it('cycles bands with B and reflects it in the readout and the active button', () => {
tuner.reveal();
tuner.onKey('b');
expect(q('.fk-tuner-readout').textContent).toContain('SW');
expect(bandBtn('SW').classList.contains('is-active')).toBe(true);
expect(bandBtn('FM').classList.contains('is-active')).toBe(false);
});
it('switches band by clicking a band button', () => {
tuner.reveal();
bandBtn('AM').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(q('.fk-tuner-readout').textContent).toContain('AM');
expect(bandBtn('AM').classList.contains('is-active')).toBe(true);
});
it('mutes and unmutes with M, and says so', () => {
tuner.reveal();
tuner.onKey('m');
expect(q('.fk-tuner-mute').textContent).toBe('MUTED');
expect(tuner.el.classList.contains('is-muted')).toBe(true);
tuner.onKey('m');
expect(q('.fk-tuner-mute').textContent).toBe('MUTE');
expect(tuner.el.classList.contains('is-muted')).toBe(false);
});
it('mutes by clicking the mute button', () => {
tuner.reveal();
q('.fk-tuner-mute').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(q('.fk-tuner-mute').textContent).toBe('MUTED');
});
it('reads out static when no station locks, in the house voice', () => {
tuner.reveal();
expect(q('.fk-tuner-readout').textContent).toContain('STATIC');
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(false);
});
it('reads SIGNAL LOCKED and lights up when the engine reports a station', () => {
// What it looks like once THE SPEAKER is driving it for real.
const withStation: RadioControl = {
...createEchoRadio(),
subscribe(cb) {
cb({ freqMHz: 88.1, band: 'FM', volume: 0.7, muted: false, station: 'FKTRY' });
return () => {};
},
};
(globalThis as any).__fktrySpeaker = { radio: withStation };
const t = mount();
t.reveal();
expect(q('.fk-tuner-readout').textContent).toBe('88.1 FKTRY FM — SIGNAL LOCKED');
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(true);
expect(t.isLive()).toBe(true);
delete (globalThis as any).__fktrySpeaker;
});
});

154
fktry/src/ui/tuner.ts Normal file
View File

@ -0,0 +1,154 @@
/**
* LANE-UI THE TUNER DOCK (order 9). The radio's face.
*
* Hidden until the optical fossil is excavated (`snapshot.relics[].found` or a
* `relicFound` event). Then it slides in bottom-right with a frequency dial, band select
* (AM/FM/SW), volume + mute, and a station readout in the house voice. Every control
* drives THE SPEAKER through the resolved `RadioControl` (radio.ts); the readout reflects
* the control's own state via `subscribe`, so it's honest whether the real engine or the
* echo stand-in is attached.
*
* Radio state is per-session (it lives in the control); the durable part is the fossil's
* `found`, which is sim-side.
*/
import { cls, el, panel, style, text } from './dom';
import { BAND_RANGE, resolveRadio, type Band, type RadioControl, type RadioState } from './radio';
import { COPY, stationReadout } from './voice';
export interface Tuner {
el: HTMLElement;
/** Reveal the dock (discovery ceremony). Idempotent. */
reveal(): void;
isRevealed(): boolean;
/** Keyboard: ← → tune, B band, M mute — active only once revealed. */
onKey(key: string): boolean;
/** Whether the dock is driving the real audio engine vs the silent stand-in. */
isLive(): boolean;
}
const BANDS: Band[] = ['AM', 'FM', 'SW'];
export function createTuner(): Tuner {
const { control, live } = resolveRadio();
const root = panel();
root.id = 'fk-tuner';
const head = el('div', 'fk-h', [COPY.tunerHeader]);
const readout = el('div', 'fk-tuner-readout');
// Frequency dial: a click/drag track. The fill shows position within the band.
const dialFill = el('div', 'fk-tuner-dial-fill');
const dial = el('div', 'fk-tuner-dial', [dialFill]);
// Band buttons.
const bandRow = el('div', 'fk-tuner-bands');
const bandBtns = BANDS.map((b) => {
const btn = el('button', 'fk-tuner-band', [b]);
btn.setAttribute('type', 'button');
btn.addEventListener('click', () => control.setBand(b));
bandRow.append(btn);
return { band: b, btn };
});
// Volume + mute.
const volFill = el('div', 'fk-tuner-vol-fill');
const volTrack = el('div', 'fk-tuner-vol', [volFill]);
const muteBtn = el('button', 'fk-tuner-mute', [COPY.tunerMute]);
muteBtn.setAttribute('type', 'button');
const volRow = el('div', 'fk-tuner-volrow', [
el('span', 'fk-tuner-vol-label', [COPY.tunerVolLabel]), volTrack, muteBtn,
]);
const hint = el('div', 'fk-tuner-hint', [COPY.tunerHint]);
root.append(head, readout, dial, bandRow, volRow, hint);
// ---------------------------------------------------------------- interaction
/** Set frequency from a 0..1 position within the current band. */
function tuneToFraction(f: number, band: Band) {
const { min, max } = BAND_RANGE[band];
control.setFrequency(min + Math.max(0, Math.min(1, f)) * (max - min));
}
function dialFractionFromEvent(clientX: number): number {
const r = dial.getBoundingClientRect();
return r.width > 0 ? (clientX - r.left) / r.width : 0;
}
let dragging = false;
dial.addEventListener('pointerdown', (e) => {
dragging = true;
dial.setPointerCapture?.(e.pointerId);
tuneToFraction(dialFractionFromEvent(e.clientX), control.getState().band);
});
dial.addEventListener('pointermove', (e) => {
if (dragging) tuneToFraction(dialFractionFromEvent(e.clientX), control.getState().band);
});
const endDrag = () => { dragging = false; };
dial.addEventListener('pointerup', endDrag);
dial.addEventListener('pointercancel', endDrag);
volTrack.addEventListener('pointerdown', (e) => {
const r = volTrack.getBoundingClientRect();
if (r.width > 0) control.volume((e.clientX - r.left) / r.width);
});
muteBtn.addEventListener('click', () => control.mute(!control.getState().muted));
function step(dir: number) {
const s = control.getState();
const { min, max, step } = BAND_RANGE[s.band];
control.setFrequency(Math.max(min, Math.min(max, s.freqMHz + dir * step)));
}
function cycleBand(dir: number) {
const s = control.getState();
const i = (BANDS.indexOf(s.band) + dir + BANDS.length) % BANDS.length;
control.setBand(BANDS[i]);
}
// ---------------------------------------------------------------- render from state
function render(s: RadioState) {
text(readout, stationReadout(s.freqMHz, s.band, s.station));
cls(readout, 'is-locked', s.station !== null);
const { min, max } = BAND_RANGE[s.band];
const frac = max > min ? (s.freqMHz - min) / (max - min) : 0;
style(dialFill, 'width', `${Math.max(0, Math.min(1, frac)) * 100}%`);
for (const b of bandBtns) cls(b.btn, 'is-active', b.band === s.band);
style(volFill, 'width', `${s.volume * 100}%`);
cls(muteBtn, 'is-on', s.muted);
text(muteBtn, s.muted ? COPY.tunerMuted : COPY.tunerMute);
cls(root, 'is-muted', s.muted);
}
const unsubscribe = control.subscribe(render);
void unsubscribe; // dock lives for the session; nothing to tear down
let revealed = false;
return {
el: root,
reveal() {
if (revealed) return;
revealed = true;
cls(root, 'is-revealed', true);
},
isRevealed: () => revealed,
onKey(key) {
if (!revealed) return false;
switch (key) {
case 'arrowleft': step(-1); return true;
case 'arrowright': step(1); return true;
case 'b': cycleBand(1); return true;
case 'm': control.mute(!control.getState().muted); return true;
default: return false;
}
},
isLive: () => live,
};
}
export type { RadioControl };

View File

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

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