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), + }; + }), + ); + } + }, + }; +}