import { describe, it, expect, beforeEach } from 'vitest'; import schoolsData from '../src/data/schools.json'; import elementsData from '../src/data/elements.json'; import type { SchoolData, RunState, MetaState, CrisisConfig } from '../src/run/types'; import { RunPhase, RUN_PHASE_NAMES, BODY_COMPOSITION, SPORE_REWARDS, PHASE_DURATIONS, ESCALATION_RATE, } from '../src/run/types'; import { createRunState, advancePhase, updateEscalation, recordDiscovery, calculateSpores, } from '../src/run/state'; import { createMetaState, applyRunResults, isSchoolUnlocked, getCodexEntries, getCodexCount, } from '../src/run/meta'; import { createCrisisState, applyCrisisDamage, attemptNeutralize, isCrisisResolved, CHEMICAL_PLAGUE, } from '../src/run/crisis'; // ─── School Data ───────────────────────────────────────────────── describe('School Data', () => { const schools = schoolsData as SchoolData[]; it('should have at least one school (Alchemist)', () => { expect(schools.length).toBeGreaterThanOrEqual(1); }); it('Alchemist has correct starting elements', () => { const alchemist = schools.find(s => s.id === 'alchemist'); expect(alchemist).toBeDefined(); expect(alchemist!.startingElements).toEqual(['H', 'O', 'C', 'Na', 'S', 'Fe']); }); it('all starting elements exist in element registry', () => { const symbols = new Set(elementsData.map(e => e.symbol)); for (const school of schools) { for (const elem of school.startingElements) { expect(symbols.has(elem), `Element ${elem} not found in registry`).toBe(true); } } }); it('starting quantities match starting elements', () => { for (const school of schools) { for (const elem of school.startingElements) { expect( school.startingQuantities[elem], `Missing quantity for ${elem} in school ${school.id}`, ).toBeGreaterThan(0); } } }); it('each school has bilingual fields', () => { for (const school of schools) { expect(school.name.length).toBeGreaterThan(0); expect(school.nameRu.length).toBeGreaterThan(0); expect(school.description.length).toBeGreaterThan(0); expect(school.descriptionRu.length).toBeGreaterThan(0); expect(school.principle.length).toBeGreaterThan(0); expect(school.principleRu.length).toBeGreaterThan(0); } }); it('each school has a valid hex color', () => { for (const school of schools) { expect(school.color).toMatch(/^#[0-9a-fA-F]{6}$/); } }); }); // ─── Run State ─────────────────────────────────────────────────── describe('Run State', () => { let state: RunState; beforeEach(() => { state = createRunState(1, 'alchemist'); }); it('creates initial state in Awakening phase', () => { expect(state.runId).toBe(1); expect(state.schoolId).toBe('alchemist'); expect(state.phase).toBe(RunPhase.Awakening); expect(state.elapsed).toBe(0); expect(state.escalation).toBe(0); expect(state.crisisActive).toBe(false); expect(state.alive).toBe(true); }); it('advances phases in order', () => { advancePhase(state); expect(state.phase).toBe(RunPhase.Exploration); advancePhase(state); expect(state.phase).toBe(RunPhase.Escalation); advancePhase(state); expect(state.phase).toBe(RunPhase.Crisis); advancePhase(state); expect(state.phase).toBe(RunPhase.Resolution); }); it('does not advance past Resolution', () => { state.phase = RunPhase.Resolution; advancePhase(state); expect(state.phase).toBe(RunPhase.Resolution); }); it('resets phase timer on advance', () => { state.phaseTimer = 5000; advancePhase(state); expect(state.phaseTimer).toBe(0); }); it('records element discovery', () => { recordDiscovery(state, 'element', 'Na'); expect(state.discoveries.elements.has('Na')).toBe(true); }); it('records reaction discovery', () => { recordDiscovery(state, 'reaction', 'Na+Cl'); expect(state.discoveries.reactions.has('Na+Cl')).toBe(true); }); it('records compound discovery', () => { recordDiscovery(state, 'compound', 'NaCl'); expect(state.discoveries.compounds.has('NaCl')).toBe(true); }); it('records creature discovery', () => { recordDiscovery(state, 'creature', 'crystallid'); expect(state.discoveries.creatures.has('crystallid')).toBe(true); }); it('does not duplicate discoveries', () => { recordDiscovery(state, 'element', 'Na'); recordDiscovery(state, 'element', 'Na'); expect(state.discoveries.elements.size).toBe(1); }); it('calculates spores from discoveries', () => { recordDiscovery(state, 'element', 'Na'); recordDiscovery(state, 'element', 'Cl'); recordDiscovery(state, 'reaction', 'Na+Cl'); recordDiscovery(state, 'compound', 'NaCl'); recordDiscovery(state, 'creature', 'crystallid'); const spores = calculateSpores(state); const expected = 2 * SPORE_REWARDS.element + 1 * SPORE_REWARDS.reaction + 1 * SPORE_REWARDS.compound + 1 * SPORE_REWARDS.creature; expect(spores).toBe(expected); }); it('adds crisis resolved bonus to spores', () => { recordDiscovery(state, 'element', 'Na'); state.crisisResolved = true; const spores = calculateSpores(state); expect(spores).toBe(SPORE_REWARDS.element + SPORE_REWARDS.crisisResolved); }); }); // ─── Escalation ────────────────────────────────────────────────── describe('Escalation', () => { let state: RunState; beforeEach(() => { state = createRunState(1, 'alchemist'); state.phase = RunPhase.Escalation; }); it('increases escalation over time', () => { updateEscalation(state, 1000); // 1 second expect(state.escalation).toBeCloseTo(ESCALATION_RATE); }); it('clamps escalation to 1.0', () => { updateEscalation(state, 300_000); // 5 minutes expect(state.escalation).toBeLessThanOrEqual(1.0); }); it('does not escalate during Exploration', () => { state.phase = RunPhase.Exploration; updateEscalation(state, 10_000); expect(state.escalation).toBe(0); }); it('continues escalation during Crisis', () => { state.phase = RunPhase.Crisis; state.escalation = 0.5; updateEscalation(state, 1000); expect(state.escalation).toBeGreaterThan(0.5); }); }); // ─── Meta-Progression ──────────────────────────────────────────── describe('Meta-Progression', () => { let meta: MetaState; beforeEach(() => { meta = createMetaState(); }); it('creates empty meta state', () => { expect(meta.spores).toBe(0); expect(meta.codex).toEqual([]); expect(meta.totalRuns).toBe(0); expect(meta.totalDeaths).toBe(0); expect(meta.unlockedSchools).toEqual(['alchemist']); }); it('alchemist is unlocked by default', () => { expect(isSchoolUnlocked(meta, 'alchemist')).toBe(true); }); it('other schools are locked by default', () => { expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false); }); it('applies run results — adds spores', () => { const run = createRunState(1, 'alchemist'); recordDiscovery(run, 'element', 'Na'); recordDiscovery(run, 'element', 'Cl'); applyRunResults(meta, run); expect(meta.spores).toBe(2 * SPORE_REWARDS.element); expect(meta.totalRuns).toBe(1); expect(meta.totalDeaths).toBe(1); }); it('applies run results — adds codex entries', () => { const run = createRunState(1, 'alchemist'); recordDiscovery(run, 'element', 'Na'); recordDiscovery(run, 'reaction', 'Na+Cl'); applyRunResults(meta, run); expect(meta.codex.length).toBe(2); expect(getCodexEntries(meta, 'element')).toHaveLength(1); expect(getCodexEntries(meta, 'reaction')).toHaveLength(1); }); it('does not duplicate codex entries across runs', () => { const run1 = createRunState(1, 'alchemist'); recordDiscovery(run1, 'element', 'Na'); applyRunResults(meta, run1); const run2 = createRunState(2, 'alchemist'); recordDiscovery(run2, 'element', 'Na'); applyRunResults(meta, run2); expect(getCodexCount(meta)).toBe(1); }); it('accumulates spores across runs', () => { const run1 = createRunState(1, 'alchemist'); recordDiscovery(run1, 'element', 'Na'); applyRunResults(meta, run1); const run2 = createRunState(2, 'alchemist'); recordDiscovery(run2, 'element', 'Cl'); applyRunResults(meta, run2); expect(meta.spores).toBe(2 * SPORE_REWARDS.element); expect(meta.totalRuns).toBe(2); }); it('stores run history summaries', () => { const run = createRunState(1, 'alchemist'); run.phase = RunPhase.Escalation; run.elapsed = 120_000; recordDiscovery(run, 'element', 'Na'); recordDiscovery(run, 'compound', 'NaCl'); applyRunResults(meta, run); expect(meta.runHistory).toHaveLength(1); expect(meta.runHistory[0].schoolId).toBe('alchemist'); expect(meta.runHistory[0].discoveries).toBe(2); expect(meta.runHistory[0].duration).toBe(120_000); }); }); // ─── Crisis: Chemical Plague ───────────────────────────────────── describe('Crisis: Chemical Plague', () => { it('defines chemical plague config', () => { expect(CHEMICAL_PLAGUE.type).toBe('chemical-plague'); expect(CHEMICAL_PLAGUE.neutralizer).toBeDefined(); expect(CHEMICAL_PLAGUE.neutralizeAmount).toBeGreaterThan(0); }); it('creates crisis state', () => { const crisis = createCrisisState(CHEMICAL_PLAGUE); expect(crisis.active).toBe(true); expect(crisis.progress).toBe(0); expect(crisis.resolved).toBe(false); }); it('applies crisis damage over time', () => { const crisis = createCrisisState(CHEMICAL_PLAGUE); applyCrisisDamage(crisis, 1000); expect(crisis.progress).toBeGreaterThan(0); }); it('crisis progress clamps at 1.0', () => { const crisis = createCrisisState(CHEMICAL_PLAGUE); applyCrisisDamage(crisis, 999_999); expect(crisis.progress).toBeLessThanOrEqual(1.0); }); it('neutralize reduces progress', () => { const crisis = createCrisisState(CHEMICAL_PLAGUE); crisis.progress = 0.5; const result = attemptNeutralize(crisis, CHEMICAL_PLAGUE.neutralizer, 1); expect(result).toBe(true); expect(crisis.progress).toBeLessThan(0.5); }); it('wrong compound does not neutralize', () => { const crisis = createCrisisState(CHEMICAL_PLAGUE); crisis.progress = 0.5; const result = attemptNeutralize(crisis, 'WrongCompound', 1); expect(result).toBe(false); expect(crisis.progress).toBe(0.5); }); it('sufficient neutralization resolves crisis', () => { const crisis = createCrisisState(CHEMICAL_PLAGUE); crisis.progress = 0.1; attemptNeutralize(crisis, CHEMICAL_PLAGUE.neutralizer, CHEMICAL_PLAGUE.neutralizeAmount); expect(isCrisisResolved(crisis)).toBe(true); expect(crisis.resolved).toBe(true); }); }); // ─── Body Composition ──────────────────────────────────────────── describe('Body Composition', () => { it('fractions sum to approximately 1.0', () => { const sum = BODY_COMPOSITION.reduce((acc, e) => acc + e.fraction, 0); // Allow some margin since we simplified (real body has trace elements) expect(sum).toBeGreaterThan(0.85); expect(sum).toBeLessThanOrEqual(1.0); }); it('all body elements exist in element registry', () => { const symbols = new Set(elementsData.map(e => e.symbol)); for (const entry of BODY_COMPOSITION) { expect(symbols.has(entry.symbol), `${entry.symbol} not in registry`).toBe(true); } }); it('oxygen is the largest fraction', () => { const oxygen = BODY_COMPOSITION.find(e => e.symbol === 'O'); expect(oxygen).toBeDefined(); expect(oxygen!.fraction).toBe(0.65); }); }); // ─── Run Phase Names ───────────────────────────────────────────── describe('Run Phase Names', () => { it('has names for all phases', () => { expect(RUN_PHASE_NAMES[RunPhase.Awakening]).toBe('Awakening'); expect(RUN_PHASE_NAMES[RunPhase.Exploration]).toBe('Exploration'); expect(RUN_PHASE_NAMES[RunPhase.Escalation]).toBe('Escalation'); expect(RUN_PHASE_NAMES[RunPhase.Crisis]).toBe('Crisis'); expect(RUN_PHASE_NAMES[RunPhase.Resolution]).toBe('Resolution'); }); it('phases are numbered 0-4', () => { expect(RunPhase.Awakening).toBe(0); expect(RunPhase.Resolution).toBe(4); }); it('phase durations are defined', () => { expect(PHASE_DURATIONS[RunPhase.Exploration]).toBeGreaterThan(0); expect(PHASE_DURATIONS[RunPhase.Escalation]).toBeGreaterThan(0); }); });