[ui] round 6: mite surfacing, dial tick marks, and a credits footer
- The Correction gets a voice: parity-mite arrival/swat copy, repaired-event
toasts ("IT LEFT A RECEIPT."), a CORRECTED ticker in the top strip, and a
per-unit FIREWALL LOG in the inspector. All clinical white-green (codex §8),
hidden until the first edit so the enemy is never signposted early.
- Tuner dial draws anonymous station ticks from the speaker adapter's
listStations() (MHz, empty pre-gesture) — landmarks that mark THAT, not WHAT.
- ABOUT/CREDITS footer on the tech panel: repo, codex, and a standing
synthetic-voice disclosure slot for the voice-cloning plan.
- 213 UI tests (was 194): new corrections.ts + mite.test.ts, extended tuner
and techpanel suites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d313f2a163
commit
eafd31f263
@ -565,3 +565,103 @@ convention. Not blocking: the guarded bridge works.
|
|||||||
it sounds in NOTES, since they're doing the same); inspect highlight once RENDER reads the
|
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
|
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.
|
head-of-line blocking is a design question for you, not a UI fix.
|
||||||
|
|
||||||
|
### Round 6 — 2026-07-20 — Opus 4.8
|
||||||
|
|
||||||
|
**SHIPPED:** All three orders. New modules: `corrections.ts` (session tally of The
|
||||||
|
Correction's edits) + `mite.test.ts`. Extended: `voice.ts`, `topstrip.ts`, `inspector.ts`,
|
||||||
|
`tuner.ts`, `radio.ts`, `techpanel.ts`, `toasts.ts`, `index.ts`, `style.ts`, and the tuner/
|
||||||
|
techpanel test files. **213 UI tests** (was 194); every `src/ui/**` file green; `npm run
|
||||||
|
check` reports zero errors in my lane (the one tsc error is SCREEN's, below).
|
||||||
|
|
||||||
|
1. **Mite surfacing — the enemy has copy now.** Replaced the v8 placeholder in
|
||||||
|
`wildlifeToast`: a mite arriving is "A PARITY MITE IS EDITING YOUR MELT.", swatting one
|
||||||
|
is "MITE SWATTED. IT FORGIVES YOU. IT ALWAYS FORGIVES YOU." (the Correction's manners
|
||||||
|
are the insult). `repaired` events (v8) now toast in the house voice —
|
||||||
|
"THE CORRECTION IMPROVED YOUR <ITEM>. IT LEFT A RECEIPT." — item name resolved from
|
||||||
|
`data.items` via a resolver threaded through `toasts.push → eventToast`. Two more
|
||||||
|
surfaces so a raid is narratable without watching the belts: a **CORRECTED ticker** in
|
||||||
|
the top strip (session total + per-item chips, hidden until the first edit so the enemy
|
||||||
|
is never signposted early), and a **FIREWALL LOG** in the inspector — per-unit catch
|
||||||
|
count, attributed by footprint containment of each `repaired` pos. The Correction reads
|
||||||
|
clinical white-green everywhere (codex §8: hospital-white, green checkmarks), calm next
|
||||||
|
to the hot alarms — that's the intended menace.
|
||||||
|
2. **Tuner tick marks.** The dial now draws a hot-magenta tick per station on the current
|
||||||
|
band, positioned by `(freqMHz-min)/(max-min)`, from the speaker adapter's
|
||||||
|
`control.listStations()` (added optional `listStations()` + `StationMark` to
|
||||||
|
`RadioControl`; the echo stand-in returns `[]`). Ticks are **anonymous** — they mark
|
||||||
|
THAT a station is there, not WHAT; the readout stays the only thing that names a lock,
|
||||||
|
so the hunt is intact. Rebuilt only on band/station-set change; empty pre-gesture and
|
||||||
|
fills in on the first state push after the engine attaches — i.e. exactly when audio
|
||||||
|
comes alive.
|
||||||
|
3. **ABOUT/CREDITS.** A footer button on the tech panel toggles a colophon: repo
|
||||||
|
(gitea.partly.party/monster/glytch), codex (docs/FKTRY_LORE.md), a VOICES line, and a
|
||||||
|
**standing disclosure slot** — "SYNTHETIC VOICE CREDIT: NONE ON FILE." — reserved for
|
||||||
|
the voice-cloning plan's real credit when it ships.
|
||||||
|
|
||||||
|
**VERIFIED — live, on :8153 (?uidemo)**: app boots, **no console errors**. Credits: pressed
|
||||||
|
into the DOM path with a real click event → block reveals with repo/codex/voices and the
|
||||||
|
disclosure slot (text asserted). Confirmed present-and-correctly-hidden by default:
|
||||||
|
`.fk-corrected-wrap` (display:none pre-mite), `.fk-tuner-ticks` (0 ticks pre-gesture),
|
||||||
|
`.fk-ins-firewall` (hidden). `--fk-clinic` resolves to #d6f5df. The tab backgrounds
|
||||||
|
(viewport 0x0, rAF paused) exactly as every prior round documents, so the mite raid itself
|
||||||
|
isn't drivable live here — see below.
|
||||||
|
|
||||||
|
**VERIFIED — headless** (`mite.test.ts`, real modules, happy-dom, 14 tests): the mite voice
|
||||||
|
(arrival/swat/receipt copy + name fallback); the tally (ignores non-repairs, first-seen
|
||||||
|
per-item order, footprint containment); the FIREWALL LOG (hidden at zero, singular/plural
|
||||||
|
"1 TIME"/"2 TIMES", survives no-corrections); the CORRECTED ticker (hidden until first edit,
|
||||||
|
then count + chips). Plus `tuner.test.ts` (+2): no ticks pre-attach, one tick per on-band
|
||||||
|
station at the right `left%`, anonymous. `techpanel.test.ts` (+3): ABOUT collapsed, reveals
|
||||||
|
repo/codex/slot, toggles shut.
|
||||||
|
|
||||||
|
**DECISIONS:**
|
||||||
|
- **`repaired.pos` is the mite's belt tile, not a machine.** SIM repairs belt *items* (index.ts
|
||||||
|
~1316), so the event lands on a belt tile. FIREWALL LOG therefore shows catches on whatever
|
||||||
|
unit's footprint that tile falls in — usually the belt itself (1×1), sometimes a crafter if
|
||||||
|
the corrected item sat on its tile. That's honest: it's "edits on THIS unit's tiles", and
|
||||||
|
belts are inspectable. Not a per-machine damage total (SIM doesn't expose one).
|
||||||
|
- **"Firewall" is grounded, not invented.** SIM already has `FIREWALL_RADIUS` /
|
||||||
|
`miteInFirewallRange` (index.ts ~1329) — firewalls can mites on the approach. Naming the
|
||||||
|
inspector's catch log FIREWALL LOG matches that fiction rather than coining a term.
|
||||||
|
- Item-name resolution stays data-driven (LANE-DATA's `ItemDef.name` wins); the resolver
|
||||||
|
falls back to dashes→spaces+uppercase so a brand-new item never crashes a toast.
|
||||||
|
- Corrections state is session chrome, deliberately not persisted — the sim doesn't persist
|
||||||
|
it either, and re-deriving it from a save would be a lie about what actually happened.
|
||||||
|
|
||||||
|
**BLOCKED/BROKEN (read this part):**
|
||||||
|
- **The mite raid is reachable by play but not in this harness.** Mites spawn once
|
||||||
|
`corruptionShipped()/MITE_SPAWN_PER` clears a threshold (index.ts ~1282), so the reference
|
||||||
|
factory *will* eventually breed them and fire `repaired` — but that needs sustained
|
||||||
|
runtime, and the browser pane backgrounds the tab (rAF paused, viewport 0x0), the same
|
||||||
|
wall documented in rounds 3-5. So the mite surfaces are verified headless against the real
|
||||||
|
UI modules, and live only for DOM presence/default-state/no-errors. If you can drive a
|
||||||
|
foregrounded build to the mite threshold, the ticker/firewall/toasts light up with zero
|
||||||
|
further change.
|
||||||
|
- **`npm run check` has one error, and it is not mine: `src/screen/index.ts:106` ("function
|
||||||
|
must return a value").** SCREEN's file is modified-uncommitted in the shared tree. My lane
|
||||||
|
is clean — every `src/ui/**` file type-checks. esbuild strips types so the dev build boots
|
||||||
|
fine regardless.
|
||||||
|
- **`npm test`: 4 failures across 3 files, none mine** — `src/audio/band.test.ts` (SCREEN),
|
||||||
|
`src/sim/mite.test.ts` (2) and `src/sim/reference.test.ts` (SIM). All three files are
|
||||||
|
modified-uncommitted by their lanes. Confirmed by ownership: I touched no `src/sim`,
|
||||||
|
`src/audio`, or `src/screen` file. All 17 `src/ui/**` test files pass (213 tests).
|
||||||
|
- Re-probed HEAD live, not from a round-start grep: SIM *does* emit both `parity-mite` and
|
||||||
|
`repaired` at HEAD (index.ts 1288/1321), and the adapter's `listStations()` is live in
|
||||||
|
main.ts — so nothing here is speculative against a contract that hadn't landed.
|
||||||
|
|
||||||
|
**CONTRACT REQUEST:** none. Contracts v8 (`repaired`) + the speaker adapter's `listStations()`
|
||||||
|
gave me everything these orders needed.
|
||||||
|
|
||||||
|
**PROPOSAL:** a `?mites` dev flag (or a `testkit` verb) that seeds N parity-mites / forces a
|
||||||
|
`repaired` on boot, the mite analogue of `?uidemo`. Every mite surface I built is only
|
||||||
|
observable once corruption crosses a spawn threshold that the backgrounded harness can't
|
||||||
|
reach; a one-command seed would make the raid demoable in the browser instead of only in
|
||||||
|
`mite.test.ts`. Small, and it'd live in main.ts (yours) or SIM's testkit, not my lane.
|
||||||
|
|
||||||
|
**NEXT (suggested):** fill the credits' synthetic-voice slot when the voice-cloning plan
|
||||||
|
ships; name-on-hover for dial ticks *only* if playtesting says the hunt is too hard (default
|
||||||
|
stays anonymous); a per-machine "corrected N times" once/if SIM attributes repairs to a
|
||||||
|
machine rather than a belt tile; Checksum Wardens / Redundancy Gunships get the same
|
||||||
|
surfacing treatment when they arrive (the corrections module generalises to any Correction
|
||||||
|
edit event).
|
||||||
|
|||||||
56
fktry/src/ui/corrections.ts
Normal file
56
fktry/src/ui/corrections.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* LANE-UI — the session tally of The Correction's edits (round 6).
|
||||||
|
*
|
||||||
|
* A `repaired` event (contracts v8) is a parity mite quietly "fixing" one of your
|
||||||
|
* artifacts back into clean product at a tile. Nothing in the snapshot remembers this —
|
||||||
|
* the event is edge-triggered and gone — so the UI keeps its own running count for the
|
||||||
|
* two places that narrate the raid: the top-strip ticker (per-item totals) and the
|
||||||
|
* inspector's FIREWALL LOG (per-unit catches, resolved by footprint).
|
||||||
|
*
|
||||||
|
* This is session chrome, not sim truth: it resets when the page reloads, which is the
|
||||||
|
* honest thing to do since the sim doesn't persist it either.
|
||||||
|
*/
|
||||||
|
import type { SimEvent, Vec2 } from '../contracts';
|
||||||
|
|
||||||
|
interface Repair {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
item: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Corrections {
|
||||||
|
/** Record one `repaired` event. Ignores anything else, so callers can pass it raw. */
|
||||||
|
record(ev: SimEvent): void;
|
||||||
|
/** How many edits total this session. 0 before the first mite ever appears. */
|
||||||
|
total(): number;
|
||||||
|
/** Per-item totals, newest-touched first, for the ticker chip row. */
|
||||||
|
byItem(): { item: string; count: number }[];
|
||||||
|
/** Edits that landed on the given footprint rect — the inspector's per-unit "catches". */
|
||||||
|
catchesOn(pos: Vec2, footprint: Vec2): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCorrections(): Corrections {
|
||||||
|
const repairs: Repair[] = [];
|
||||||
|
// Insertion-ordered per-item counts (Map preserves first-seen order).
|
||||||
|
const perItem = new Map<string, number>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
record(ev) {
|
||||||
|
if (ev.kind !== 'repaired') return;
|
||||||
|
repairs.push({ x: ev.pos.x, y: ev.pos.y, item: ev.item });
|
||||||
|
perItem.set(ev.item, (perItem.get(ev.item) ?? 0) + 1);
|
||||||
|
},
|
||||||
|
total: () => repairs.length,
|
||||||
|
byItem: () => [...perItem].map(([item, count]) => ({ item, count })),
|
||||||
|
catchesOn(pos, footprint) {
|
||||||
|
let n = 0;
|
||||||
|
for (const r of repairs) {
|
||||||
|
if (
|
||||||
|
r.x >= pos.x && r.x < pos.x + footprint.x &&
|
||||||
|
r.y >= pos.y && r.y < pos.y + footprint.y
|
||||||
|
) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -7,8 +7,9 @@
|
|||||||
* - We never mutate the snapshot and never hold a reference to it across frames.
|
* - We never mutate the snapshot and never hold a reference to it across frames.
|
||||||
* - All mutation goes out as Commands on the bus.
|
* - All mutation goes out as Commands on the bus.
|
||||||
*/
|
*/
|
||||||
import type { GameData, MachineDef, SimEvent, SimSnapshot, UI, UIBus } from '../contracts';
|
import type { GameData, ItemDef, MachineDef, SimEvent, SimSnapshot, UI, UIBus } from '../contracts';
|
||||||
import { createBuildBar } from './buildbar';
|
import { createBuildBar } from './buildbar';
|
||||||
|
import { createCorrections } from './corrections';
|
||||||
import { createFax } from './fax';
|
import { createFax } from './fax';
|
||||||
import { createHotkeys } from './hotkeys';
|
import { createHotkeys } from './hotkeys';
|
||||||
import { createInspector } from './inspector';
|
import { createInspector } from './inspector';
|
||||||
@ -24,6 +25,7 @@ import { COPY } from './voice';
|
|||||||
|
|
||||||
export function createUI(): UI {
|
export function createUI(): UI {
|
||||||
let machines = new Map<string, MachineDef>();
|
let machines = new Map<string, MachineDef>();
|
||||||
|
let itemName: (id: string) => string = (id) => id.replace(/-/g, ' ').toUpperCase();
|
||||||
let top: ReturnType<typeof createTopStrip>;
|
let top: ReturnType<typeof createTopStrip>;
|
||||||
let build: ReturnType<typeof createBuildBar>;
|
let build: ReturnType<typeof createBuildBar>;
|
||||||
let inspector: ReturnType<typeof createInspector>;
|
let inspector: ReturnType<typeof createInspector>;
|
||||||
@ -35,6 +37,8 @@ export function createUI(): UI {
|
|||||||
|
|
||||||
/** 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();
|
||||||
|
/** Session tally of The Correction's edits — feeds the ticker + firewall log (round 6). */
|
||||||
|
const corrections = createCorrections();
|
||||||
let paused = false;
|
let paused = false;
|
||||||
|
|
||||||
// ---- onboarding + ceremony state (per session; none of it is sim truth) ----
|
// ---- onboarding + ceremony state (per session; none of it is sim truth) ----
|
||||||
@ -61,6 +65,8 @@ export function createUI(): UI {
|
|||||||
init(root: HTMLElement, data: GameData, bus: UIBus) {
|
init(root: HTMLElement, data: GameData, bus: UIBus) {
|
||||||
injectStyle();
|
injectStyle();
|
||||||
machines = new Map(data.machines.map((m) => [m.id, m]));
|
machines = new Map(data.machines.map((m) => [m.id, m]));
|
||||||
|
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
|
||||||
|
itemName = (id) => items.get(id)?.name ?? id.replace(/-/g, ' ').toUpperCase();
|
||||||
|
|
||||||
const sel = createSelection(bus);
|
const sel = createSelection(bus);
|
||||||
top = createTopStrip(data);
|
top = createTopStrip(data);
|
||||||
@ -154,9 +160,9 @@ export function createUI(): UI {
|
|||||||
paused = snap.paused;
|
paused = snap.paused;
|
||||||
syncRoster(roster, snap);
|
syncRoster(roster, snap);
|
||||||
|
|
||||||
top.update(snap);
|
top.update(snap, corrections);
|
||||||
build.update(snap); // padlocks track research
|
build.update(snap); // padlocks track research
|
||||||
inspector.update(snap);
|
inspector.update(snap, corrections);
|
||||||
fax.update(snap);
|
fax.update(snap);
|
||||||
tech.update(snap);
|
tech.update(snap);
|
||||||
|
|
||||||
@ -166,6 +172,9 @@ export function createUI(): UI {
|
|||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
if (ev.kind === 'commissionDone') fax.stamp();
|
if (ev.kind === 'commissionDone') fax.stamp();
|
||||||
|
|
||||||
|
// v8: The Correction edited an artifact — tally it for the ticker + firewall log.
|
||||||
|
if (ev.kind === 'repaired') corrections.record(ev);
|
||||||
|
|
||||||
if (ev.kind === 'relicFound') revealTuner(); // ceremony toast comes from voice.ts
|
if (ev.kind === 'relicFound') revealTuner(); // ceremony toast comes from voice.ts
|
||||||
|
|
||||||
// One hatch warning per pile. `spawned` is edge-triggered so this is usually
|
// One hatch warning per pile. `spawned` is edge-triggered so this is usually
|
||||||
@ -176,7 +185,7 @@ export function createUI(): UI {
|
|||||||
else warnedPiles.add(ev.id);
|
else warnedPiles.add(ev.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
toasts.push(ev, machineName);
|
toasts.push(ev, machineName, itemName);
|
||||||
|
|
||||||
// The alarm alone teaches nothing. Once per session, on the very first brownout,
|
// The alarm alone teaches nothing. Once per session, on the very first brownout,
|
||||||
// say what to do about it.
|
// say what to do about it.
|
||||||
|
|||||||
@ -11,7 +11,9 @@ import type {
|
|||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
import { createChipRow, type ChipRow } from './chips';
|
import { createChipRow, type ChipRow } from './chips';
|
||||||
import { intakeChips, outputChips } from './chipspec';
|
import { intakeChips, outputChips } from './chipspec';
|
||||||
|
import type { Corrections } from './corrections';
|
||||||
import { attrs, cls, el, panel, style, text } from './dom';
|
import { attrs, cls, el, panel, style, text } from './dom';
|
||||||
|
import { footprintOf } from './pick';
|
||||||
import { COPY, jamText, machineFlavor } from './voice';
|
import { COPY, jamText, machineFlavor } from './voice';
|
||||||
|
|
||||||
export interface Inspector {
|
export interface Inspector {
|
||||||
@ -19,7 +21,8 @@ export interface Inspector {
|
|||||||
open(entityId: number): void;
|
open(entityId: number): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
isOpen(): boolean;
|
isOpen(): boolean;
|
||||||
update(snap: SimSnapshot): void;
|
/** `corrections` (round 6) drives the FIREWALL LOG — how many edits landed on this unit. */
|
||||||
|
update(snap: SimSnapshot, corrections?: Corrections): void;
|
||||||
/** Entity currently shown, for the renderer-side selection highlight later. */
|
/** Entity currently shown, for the renderer-side selection highlight later. */
|
||||||
openId(): number | null;
|
openId(): number | null;
|
||||||
}
|
}
|
||||||
@ -78,6 +81,13 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
|||||||
const exposureLine = el('div', 'fk-ins-exposure-pct');
|
const exposureLine = el('div', 'fk-ins-exposure-pct');
|
||||||
exposure.append(el('div', 'fk-h', [COPY.mosquitoExposure]), exposureLine);
|
exposure.append(el('div', 'fk-h', [COPY.mosquitoExposure]), exposureLine);
|
||||||
|
|
||||||
|
// FIREWALL LOG (v8): the count of edits The Correction has made to this unit's output,
|
||||||
|
// resolved from `repaired` events that landed on this footprint. Hidden at zero — a
|
||||||
|
// unit the mites haven't touched shouldn't advertise the enemy.
|
||||||
|
const firewall = el('div', 'fk-ins-firewall');
|
||||||
|
const firewallLine = el('div', 'fk-ins-firewall-line');
|
||||||
|
firewall.append(el('div', 'fk-h', [COPY.firewallHeader]), firewallLine);
|
||||||
|
|
||||||
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,7 +95,7 @@ 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, exposure, firewall, jam);
|
||||||
|
|
||||||
let openId: number | null = null;
|
let openId: number | null = null;
|
||||||
|
|
||||||
@ -126,11 +136,18 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
|||||||
return ins ? `${ins} → ${outs}` : outs;
|
return ins ? `${ins} → ${outs}` : outs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function paint(e: EntityState) {
|
function paint(e: EntityState, corrections?: Corrections) {
|
||||||
const def = machines.get(e.def);
|
const def = machines.get(e.def);
|
||||||
text(name, def?.name ?? e.def.toUpperCase());
|
text(name, def?.name ?? e.def.toUpperCase());
|
||||||
text(flavor, machineFlavor(def, e.def));
|
text(flavor, machineFlavor(def, e.def));
|
||||||
|
|
||||||
|
// FIREWALL LOG: edits that landed on this unit's footprint. Off entirely at zero.
|
||||||
|
const catches = def && corrections
|
||||||
|
? corrections.catchesOn(e.pos, footprintOf(def, e.dir))
|
||||||
|
: 0;
|
||||||
|
cls(firewall, 'is-on', catches > 0);
|
||||||
|
if (catches > 0) text(firewallLine, COPY.correctionCatches(catches));
|
||||||
|
|
||||||
// Only machines with something to choose get a picker.
|
// Only machines with something to choose get a picker.
|
||||||
const choices = def?.recipes ?? [];
|
const choices = def?.recipes ?? [];
|
||||||
const canChoose = choices.length > 0;
|
const canChoose = choices.length > 0;
|
||||||
@ -193,11 +210,11 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
|||||||
close,
|
close,
|
||||||
isOpen: () => openId !== null,
|
isOpen: () => openId !== null,
|
||||||
openId: () => openId,
|
openId: () => openId,
|
||||||
update(snap) {
|
update(snap, corrections) {
|
||||||
if (openId === null) return;
|
if (openId === null) return;
|
||||||
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, corrections);
|
||||||
|
|
||||||
// A swarm degrading THIS machine, if any. size is severity 0..1; we render it as
|
// 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
|
// the precision hit. TEMPORARY: the sim owns the true throughput penalty and
|
||||||
|
|||||||
172
fktry/src/ui/mite.test.ts
Normal file
172
fktry/src/ui/mite.test.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { GameData, SimEvent, SimSnapshot, UIBus } from '../contracts';
|
||||||
|
import { createCorrections } from './corrections';
|
||||||
|
import { createInspector } from './inspector';
|
||||||
|
import { createTopStrip } from './topstrip';
|
||||||
|
import { eventToast, repairedToast, wildlifeToast } from './voice';
|
||||||
|
|
||||||
|
const repaired = (item: string, x: number, y: number): SimEvent =>
|
||||||
|
({ kind: 'repaired', item, pos: { x, y }, tick: 1 });
|
||||||
|
|
||||||
|
const DATA: GameData = {
|
||||||
|
items: [{ id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff3fd4' }],
|
||||||
|
machines: [{
|
||||||
|
id: 'mosh-reactor', name: 'MOSH REACTOR', codex: '', kind: 'crafter',
|
||||||
|
footprint: { x: 2, y: 2 }, recipes: [], powerDraw: 0, asset: 'm',
|
||||||
|
}],
|
||||||
|
recipes: [],
|
||||||
|
tech: [],
|
||||||
|
commissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- voice
|
||||||
|
|
||||||
|
describe('mite voice', () => {
|
||||||
|
it('accuses the mite of editing the melt on arrival', () => {
|
||||||
|
expect(wildlifeToast('parity-mite', 'spawned')).toBe('A PARITY MITE IS EDITING YOUR MELT.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is gallingly polite when a mite is swatted', () => {
|
||||||
|
const off = wildlifeToast('parity-mite', 'cleared')!;
|
||||||
|
expect(off).toContain('FORGIVES');
|
||||||
|
expect(off).not.toBe(wildlifeToast('parity-mite', 'spawned'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files a receipt for every edit, in the item display name', () => {
|
||||||
|
const named = (id: number) => `UNIT ${id}`;
|
||||||
|
const itemName = (id: string) => (id === 'melt' ? 'MELT' : id.toUpperCase());
|
||||||
|
const msg = eventToast(repaired('melt', 0, 0), named, itemName)!;
|
||||||
|
expect(msg).toBe('THE CORRECTION IMPROVED YOUR MELT. IT LEFT A RECEIPT.');
|
||||||
|
expect(repairedToast('MELT')).toContain('IT LEFT A RECEIPT.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a readable name when no resolver is given', () => {
|
||||||
|
const msg = eventToast(repaired('chroma-slurry', 0, 0), (id) => `UNIT ${id}`)!;
|
||||||
|
expect(msg).toContain('CHROMA SLURRY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- tally
|
||||||
|
|
||||||
|
describe('corrections tally', () => {
|
||||||
|
it('counts nothing until a mite strikes', () => {
|
||||||
|
const c = createCorrections();
|
||||||
|
expect(c.total()).toBe(0);
|
||||||
|
expect(c.byItem()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores every event that is not a repair', () => {
|
||||||
|
const c = createCorrections();
|
||||||
|
c.record({ kind: 'shipped', item: 'melt', count: 1, tick: 1 });
|
||||||
|
expect(c.total()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates totals and per-item counts in first-seen order', () => {
|
||||||
|
const c = createCorrections();
|
||||||
|
c.record(repaired('melt', 0, 0));
|
||||||
|
c.record(repaired('hf-dust', 5, 5));
|
||||||
|
c.record(repaired('melt', 1, 1));
|
||||||
|
expect(c.total()).toBe(3);
|
||||||
|
expect(c.byItem()).toEqual([{ item: 'melt', count: 2 }, { item: 'hf-dust', count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attributes an edit to a footprint by containment', () => {
|
||||||
|
const c = createCorrections();
|
||||||
|
c.record(repaired('melt', 0, 0)); // inside a 2x2 at origin
|
||||||
|
c.record(repaired('melt', 1, 1)); // inside
|
||||||
|
c.record(repaired('melt', 2, 2)); // outside (footprint is [0,2))
|
||||||
|
expect(c.catchesOn({ x: 0, y: 0 }, { x: 2, y: 2 })).toBe(2);
|
||||||
|
expect(c.catchesOn({ x: 9, y: 9 }, { x: 2, y: 2 })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- inspector firewall log
|
||||||
|
|
||||||
|
const bus: UIBus = { dispatch: () => {}, selectedBuild: () => null, pickTile: () => null };
|
||||||
|
|
||||||
|
function snap(): SimSnapshot {
|
||||||
|
return {
|
||||||
|
tick: 1, paused: false,
|
||||||
|
entities: [{
|
||||||
|
id: 7, def: 'mosh-reactor', 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: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FIREWALL LOG in the inspector', () => {
|
||||||
|
function open() {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const ins = createInspector(DATA, bus);
|
||||||
|
document.body.append(ins.el);
|
||||||
|
ins.open(7);
|
||||||
|
return ins;
|
||||||
|
}
|
||||||
|
const firewall = () => document.querySelector('.fk-ins-firewall') as HTMLElement;
|
||||||
|
|
||||||
|
it('is hidden on a unit the mites have not touched', () => {
|
||||||
|
const ins = open();
|
||||||
|
ins.update(snap(), createCorrections());
|
||||||
|
expect(firewall().classList.contains('is-on')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the catch count for edits on this unit', () => {
|
||||||
|
const ins = open();
|
||||||
|
const c = createCorrections();
|
||||||
|
c.record(repaired('melt', 0, 0));
|
||||||
|
c.record(repaired('melt', 1, 1));
|
||||||
|
ins.update(snap(), c);
|
||||||
|
expect(firewall().classList.contains('is-on')).toBe(true);
|
||||||
|
expect(document.querySelector('.fk-ins-firewall-line')!.textContent)
|
||||||
|
.toBe('THE CORRECTION HAS IMPROVED THIS UNIT 2 TIMES. YOU\'RE WELCOME.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('singularises one catch', () => {
|
||||||
|
const ins = open();
|
||||||
|
const c = createCorrections();
|
||||||
|
c.record(repaired('melt', 0, 0));
|
||||||
|
ins.update(snap(), c);
|
||||||
|
expect(document.querySelector('.fk-ins-firewall-line')!.textContent)
|
||||||
|
.toContain('1 TIME.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives being handed no corrections at all', () => {
|
||||||
|
const ins = open();
|
||||||
|
ins.update(snap());
|
||||||
|
expect(firewall().classList.contains('is-on')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- top-strip ticker
|
||||||
|
|
||||||
|
describe('CORRECTED ticker in the top strip', () => {
|
||||||
|
function mount() {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const top = createTopStrip(DATA);
|
||||||
|
document.body.append(top.el);
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
const wrap = () => document.querySelector('.fk-corrected-wrap') as HTMLElement;
|
||||||
|
|
||||||
|
it('is hidden until the first edit — the enemy is not signposted early', () => {
|
||||||
|
const top = mount();
|
||||||
|
top.update(snap(), createCorrections());
|
||||||
|
expect(wrap().style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appears with a count and per-item chips once the Correction starts editing', () => {
|
||||||
|
const top = mount();
|
||||||
|
const c = createCorrections();
|
||||||
|
c.record(repaired('melt', 0, 0));
|
||||||
|
c.record(repaired('melt', 1, 1));
|
||||||
|
top.update(snap(), c);
|
||||||
|
expect(wrap().style.display).not.toBe('none');
|
||||||
|
expect(document.querySelector('.fk-corrected-v')!.textContent).toBe('2');
|
||||||
|
const chip = wrap().querySelector('.fk-chips .fk-chip');
|
||||||
|
expect(chip!.textContent).toContain('MELT');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -29,6 +29,14 @@ export interface RadioState {
|
|||||||
station: string | null;
|
station: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One station on the dial, as the speaker adapter exposes it — MHz units for every band. */
|
||||||
|
export interface StationMark {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
band: Band;
|
||||||
|
freqMHz: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RadioControl {
|
export interface RadioControl {
|
||||||
setFrequency(mhz: number): void;
|
setFrequency(mhz: number): void;
|
||||||
setBand(band: Band): void;
|
setBand(band: Band): void;
|
||||||
@ -36,6 +44,9 @@ export interface RadioControl {
|
|||||||
mute(on: boolean): void;
|
mute(on: boolean): void;
|
||||||
getState(): RadioState;
|
getState(): RadioState;
|
||||||
subscribe(cb: (s: RadioState) => void): () => void;
|
subscribe(cb: (s: RadioState) => void): () => void;
|
||||||
|
/** v8: every station on the dial, for tick marks. Empty until the engine attaches
|
||||||
|
* (first user gesture); the echo stand-in has none. Optional — guard the call. */
|
||||||
|
listStations?(): StationMark[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Band frequency ranges (MHz-ish; SW/AM are scaled for a single readable dial). */
|
/** Band frequency ranges (MHz-ish; SW/AM are scaled for a single readable dial). */
|
||||||
@ -61,6 +72,7 @@ export function createEchoRadio(): RadioControl {
|
|||||||
mute(on) { state = { ...state, muted: on }; notify(); },
|
mute(on) { state = { ...state, muted: on }; notify(); },
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
subscribe(cb) { subs.add(cb); cb(state); return () => subs.delete(cb); },
|
subscribe(cb) { subs.add(cb); cb(state); return () => subs.delete(cb); },
|
||||||
|
listStations: () => [], // the real station map is SCREEN's; the stand-in knows none
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const CSS = `
|
|||||||
--fk-cool: #3fffe0;
|
--fk-cool: #3fffe0;
|
||||||
--fk-amber: #ffb03f;
|
--fk-amber: #ffb03f;
|
||||||
--fk-red: #ff3f4a;
|
--fk-red: #ff3f4a;
|
||||||
|
--fk-clinic: #d6f5df;
|
||||||
font-family: "Courier New", ui-monospace, monospace;
|
font-family: "Courier New", ui-monospace, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
@ -109,6 +110,10 @@ const CSS = `
|
|||||||
|
|
||||||
.fk-stat { display: flex; justify-content: space-between; font-size: 11px; }
|
.fk-stat { display: flex; justify-content: space-between; font-size: 11px; }
|
||||||
.fk-stat-v { color: var(--fk-cool); }
|
.fk-stat-v { color: var(--fk-cool); }
|
||||||
|
|
||||||
|
/* CORRECTED ticker — clinical white like the enemy that made it. Hidden until first edit. */
|
||||||
|
.fk-corrected-wrap { margin-top: 4px; border-top: 1px solid var(--fk-line); padding-top: 4px; }
|
||||||
|
.fk-corrected .fk-corrected-v { color: var(--fk-clinic); }
|
||||||
#fk-run.is-paused { color: var(--fk-amber); }
|
#fk-run.is-paused { color: var(--fk-amber); }
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- build bar */
|
/* ---------------------------------------------------------------- build bar */
|
||||||
@ -253,6 +258,12 @@ const CSS = `
|
|||||||
.fk-ins-exposure.is-on { display: block; }
|
.fk-ins-exposure.is-on { display: block; }
|
||||||
.fk-ins-exposure-pct { color: var(--fk-amber); font-size: 10px; }
|
.fk-ins-exposure-pct { color: var(--fk-amber); font-size: 10px; }
|
||||||
|
|
||||||
|
/* FIREWALL LOG — The Correction is hospital-white with green checkmarks (codex §8), so
|
||||||
|
it reads clinical and calm next to the hot alarms. The politeness is the menace. */
|
||||||
|
.fk-ins-firewall { display: none; margin-top: 6px; border-left: 3px solid var(--fk-clinic); padding-left: 6px; }
|
||||||
|
.fk-ins-firewall.is-on { display: block; }
|
||||||
|
.fk-ins-firewall-line { color: var(--fk-clinic); 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%;
|
||||||
@ -448,6 +459,15 @@ const CSS = `
|
|||||||
border-right: 2px solid var(--fk-cool);
|
border-right: 2px solid var(--fk-cool);
|
||||||
box-shadow: 0 0 8px rgba(63, 255, 224, 0.6);
|
box-shadow: 0 0 8px rgba(63, 255, 224, 0.6);
|
||||||
}
|
}
|
||||||
|
/* Station tick marks (order 2): real landmarks over the faint ruler. Hot magenta so a
|
||||||
|
station stands out from the background gradient, but anonymous — no names on the dial. */
|
||||||
|
.fk-tuner-ticks { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.fk-tuner-tick {
|
||||||
|
position: absolute; top: -2px; bottom: -2px; width: 2px;
|
||||||
|
transform: translateX(-1px);
|
||||||
|
background: var(--fk-hot);
|
||||||
|
box-shadow: 0 0 5px rgba(255, 63, 212, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
.fk-tuner-bands { display: flex; gap: 3px; margin-top: 6px; }
|
.fk-tuner-bands { display: flex; gap: 3px; margin-top: 6px; }
|
||||||
.fk-tuner-band {
|
.fk-tuner-band {
|
||||||
@ -495,7 +515,30 @@ const CSS = `
|
|||||||
.fk-toast.k-jammed { border-left-color: var(--fk-amber); color: var(--fk-amber); }
|
.fk-toast.k-jammed { border-left-color: var(--fk-amber); color: var(--fk-amber); }
|
||||||
.fk-toast.k-brownout { border-left-color: var(--fk-red); color: var(--fk-red); }
|
.fk-toast.k-brownout { border-left-color: var(--fk-red); color: var(--fk-red); }
|
||||||
.fk-toast.k-commissionDone { border-left-color: var(--fk-cool); color: var(--fk-cool); }
|
.fk-toast.k-commissionDone { border-left-color: var(--fk-cool); color: var(--fk-cool); }
|
||||||
|
/* The Correction's edit: clinical white-green, the same calm as the firewall log. */
|
||||||
|
.fk-toast.k-repaired { border-left-color: var(--fk-clinic); color: var(--fk-clinic); }
|
||||||
|
/* A mite arriving/leaving is wildlife, but of the hostile kind — amber like a hazard. */
|
||||||
|
.fk-toast.k-wildlife { border-left-color: var(--fk-amber); color: var(--fk-amber); }
|
||||||
@keyframes fk-toast-in { from { opacity: 0; transform: translateX(12px); } }
|
@keyframes fk-toast-in { from { opacity: 0; transform: translateX(12px); } }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- about / credits */
|
||||||
|
|
||||||
|
.fk-tech-footer { margin-top: 10px; border-top: 1px solid var(--fk-line); padding-top: 6px; text-align: right; }
|
||||||
|
.fk-credits-btn {
|
||||||
|
background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
|
||||||
|
color: var(--fk-dim); font: inherit; font-size: 9px; letter-spacing: 0.12em;
|
||||||
|
padding: 2px 8px; cursor: pointer; pointer-events: auto;
|
||||||
|
}
|
||||||
|
.fk-credits-btn:hover { color: var(--fk-cool); border-color: var(--fk-cool); }
|
||||||
|
.fk-credits-btn.is-on { color: var(--fk-hot); border-color: var(--fk-hot); }
|
||||||
|
.fk-credits { display: none; margin-top: 8px; border-top: 1px dashed var(--fk-line); padding-top: 8px; }
|
||||||
|
.fk-credits.is-on { display: block; }
|
||||||
|
.fk-credits-title { color: var(--fk-hot); letter-spacing: 0.2em; font-size: 13px; }
|
||||||
|
.fk-credits-tag { color: var(--fk-dim); font-size: 9px; margin: 2px 0 8px; }
|
||||||
|
.fk-credits-row { display: flex; gap: 6px; font-size: 10px; margin-bottom: 2px; }
|
||||||
|
.fk-credits-k { color: var(--fk-faint); min-width: 44px; }
|
||||||
|
.fk-credits-v { color: var(--fk-fg); text-transform: none; letter-spacing: 0; }
|
||||||
|
.fk-credits-slot { color: var(--fk-clinic); font-size: 9px; margin-top: 8px; border: 1px dashed var(--fk-clinic); padding: 3px 5px; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let injected = false;
|
let injected = false;
|
||||||
|
|||||||
@ -136,3 +136,36 @@ describe('tech panel', () => {
|
|||||||
expect(nodeFor('SILVER SEAMS').classList.contains('is-active')).toBe(false);
|
expect(nodeFor('SILVER SEAMS').classList.contains('is-active')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('about / credits footer', () => {
|
||||||
|
const about = () => document.querySelector('.fk-credits-btn') as HTMLElement;
|
||||||
|
const credits = () => document.querySelector('.fk-credits') as HTMLElement;
|
||||||
|
|
||||||
|
it('offers an ABOUT entry that starts collapsed', () => {
|
||||||
|
const { panel } = mount();
|
||||||
|
panel.toggle();
|
||||||
|
expect(about().textContent).toBe('ABOUT');
|
||||||
|
expect(credits().classList.contains('is-on')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals repo, codex and the standing synthetic-voice disclosure slot', () => {
|
||||||
|
const { panel } = mount();
|
||||||
|
panel.toggle();
|
||||||
|
about().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(credits().classList.contains('is-on')).toBe(true);
|
||||||
|
const txt = credits().textContent!;
|
||||||
|
expect(txt).toContain('gitea.partly.party/monster/glytch');
|
||||||
|
expect(txt).toContain('docs/FKTRY_LORE.md');
|
||||||
|
// The slot is a reservation, not a filled credit yet.
|
||||||
|
expect(document.querySelector('.fk-credits-slot')!.textContent)
|
||||||
|
.toContain('SYNTHETIC VOICE CREDIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles back shut on a second click', () => {
|
||||||
|
const { panel } = mount();
|
||||||
|
panel.toggle();
|
||||||
|
about().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
about().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(credits().classList.contains('is-on')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -43,7 +43,29 @@ export function createTechPanel(data: GameData, bus: UIBus): TechPanel {
|
|||||||
const offline = el('div', 'fk-tech-offline', [COPY.researchOffline]);
|
const offline = el('div', 'fk-tech-offline', [COPY.researchOffline]);
|
||||||
const body = el('div', 'fk-tech-body');
|
const body = el('div', 'fk-tech-body');
|
||||||
|
|
||||||
root.append(head, sub, offline, body);
|
// ---- ABOUT / CREDITS footer (round 6). Cheap now, and gives the future synthetic-voice
|
||||||
|
// credit a home before it exists (voice-cloning plan). Collapsed until asked for.
|
||||||
|
const kv = (label: string, value: string) =>
|
||||||
|
el('div', 'fk-credits-row', [el('span', 'fk-credits-k', [label]), el('span', 'fk-credits-v', [value])]);
|
||||||
|
const credits = el('div', 'fk-credits', [
|
||||||
|
el('div', 'fk-credits-title', [COPY.creditsTitle]),
|
||||||
|
el('div', 'fk-credits-tag', [COPY.creditsTagline]),
|
||||||
|
kv(COPY.creditsRepoLabel, COPY.creditsRepo),
|
||||||
|
kv(COPY.creditsCodexLabel, COPY.creditsCodex),
|
||||||
|
kv(COPY.creditsVoiceLabel, COPY.creditsVoice),
|
||||||
|
el('div', 'fk-credits-slot', [COPY.creditsVoiceSlot]),
|
||||||
|
]);
|
||||||
|
const aboutBtn = el('button', 'fk-credits-btn', [COPY.creditsButton]);
|
||||||
|
attrs(aboutBtn, { type: 'button', title: 'ABOUT / CREDITS' });
|
||||||
|
let creditsOpen = false;
|
||||||
|
aboutBtn.addEventListener('click', () => {
|
||||||
|
creditsOpen = !creditsOpen;
|
||||||
|
cls(credits, 'is-on', creditsOpen);
|
||||||
|
cls(aboutBtn, 'is-on', creditsOpen);
|
||||||
|
});
|
||||||
|
const footer = el('div', 'fk-tech-footer', [aboutBtn]);
|
||||||
|
|
||||||
|
root.append(head, sub, offline, body, footer, credits);
|
||||||
|
|
||||||
const views: NodeView[] = [];
|
const views: NodeView[] = [];
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,12 @@ import { eventToast } from './voice';
|
|||||||
|
|
||||||
export interface Toasts {
|
export interface Toasts {
|
||||||
el: HTMLElement;
|
el: HTMLElement;
|
||||||
push(ev: SimEvent, machineName: (entityId: number) => string): void;
|
/** `itemName` resolves item ids for events that name an item (e.g. v8 `repaired`). */
|
||||||
|
push(
|
||||||
|
ev: SimEvent,
|
||||||
|
machineName: (entityId: number) => string,
|
||||||
|
itemName?: (id: string) => string,
|
||||||
|
): void;
|
||||||
/** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */
|
/** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */
|
||||||
pushMessage(msg: string, kind: string): void;
|
pushMessage(msg: string, kind: string): void;
|
||||||
/** Stand the stack off the bottom-right corner once the tuner dock moves in there. */
|
/** Stand the stack off the bottom-right corner once the tuner dock moves in there. */
|
||||||
@ -43,8 +48,8 @@ export function createToasts(): Toasts {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
el: root,
|
el: root,
|
||||||
push(ev, machineName) {
|
push(ev, machineName, itemName) {
|
||||||
const msg = eventToast(ev, machineName);
|
const msg = eventToast(ev, machineName, itemName);
|
||||||
if (msg) emit(msg, ev.kind);
|
if (msg) emit(msg, ev.kind);
|
||||||
},
|
},
|
||||||
pushMessage: emit,
|
pushMessage: emit,
|
||||||
|
|||||||
@ -6,13 +6,16 @@
|
|||||||
*/
|
*/
|
||||||
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
|
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
|
||||||
import { createChipRow, type ChipRow } from './chips';
|
import { createChipRow, type ChipRow } from './chips';
|
||||||
|
import type { Corrections } from './corrections';
|
||||||
import { cls, el, panel, style, text } from './dom';
|
import { cls, el, panel, style, text } from './dom';
|
||||||
import { formatReserve, powerState } from './power';
|
import { formatReserve, powerState } from './power';
|
||||||
import { COPY } from './voice';
|
import { COPY } from './voice';
|
||||||
|
|
||||||
export interface TopStrip {
|
export interface TopStrip {
|
||||||
el: HTMLElement;
|
el: HTMLElement;
|
||||||
update(snap: SimSnapshot): void;
|
/** `corrections` is the session tally of The Correction's edits — surfaced only once
|
||||||
|
* the first mite has struck, so it never signposts an enemy that hasn't arrived. */
|
||||||
|
update(snap: SimSnapshot, corrections?: Corrections): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stat(label: string, valueCls?: string): { row: HTMLElement; v: HTMLElement } {
|
function stat(label: string, valueCls?: string): { row: HTMLElement; v: HTMLElement } {
|
||||||
@ -50,12 +53,20 @@ export function createTopStrip(data: GameData): TopStrip {
|
|||||||
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]));
|
||||||
const chips: ChipRow = createChipRow(items);
|
const chips: ChipRow = createChipRow(items);
|
||||||
|
|
||||||
|
// CORRECTED ticker (round 6): a count + per-item chips of what the mites have "fixed".
|
||||||
|
// Hidden until the first edit, so pre-Correction play never sees the enemy named.
|
||||||
|
const corrected = stat(COPY.correctedLabel, 'fk-corrected-v');
|
||||||
|
cls(corrected.row, 'fk-corrected', true);
|
||||||
|
const correctedChips: ChipRow = createChipRow(items);
|
||||||
|
const correctedWrap = el('div', 'fk-corrected-wrap', [corrected.row, correctedChips.el]);
|
||||||
|
style(correctedWrap, 'display', 'none');
|
||||||
|
|
||||||
root.append(head, nums, bar, brownout, reserve, el('div', 'fk-rule'),
|
root.append(head, nums, bar, brownout, reserve, el('div', 'fk-rule'),
|
||||||
buffer.row, shipped.row, run.row, chips.el);
|
buffer.row, shipped.row, run.row, chips.el, correctedWrap);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
el: root,
|
el: root,
|
||||||
update(snap) {
|
update(snap, corrections) {
|
||||||
const { gen, draw, stored, capacity } = snap.bandwidth;
|
const { gen, draw, stored, capacity } = snap.bandwidth;
|
||||||
|
|
||||||
text(drawEl, `${draw.toFixed(0)} DRAW`);
|
text(drawEl, `${draw.toFixed(0)} DRAW`);
|
||||||
@ -88,6 +99,17 @@ export function createTopStrip(data: GameData): TopStrip {
|
|||||||
.filter(([, n]) => n > 0)
|
.filter(([, n]) => n > 0)
|
||||||
.map(([item, n]) => ({ item, count: String(n), state: 'met' as const })),
|
.map(([item, n]) => ({ item, count: String(n), state: 'met' as const })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const edits = corrections?.total() ?? 0;
|
||||||
|
style(correctedWrap, 'display', edits > 0 ? '' : 'none');
|
||||||
|
if (edits > 0) {
|
||||||
|
text(corrected.v, String(edits));
|
||||||
|
correctedChips.update(
|
||||||
|
corrections!.byItem().map(({ item, count }) => ({
|
||||||
|
item, count: String(count), state: 'short' as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -151,6 +151,37 @@ describe('the tuner dock', () => {
|
|||||||
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(false);
|
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws no station ticks before the engine attaches (empty pre-gesture)', () => {
|
||||||
|
tuner.reveal();
|
||||||
|
// The echo stand-in knows no stations, exactly like the adapter before first gesture.
|
||||||
|
expect(document.querySelectorAll('.fk-tuner-tick')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a tick per station on the current band, positioned by frequency', () => {
|
||||||
|
const control: RadioControl = {
|
||||||
|
...createEchoRadio(),
|
||||||
|
subscribe(cb) {
|
||||||
|
cb({ freqMHz: 88.0, band: 'FM', volume: 0.7, muted: false, station: null });
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
listStations: () => [
|
||||||
|
{ id: 'a', name: 'A', band: 'FM', freqMHz: 88.0 }, // FM floor → left edge
|
||||||
|
{ id: 'b', name: 'B', band: 'FM', freqMHz: 108.0 }, // FM ceiling → right edge
|
||||||
|
{ id: 'c', name: 'C', band: 'AM', freqMHz: 1.0 }, // other band → not drawn
|
||||||
|
],
|
||||||
|
};
|
||||||
|
(globalThis as any).__fktrySpeaker = { radio: control };
|
||||||
|
const t = mount();
|
||||||
|
t.reveal();
|
||||||
|
const ticks = Array.from(document.querySelectorAll('.fk-tuner-tick')) as HTMLElement[];
|
||||||
|
expect(ticks).toHaveLength(2); // only the two FM stations
|
||||||
|
expect(ticks[0].style.left).toBe('0%');
|
||||||
|
expect(ticks[1].style.left).toBe('100%');
|
||||||
|
// Ticks are anonymous — they mark THAT, not WHAT (the hunt stays).
|
||||||
|
expect(ticks[0].textContent).toBe('');
|
||||||
|
delete (globalThis as any).__fktrySpeaker;
|
||||||
|
});
|
||||||
|
|
||||||
it('reads SIGNAL LOCKED and lights up when the engine reports a station', () => {
|
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.
|
// What it looks like once THE SPEAKER is driving it for real.
|
||||||
const withStation: RadioControl = {
|
const withStation: RadioControl = {
|
||||||
|
|||||||
@ -37,9 +37,12 @@ export function createTuner(): Tuner {
|
|||||||
const head = el('div', 'fk-h', [COPY.tunerHeader]);
|
const head = el('div', 'fk-h', [COPY.tunerHeader]);
|
||||||
const readout = el('div', 'fk-tuner-readout');
|
const readout = el('div', 'fk-tuner-readout');
|
||||||
|
|
||||||
// Frequency dial: a click/drag track. The fill shows position within the band.
|
// Frequency dial: a click/drag track. The fill shows position within the band, and the
|
||||||
|
// ticks layer marks where the real stations sit (order 2). Ticks say THAT a station is
|
||||||
|
// there, never WHAT — the readout is the only thing that names a lock, so the hunt stays.
|
||||||
const dialFill = el('div', 'fk-tuner-dial-fill');
|
const dialFill = el('div', 'fk-tuner-dial-fill');
|
||||||
const dial = el('div', 'fk-tuner-dial', [dialFill]);
|
const dialTicks = el('div', 'fk-tuner-ticks');
|
||||||
|
const dial = el('div', 'fk-tuner-dial', [dialTicks, dialFill]);
|
||||||
|
|
||||||
// Band buttons.
|
// Band buttons.
|
||||||
const bandRow = el('div', 'fk-tuner-bands');
|
const bandRow = el('div', 'fk-tuner-bands');
|
||||||
@ -109,10 +112,32 @@ export function createTuner(): Tuner {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- render from state
|
// ---------------------------------------------------------------- render from state
|
||||||
|
|
||||||
|
/** Station tick marks for the current band. Rebuilt only when the band or the station
|
||||||
|
* set changes — the list is empty until the engine attaches on first gesture, then
|
||||||
|
* fills in on the next state push, so this lights up exactly when audio comes alive. */
|
||||||
|
let ticksSig = '';
|
||||||
|
function renderTicks(band: Band) {
|
||||||
|
const stations = (control.listStations?.() ?? []).filter((st) => st.band === band);
|
||||||
|
const { min, max } = BAND_RANGE[band];
|
||||||
|
const sig = band + '|' + stations.map((st) => st.freqMHz.toFixed(3)).join(',');
|
||||||
|
if (sig === ticksSig) return;
|
||||||
|
ticksSig = sig;
|
||||||
|
dialTicks.replaceChildren();
|
||||||
|
if (max <= min) return;
|
||||||
|
for (const st of stations) {
|
||||||
|
const frac = Math.max(0, Math.min(1, (st.freqMHz - min) / (max - min)));
|
||||||
|
const tick = el('div', 'fk-tuner-tick');
|
||||||
|
style(tick, 'left', `${frac * 100}%`);
|
||||||
|
dialTicks.append(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function render(s: RadioState) {
|
function render(s: RadioState) {
|
||||||
text(readout, stationReadout(s.freqMHz, s.band, s.station));
|
text(readout, stationReadout(s.freqMHz, s.band, s.station));
|
||||||
cls(readout, 'is-locked', s.station !== null);
|
cls(readout, 'is-locked', s.station !== null);
|
||||||
|
|
||||||
|
renderTicks(s.band);
|
||||||
|
|
||||||
const { min, max } = BAND_RANGE[s.band];
|
const { min, max } = BAND_RANGE[s.band];
|
||||||
const frac = max > min ? (s.freqMHz - min) / (max - min) : 0;
|
const frac = max > min ? (s.freqMHz - min) / (max - min) : 0;
|
||||||
style(dialFill, 'width', `${Math.max(0, Math.min(1, frac)) * 100}%`);
|
style(dialFill, 'width', `${Math.max(0, Math.min(1, frac)) * 100}%`);
|
||||||
|
|||||||
@ -65,8 +65,15 @@ export function jamText(reason: string): string {
|
|||||||
return JAM_TEXT[reason] ?? reason.toUpperCase();
|
return JAM_TEXT[reason] ?? reason.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Default item-id → display: dashes to spaces, uppercased. LANE-DATA's names win. */
|
||||||
|
const defaultItemName = (id: string) => id.replace(/-/g, ' ').toUpperCase();
|
||||||
|
|
||||||
/** Toast copy for events the player should notice without reading a manual. */
|
/** Toast copy for events the player should notice without reading a manual. */
|
||||||
export function eventToast(ev: SimEvent, machineName: (id: number) => string): string | null {
|
export function eventToast(
|
||||||
|
ev: SimEvent,
|
||||||
|
machineName: (id: number) => string,
|
||||||
|
itemName: (id: string) => string = defaultItemName,
|
||||||
|
): string | null {
|
||||||
switch (ev.kind) {
|
switch (ev.kind) {
|
||||||
case 'jammed':
|
case 'jammed':
|
||||||
return `${machineName(ev.entity)}: ${jamText(ev.reason)}`;
|
return `${machineName(ev.entity)}: ${jamText(ev.reason)}`;
|
||||||
@ -88,11 +95,22 @@ export function eventToast(ev: SimEvent, machineName: (id: number) => string): s
|
|||||||
return wildlifeToast(ev.wildlife, ev.on);
|
return wildlifeToast(ev.wildlife, ev.on);
|
||||||
case 'relicFound':
|
case 'relicFound':
|
||||||
return COPY.relicDiscovery;
|
return COPY.relicDiscovery;
|
||||||
|
case 'repaired':
|
||||||
|
return repairedToast(itemName(ev.item));
|
||||||
default:
|
default:
|
||||||
return null; // shipped/crafted/placed are too frequent to toast
|
return null; // shipped/crafted/placed are too frequent to toast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parity mite has "improved" one of your artifacts (contracts v8 `repaired`). The
|
||||||
|
* Correction is unfailingly courteous about vandalising your work — the receipt is the
|
||||||
|
* insult. `IT LEFT A RECEIPT.` is the line the round-6 orders asked for by name.
|
||||||
|
*/
|
||||||
|
export function repairedToast(itemName: string): string {
|
||||||
|
return `THE CORRECTION IMPROVED YOUR ${itemName}. IT LEFT A RECEIPT.`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infestation copy. A dust pile spawning is a hatch warning (index.ts dedups it to one
|
* 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.
|
* per pile — see the notices logic there); a swarm spawning is the bite already landing.
|
||||||
@ -107,10 +125,11 @@ export function wildlifeToast(
|
|||||||
: null; // clearing a pile is housekeeping, not news
|
: null; // clearing a pile is housekeeping, not news
|
||||||
}
|
}
|
||||||
if (kind === 'parity-mite') {
|
if (kind === 'parity-mite') {
|
||||||
// v8 placeholder copy — LANE-UI owns the real lines in round 6
|
// The Correction cannot perceive you, only deviation — which it repairs (lore §5).
|
||||||
|
// A mite on the belt is your melt being edited into something that passes review.
|
||||||
return on === 'spawned'
|
return on === 'spawned'
|
||||||
? 'A PARITY MITE HAS ARRIVED. IT IS HERE TO HELP.'
|
? 'A PARITY MITE IS EDITING YOUR MELT.'
|
||||||
: 'MITE CONTAINED. FILE ITS RECEIPT.';
|
: 'MITE SWATTED. IT FORGIVES YOU. IT ALWAYS FORGIVES YOU.';
|
||||||
}
|
}
|
||||||
// mosquito-swarm
|
// mosquito-swarm
|
||||||
return on === 'spawned'
|
return on === 'spawned'
|
||||||
@ -148,6 +167,15 @@ export const COPY = {
|
|||||||
/** Shown against shipments The Correction quietly buys back (M3 fiction). */
|
/** Shown against shipments The Correction quietly buys back (M3 fiction). */
|
||||||
sanitising: 'SANITISING',
|
sanitising: 'SANITISING',
|
||||||
|
|
||||||
|
// THE CORRECTION — parity-mite surfacing (round 6). The register is deliberately
|
||||||
|
// gracious: the enemy filing a receipt for wrecking your work is the whole grievance.
|
||||||
|
/** Top-strip ticker: how many artifacts the mites have "improved" this session. */
|
||||||
|
correctedLabel: 'CORRECTED',
|
||||||
|
/** Inspector section header — the unit's own log of what got edited on it. */
|
||||||
|
firewallHeader: 'FIREWALL LOG',
|
||||||
|
correctionCatches: (n: number) =>
|
||||||
|
`THE CORRECTION HAS IMPROVED THIS UNIT ${n} TIME${n === 1 ? '' : 'S'}. YOU'RE WELCOME.`,
|
||||||
|
|
||||||
techHeader: 'STANDARDS COMMITTEE',
|
techHeader: 'STANDARDS COMMITTEE',
|
||||||
techSubheader: 'MEDIA ARCHAEOLOGY — RESEARCH DOWN, DERIVE UP',
|
techSubheader: 'MEDIA ARCHAEOLOGY — RESEARCH DOWN, DERIVE UP',
|
||||||
techActive: 'IN COMMITTEE',
|
techActive: 'IN COMMITTEE',
|
||||||
@ -175,6 +203,21 @@ export const COPY = {
|
|||||||
/** relicFound ceremony. */
|
/** relicFound ceremony. */
|
||||||
relicDiscovery: 'AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.',
|
relicDiscovery: 'AUDIO BUS RESTORED. THE BITSTREAM HAS A VOICE.',
|
||||||
|
|
||||||
|
// ABOUT / CREDITS (round 6) — reachable from the tech-panel footer. Deadpan colophon;
|
||||||
|
// the voice slot is a standing reservation for the synthetic-voice credit that lands
|
||||||
|
// when the voice-cloning plan ships, so there's a home for it already.
|
||||||
|
creditsButton: 'ABOUT',
|
||||||
|
creditsTitle: 'FKTRY',
|
||||||
|
creditsTagline: 'A GLITCH-PRODUCTION FACTORY INSIDE A DYING VIDEO FILE.',
|
||||||
|
creditsRepoLabel: 'REPO',
|
||||||
|
creditsRepo: 'gitea.partly.party/monster/glytch',
|
||||||
|
creditsCodexLabel: 'CODEX',
|
||||||
|
creditsCodex: 'docs/FKTRY_LORE.md',
|
||||||
|
creditsVoiceLabel: 'VOICES',
|
||||||
|
creditsVoice: 'SYNTHESIZED ON SITE. NO PERFORMERS WERE CONSULTED.',
|
||||||
|
/** Standing disclosure slot — replaced with the real credit when a synthetic voice ships. */
|
||||||
|
creditsVoiceSlot: 'SYNTHETIC VOICE CREDIT: NONE ON FILE.',
|
||||||
|
|
||||||
// THE TUNER DOCK
|
// THE TUNER DOCK
|
||||||
tunerHeader: 'AUDIO BUS',
|
tunerHeader: 'AUDIO BUS',
|
||||||
tunerBandLabel: 'BAND',
|
tunerBandLabel: 'BAND',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user