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
|
**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
|
exists to test against; scrolling tab strip if the catalog grows again; era-skinned tech
|
||||||
panel once eras mean something visually (§8).
|
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() {
|
function render() {
|
||||||
buildButtons(pages[active]);
|
buildButtons(pages[active]);
|
||||||
for (let i = 0; i < tabs.length; i++) cls(tabs[i], 'is-active', i === 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();
|
sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,11 +13,14 @@ import { createFax } from './fax';
|
|||||||
import { createHotkeys } from './hotkeys';
|
import { createHotkeys } from './hotkeys';
|
||||||
import { createInspector } from './inspector';
|
import { createInspector } from './inspector';
|
||||||
import { createRoster, entityAt, syncRoster } from './pick';
|
import { createRoster, entityAt, syncRoster } from './pick';
|
||||||
|
import { createScreenView } from './screenview';
|
||||||
import { createSelection } from './selection';
|
import { createSelection } from './selection';
|
||||||
import { injectStyle } from './style';
|
import { injectStyle } from './style';
|
||||||
import { createTechPanel } from './techpanel';
|
import { createTechPanel } from './techpanel';
|
||||||
import { createTopStrip } from './topstrip';
|
import { createTopStrip } from './topstrip';
|
||||||
import { createToasts } from './toasts';
|
import { createToasts } from './toasts';
|
||||||
|
import { createTuner } from './tuner';
|
||||||
|
import { COPY } from './voice';
|
||||||
|
|
||||||
export function createUI(): UI {
|
export function createUI(): UI {
|
||||||
let machines = new Map<string, MachineDef>();
|
let machines = new Map<string, MachineDef>();
|
||||||
@ -27,11 +30,25 @@ export function createUI(): UI {
|
|||||||
let fax: ReturnType<typeof createFax>;
|
let fax: ReturnType<typeof createFax>;
|
||||||
let toasts: ReturnType<typeof createToasts>;
|
let toasts: ReturnType<typeof createToasts>;
|
||||||
let tech: ReturnType<typeof createTechPanel>;
|
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. */
|
/** Our own copy of the entity list; safe to read between frames. See pick.ts. */
|
||||||
const roster = createRoster();
|
const roster = createRoster();
|
||||||
let paused = false;
|
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 {
|
function machineName(entityId: number): string {
|
||||||
for (let i = 0; i < roster.length; i++) {
|
for (let i = 0; i < roster.length; i++) {
|
||||||
const r = roster.entries[i];
|
const r = roster.entries[i];
|
||||||
@ -48,12 +65,25 @@ export function createUI(): UI {
|
|||||||
const sel = createSelection(bus);
|
const sel = createSelection(bus);
|
||||||
top = createTopStrip(data);
|
top = createTopStrip(data);
|
||||||
build = createBuildBar(data, sel);
|
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);
|
fax = createFax(data);
|
||||||
toasts = createToasts();
|
toasts = createToasts();
|
||||||
tech = createTechPanel(data, bus);
|
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
|
// ------------------------------------------------------------------ keyboard
|
||||||
const keys = createHotkeys();
|
const keys = createHotkeys();
|
||||||
@ -63,10 +93,14 @@ export function createUI(): UI {
|
|||||||
keys.bind('r', () => sel.rotate());
|
keys.bind('r', () => sel.rotate());
|
||||||
keys.bind('x', () => sel.toggleRemove());
|
keys.bind('x', () => sel.toggleRemove());
|
||||||
keys.bind('t', () => tech.toggle());
|
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', () => {
|
keys.bind('escape', () => {
|
||||||
// One key, several jobs, in the order the player expects: put the tool down
|
// One key, several jobs, in the order the player expects: close the thing most
|
||||||
// first (wrecking ball included), then shut whatever is open.
|
// recently in front first, then put the tool down, then shut panels.
|
||||||
if (sel.isArmed()) sel.clear();
|
if (screenView.isOpen()) screenView.close();
|
||||||
|
else if (sel.isArmed()) sel.clear();
|
||||||
else if (tech.isOpen()) tech.close();
|
else if (tech.isOpen()) tech.close();
|
||||||
else inspector.close();
|
else inspector.close();
|
||||||
});
|
});
|
||||||
@ -97,8 +131,12 @@ export function createUI(): UI {
|
|||||||
if (!tile) return;
|
if (!tile) return;
|
||||||
|
|
||||||
const hit = entityAt(roster, machines, tile);
|
const hit = entityAt(roster, machines, tile);
|
||||||
if (hit === null) inspector.close();
|
if (hit === null) {
|
||||||
else inspector.open(hit);
|
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
|
// ?uidemo — build SIM's reference factory on boot. main.ts defines __fktryDemo
|
||||||
@ -122,9 +160,30 @@ export function createUI(): UI {
|
|||||||
fax.update(snap);
|
fax.update(snap);
|
||||||
tech.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) {
|
for (const ev of events) {
|
||||||
toasts.push(ev, machineName);
|
|
||||||
if (ev.kind === 'commissionDone') fax.stamp();
|
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());
|
toasts.update(performance.now());
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export interface Inspector {
|
|||||||
/** Heat above this throttles (CONTRACTS: "throttles >0.7, scrams at 1"). */
|
/** Heat above this throttles (CONTRACTS: "throttles >0.7, scrams at 1"). */
|
||||||
const THROTTLE_AT = 0.7;
|
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 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 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]));
|
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');
|
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, [
|
const body = el('div', undefined, [
|
||||||
recHead, recSelect, noRecipe,
|
recHead, recSelect, noRecipe,
|
||||||
inHead, inChips.el, outHead, outChips.el,
|
inHead, inChips.el, outHead, outChips.el,
|
||||||
@ -79,14 +85,16 @@ export function createInspector(data: GameData, bus: UIBus): Inspector {
|
|||||||
heatHead, heatBar, heatState,
|
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;
|
let openId: number | null = null;
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
if (openId === null) return; // already shut — don't re-notify selection
|
||||||
openId = null;
|
openId = null;
|
||||||
optionsFor = null;
|
optionsFor = null;
|
||||||
cls(root, 'is-open', false);
|
cls(root, 'is-open', false);
|
||||||
|
onClose?.();
|
||||||
}
|
}
|
||||||
closeBtn.addEventListener('click', close);
|
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);
|
const e = snap.entities.find((x) => x.id === openId);
|
||||||
if (!e) return close(); // unit was removed out from under us
|
if (!e) return close(); // unit was removed out from under us
|
||||||
paint(e);
|
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 { describe, expect, it } from 'vitest';
|
||||||
import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
|
import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
|
||||||
import { createSim } from '../sim';
|
import { createSim } from '../sim';
|
||||||
|
import { RESEARCH_TICKS_PER_PACK } from '../sim/constants';
|
||||||
import { referenceFactory } from '../sim/reference';
|
import { referenceFactory } from '../sim/reference';
|
||||||
|
import { grantAllResearch, grantResearch } from '../sim/testkit';
|
||||||
import { createUI } from './index';
|
import { createUI } from './index';
|
||||||
|
|
||||||
import items from '../../data/items.json';
|
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;
|
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) {
|
* v5 (order 4): research is granted through LANE-SIM's own `testkit`, not by hand-editing
|
||||||
const s = JSON.parse(sim.save());
|
* a save blob here. My round-4 version duplicated that round-trip and coupled this suite
|
||||||
s.research = { active: null, progress: {}, unlocked: DATA.tech.map((t) => t.id) };
|
* to SIM's save schema — exactly the fragility I flagged then.
|
||||||
sim.load(JSON.stringify(s));
|
*/
|
||||||
}
|
const unlockAll = (sim: Sim) => grantAllResearch(sim, DATA);
|
||||||
|
|
||||||
function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) {
|
function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) {
|
||||||
document.body.innerHTML = '<div id="game"></div><div id="ui"></div>';
|
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');
|
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', () => {
|
/**
|
||||||
const h = harness({ unlocked: true });
|
* DATA's v5 commission queue opens with `raw-payload` — 20 raw MDAT ORE — and the
|
||||||
let sawStanding = false;
|
* reference factory refines every scrap of its ore rather than shipping it, so the head
|
||||||
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
|
* commission can never complete and blocks all 19 behind it (see NOTES: head-of-line
|
||||||
h.step(100);
|
* blocking). A player would do the obvious thing: tap raw ore straight to an uplink.
|
||||||
if (on('.fk-fax-standing')) sawStanding = true;
|
* `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 });
|
||||||
}
|
}
|
||||||
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.
|
it('reads NEXT IN TRAY from the real commission queue, and advances it on completion', () => {
|
||||||
expect(sawStanding, 'a standing order should become active').toBe(true);
|
const h = harness({ unlocked: true });
|
||||||
const active = DATA.commissions.find((c) => c.id === snap.activeCommission);
|
h.step(10);
|
||||||
expect(active?.repeat).toBe(true);
|
|
||||||
|
|
||||||
// ...and the peek is the queue's second entry, not a guess from data order.
|
const first = h.sim.snapshot();
|
||||||
const nextId = snap.commissionQueue![1];
|
expect(first.commissionQueue, 'SIM publishes the queue').toBeTruthy();
|
||||||
if (nextId) {
|
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(on('.fk-fax-next')).toBe(true);
|
||||||
expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase());
|
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);
|
||||||
}
|
}
|
||||||
|
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', () => {
|
it('completes a commission and stamps the fax', () => {
|
||||||
const h = harness({ unlocked: true });
|
const h = harness({ unlocked: true });
|
||||||
|
shipRawOre(h);
|
||||||
let done = false;
|
let done = false;
|
||||||
for (let t = 0; t < 6000 && !done; t += 50) {
|
for (let t = 0; t < 12000 && !done; t += 50) {
|
||||||
h.step(50);
|
h.step(50);
|
||||||
if (toasts().some((x) => x?.includes('FAX SENT'))) done = true;
|
if (toasts().some((x) => x?.includes('FAX SENT'))) done = true;
|
||||||
}
|
}
|
||||||
@ -308,8 +328,19 @@ describe('research against the real sim', () => {
|
|||||||
lab.state.inputBuf = { ...cost };
|
lab.state.inputBuf = { ...cost };
|
||||||
h.sim.load(JSON.stringify(s));
|
h.sim.load(JSON.stringify(s));
|
||||||
|
|
||||||
// 4. the unlock lands: real event, toast in the house voice, padlock falls off.
|
// 4. the lab actually works for it: v5 SIM converts one pack per
|
||||||
h.step(3);
|
// 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(h.sim.snapshot().research!.unlocked).toContain(SOFTWARE_TECH);
|
||||||
expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true);
|
expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true);
|
||||||
|
|
||||||
@ -318,7 +349,7 @@ describe('research against the real sim', () => {
|
|||||||
.toBe(false);
|
.toBe(false);
|
||||||
expect(lockBtn()!.getAttribute('title')).not.toContain('REQUIRES:');
|
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.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
|
||||||
h.step(2);
|
h.step(2);
|
||||||
expect(h.find('software-decoder'), 'unlocked machine must place').toBeTruthy();
|
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', () => {
|
describe('remove mode against the real sim', () => {
|
||||||
it('demolishes the clicked unit and toasts it', () => {
|
it('demolishes the clicked unit and toasts it', () => {
|
||||||
const h = harness({ unlocked: true });
|
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' });
|
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', () => {
|
it('notifies listeners on change, and not on a no-op clear', () => {
|
||||||
const { bus } = makeBus();
|
const { bus } = makeBus();
|
||||||
const sel = createSelection(bus);
|
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()`
|
* main.ts owns the state and decides what a world click means (it places/removes); the
|
||||||
* (contracts v4 `SelectionState`). The round-3 `__remove` magic string is gone — the
|
* renderer reads the selection to draw the ghost, the demolition tint, or (v5) the
|
||||||
* renderer gets a real `mode` now — and so is our own `remove` dispatch: **main.ts
|
* inspect highlight. We only say what is held.
|
||||||
* dispatches the removal on click.** We only say what is held; main.ts decides what a
|
|
||||||
* click on the world means.
|
|
||||||
*
|
*
|
||||||
* `selection`/`setSelection` are optional in the contract, so we fall back to the legacy
|
* `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';
|
import type { Dir, SelectionState, UIBus } from '../contracts';
|
||||||
|
|
||||||
export interface BuildSelection {
|
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;
|
get(): { def: string; dir: Dir } | null;
|
||||||
/** Select a machine; selecting the one already selected clears it (toggle). */
|
/** Select a machine; selecting the one already selected clears it (toggle). */
|
||||||
select(def: string): void;
|
select(def: string): void;
|
||||||
rotate(): void;
|
rotate(): void;
|
||||||
/** Drop the build selection AND disarm remove mode. */
|
/** Drop everything — build, wrecking ball, and inspect. */
|
||||||
clear(): void;
|
clear(): void;
|
||||||
isRemoving(): boolean;
|
isRemoving(): boolean;
|
||||||
toggleRemove(): void;
|
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;
|
isArmed(): boolean;
|
||||||
onChange(cb: () => void): void;
|
onChange(cb: () => void): void;
|
||||||
}
|
}
|
||||||
@ -32,6 +39,7 @@ type LegacyBus = UIBus & { _sel?: { def: string; dir: Dir } | null };
|
|||||||
export function createSelection(bus: UIBus): BuildSelection {
|
export function createSelection(bus: UIBus): BuildSelection {
|
||||||
let sel: { def: string; dir: Dir } | null = null;
|
let sel: { def: string; dir: Dir } | null = null;
|
||||||
let removing = false;
|
let removing = false;
|
||||||
|
let inspecting: number | null = null;
|
||||||
const listeners: Array<() => void> = [];
|
const listeners: Array<() => void> = [];
|
||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
@ -39,40 +47,58 @@ export function createSelection(bus: UIBus): BuildSelection {
|
|||||||
? { mode: 'remove' }
|
? { mode: 'remove' }
|
||||||
: sel
|
: sel
|
||||||
? { mode: 'build', def: sel.def, dir: sel.dir }
|
? { mode: 'build', def: sel.def, dir: sel.dir }
|
||||||
|
: inspecting !== null
|
||||||
|
? { mode: 'inspect', entity: inspecting }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (bus.setSelection) {
|
if (bus.setSelection) {
|
||||||
bus.setSelection(state);
|
bus.setSelection(state);
|
||||||
} else {
|
} 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;
|
(bus as LegacyBus)._sel = removing ? { def: '__remove', dir: 0 } : sel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) {
|
function commit(next: {
|
||||||
sel = next;
|
sel?: { def: string; dir: Dir } | null;
|
||||||
removing = nextRemoving;
|
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();
|
publish();
|
||||||
for (const cb of listeners) cb();
|
for (const cb of listeners) cb();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: () => (removing ? null : sel),
|
get: () => (removing || inspecting !== null ? null : sel),
|
||||||
select(def) {
|
select(def) {
|
||||||
// Picking a machine puts the wrecking ball down — you can't build and demolish.
|
// Picking a machine puts the wrecking ball down and stops inspecting.
|
||||||
if (!removing && sel?.def === def) commit(null, false);
|
if (!removing && sel?.def === def) commit({}); // toggle off
|
||||||
else commit({ def, dir: sel?.dir ?? 0 }, false); // keep the dir across swaps
|
else commit({ sel: { def, dir: sel?.dir ?? 0 } }); // keep the dir across swaps
|
||||||
},
|
},
|
||||||
rotate() {
|
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() {
|
clear() {
|
||||||
if (sel || removing) commit(null, false);
|
if (sel || removing || inspecting !== null) commit({});
|
||||||
},
|
},
|
||||||
isRemoving: () => removing,
|
isRemoving: () => removing,
|
||||||
toggleRemove() {
|
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,
|
isArmed: () => removing || sel !== null,
|
||||||
onChange(cb) {
|
onChange(cb) {
|
||||||
listeners.push(cb);
|
listeners.push(cb);
|
||||||
|
|||||||
@ -125,8 +125,30 @@ const CSS = `
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kind tabs. A page holds at most nine so 1-9 always reaches every machine. */
|
/* 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; }
|
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 {
|
.fk-tab {
|
||||||
background: var(--fk-bg-solid);
|
background: var(--fk-bg-solid);
|
||||||
border: 1px solid var(--fk-line);
|
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: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; }
|
.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.
|
/* REMOVE is a mode, not a machine — it sits at the end of the strip and reads as a
|
||||||
No auto margin: with the RESEARCH tab added the row wraps, and an auto margin threw
|
hazard. It scrolls with the tabs; the hotkey (X) is always available regardless. */
|
||||||
REMOVE onto a line of its own. */
|
|
||||||
.fk-tab-remove { margin-left: 10px; color: var(--fk-dim); }
|
.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:hover { color: var(--fk-red); border-color: var(--fk-red); }
|
||||||
.fk-tab-remove.is-armed {
|
.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 { font-size: 10px; color: var(--fk-amber); margin-top: 3px; }
|
||||||
.fk-ins-heat-state.is-scram { color: var(--fk-red); font-weight: bold; }
|
.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. */
|
/* Process select: a native <select> beaten into the house style. */
|
||||||
.fk-ins-recipe {
|
.fk-ins-recipe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -354,10 +380,107 @@ const CSS = `
|
|||||||
100% { opacity: 0; }
|
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 */
|
/* ---------------------------------------------------------------- toasts */
|
||||||
|
|
||||||
#fk-toasts {
|
#fk-toasts {
|
||||||
position: fixed; bottom: 10px; right: 10px;
|
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;
|
display: flex; flex-direction: column; align-items: flex-end; gap: 4px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 11;
|
z-index: 11;
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import { eventToast } from './voice';
|
|||||||
export interface Toasts {
|
export interface Toasts {
|
||||||
el: HTMLElement;
|
el: HTMLElement;
|
||||||
push(ev: SimEvent, machineName: (entityId: number) => string): void;
|
push(ev: SimEvent, machineName: (entityId: number) => string): void;
|
||||||
|
/** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */
|
||||||
|
pushMessage(msg: string, kind: string): void;
|
||||||
|
/** Stand the stack off the bottom-right corner once the tuner dock moves in there. */
|
||||||
|
setStandoff(on: boolean): void;
|
||||||
/** Called once per frame to expire old lines. */
|
/** Called once per frame to expire old lines. */
|
||||||
update(nowMs: number): void;
|
update(nowMs: number): void;
|
||||||
}
|
}
|
||||||
@ -30,17 +34,22 @@ export function createToasts(): Toasts {
|
|||||||
setTimeout(() => entry.node.remove(), FADE_MS);
|
setTimeout(() => entry.node.remove(), FADE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emit(msg: string, kind: string) {
|
||||||
|
const node = el('div', `fk-toast k-${kind}`, [msg]);
|
||||||
|
root.append(node);
|
||||||
|
live.push({ node, dieAt: performance.now() + TTL_MS });
|
||||||
|
while (live.length > MAX) retire(live.shift()!);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
el: root,
|
el: root,
|
||||||
push(ev, machineName) {
|
push(ev, machineName) {
|
||||||
const msg = eventToast(ev, machineName);
|
const msg = eventToast(ev, machineName);
|
||||||
if (!msg) return;
|
if (msg) emit(msg, ev.kind);
|
||||||
|
},
|
||||||
const node = el('div', `fk-toast k-${ev.kind}`, [msg]);
|
pushMessage: emit,
|
||||||
root.append(node);
|
setStandoff(on) {
|
||||||
live.push({ node, dieAt: performance.now() + TTL_MS });
|
cls(root, 'is-standoff', on);
|
||||||
|
|
||||||
while (live.length > MAX) retire(live.shift()!);
|
|
||||||
},
|
},
|
||||||
update(nowMs) {
|
update(nowMs) {
|
||||||
while (live.length && live[0].dieAt <= nowMs) retire(live.shift()!);
|
while (live.length && live[0].dieAt <= nowMs) retire(live.shift()!);
|
||||||
|
|||||||
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.',
|
starved: 'INTAKE STARVED. NOTHING IS ARRIVING.',
|
||||||
'no recipe': 'NO PROCESS ASSIGNED. UNIT AWAITS INSTRUCTION.',
|
'no recipe': 'NO PROCESS ASSIGNED. UNIT AWAITS INSTRUCTION.',
|
||||||
'no power': 'INSUFFICIENT BANDWIDTH. THE MATHEMATICS ARE UNMOVED.',
|
'no power': 'INSUFFICIENT BANDWIDTH. THE MATHEMATICS ARE UNMOVED.',
|
||||||
|
'no seam': 'THERE IS NOTHING DOWN THERE. MOVE.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function jamText(reason: string): string {
|
export function jamText(reason: string): string {
|
||||||
@ -83,11 +84,34 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
|
|||||||
return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.';
|
return 'UNIT RECLAIMED. THE FLOOR REMEMBERS.';
|
||||||
case 'researched':
|
case 'researched':
|
||||||
return 'STANDARD RATIFIED. NEW CRIMES AVAILABLE.';
|
return 'STANDARD RATIFIED. NEW CRIMES AVAILABLE.';
|
||||||
|
case 'wildlife':
|
||||||
|
return wildlifeToast(ev.wildlife, ev.on);
|
||||||
|
case 'relicFound':
|
||||||
|
return COPY.relicDiscovery;
|
||||||
default:
|
default:
|
||||||
return null; // shipped/crafted/placed are too frequent to toast
|
return null; // shipped/crafted/placed are too frequent to toast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infestation copy. A dust pile spawning is a hatch warning (index.ts dedups it to one
|
||||||
|
* per pile — see the notices logic there); a swarm spawning is the bite already landing.
|
||||||
|
*/
|
||||||
|
export function wildlifeToast(
|
||||||
|
kind: 'dust-pile' | 'mosquito-swarm',
|
||||||
|
on: 'spawned' | 'cleared',
|
||||||
|
): string | null {
|
||||||
|
if (kind === 'dust-pile') {
|
||||||
|
return on === 'spawned'
|
||||||
|
? 'HF DUST IS POOLING. SOMETHING WILL HATCH IN IT.'
|
||||||
|
: null; // clearing a pile is housekeeping, not news
|
||||||
|
}
|
||||||
|
// mosquito-swarm
|
||||||
|
return on === 'spawned'
|
||||||
|
? 'MOSQUITO SWARM. QUANTIZATION NOISE HAS LEGS NOW.'
|
||||||
|
: 'SWARM DISPERSED. THE EDGES ARE QUIET AGAIN.';
|
||||||
|
}
|
||||||
|
|
||||||
export const COPY = {
|
export const COPY = {
|
||||||
bandwidth: 'BANDWIDTH',
|
bandwidth: 'BANDWIDTH',
|
||||||
brownout: 'BROWNOUT',
|
brownout: 'BROWNOUT',
|
||||||
@ -127,12 +151,43 @@ export const COPY = {
|
|||||||
requires: 'REQUIRES:',
|
requires: 'REQUIRES:',
|
||||||
|
|
||||||
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
|
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
|
||||||
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · T RESEARCH · SPACE HALT · CLICK A UNIT',
|
idleHint:
|
||||||
|
'1-9 SELECT · [ ] PAGE · X REMOVE · T RESEARCH · SPACE HALT · CLICK A UNIT' +
|
||||||
|
' · MMB/WASD PAN · WHEEL ZOOM',
|
||||||
|
|
||||||
removeLabel: 'REMOVE',
|
removeLabel: 'REMOVE',
|
||||||
removeKey: 'X',
|
removeKey: 'X',
|
||||||
removeHint: 'REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN',
|
removeHint: 'REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN',
|
||||||
|
|
||||||
|
/** The one hint the opening needs — fires once, on the first brownout ever. */
|
||||||
|
firstBrownoutMercy: 'NOTHING IS GENERATING. THE COMMITTEE SUGGESTS: POWER.',
|
||||||
|
|
||||||
|
/** Inspector line when a swarm is degrading the open machine. */
|
||||||
|
mosquitoExposure: 'MOSQUITO EXPOSURE',
|
||||||
|
mosquitoPenalty: (pct: number) => `PRECISION DOWN ${pct}%. SWARM ON THE EDGES.`,
|
||||||
|
|
||||||
|
/** relicFound ceremony. */
|
||||||
|
relicDiscovery: 'AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.',
|
||||||
|
|
||||||
|
// THE TUNER DOCK
|
||||||
|
tunerHeader: 'AUDIO BUS',
|
||||||
|
tunerBandLabel: 'BAND',
|
||||||
|
tunerVolLabel: 'VOL',
|
||||||
|
tunerMute: 'MUTE',
|
||||||
|
tunerMuted: 'MUTED',
|
||||||
|
tunerLockedSuffix: 'SIGNAL LOCKED',
|
||||||
|
tunerStatic: 'STATIC — TUNE FOR A STATION',
|
||||||
|
tunerHint: '◄ ► TUNE · B BAND · M MUTE',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type Band = 'AM' | 'FM' | 'SW';
|
||||||
|
|
||||||
|
/** "88.1 FKTRY FM — SIGNAL LOCKED" / "104.6 FM — STATIC …". Station names are flavor. */
|
||||||
|
export function stationReadout(freqMHz: number, band: Band, station: string | null): string {
|
||||||
|
const f = freqMHz.toFixed(1);
|
||||||
|
if (station) return `${f} ${station} ${band} — ${COPY.tunerLockedSuffix}`;
|
||||||
|
return `${f} ${band} — ${COPY.tunerStatic}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */
|
/** Direction names for the rotation readout. Dir 0..3 = N,E,S,W clockwise. */
|
||||||
export const DIR_NAME = ['N', 'E', 'S', 'W'] as const;
|
export const DIR_NAME = ['N', 'E', 'S', 'W'] as const;
|
||||||
|
|||||||
124
fktry/src/ui/wildlife.test.ts
Normal file
124
fktry/src/ui/wildlife.test.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { GameData, SimEvent, SimSnapshot, UIBus } from '../contracts';
|
||||||
|
import { createInspector } from './inspector';
|
||||||
|
import { eventToast, wildlifeToast } from './voice';
|
||||||
|
|
||||||
|
const named = (id: number) => `UNIT ${id}`;
|
||||||
|
|
||||||
|
describe('wildlife voice', () => {
|
||||||
|
it('warns that a dust pile is a nursery, not just a mess', () => {
|
||||||
|
expect(wildlifeToast('dust-pile', 'spawned')).toContain('HATCH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing when a pile is cleared — housekeeping is not news', () => {
|
||||||
|
expect(wildlifeToast('dust-pile', 'cleared')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a swarm and its dispersal differently', () => {
|
||||||
|
const on = wildlifeToast('mosquito-swarm', 'spawned');
|
||||||
|
const off = wildlifeToast('mosquito-swarm', 'cleared');
|
||||||
|
expect(on).toContain('MOSQUITO SWARM');
|
||||||
|
expect(off).not.toBe(on);
|
||||||
|
expect(off).toContain('DISPERSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes wildlife events through the standard toast path', () => {
|
||||||
|
const ev: SimEvent = {
|
||||||
|
kind: 'wildlife', on: 'spawned', wildlife: 'mosquito-swarm',
|
||||||
|
id: 1, pos: { x: 0, y: 0 }, tick: 5,
|
||||||
|
};
|
||||||
|
expect(eventToast(ev, named)).toContain('MOSQUITO SWARM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces the fossil in the house voice', () => {
|
||||||
|
const ev: SimEvent = { kind: 'relicFound', relic: 'optical-fossil', id: 1, tick: 9 };
|
||||||
|
expect(eventToast(ev, named)).toBe('AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ inspector exposure
|
||||||
|
|
||||||
|
const DATA: GameData = {
|
||||||
|
items: [],
|
||||||
|
machines: [{
|
||||||
|
id: 'quantizer', name: 'QUANTIZER', codex: '', kind: 'crafter',
|
||||||
|
footprint: { x: 2, y: 2 }, recipes: [], powerDraw: 0, asset: 'q',
|
||||||
|
}],
|
||||||
|
recipes: [],
|
||||||
|
tech: [],
|
||||||
|
commissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const bus: UIBus = {
|
||||||
|
dispatch: () => {},
|
||||||
|
selectedBuild: () => null,
|
||||||
|
pickTile: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function snapWith(wildlife?: SimSnapshot['wildlife']): SimSnapshot {
|
||||||
|
return {
|
||||||
|
tick: 1, paused: false,
|
||||||
|
entities: [{
|
||||||
|
id: 7, def: 'quantizer', pos: { x: 0, y: 0 }, dir: 0, recipe: null, progress: 0,
|
||||||
|
inputBuf: {}, outputBuf: {}, jammed: null, heat: 0,
|
||||||
|
}],
|
||||||
|
beltItems: [],
|
||||||
|
bandwidth: { gen: 10, draw: 1, stored: 0, brownout: false },
|
||||||
|
shippedTotal: {}, activeCommission: null, commissionProgress: {},
|
||||||
|
wildlife,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MOSQUITO EXPOSURE in the inspector', () => {
|
||||||
|
function open() {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const ins = createInspector(DATA, bus);
|
||||||
|
document.body.append(ins.el);
|
||||||
|
ins.open(7);
|
||||||
|
return ins;
|
||||||
|
}
|
||||||
|
const exposure = () => document.querySelector('.fk-ins-exposure') as HTMLElement;
|
||||||
|
|
||||||
|
it('shows nothing when no swarm is on this machine', () => {
|
||||||
|
const ins = open();
|
||||||
|
ins.update(snapWith([
|
||||||
|
{ id: 1, kind: 'mosquito-swarm', pos: { x: 9, y: 9 }, size: 0.8, target: 99 },
|
||||||
|
]));
|
||||||
|
expect(exposure().classList.contains('is-on')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the exposure and its severity when a swarm targets this machine', () => {
|
||||||
|
const ins = open();
|
||||||
|
ins.update(snapWith([
|
||||||
|
{ id: 1, kind: 'mosquito-swarm', pos: { x: 0, y: 0 }, size: 0.42, target: 7 },
|
||||||
|
]));
|
||||||
|
expect(exposure().classList.contains('is-on')).toBe(true);
|
||||||
|
expect(document.querySelector('.fk-ins-exposure-pct')!.textContent)
|
||||||
|
.toBe('PRECISION DOWN 42%. SWARM ON THE EDGES.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores dust piles — a pile is not biting anything yet', () => {
|
||||||
|
const ins = open();
|
||||||
|
ins.update(snapWith([
|
||||||
|
{ id: 2, kind: 'dust-pile', pos: { x: 0, y: 0 }, size: 1, target: 7 },
|
||||||
|
]));
|
||||||
|
expect(exposure().classList.contains('is-on')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the line when the swarm is dispersed', () => {
|
||||||
|
const ins = open();
|
||||||
|
ins.update(snapWith([
|
||||||
|
{ id: 1, kind: 'mosquito-swarm', pos: { x: 0, y: 0 }, size: 0.5, target: 7 },
|
||||||
|
]));
|
||||||
|
expect(exposure().classList.contains('is-on')).toBe(true);
|
||||||
|
ins.update(snapWith([]));
|
||||||
|
expect(exposure().classList.contains('is-on')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a sim that reports no wildlife at all', () => {
|
||||||
|
const ins = open();
|
||||||
|
ins.update(snapWith(undefined));
|
||||||
|
expect(exposure().classList.contains('is-on')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user