Compare commits
8 Commits
c441ec3a53
...
0cc80d3c82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cc80d3c82 | ||
|
|
4da8096de9 | ||
|
|
0bf0e5a076 | ||
|
|
d6d6833b78 | ||
|
|
5d6afdac97 | ||
|
|
e24100c26e | ||
|
|
acb1d88038 | ||
|
|
7e24eab171 |
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
},
|
||||
|
||||
@ -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)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
80
fktry/src/ui/radio.ts
Normal 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 };
|
||||
}
|
||||
80
fktry/src/ui/screenview.test.ts
Normal file
80
fktry/src/ui/screenview.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
82
fktry/src/ui/screenview.ts
Normal file
82
fktry/src/ui/screenview.ts
Normal 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 };
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
171
fktry/src/ui/tuner.test.ts
Normal 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
154
fktry/src/ui/tuner.ts
Normal 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 };
|
||||
@ -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