[ui] research: the STANDARDS COMMITTEE, and padlocks that fall off live

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: <TECH> 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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:13:50 +10:00
parent 5a36e32246
commit 2904ddbb8c
7 changed files with 573 additions and 9 deletions

View File

@ -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<string>();
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<string>();
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;

View File

@ -26,7 +26,7 @@ const GROUP_OF: Record<MachineKind, string> = {
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". */

View File

@ -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> = {}): 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');
});
});

91
fktry/src/ui/research.ts Normal file
View File

@ -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<string, TechDef>;
/** 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<string, TechDef>();
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<string, TechDef[]>();
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;
}

View File

@ -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 {

View File

@ -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);
});
});

130
fktry/src/ui/techpanel.ts Normal file
View File

@ -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<string, ItemDef>(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),
};
}),
);
}
},
};
}