Compare commits
No commits in common. "0cc80d3c82e74b241a8da4efd3adfa940f14be16" and "c441ec3a530975ed9f65c012b4420ef4a9bd636a" have entirely different histories.
0cc80d3c82
...
c441ec3a53
@ -471,120 +471,3 @@ 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,9 +115,6 @@ 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,14 +13,11 @@ 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>();
|
||||||
@ -30,25 +27,11 @@ 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];
|
||||||
@ -65,25 +48,12 @@ 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);
|
||||||
// v5: closing the inspector — by X, by Esc, or because its unit was demolished —
|
inspector = createInspector(data, bus);
|
||||||
// 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'));
|
|
||||||
|
|
||||||
// A held tool and an open inspector are mutually exclusive: picking a machine or
|
root.append(top.el, build.el, inspector.el, fax.el, tech.el, toasts.el);
|
||||||
// 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();
|
||||||
@ -93,14 +63,10 @@ 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: close the thing most
|
// One key, several jobs, in the order the player expects: put the tool down
|
||||||
// recently in front first, then put the tool down, then shut panels.
|
// first (wrecking ball included), then shut whatever is open.
|
||||||
if (screenView.isOpen()) screenView.close();
|
if (sel.isArmed()) sel.clear();
|
||||||
else if (sel.isArmed()) sel.clear();
|
|
||||||
else if (tech.isOpen()) tech.close();
|
else if (tech.isOpen()) tech.close();
|
||||||
else inspector.close();
|
else inspector.close();
|
||||||
});
|
});
|
||||||
@ -131,12 +97,8 @@ 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) {
|
if (hit === null) inspector.close();
|
||||||
inspector.close();
|
else inspector.open(hit);
|
||||||
} 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
|
||||||
@ -160,30 +122,9 @@ 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) {
|
||||||
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);
|
toasts.push(ev, machineName);
|
||||||
|
if (ev.kind === 'commissionDone') fax.stamp();
|
||||||
// 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, onClose?: () => void): Inspector {
|
export function createInspector(data: GameData, bus: UIBus): 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,12 +72,6 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
|||||||
|
|
||||||
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,
|
||||||
@ -85,16 +79,14 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
|||||||
heatHead, heatBar, heatState,
|
heatHead, heatBar, heatState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
root.append(head, flavor, el('div', 'fk-rule'), body, exposure, jam);
|
root.append(head, flavor, el('div', 'fk-rule'), body, 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);
|
||||||
|
|
||||||
@ -198,16 +190,6 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
|||||||
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,9 +17,7 @@
|
|||||||
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';
|
||||||
@ -30,12 +28,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(). */
|
||||||
* v5 (order 4): research is granted through LANE-SIM's own `testkit`, not by hand-editing
|
function unlockAll(sim: Sim) {
|
||||||
* a save blob here. My round-4 version duplicated that round-trip and coupled this suite
|
const s = JSON.parse(sim.save());
|
||||||
* to SIM's save schema — exactly the fragility I flagged then.
|
s.research = { active: null, progress: {}, unlocked: DATA.tech.map((t) => t.id) };
|
||||||
*/
|
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>';
|
||||||
@ -178,51 +176,33 @@ 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', () => {
|
||||||
* DATA's v5 commission queue opens with `raw-payload` — 20 raw MDAT ORE — and the
|
|
||||||
* reference factory refines every scrap of its ore rather than shipping it, so the head
|
|
||||||
* commission can never complete and blocks all 19 behind it (see NOTES: head-of-line
|
|
||||||
* blocking). A player would do the obvious thing: tap raw ore straight to an uplink.
|
|
||||||
* `GameData` ships no seam map, so the whole map is minable and this works anywhere.
|
|
||||||
*/
|
|
||||||
function shipRawOre(h: ReturnType<typeof harness>, x = 24, y = 24) {
|
|
||||||
// extractor (2x2) -> belt -> uplink (2x2), all facing east. The belt is required:
|
|
||||||
// machines only ever push onto belts, never straight into an adjacent machine.
|
|
||||||
h.bus.dispatch({ kind: 'place', def: 'seam-extractor', pos: { x, y }, dir: 1 });
|
|
||||||
h.bus.dispatch({ kind: 'place', def: 'belt', pos: { x: x + 2, y }, dir: 1 });
|
|
||||||
h.bus.dispatch({ kind: 'place', def: 'shipper', pos: { x: x + 3, y }, dir: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
it('reads NEXT IN TRAY from the real commission queue, and advances it on completion', () => {
|
|
||||||
const h = harness({ unlocked: true });
|
const h = harness({ unlocked: true });
|
||||||
h.step(10);
|
let sawStanding = false;
|
||||||
|
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
|
||||||
const first = h.sim.snapshot();
|
|
||||||
expect(first.commissionQueue, 'SIM publishes the queue').toBeTruthy();
|
|
||||||
expect(first.activeCommission).toBe(first.commissionQueue![0]);
|
|
||||||
|
|
||||||
// The peek is the queue's second entry, not a guess from data order.
|
|
||||||
const nextId = first.commissionQueue![1];
|
|
||||||
expect(on('.fk-fax-next')).toBe(true);
|
|
||||||
expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase());
|
|
||||||
|
|
||||||
// Satisfy the head commission for real, and watch the tray move up.
|
|
||||||
shipRawOre(h);
|
|
||||||
for (let t = 0; t < 12000 && h.sim.snapshot().activeCommission === first.activeCommission; t += 100) {
|
|
||||||
h.step(100);
|
h.step(100);
|
||||||
|
if (on('.fk-fax-standing')) sawStanding = true;
|
||||||
|
}
|
||||||
|
const snap = h.sim.snapshot();
|
||||||
|
expect(snap.commissionQueue, 'SIM publishes the queue').toBeTruthy();
|
||||||
|
|
||||||
|
// A repeat commission reached the head of SIM's real queue and the fax stamped it.
|
||||||
|
expect(sawStanding, 'a standing order should become active').toBe(true);
|
||||||
|
const active = DATA.commissions.find((c) => c.id === snap.activeCommission);
|
||||||
|
expect(active?.repeat).toBe(true);
|
||||||
|
|
||||||
|
// ...and the peek is the queue's second entry, not a guess from data order.
|
||||||
|
const nextId = snap.commissionQueue![1];
|
||||||
|
if (nextId) {
|
||||||
|
expect(on('.fk-fax-next')).toBe(true);
|
||||||
|
expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase());
|
||||||
}
|
}
|
||||||
const after = h.sim.snapshot();
|
|
||||||
expect(after.activeCommission, 'the queue should advance once the head is paid')
|
|
||||||
.toBe(nextId);
|
|
||||||
expect($('.fk-fax-next-name').textContent)
|
|
||||||
.toBe(after.commissionQueue![1].replace(/-/g, ' ').toUpperCase());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('completes a commission and stamps the fax', () => {
|
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 < 12000 && !done; t += 50) {
|
for (let t = 0; t < 6000 && !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;
|
||||||
}
|
}
|
||||||
@ -328,19 +308,8 @@ 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 lab actually works for it: v5 SIM converts one pack per
|
// 4. the unlock lands: real event, toast in the house voice, padlock falls off.
|
||||||
// RESEARCH_TICKS_PER_PACK ticks, so research is a process, not an instant.
|
h.step(3);
|
||||||
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);
|
||||||
|
|
||||||
@ -349,7 +318,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:');
|
||||||
|
|
||||||
// 6. and now it builds.
|
// 5. 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();
|
||||||
@ -357,79 +326,6 @@ 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 });
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
// @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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,84 +121,6 @@ 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,35 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* LANE-UI — the player's current intent: a pending build, remove mode, or an inspected
|
* LANE-UI — the pending build selection, and remove mode.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* main.ts owns the state and decides what a world click means (it places/removes); the
|
* v4: the selection is main.ts's state and we write it through `bus.setSelection()`
|
||||||
* renderer reads the selection to draw the ghost, the demolition tint, or (v5) the
|
* (contracts v4 `SelectionState`). The round-3 `__remove` magic string is gone — the
|
||||||
* inspect highlight. We only say what is held.
|
* renderer gets a real `mode` now — and so is our own `remove` dispatch: **main.ts
|
||||||
|
* dispatches the removal on click.** We only say what is held; main.ts decides what a
|
||||||
|
* click on the world means.
|
||||||
*
|
*
|
||||||
* `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). The
|
* `_sel` field if a host doesn't serve them yet (live.test.ts's own bus, for one).
|
||||||
* 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 removing or inspecting. */
|
/** The pending *build*, or null. Null whenever remove mode is armed. */
|
||||||
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 everything — build, wrecking ball, and inspect. */
|
/** Drop the build selection AND disarm remove mode. */
|
||||||
clear(): void;
|
clear(): void;
|
||||||
isRemoving(): boolean;
|
isRemoving(): boolean;
|
||||||
toggleRemove(): void;
|
toggleRemove(): void;
|
||||||
/** v5: publish that the inspector is showing this entity, so the renderer highlights it. */
|
/** True when the player is holding anything at all (build or the wrecking ball). */
|
||||||
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;
|
||||||
}
|
}
|
||||||
@ -39,7 +32,6 @@ 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() {
|
||||||
@ -47,58 +39,40 @@ 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
|
: null;
|
||||||
? { mode: 'inspect', entity: inspecting }
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (bus.setSelection) {
|
if (bus.setSelection) {
|
||||||
bus.setSelection(state);
|
bus.setSelection(state);
|
||||||
} else {
|
} else {
|
||||||
// Legacy host: keep the old build/remove shape alive. Inspect has no legacy form —
|
// Legacy host: keep the old shape alive rather than silently doing nothing.
|
||||||
// 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: {
|
function commit(next: { def: string; dir: Dir } | null, nextRemoving: boolean) {
|
||||||
sel?: { def: string; dir: Dir } | null;
|
sel = next;
|
||||||
removing?: boolean;
|
removing = nextRemoving;
|
||||||
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 || inspecting !== null ? null : sel),
|
get: () => (removing ? null : sel),
|
||||||
select(def) {
|
select(def) {
|
||||||
// Picking a machine puts the wrecking ball down and stops inspecting.
|
// Picking a machine puts the wrecking ball down — you can't build and demolish.
|
||||||
if (!removing && sel?.def === def) commit({}); // toggle off
|
if (!removing && sel?.def === def) commit(null, false);
|
||||||
else commit({ sel: { def, dir: sel?.dir ?? 0 } }); // keep the dir across swaps
|
else commit({ def, dir: sel?.dir ?? 0 }, false); // keep the dir across swaps
|
||||||
},
|
},
|
||||||
rotate() {
|
rotate() {
|
||||||
if (!removing && sel) commit({ sel: { def: sel.def, dir: ((sel.dir + 1) % 4) as Dir } });
|
if (!removing && sel) commit({ def: sel.def, dir: ((sel.dir + 1) % 4) as Dir }, false);
|
||||||
},
|
},
|
||||||
clear() {
|
clear() {
|
||||||
if (sel || removing || inspecting !== null) commit({});
|
if (sel || removing) commit(null, false);
|
||||||
},
|
},
|
||||||
isRemoving: () => removing,
|
isRemoving: () => removing,
|
||||||
toggleRemove() {
|
toggleRemove() {
|
||||||
commit({ removing: !removing });
|
commit(null, !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,30 +125,8 @@ 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. */
|
||||||
v5: a single scrolling strip rather than a wrapping row — the catalog keeps growing
|
.fk-tabs { display: flex; gap: 3px; margin-bottom: 5px; flex-wrap: wrap; justify-content: center; }
|
||||||
(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);
|
||||||
@ -160,8 +138,9 @@ 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 at the end of the strip and reads as a
|
/* REMOVE is a mode, not a machine — it sits with the tabs and reads as a hazard.
|
||||||
hazard. It scrolls with the tabs; the hotkey (X) is always available regardless. */
|
No auto margin: with the RESEARCH tab added the row wraps, and an auto margin threw
|
||||||
|
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 {
|
||||||
@ -248,11 +227,6 @@ 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%;
|
||||||
@ -380,107 +354,10 @@ 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,10 +11,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -34,22 +30,17 @@ 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) emit(msg, ev.kind);
|
if (!msg) return;
|
||||||
},
|
|
||||||
pushMessage: emit,
|
const node = el('div', `fk-toast k-${ev.kind}`, [msg]);
|
||||||
setStandoff(on) {
|
root.append(node);
|
||||||
cls(root, 'is-standoff', on);
|
live.push({ node, dieAt: performance.now() + TTL_MS });
|
||||||
|
|
||||||
|
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()!);
|
||||||
|
|||||||
@ -1,171 +0,0 @@
|
|||||||
// @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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,7 +58,6 @@ 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 {
|
||||||
@ -84,34 +83,11 @@ 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',
|
||||||
@ -151,43 +127,12 @@ export const COPY = {
|
|||||||
requires: 'REQUIRES:',
|
requires: 'REQUIRES:',
|
||||||
|
|
||||||
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
|
placingHint: 'LMB PLACE · R ROTATE · ESC CANCEL',
|
||||||
idleHint:
|
idleHint: '1-9 SELECT · [ ] PAGE · X REMOVE · T RESEARCH · SPACE HALT · CLICK A UNIT',
|
||||||
'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;
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
// @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