[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
|
||||
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.
|
||||
|
||||
### 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.
|
||||
* - 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 { createCorrections } from './corrections';
|
||||
import { createFax } from './fax';
|
||||
import { createHotkeys } from './hotkeys';
|
||||
import { createInspector } from './inspector';
|
||||
@ -24,6 +25,7 @@ import { COPY } from './voice';
|
||||
|
||||
export function createUI(): UI {
|
||||
let machines = new Map<string, MachineDef>();
|
||||
let itemName: (id: string) => string = (id) => id.replace(/-/g, ' ').toUpperCase();
|
||||
let top: ReturnType<typeof createTopStrip>;
|
||||
let build: ReturnType<typeof createBuildBar>;
|
||||
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. */
|
||||
const roster = createRoster();
|
||||
/** Session tally of The Correction's edits — feeds the ticker + firewall log (round 6). */
|
||||
const corrections = createCorrections();
|
||||
let paused = false;
|
||||
|
||||
// ---- 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) {
|
||||
injectStyle();
|
||||
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);
|
||||
top = createTopStrip(data);
|
||||
@ -154,9 +160,9 @@ export function createUI(): UI {
|
||||
paused = snap.paused;
|
||||
syncRoster(roster, snap);
|
||||
|
||||
top.update(snap);
|
||||
top.update(snap, corrections);
|
||||
build.update(snap); // padlocks track research
|
||||
inspector.update(snap);
|
||||
inspector.update(snap, corrections);
|
||||
fax.update(snap);
|
||||
tech.update(snap);
|
||||
|
||||
@ -166,6 +172,9 @@ export function createUI(): UI {
|
||||
for (const ev of events) {
|
||||
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
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
toasts.push(ev, machineName);
|
||||
toasts.push(ev, machineName, itemName);
|
||||
|
||||
// The alarm alone teaches nothing. Once per session, on the very first brownout,
|
||||
// say what to do about it.
|
||||
|
||||
@ -11,7 +11,9 @@ import type {
|
||||
} from '../contracts';
|
||||
import { createChipRow, type ChipRow } from './chips';
|
||||
import { intakeChips, outputChips } from './chipspec';
|
||||
import type { Corrections } from './corrections';
|
||||
import { attrs, cls, el, panel, style, text } from './dom';
|
||||
import { footprintOf } from './pick';
|
||||
import { COPY, jamText, machineFlavor } from './voice';
|
||||
|
||||
export interface Inspector {
|
||||
@ -19,7 +21,8 @@ export interface Inspector {
|
||||
open(entityId: number): void;
|
||||
close(): void;
|
||||
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. */
|
||||
openId(): number | null;
|
||||
}
|
||||
@ -78,6 +81,13 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
||||
const exposureLine = el('div', 'fk-ins-exposure-pct');
|
||||
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, [
|
||||
recHead, recSelect, noRecipe,
|
||||
inHead, inChips.el, outHead, outChips.el,
|
||||
@ -85,7 +95,7 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
||||
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;
|
||||
|
||||
@ -126,11 +136,18 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
||||
return ins ? `${ins} → ${outs}` : outs;
|
||||
}
|
||||
|
||||
function paint(e: EntityState) {
|
||||
function paint(e: EntityState, corrections?: Corrections) {
|
||||
const def = machines.get(e.def);
|
||||
text(name, def?.name ?? e.def.toUpperCase());
|
||||
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.
|
||||
const choices = def?.recipes ?? [];
|
||||
const canChoose = choices.length > 0;
|
||||
@ -193,11 +210,11 @@ export function createInspector(data: GameData, bus: UIBus, onClose?: () => void
|
||||
close,
|
||||
isOpen: () => openId !== null,
|
||||
openId: () => openId,
|
||||
update(snap) {
|
||||
update(snap, corrections) {
|
||||
if (openId === null) return;
|
||||
const e = snap.entities.find((x) => x.id === openId);
|
||||
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
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
setFrequency(mhz: number): void;
|
||||
setBand(band: Band): void;
|
||||
@ -36,6 +44,9 @@ export interface RadioControl {
|
||||
mute(on: boolean): void;
|
||||
getState(): RadioState;
|
||||
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). */
|
||||
@ -61,6 +72,7 @@ export function createEchoRadio(): RadioControl {
|
||||
mute(on) { state = { ...state, muted: on }; notify(); },
|
||||
getState: () => state,
|
||||
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-amber: #ffb03f;
|
||||
--fk-red: #ff3f4a;
|
||||
--fk-clinic: #d6f5df;
|
||||
font-family: "Courier New", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
@ -109,6 +110,10 @@ const CSS = `
|
||||
|
||||
.fk-stat { display: flex; justify-content: space-between; font-size: 11px; }
|
||||
.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); }
|
||||
|
||||
/* ---------------------------------------------------------------- build bar */
|
||||
@ -253,6 +258,12 @@ const CSS = `
|
||||
.fk-ins-exposure.is-on { display: block; }
|
||||
.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. */
|
||||
.fk-ins-recipe {
|
||||
width: 100%;
|
||||
@ -448,6 +459,15 @@ const CSS = `
|
||||
border-right: 2px solid var(--fk-cool);
|
||||
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-band {
|
||||
@ -495,7 +515,30 @@ const CSS = `
|
||||
.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-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); } }
|
||||
|
||||
/* ---------------------------------------------------------------- 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;
|
||||
|
||||
@ -136,3 +136,36 @@ describe('tech panel', () => {
|
||||
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 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[] = [];
|
||||
|
||||
|
||||
@ -10,7 +10,12 @@ import { eventToast } from './voice';
|
||||
|
||||
export interface Toasts {
|
||||
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. */
|
||||
pushMessage(msg: string, kind: string): void;
|
||||
/** Stand the stack off the bottom-right corner once the tuner dock moves in there. */
|
||||
@ -43,8 +48,8 @@ export function createToasts(): Toasts {
|
||||
|
||||
return {
|
||||
el: root,
|
||||
push(ev, machineName) {
|
||||
const msg = eventToast(ev, machineName);
|
||||
push(ev, machineName, itemName) {
|
||||
const msg = eventToast(ev, machineName, itemName);
|
||||
if (msg) emit(msg, ev.kind);
|
||||
},
|
||||
pushMessage: emit,
|
||||
|
||||
@ -6,13 +6,16 @@
|
||||
*/
|
||||
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
|
||||
import { createChipRow, type ChipRow } from './chips';
|
||||
import type { Corrections } from './corrections';
|
||||
import { cls, el, panel, style, text } from './dom';
|
||||
import { formatReserve, powerState } from './power';
|
||||
import { COPY } from './voice';
|
||||
|
||||
export interface TopStrip {
|
||||
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 } {
|
||||
@ -50,12 +53,20 @@ export function createTopStrip(data: GameData): TopStrip {
|
||||
const items = new Map<string, ItemDef>(data.items.map((i) => [i.id, i]));
|
||||
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'),
|
||||
buffer.row, shipped.row, run.row, chips.el);
|
||||
buffer.row, shipped.row, run.row, chips.el, correctedWrap);
|
||||
|
||||
return {
|
||||
el: root,
|
||||
update(snap) {
|
||||
update(snap, corrections) {
|
||||
const { gen, draw, stored, capacity } = snap.bandwidth;
|
||||
|
||||
text(drawEl, `${draw.toFixed(0)} DRAW`);
|
||||
@ -88,6 +99,17 @@ export function createTopStrip(data: GameData): TopStrip {
|
||||
.filter(([, n]) => n > 0)
|
||||
.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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// What it looks like once THE SPEAKER is driving it for real.
|
||||
const withStation: RadioControl = {
|
||||
|
||||
@ -37,9 +37,12 @@ export function createTuner(): 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.
|
||||
// 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 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.
|
||||
const bandRow = el('div', 'fk-tuner-bands');
|
||||
@ -109,10 +112,32 @@ export function createTuner(): Tuner {
|
||||
|
||||
// ---------------------------------------------------------------- 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) {
|
||||
text(readout, stationReadout(s.freqMHz, s.band, s.station));
|
||||
cls(readout, 'is-locked', s.station !== null);
|
||||
|
||||
renderTicks(s.band);
|
||||
|
||||
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}%`);
|
||||
|
||||
@ -65,8 +65,15 @@ export function jamText(reason: string): string {
|
||||
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. */
|
||||
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) {
|
||||
case 'jammed':
|
||||
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);
|
||||
case 'relicFound':
|
||||
return COPY.relicDiscovery;
|
||||
case 'repaired':
|
||||
return repairedToast(itemName(ev.item));
|
||||
default:
|
||||
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
|
||||
* 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
|
||||
}
|
||||
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'
|
||||
? 'A PARITY MITE HAS ARRIVED. IT IS HERE TO HELP.'
|
||||
: 'MITE CONTAINED. FILE ITS RECEIPT.';
|
||||
? 'A PARITY MITE IS EDITING YOUR MELT.'
|
||||
: 'MITE SWATTED. IT FORGIVES YOU. IT ALWAYS FORGIVES YOU.';
|
||||
}
|
||||
// mosquito-swarm
|
||||
return on === 'spawned'
|
||||
@ -148,6 +167,15 @@ export const COPY = {
|
||||
/** Shown against shipments The Correction quietly buys back (M3 fiction). */
|
||||
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',
|
||||
techSubheader: 'MEDIA ARCHAEOLOGY — RESEARCH DOWN, DERIVE UP',
|
||||
techActive: 'IN COMMITTEE',
|
||||
@ -175,6 +203,21 @@ export const COPY = {
|
||||
/** relicFound ceremony. */
|
||||
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
|
||||
tunerHeader: 'AUDIO BUS',
|
||||
tunerBandLabel: 'BAND',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user