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>
90 lines
4.2 KiB
TypeScript
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');
|
|
});
|
|
});
|