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, checkSchoolUnlocks, getSchoolBonuses, } 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 exactly 4 schools', () => { expect(schools.length).toBe(4); }); 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('Mechanic has correct starting elements', () => { const mechanic = schools.find(s => s.id === 'mechanic'); expect(mechanic).toBeDefined(); expect(mechanic!.startingElements).toEqual(['Fe', 'Cu', 'Sn', 'Si', 'C']); }); it('Naturalist has correct starting elements', () => { const naturalist = schools.find(s => s.id === 'naturalist'); expect(naturalist).toBeDefined(); expect(naturalist!.startingElements).toEqual(['C', 'N', 'O', 'P', 'K']); }); it('Navigator has correct starting elements', () => { const navigator = schools.find(s => s.id === 'navigator'); expect(navigator).toBeDefined(); expect(navigator!.startingElements).toEqual(['Si', 'Fe', 'C', 'H', 'O']); }); 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}$/); } }); it('each school has unique id', () => { const ids = schools.map(s => s.id); expect(new Set(ids).size).toBe(ids.length); }); it('each school has unique color', () => { const colors = schools.map(s => s.color); expect(new Set(colors).size).toBe(colors.length); }); it('each school has bonuses defined', () => { for (const school of schools) { expect(school.bonuses).toBeDefined(); expect(Object.keys(school.bonuses).length).toBeGreaterThan(0); } }); it('locked schools have unlock conditions', () => { for (const school of schools) { if (school.id === 'alchemist') { expect(school.unlockCondition).toBeUndefined(); } else { expect(school.unlockCondition).toBeDefined(); expect(school.unlockCondition!.threshold).toBeGreaterThan(0); expect(school.unlockCondition!.hint.length).toBeGreaterThan(0); expect(school.unlockCondition!.hintRu.length).toBeGreaterThan(0); } } }); it('each school has a real scientific principle', () => { const alchemist = schools.find(s => s.id === 'alchemist')!; expect(alchemist.principle).toBe('Chemical Equilibrium'); const mechanic = schools.find(s => s.id === 'mechanic')!; expect(mechanic.principle).toBe('Lever & Moment of Force'); const naturalist = schools.find(s => s.id === 'naturalist')!; expect(naturalist.principle).toBe('Photosynthesis'); const navigator = schools.find(s => s.id === 'navigator')!; expect(navigator.principle).toBe('Angular Measurement'); }); }); // ─── 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); }); }); // ─── School Unlock System ──────────────────────────────────────── describe('School Unlock System', () => { let meta: MetaState; beforeEach(() => { meta = createMetaState(); }); it('only alchemist is unlocked at start', () => { expect(meta.unlockedSchools).toEqual(['alchemist']); }); it('mechanic unlocks after discovering 10 elements', () => { const run = createRunState(1, 'alchemist'); for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) { recordDiscovery(run, 'element', sym); } applyRunResults(meta, run); expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true); }); it('mechanic does NOT unlock with fewer than 10 elements', () => { const run = createRunState(1, 'alchemist'); for (const sym of ['H', 'O', 'C', 'Na', 'S']) { recordDiscovery(run, 'element', sym); } applyRunResults(meta, run); expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false); }); it('naturalist unlocks after discovering 3 creatures', () => { const run = createRunState(1, 'alchemist'); recordDiscovery(run, 'creature', 'crystallid'); recordDiscovery(run, 'creature', 'acidophile'); recordDiscovery(run, 'creature', 'reagent'); applyRunResults(meta, run); expect(isSchoolUnlocked(meta, 'naturalist')).toBe(true); }); it('naturalist does NOT unlock with fewer than 3 creatures', () => { const run = createRunState(1, 'alchemist'); recordDiscovery(run, 'creature', 'crystallid'); recordDiscovery(run, 'creature', 'acidophile'); applyRunResults(meta, run); expect(isSchoolUnlocked(meta, 'naturalist')).toBe(false); }); it('navigator unlocks after 3 completed runs', () => { for (let i = 1; i <= 3; i++) { const run = createRunState(i, 'alchemist'); recordDiscovery(run, 'element', `elem${i}`); applyRunResults(meta, run); } expect(isSchoolUnlocked(meta, 'navigator')).toBe(true); }); it('navigator does NOT unlock with only 2 runs', () => { for (let i = 1; i <= 2; i++) { const run = createRunState(i, 'alchemist'); recordDiscovery(run, 'element', `elem${i}`); applyRunResults(meta, run); } expect(isSchoolUnlocked(meta, 'navigator')).toBe(false); }); it('unlocks persist across subsequent runs', () => { // Unlock mechanic with 10 element discoveries const run1 = createRunState(1, 'alchemist'); for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) { recordDiscovery(run1, 'element', sym); } applyRunResults(meta, run1); expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true); // Next run with no discoveries — mechanic still unlocked const run2 = createRunState(2, 'alchemist'); applyRunResults(meta, run2); expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true); }); it('multiple schools can unlock in the same run', () => { const run = createRunState(1, 'alchemist'); // 10 elements → mechanic for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) { recordDiscovery(run, 'element', sym); } // 3 creatures → naturalist recordDiscovery(run, 'creature', 'crystallid'); recordDiscovery(run, 'creature', 'acidophile'); recordDiscovery(run, 'creature', 'reagent'); applyRunResults(meta, run); expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true); expect(isSchoolUnlocked(meta, 'naturalist')).toBe(true); }); it('cumulative codex entries across runs unlock schools', () => { // Run 1: discover 6 elements const run1 = createRunState(1, 'alchemist'); for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe']) { recordDiscovery(run1, 'element', sym); } applyRunResults(meta, run1); expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false); // Run 2: discover 4 more → total 10 const run2 = createRunState(2, 'alchemist'); for (const sym of ['Cu', 'Si', 'Sn', 'N']) { recordDiscovery(run2, 'element', sym); } applyRunResults(meta, run2); expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true); }); it('checkSchoolUnlocks can be called standalone', () => { // Manually populate codex with 10 elements for (let i = 0; i < 10; i++) { meta.codex.push({ id: `elem${i}`, type: 'element', discoveredOnRun: 1 }); } checkSchoolUnlocks(meta); expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true); }); }); // ─── School Bonuses ────────────────────────────────────────────── describe('School Bonuses', () => { it('alchemist has reaction efficiency bonus', () => { const bonus = getSchoolBonuses('alchemist'); expect(bonus.reactionEfficiency).toBe(1.25); expect(bonus.projectileDamage).toBe(1.0); expect(bonus.movementSpeed).toBe(1.0); expect(bonus.creatureAggroRange).toBe(1.0); }); it('mechanic has projectile damage bonus', () => { const bonus = getSchoolBonuses('mechanic'); expect(bonus.projectileDamage).toBe(1.3); expect(bonus.movementSpeed).toBe(1.0); }); it('naturalist has creature aggro reduction', () => { const bonus = getSchoolBonuses('naturalist'); expect(bonus.creatureAggroRange).toBe(0.6); expect(bonus.projectileDamage).toBe(1.0); }); it('navigator has movement speed bonus', () => { const bonus = getSchoolBonuses('navigator'); expect(bonus.movementSpeed).toBe(1.2); expect(bonus.creatureAggroRange).toBe(1.0); }); it('unknown school returns all-default bonuses', () => { const bonus = getSchoolBonuses('nonexistent'); expect(bonus.projectileDamage).toBe(1.0); expect(bonus.movementSpeed).toBe(1.0); expect(bonus.creatureAggroRange).toBe(1.0); expect(bonus.reactionEfficiency).toBe(1.0); }); }); // ─── 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); }); });