[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:
parent
5a36e32246
commit
2904ddbb8c
@ -2,10 +2,11 @@
|
|||||||
* LANE-UI — build bar. Kind tabs, `[`/`]` to page, 1-9 within the active page.
|
* 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.
|
* 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 { attrs, cls, el, panel, text } from './dom';
|
||||||
import { paginate, pageOf, type BuildPage } from './pages';
|
import { paginate, pageOf, type BuildPage } from './pages';
|
||||||
import { createAccentLookup, machineChassis } from './palette';
|
import { createAccentLookup, machineChassis } from './palette';
|
||||||
|
import { gateFor, indexTech, type TechIndex } from './research';
|
||||||
import type { BuildSelection } from './selection';
|
import type { BuildSelection } from './selection';
|
||||||
import { COPY, DIR_NAME } from './voice';
|
import { COPY, DIR_NAME } from './voice';
|
||||||
|
|
||||||
@ -15,11 +16,16 @@ export interface BuildBar {
|
|||||||
selectSlot(n: number): void;
|
selectSlot(n: number): void;
|
||||||
pageBy(delta: number): void;
|
pageBy(delta: number): void;
|
||||||
sync(): 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 {
|
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||||
const pages: BuildPage[] = paginate(data.machines);
|
const pages: BuildPage[] = paginate(data.machines);
|
||||||
const accentOf = createAccentLookup(data);
|
const accentOf = createAccentLookup(data);
|
||||||
|
const techIndex: TechIndex = indexTech(data);
|
||||||
|
/** Machine ids currently gated by unfinished research. */
|
||||||
|
let locked = new Set<string>();
|
||||||
|
|
||||||
const root = panel();
|
const root = panel();
|
||||||
root.id = 'fk-build';
|
root.id = 'fk-build';
|
||||||
@ -54,13 +60,12 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
|||||||
tabRow.append(removeBtn);
|
tabRow.append(removeBtn);
|
||||||
|
|
||||||
/** Buttons for the active page only; rebuilt on page change (nine at most). */
|
/** 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) {
|
function buildButtons(page: BuildPage) {
|
||||||
row.replaceChildren();
|
row.replaceChildren();
|
||||||
buttons = page.machines.map((m: MachineDef, i: number) => {
|
buttons = page.machines.map((m: MachineDef, i: number) => {
|
||||||
const btn = el('button', 'fk-build-btn');
|
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.
|
// Two-material rule (codex §8): grimy body, one impossible element.
|
||||||
const ico = el('div', 'fk-build-ico');
|
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.background = accentOf(m);
|
||||||
accent.style.boxShadow = `0 0 5px ${accentOf(m)}`;
|
accent.style.boxShadow = `0 0 5px ${accentOf(m)}`;
|
||||||
ico.append(accent);
|
ico.append(accent);
|
||||||
|
const lock = el('span', 'fk-build-lock', ['\u{1F512}']);
|
||||||
|
ico.append(lock);
|
||||||
btn.append(ico);
|
btn.append(ico);
|
||||||
|
|
||||||
const key = el('span', 'fk-build-key');
|
const key = el('span', 'fk-build-key');
|
||||||
@ -76,10 +83,33 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
|||||||
btn.append(key);
|
btn.append(key);
|
||||||
|
|
||||||
btn.append(el('span', 'fk-build-name', [m.name]));
|
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);
|
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() {
|
function render() {
|
||||||
@ -126,9 +156,24 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
el: root,
|
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) {
|
selectSlot(n) {
|
||||||
const b = buttons[n - 1];
|
const b = buttons[n - 1];
|
||||||
if (b) sel.select(b.def);
|
if (b) select(b.def);
|
||||||
},
|
},
|
||||||
pageBy(delta) {
|
pageBy(delta) {
|
||||||
if (pages.length === 0) return;
|
if (pages.length === 0) return;
|
||||||
|
|||||||
@ -26,7 +26,7 @@ const GROUP_OF: Record<MachineKind, string> = {
|
|||||||
lab: 'RESEARCH', // v4 kind
|
lab: 'RESEARCH', // v4 kind
|
||||||
};
|
};
|
||||||
|
|
||||||
const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP'];
|
const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'RESEARCH', 'SHIP'];
|
||||||
|
|
||||||
export interface BuildPage {
|
export interface BuildPage {
|
||||||
/** Tab label, e.g. "REFINE" or "REFINE 2/2". */
|
/** Tab label, e.g. "REFINE" or "REFINE 2/2". */
|
||||||
|
|||||||
89
fktry/src/ui/research.test.ts
Normal file
89
fktry/src/ui/research.test.ts
Normal 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
91
fktry/src/ui/research.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -82,6 +82,19 @@ const CSS = `
|
|||||||
.fk-bw-nums { display: flex; justify-content: space-between; font-size: 11px; }
|
.fk-bw-nums { display: flex; justify-content: space-between; font-size: 11px; }
|
||||||
.fk-bw-draw { color: var(--fk-fg); }
|
.fk-bw-draw { color: var(--fk-fg); }
|
||||||
#fk-top.is-tight .fk-bar-f { background: var(--fk-amber); }
|
#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 { 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-top.is-brownout .fk-bar-f { background: var(--fk-red); }
|
||||||
#fk-brownout {
|
#fk-brownout {
|
||||||
@ -125,8 +138,10 @@ const CSS = `
|
|||||||
.fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
|
.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; }
|
.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. */
|
/* 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); }
|
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:hover { color: var(--fk-red); border-color: var(--fk-red); }
|
||||||
.fk-tab-remove.is-armed {
|
.fk-tab-remove.is-armed {
|
||||||
color: #12080a; background: var(--fk-red); border-color: var(--fk-red);
|
color: #12080a; background: var(--fk-red); border-color: var(--fk-red);
|
||||||
@ -160,6 +175,21 @@ const CSS = `
|
|||||||
display: flex; align-items: flex-end;
|
display: flex; align-items: flex-end;
|
||||||
}
|
}
|
||||||
.fk-build-accent { width: 100%; height: 4px; }
|
.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 {
|
.fk-build-key {
|
||||||
position: absolute; top: 1px; right: 2px;
|
position: absolute; top: 1px; right: 2px;
|
||||||
font-size: 8px; color: var(--fk-faint);
|
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: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); }
|
.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 */
|
/* ---------------------------------------------------------------- fax */
|
||||||
|
|
||||||
#fk-fax {
|
#fk-fax {
|
||||||
|
|||||||
138
fktry/src/ui/techpanel.test.ts
Normal file
138
fktry/src/ui/techpanel.test.ts
Normal 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
130
fktry/src/ui/techpanel.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user