glytch/fktry/src/ui/research.test.ts
type-two 2904ddbb8c [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>
2026-07-17 20:13:50 +10:00

90 lines
4.2 KiB
TypeScript

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