From 2904ddbb8c803a950900c2bf6e9f8d9beb60f5f1 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 20:13:50 +1000 Subject: [PATCH] [ui] research: the STANDARDS COMMITTEE, and padlocks that fall off live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T opens the tech tree — every tech grouped by era in era order, cost chips in item colours, era prefix stripped from node names (the heading already says it), UNLOCKS: tooltips in machine names not ids. Pick a node to dispatch setResearch; picking the active one stands it down; ratified nodes are inert. The v4 gating rule lives in a pure research.ts: an id any tech claims is locked until that tech completes, and an id no tech mentions is always available — so belts never padlock. Locked machines are greyed with a padlock and a REQUIRES: tooltip, and can't be picked by click or by hotkey. The padlock falls off in the same frame `researched` lands, and the player never keeps holding something they can no longer build. With no ResearchState at all the safe reading is that gates hold rather than fall open, and the panel says RESEARCH OFFLINE instead of rendering a tree that can't do anything. Accent precedence now matches RENDER exactly: MachineDef.accent, then the first-output derivation, then the codex kind table. RESEARCH joins the page order for the new 'lab' kind. Co-Authored-By: Claude Opus 4.8 --- fktry/src/ui/buildbar.ts | 57 ++++++++++++-- fktry/src/ui/pages.ts | 2 +- fktry/src/ui/research.test.ts | 89 +++++++++++++++++++++ fktry/src/ui/research.ts | 91 ++++++++++++++++++++++ fktry/src/ui/style.ts | 75 +++++++++++++++++- fktry/src/ui/techpanel.test.ts | 138 +++++++++++++++++++++++++++++++++ fktry/src/ui/techpanel.ts | 130 +++++++++++++++++++++++++++++++ 7 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 fktry/src/ui/research.test.ts create mode 100644 fktry/src/ui/research.ts create mode 100644 fktry/src/ui/techpanel.test.ts create mode 100644 fktry/src/ui/techpanel.ts diff --git a/fktry/src/ui/buildbar.ts b/fktry/src/ui/buildbar.ts index f0c93cd..dd151ec 100644 --- a/fktry/src/ui/buildbar.ts +++ b/fktry/src/ui/buildbar.ts @@ -2,10 +2,11 @@ * LANE-UI — build bar. Kind tabs, `[`/`]` to page, 1-9 within the active page. * Click or hotkey selects for placement; main.ts does the ghost and the actual place. */ -import type { GameData, MachineDef } from '../contracts'; +import type { GameData, MachineDef, SimSnapshot } from '../contracts'; import { attrs, cls, el, panel, text } from './dom'; import { paginate, pageOf, type BuildPage } from './pages'; import { createAccentLookup, machineChassis } from './palette'; +import { gateFor, indexTech, type TechIndex } from './research'; import type { BuildSelection } from './selection'; import { COPY, DIR_NAME } from './voice'; @@ -15,11 +16,16 @@ export interface BuildBar { selectSlot(n: number): void; pageBy(delta: number): void; sync(): void; + /** Re-read the lock state; cheap enough to call per frame. */ + update(snap: SimSnapshot): void; } export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { const pages: BuildPage[] = paginate(data.machines); const accentOf = createAccentLookup(data); + const techIndex: TechIndex = indexTech(data); + /** Machine ids currently gated by unfinished research. */ + let locked = new Set(); const root = panel(); root.id = 'fk-build'; @@ -54,13 +60,12 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { tabRow.append(removeBtn); /** Buttons for the active page only; rebuilt on page change (nine at most). */ - let buttons: Array<{ def: string; btn: HTMLButtonElement }> = []; + let buttons: Array<{ def: string; btn: HTMLButtonElement; lock: HTMLElement }> = []; function buildButtons(page: BuildPage) { row.replaceChildren(); buttons = page.machines.map((m: MachineDef, i: number) => { const btn = el('button', 'fk-build-btn'); - attrs(btn, { type: 'button', title: `${m.name} — ${m.kind}` }); // Two-material rule (codex §8): grimy body, one impossible element. const ico = el('div', 'fk-build-ico'); @@ -69,6 +74,8 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { accent.style.background = accentOf(m); accent.style.boxShadow = `0 0 5px ${accentOf(m)}`; ico.append(accent); + const lock = el('span', 'fk-build-lock', ['\u{1F512}']); + ico.append(lock); btn.append(ico); const key = el('span', 'fk-build-key'); @@ -76,10 +83,33 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { btn.append(key); btn.append(el('span', 'fk-build-name', [m.name])); - btn.addEventListener('click', () => sel.select(m.id)); + btn.addEventListener('click', () => select(m.id)); row.append(btn); - return { def: m.id, btn }; + return { def: m.id, btn, lock }; }); + applyLocks(); + } + + /** A locked machine can't be picked — by click or by hotkey. */ + function select(defId: string) { + if (locked.has(defId)) return; + sel.select(defId); + } + + function applyLocks() { + for (const b of buttons) { + const isLocked = locked.has(b.def); + cls(b.btn, 'is-locked', isLocked); + cls(b.lock, 'is-on', isLocked); + + const m = data.machines.find((x) => x.id === b.def); + const gate = isLocked ? techIndex.gatedBy.get(b.def) : null; + attrs(b.btn, { + title: gate + ? `${m?.name ?? b.def} — ${COPY.requires} ${gate.id.replace(/-/g, ' ').toUpperCase()}` + : `${m?.name ?? b.def} — ${m?.kind ?? ''}`, + }); + } } function render() { @@ -126,9 +156,24 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar { return { el: root, + update(snap) { + // Recompute only when the unlocked set actually changes — the padlocks then fall + // off in the same frame the `researched` event lands. + const next = new Set(); + for (const m of data.machines) { + if (gateFor(techIndex, snap.research, m.id)) next.add(m.id); + } + if (next.size === locked.size && [...next].every((d) => locked.has(d))) return; + locked = next; + applyLocks(); + + // Never leave the player holding something they can no longer build. + const cur = sel.get(); + if (cur && locked.has(cur.def)) sel.clear(); + }, selectSlot(n) { const b = buttons[n - 1]; - if (b) sel.select(b.def); + if (b) select(b.def); }, pageBy(delta) { if (pages.length === 0) return; diff --git a/fktry/src/ui/pages.ts b/fktry/src/ui/pages.ts index bde3b05..eec86da 100644 --- a/fktry/src/ui/pages.ts +++ b/fktry/src/ui/pages.ts @@ -26,7 +26,7 @@ const GROUP_OF: Record = { lab: 'RESEARCH', // v4 kind }; -const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP']; +const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'RESEARCH', 'SHIP']; export interface BuildPage { /** Tab label, e.g. "REFINE" or "REFINE 2/2". */ diff --git a/fktry/src/ui/research.test.ts b/fktry/src/ui/research.test.ts new file mode 100644 index 0000000..75bc439 --- /dev/null +++ b/fktry/src/ui/research.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import type { GameData, ResearchState, TechDef } from '../contracts'; +import { gateFor, indexTech, isUnlocked, techProgress, techStatus } from './research'; + +const TECH: TechDef[] = [ + { id: 'disc-artifact-bottling', era: 'disc', cost: { 'analog-pack': 10 }, unlocks: ['artifact-bottler'] }, + { id: 'reel-silver-seams', era: 'reel', cost: { 'analog-pack': 5 }, unlocks: ['extract-silver-frames'] }, + { id: 'stream-software-decoding', era: 'stream', cost: { 'spatial-pack': 4, 'analog-pack': 2 }, unlocks: ['software-decoder', 'mosh-reactor'] }, +]; + +const DATA = { items: [], machines: [], recipes: [], tech: TECH, commissions: [] } as unknown as GameData; +const index = indexTech(DATA); +const research = (over: Partial = {}): ResearchState => + ({ active: null, progress: {}, unlocked: [], ...over }); + +describe('the v4 gating rule', () => { + it('locks an id that any tech claims', () => { + expect(isUnlocked(index, research(), 'artifact-bottler')).toBe(false); + expect(isUnlocked(index, research(), 'mosh-reactor')).toBe(false); + }); + + it('leaves ids referenced by no tech always available', () => { + // "ids referenced by no tech are always available" — belts must never padlock. + expect(isUnlocked(index, research(), 'belt')).toBe(true); + expect(isUnlocked(index, research(), 'quantizer')).toBe(true); + }); + + it('unlocks every id a completed tech claims', () => { + const r = research({ unlocked: ['stream-software-decoding'] }); + expect(isUnlocked(index, r, 'software-decoder')).toBe(true); + expect(isUnlocked(index, r, 'mosh-reactor')).toBe(true); + expect(isUnlocked(index, r, 'artifact-bottler')).toBe(false); // different tech + }); + + it('treats absent research as nothing unlocked, not as everything unlocked', () => { + // SIM has no research yet; the safe reading of the rule is that gates hold. + expect(isUnlocked(index, undefined, 'artifact-bottler')).toBe(false); + expect(isUnlocked(index, undefined, 'belt')).toBe(true); + }); + + it('names the tech a locked machine is waiting on, for the REQUIRES tooltip', () => { + expect(gateFor(index, research(), 'artifact-bottler')?.id).toBe('disc-artifact-bottling'); + expect(gateFor(index, research(), 'belt')).toBeNull(); + expect(gateFor(index, research({ unlocked: ['disc-artifact-bottling'] }), 'artifact-bottler')) + .toBeNull(); + }); +}); + +describe('indexTech', () => { + it('groups techs by era in era order, skipping empty eras', () => { + expect(indexTech(DATA).byEra.map((g) => g.era)).toEqual(['reel', 'disc', 'stream']); + }); + + it('keeps an era DATA invents rather than dropping its techs', () => { + const odd = { ...DATA, tech: [...TECH, { id: 'x', era: 'vhs', cost: {}, unlocks: [] } as unknown as TechDef] }; + const eras = indexTech(odd as GameData).byEra.map((g) => g.era); + expect(eras).toContain('vhs'); + expect(eras.at(-1)).toBe('vhs'); // last, not jumping the queue + }); +}); + +describe('techStatus / techProgress', () => { + it('reports done, active and available', () => { + expect(techStatus(research({ unlocked: ['reel-silver-seams'] }), TECH[1])).toBe('done'); + expect(techStatus(research({ active: 'reel-silver-seams' }), TECH[1])).toBe('active'); + expect(techStatus(research(), TECH[1])).toBe('available'); + }); + + it('is 1 when done and 0 when not being worked on', () => { + expect(techProgress(research({ unlocked: ['reel-silver-seams'] }), TECH[1])).toBe(1); + expect(techProgress(research({ active: 'disc-artifact-bottling' }), TECH[1])).toBe(0); + }); + + it('averages across every pack the tech costs', () => { + // stream-software-decoding wants 4 spatial + 2 analog = 6 packs; 3 delivered = 0.5 + const r = research({ active: 'stream-software-decoding', progress: { 'spatial-pack': 2, 'analog-pack': 1 } }); + expect(techProgress(r, TECH[2])).toBeCloseTo(0.5); + }); + + it('never exceeds 1 when the sim over-delivers a pack', () => { + const r = research({ active: 'reel-silver-seams', progress: { 'analog-pack': 99 } }); + expect(techProgress(r, TECH[1])).toBe(1); + }); + + it('is 0 with no research state at all', () => { + expect(techProgress(undefined, TECH[1])).toBe(0); + expect(techStatus(undefined, TECH[1])).toBe('available'); + }); +}); diff --git a/fktry/src/ui/research.ts b/fktry/src/ui/research.ts new file mode 100644 index 0000000..14d17e2 --- /dev/null +++ b/fktry/src/ui/research.ts @@ -0,0 +1,91 @@ +/** + * LANE-UI — the research model (pure; see research.test.ts). + * + * CONTRACTS v4 gating rule, verbatim: "a machine/recipe id that appears in ANY + * TechDef.unlocks is locked until that tech completes; ids referenced by no tech are + * always available." + * + * So a lock is a property of the *data*, not of the sim: the tree says what gates what, + * and `ResearchState.unlocked` says what has been paid for. With no research state at all + * (SIM hasn't shipped it yet) nothing is unlocked, which is the correct reading of the + * rule rather than a failure — every gated machine shows its padlock. + */ +import type { GameData, ResearchState, TechDef } from '../contracts'; + +export const ERA_ORDER = ['reel', 'broadcast', 'disc', 'stream', 'fortress'] as const; +export type Era = (typeof ERA_ORDER)[number]; + +export interface TechIndex { + /** id (machine or recipe) -> the tech that gates it. */ + gatedBy: Map; + /** techs grouped by era, in era order; eras with no techs are omitted. */ + byEra: Array<{ era: string; techs: TechDef[] }>; +} + +export function indexTech(data: GameData): TechIndex { + const gatedBy = new Map(); + for (const t of data.tech) { + for (const id of t.unlocks) { + // First tech to claim an id wins; a second one would be a data bug, and picking + // deterministically beats picking randomly. + if (!gatedBy.has(id)) gatedBy.set(id, t); + } + } + + const groups = new Map(); + for (const t of data.tech) { + const list = groups.get(t.era); + if (list) list.push(t); + else groups.set(t.era, [t]); + } + + const byEra: TechIndex['byEra'] = []; + for (const era of ERA_ORDER) { + const techs = groups.get(era); + if (techs) byEra.push({ era, techs }); + } + // An era DATA invents that we've never heard of still gets shown, just last. + for (const [era, techs] of groups) { + if (!(ERA_ORDER as readonly string[]).includes(era)) byEra.push({ era, techs }); + } + return { gatedBy, byEra }; +} + +export function isUnlocked(index: TechIndex, research: ResearchState | undefined, id: string): boolean { + const gate = index.gatedBy.get(id); + if (!gate) return true; // gated by nothing — always available + return !!research?.unlocked.includes(gate.id); +} + +/** The tech a locked id is waiting on, or null if it isn't locked. */ +export function gateFor( + index: TechIndex, + research: ResearchState | undefined, + id: string, +): TechDef | null { + const gate = index.gatedBy.get(id); + if (!gate) return null; + return research?.unlocked.includes(gate.id) ? null : gate; +} + +export type TechStatus = 'done' | 'active' | 'available'; + +export function techStatus(research: ResearchState | undefined, tech: TechDef): TechStatus { + if (research?.unlocked.includes(tech.id)) return 'done'; + if (research?.active === tech.id) return 'active'; + return 'available'; +} + +/** 0..1 across every pack the tech costs; 1 when done. */ +export function techProgress(research: ResearchState | undefined, tech: TechDef): number { + if (research?.unlocked.includes(tech.id)) return 1; + if (!research || research.active !== tech.id) return 0; + + let need = 0; + let have = 0; + for (const [item, n] of Object.entries(tech.cost)) { + need += n; + have += Math.min(n, research.progress[item] ?? 0); + } + return need > 0 ? have / need : 0; +} diff --git a/fktry/src/ui/style.ts b/fktry/src/ui/style.ts index fb7f77a..796902f 100644 --- a/fktry/src/ui/style.ts +++ b/fktry/src/ui/style.ts @@ -82,6 +82,19 @@ const CSS = ` .fk-bw-nums { display: flex; justify-content: space-between; font-size: 11px; } .fk-bw-draw { color: var(--fk-fg); } #fk-top.is-tight .fk-bar-f { background: var(--fk-amber); } + +/* ON RESERVE: amber, steady, no flashing. The tanks are covering the deficit — that is + the factory working, not failing. Only a real brownout gets to shout. */ +#fk-top.is-reserve { border-color: var(--fk-amber); } +#fk-top.is-reserve .fk-bar-f { background: var(--fk-amber); } +#fk-reserve { + display: none; + color: var(--fk-amber); + letter-spacing: 0.12em; + margin-top: 3px; +} +#fk-top.is-reserve #fk-reserve { display: block; } + #fk-top.is-brownout { border-color: var(--fk-red); animation: fk-flash 0.45s steps(2) infinite; } #fk-top.is-brownout .fk-bar-f { background: var(--fk-red); } #fk-brownout { @@ -125,8 +138,10 @@ const CSS = ` .fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); } .fk-tab.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; } -/* REMOVE is a mode, not a machine — it sits with the tabs and reads as a hazard. */ -.fk-tab-remove { margin-left: auto; color: var(--fk-dim); } +/* REMOVE is a mode, not a machine — it sits with the tabs and reads as a hazard. + No auto margin: with the RESEARCH tab added the row wraps, and an auto margin threw + REMOVE onto a line of its own. */ +.fk-tab-remove { margin-left: 10px; color: var(--fk-dim); } .fk-tab-remove:hover { color: var(--fk-red); border-color: var(--fk-red); } .fk-tab-remove.is-armed { color: #12080a; background: var(--fk-red); border-color: var(--fk-red); @@ -160,6 +175,21 @@ const CSS = ` display: flex; align-items: flex-end; } .fk-build-accent { width: 100%; height: 4px; } + +/* Locked: the machine exists, you just haven't earned it. Greyed, padlocked, still + hoverable so the REQUIRES tooltip can explain itself. */ +.fk-build-lock { + display: none; + position: absolute; inset: 0; + align-items: center; justify-content: center; + font-size: 11px; + text-shadow: 0 0 4px #000; +} +.fk-build-lock.is-on { display: flex; } +.fk-build-btn.is-locked { cursor: not-allowed; } +.fk-build-btn.is-locked .fk-build-ico { filter: grayscale(1) brightness(0.45); } +.fk-build-btn.is-locked .fk-build-name { color: var(--fk-faint); } +.fk-build-btn.is-locked:hover { border-color: var(--fk-line); color: var(--fk-faint); } .fk-build-key { position: absolute; top: 1px; right: 2px; font-size: 8px; color: var(--fk-faint); @@ -214,6 +244,47 @@ const CSS = ` .fk-ins-recipe:hover, .fk-ins-recipe:focus { border-color: var(--fk-cool); outline: none; } .fk-ins-recipe option { background: var(--fk-bg-solid); color: var(--fk-fg); } +/* ---------------------------------------------------------------- tech panel */ + +#fk-tech { + display: none; + top: 50%; left: 50%; transform: translate(-50%, -50%); + width: min(720px, calc(100vw - 40px)); + max-height: calc(100vh - 160px); + overflow-y: auto; + z-index: 12; +} +#fk-tech.is-open { display: block; } +.fk-tech-sub { color: var(--fk-faint); font-size: 9px; letter-spacing: 0.14em; } +.fk-tech-offline { + display: none; + color: var(--fk-amber); font-size: 10px; margin-top: 6px; + border: 1px solid var(--fk-amber); padding: 3px 6px; +} +.fk-tech-offline.is-on { display: block; } +.fk-tech-body { margin-top: 8px; } +.fk-tech-era { margin-top: 10px; border-bottom: 1px solid var(--fk-line); padding-bottom: 3px; } +.fk-tech-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 4px; + margin-top: 5px; +} +.fk-tech-node { + background: var(--fk-bg-solid); + border: 1px solid var(--fk-line); + color: var(--fk-fg); + font: inherit; font-size: 9px; text-align: left; + padding: 5px; cursor: pointer; pointer-events: auto; +} +.fk-tech-node:hover { border-color: var(--fk-cool); } +.fk-tech-node.is-active { border-color: var(--fk-hot); background: #1c0a20; } +.fk-tech-node.is-done { border-color: #2e5a3e; color: var(--fk-dim); cursor: default; } +.fk-tech-node.is-done .fk-tech-name { color: #4e8a5e; } +.fk-tech-name { color: var(--fk-cool); letter-spacing: 0.06em; margin-bottom: 3px; } +.fk-tech-node.is-active .fk-tech-name { color: var(--fk-hot); } +.fk-tech-status { font-size: 8px; letter-spacing: 0.12em; color: var(--fk-dim); margin-top: 3px; } +.fk-tech-node.is-active .fk-tech-status { color: var(--fk-hot); } +#fk-tech .fk-chip { font-size: 9px; } + /* ---------------------------------------------------------------- fax */ #fk-fax { diff --git a/fktry/src/ui/techpanel.test.ts b/fktry/src/ui/techpanel.test.ts new file mode 100644 index 0000000..add7554 --- /dev/null +++ b/fktry/src/ui/techpanel.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest'; +import type { Command, GameData, ResearchState, SimSnapshot, UIBus } from '../contracts'; +import { createTechPanel } from './techpanel'; + +const DATA: GameData = { + items: [ + { id: 'analog-pack', name: 'ANALOG PACK', codex: '', tier: 2, color: '#7fff9f' }, + { id: 'spatial-pack', name: 'SPATIAL PACK', codex: '', tier: 2, color: '#7fb0ff' }, + ], + machines: [ + { id: 'software-decoder', name: 'SOFTWARE DECODER', codex: '', kind: 'power', footprint: { x: 2, y: 2 }, recipes: [], powerDraw: 0, asset: 'x' }, + ], + recipes: [], + tech: [ + { id: 'reel-silver-seams', era: 'reel', cost: { 'analog-pack': 4 }, unlocks: ['extract-silver'] }, + { id: 'stream-software-decoding', era: 'stream', cost: { 'analog-pack': 2, 'spatial-pack': 2 }, unlocks: ['software-decoder'] }, + ], + commissions: [], +}; + +function mount(research?: ResearchState) { + document.body.innerHTML = ''; + const sent: Command[] = []; + const bus: UIBus = { + dispatch: (c) => sent.push(c), + selectedBuild: () => null, + pickTile: () => null, + }; + const panel = createTechPanel(DATA, bus); + document.body.append(panel.el); + const snap = { research } as unknown as SimSnapshot; + return { panel, sent, snap }; +} + +const nodes = () => Array.from(document.querySelectorAll('.fk-tech-node')) as HTMLElement[]; +const nodeFor = (text: string) => nodes().find((n) => n.textContent?.includes(text))!; + +describe('tech panel', () => { + it('starts shut and toggles on T', () => { + const { panel } = mount(); + expect(panel.isOpen()).toBe(false); + panel.toggle(); + expect(panel.isOpen()).toBe(true); + expect(panel.el.classList.contains('is-open')).toBe(true); + panel.close(); + expect(panel.isOpen()).toBe(false); + }); + + it('renders one node per tech, grouped under era headings in era order', () => { + const { panel } = mount(); + panel.toggle(); + expect(nodes()).toHaveLength(2); + const eras = Array.from(document.querySelectorAll('.fk-tech-era')).map((e) => e.textContent); + expect(eras).toEqual(['REEL', 'STREAM']); + }); + + it('strips the era prefix from the node name — the heading already says it', () => { + const { panel } = mount(); + panel.toggle(); + expect(nodeFor('SILVER SEAMS').querySelector('.fk-tech-name')!.textContent) + .toBe('SILVER SEAMS'); + }); + + it('says what a tech unlocks, in machine names not ids', () => { + const { panel } = mount(); + panel.toggle(); + expect(nodeFor('SOFTWARE DECODING').getAttribute('title')) + .toBe('UNLOCKS: SOFTWARE DECODER'); + }); + + it('dispatches setResearch when a node is picked', () => { + const { panel, sent, snap } = mount({ active: null, progress: {}, unlocked: [] }); + panel.toggle(); + panel.update(snap); + nodeFor('SILVER SEAMS').dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(sent).toEqual([{ kind: 'setResearch', tech: 'reel-silver-seams' }]); + }); + + it('stands the active tech down when it is picked again', () => { + const { panel, sent, snap } = mount({ active: 'reel-silver-seams', progress: {}, unlocked: [] }); + panel.toggle(); + panel.update(snap); + nodeFor('SILVER SEAMS').dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(sent).toEqual([{ kind: 'setResearch', tech: null }]); + }); + + it('ignores clicks on a ratified tech', () => { + const { panel, sent, snap } = mount({ active: null, progress: {}, unlocked: ['reel-silver-seams'] }); + panel.toggle(); + panel.update(snap); + nodeFor('SILVER SEAMS').dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(sent).toEqual([]); + }); + + it('marks active and ratified nodes, and shows pack progress on the active one', () => { + const { panel, snap } = mount({ + active: 'stream-software-decoding', + progress: { 'analog-pack': 1 }, + unlocked: ['reel-silver-seams'], + }); + panel.toggle(); + panel.update(snap); + + const done = nodeFor('SILVER SEAMS'); + expect(done.classList.contains('is-done')).toBe(true); + expect(done.querySelector('.fk-tech-status')!.textContent).toBe('RATIFIED'); + + const active = nodeFor('SOFTWARE DECODING'); + expect(active.classList.contains('is-active')).toBe(true); + expect(active.querySelector('.fk-tech-status')!.textContent).toBe('IN COMMITTEE'); + // 1 of 4 packs delivered + expect((active.querySelector('.fk-bar-f') as HTMLElement).style.width).toBe('25%'); + expect(active.textContent).toContain('1/2'); // analog packs + }); + + it('says RESEARCH OFFLINE when the sim reports no research state', () => { + const { panel, snap } = mount(undefined); + panel.toggle(); + panel.update(snap); + expect(document.querySelector('.fk-tech-offline')!.classList.contains('is-on')).toBe(true); + }); + + it('drops the offline notice once research is running', () => { + const { panel } = mount(); + panel.toggle(); + panel.update({ research: { active: null, progress: {}, unlocked: [] } } as unknown as SimSnapshot); + expect(document.querySelector('.fk-tech-offline')!.classList.contains('is-on')).toBe(false); + }); + + it('does no work while shut', () => { + const { panel, snap } = mount({ active: 'reel-silver-seams', progress: {}, unlocked: [] }); + const spy = vi.spyOn(document, 'querySelector'); + panel.update(snap); // closed + spy.mockRestore(); + expect(nodeFor('SILVER SEAMS').classList.contains('is-active')).toBe(false); + }); +}); diff --git a/fktry/src/ui/techpanel.ts b/fktry/src/ui/techpanel.ts new file mode 100644 index 0000000..f41c086 --- /dev/null +++ b/fktry/src/ui/techpanel.ts @@ -0,0 +1,130 @@ +/** + * LANE-UI — the research panel (T). The tech tree as media archaeology: you research + * downward into older strata and upward into newer codecs (lore §6), so the eras are the + * spine and each node is one ratified standard. + * + * Read-mostly: pick a node and it dispatches `setResearch`. Progress, active and unlocked + * all come from `snapshot.research` — nothing here remembers anything. + */ +import type { GameData, ItemDef, SimSnapshot, TechDef, UIBus } from '../contracts'; +import { createChipRow, type ChipRow } from './chips'; +import { attrs, cls, el, panel, style, text } from './dom'; +import { indexTech, techProgress, techStatus, type TechIndex } from './research'; +import { COPY } from './voice'; + +export interface TechPanel { + el: HTMLElement; + toggle(): void; + close(): void; + isOpen(): boolean; + update(snap: SimSnapshot): void; +} + +interface NodeView { + tech: TechDef; + root: HTMLElement; + fill: HTMLElement; + chips: ChipRow; + status: HTMLElement; +} + +export function createTechPanel(data: GameData, bus: UIBus): TechPanel { + const index: TechIndex = indexTech(data); + const items = new Map(data.items.map((i) => [i.id, i])); + const machines = new Map(data.machines.map((m) => [m.id, m.name])); + + const root = panel(); + root.id = 'fk-tech'; + + const closeBtn = el('button', 'fk-ins-x', [COPY.inspectorClose]); + attrs(closeBtn, { type: 'button', title: 'CLOSE (T / ESC)' }); + const head = el('div', 'fk-ins-head', [el('div', 'fk-ins-name', [COPY.techHeader]), closeBtn]); + const sub = el('div', 'fk-tech-sub', [COPY.techSubheader]); + const offline = el('div', 'fk-tech-offline', [COPY.researchOffline]); + const body = el('div', 'fk-tech-body'); + + root.append(head, sub, offline, body); + + const views: NodeView[] = []; + + for (const { era, techs } of index.byEra) { + body.append(el('div', 'fk-h fk-tech-era', [era.toUpperCase()])); + const grid = el('div', 'fk-tech-grid'); + + for (const tech of techs) { + const name = el('div', 'fk-tech-name', [techLabel(tech)]); + const status = el('div', 'fk-tech-status'); + const fill = el('div', 'fk-bar-f'); + const bar = el('div', 'fk-bar', [fill]); + const chips = createChipRow(items); + + const node = el('button', 'fk-tech-node', [name, chips.el, bar, status]); + attrs(node, { type: 'button', title: unlocksLabel(tech) }); + node.addEventListener('click', () => { + // Clicking the active tech stands it down; clicking a done one does nothing. + const done = views.find((v) => v.tech.id === tech.id)!.root.classList.contains('is-done'); + if (done) return; + const active = root.querySelector('.fk-tech-node.is-active'); + bus.dispatch({ kind: 'setResearch', tech: active === node ? null : tech.id }); + }); + + grid.append(node); + views.push({ tech, root: node, fill, chips, status }); + } + body.append(grid); + } + + /** "SILVER SEAMS" — the era prefix is already the heading above it. */ + function techLabel(t: TechDef): string { + return t.id.replace(new RegExp(`^${t.era}-`), '').replace(/-/g, ' ').toUpperCase(); + } + + function unlocksLabel(t: TechDef): string { + const names = t.unlocks.map((u) => machines.get(u) ?? u.replace(/-/g, ' ').toUpperCase()); + return `UNLOCKS: ${names.join(', ')}`; + } + + let open = false; + function setOpen(v: boolean) { + open = v; + cls(root, 'is-open', v); + } + closeBtn.addEventListener('click', () => setOpen(false)); + + return { + el: root, + toggle: () => setOpen(!open), + close: () => setOpen(false), + isOpen: () => open, + update(snap) { + if (!open) return; // 24 nodes is not worth a frame's work while shut + const research = snap.research; + + // No research state at all means the sim isn't running research yet — say so + // plainly rather than rendering a tree that silently can't do anything. + cls(offline, 'is-on', !research); + + for (const v of views) { + const st = techStatus(research, v.tech); + cls(v.root, 'is-done', st === 'done'); + cls(v.root, 'is-active', st === 'active'); + + style(v.fill.parentElement!, 'display', st === 'available' ? 'none' : ''); + if (st !== 'available') style(v.fill, 'width', `${techProgress(research, v.tech) * 100}%`); + + text(v.status, st === 'done' ? COPY.techDone : st === 'active' ? COPY.techActive : ''); + + v.chips.update( + Object.entries(v.tech.cost).map(([item, need]) => { + const have = st === 'done' ? need : (research?.active === v.tech.id ? research.progress[item] ?? 0 : 0); + return { + item, + count: `${Math.min(have, need)}/${need}`, + state: have >= need ? ('met' as const) : ('plain' as const), + }; + }), + ); + } + }, + }; +}