import { describe, it, expect, beforeEach } from 'vitest'; import type { MetaState, RunState, GreatCycleState, RunTrace } from '../src/run/types'; import { CYCLE_THEMES, CYCLE_THEME_NAMES, CYCLE_THEME_NAMES_RU, RUNS_PER_CYCLE, RunPhase, } from '../src/run/types'; import { createGreatCycleState, getCycleTheme, createRunTrace, recordRunAndAdvanceCycle, performGreatRenewal, getCycleWorldModifiers, getTracesForBiome, getDeathTraces, isLastRunInCycle, getCycleSummary, } from '../src/run/cycle'; import { createMetaState, applyRunResults } from '../src/run/meta'; import { createRunState, recordDiscovery } from '../src/run/state'; import narrativeData from '../src/data/cycle-narrative.json'; // ─── Great Cycle Initialization ────────────────────────────────── describe('Great Cycle Initialization', () => { it('should create initial cycle state at cycle 1, run 1', () => { const state = createGreatCycleState(); expect(state.cycleNumber).toBe(1); expect(state.runInCycle).toBe(1); expect(state.theme).toBe('awakening'); expect(state.currentCycleTraces).toEqual([]); expect(state.previousCycleTraces).toEqual([]); expect(state.renewalsCompleted).toBe(0); expect(state.myceliumMaturation).toBe(0); }); it('MetaState should include greatCycle on creation', () => { const meta = createMetaState(); expect(meta.greatCycle).toBeDefined(); expect(meta.greatCycle.cycleNumber).toBe(1); expect(meta.greatCycle.runInCycle).toBe(1); expect(meta.greatCycle.theme).toBe('awakening'); }); }); // ─── Cycle Theme Resolution ───────────────────────────────────── describe('Cycle Theme Resolution', () => { it('should have 6 predefined themes', () => { expect(CYCLE_THEMES.length).toBe(6); }); it('should return correct theme for each cycle number', () => { expect(getCycleTheme(1)).toBe('awakening'); expect(getCycleTheme(2)).toBe('doubt'); expect(getCycleTheme(3)).toBe('realization'); expect(getCycleTheme(4)).toBe('attempt'); expect(getCycleTheme(5)).toBe('acceptance'); expect(getCycleTheme(6)).toBe('synthesis'); }); it('cycles beyond 6 should use synthesis theme', () => { expect(getCycleTheme(7)).toBe('synthesis'); expect(getCycleTheme(10)).toBe('synthesis'); expect(getCycleTheme(100)).toBe('synthesis'); }); it('invalid cycle numbers default to awakening', () => { expect(getCycleTheme(0)).toBe('awakening'); expect(getCycleTheme(-1)).toBe('awakening'); }); it('all themes have Russian and English names', () => { for (const theme of CYCLE_THEMES) { expect(CYCLE_THEME_NAMES[theme]).toBeDefined(); expect(CYCLE_THEME_NAMES[theme].length).toBeGreaterThan(0); expect(CYCLE_THEME_NAMES_RU[theme]).toBeDefined(); expect(CYCLE_THEME_NAMES_RU[theme].length).toBeGreaterThan(0); } }); }); // ─── Run Trace Recording ───────────────────────────────────────── describe('Run Trace Recording', () => { let meta: MetaState; beforeEach(() => { meta = createMetaState(); }); function makeCompletedRun(overrides?: Partial): RunState { const run = createRunState(1, 'alchemist', 'catalytic-wastes', 42); run.phase = RunPhase.Resolution; run.elapsed = 120000; run.crisisResolved = true; run.deathPosition = { tileX: 30, tileY: 40 }; recordDiscovery(run, 'element', 'Na'); recordDiscovery(run, 'element', 'O'); recordDiscovery(run, 'reaction', 'Na+Cl'); if (overrides) Object.assign(run, overrides); return run; } it('should create a trace from completed run', () => { const run = makeCompletedRun(); const trace = createRunTrace(run, meta.greatCycle); expect(trace.runId).toBe(1); expect(trace.runInCycle).toBe(1); expect(trace.schoolId).toBe('alchemist'); expect(trace.biomeId).toBe('catalytic-wastes'); expect(trace.deathPosition).toEqual({ tileX: 30, tileY: 40 }); expect(trace.phaseReached).toBe(RunPhase.Resolution); expect(trace.crisisResolved).toBe(true); expect(trace.discoveryCount).toBe(3); expect(trace.keyElements).toEqual(['Na', 'O']); expect(trace.duration).toBe(120000); expect(trace.worldSeed).toBe(42); }); it('should limit key elements to 5', () => { const run = makeCompletedRun(); for (const sym of ['H', 'C', 'Fe', 'Cu', 'Zn', 'Au', 'Hg']) { recordDiscovery(run, 'element', sym); } const trace = createRunTrace(run, meta.greatCycle); expect(trace.keyElements.length).toBeLessThanOrEqual(5); }); it('should handle null death position (boss victory)', () => { const run = makeCompletedRun({ deathPosition: null }); const trace = createRunTrace(run, meta.greatCycle); expect(trace.deathPosition).toBeNull(); }); }); // ─── Cycle Advancement ─────────────────────────────────────────── describe('Cycle Advancement', () => { let meta: MetaState; beforeEach(() => { meta = createMetaState(); }); function runAndRecord(runId: number, biome: string = 'catalytic-wastes'): boolean { const run = createRunState(runId, 'alchemist', biome, runId * 100); run.phase = RunPhase.Resolution; run.elapsed = 60000; run.deathPosition = { tileX: 10, tileY: 20 }; recordDiscovery(run, 'element', 'Na'); return applyRunResults(meta, run); } it('should advance runInCycle after each run', () => { expect(meta.greatCycle.runInCycle).toBe(1); runAndRecord(1); expect(meta.greatCycle.runInCycle).toBe(2); runAndRecord(2); expect(meta.greatCycle.runInCycle).toBe(3); }); it('should stay in cycle 1 for first 6 runs', () => { for (let i = 1; i <= 6; i++) { runAndRecord(i); expect(meta.greatCycle.cycleNumber).toBe(1); expect(meta.greatCycle.theme).toBe('awakening'); } }); it('should trigger Great Renewal on 7th run', () => { for (let i = 1; i <= 6; i++) { const isRenewal = runAndRecord(i); expect(isRenewal).toBe(false); } const isRenewal = runAndRecord(7); expect(isRenewal).toBe(true); }); it('after renewal should advance to cycle 2', () => { for (let i = 1; i <= 7; i++) runAndRecord(i); expect(meta.greatCycle.cycleNumber).toBe(2); expect(meta.greatCycle.runInCycle).toBe(1); expect(meta.greatCycle.theme).toBe('doubt'); }); it('should accumulate traces within a cycle', () => { runAndRecord(1); runAndRecord(2); runAndRecord(3); expect(meta.greatCycle.currentCycleTraces.length).toBe(3); }); it('should move current traces to previous on renewal', () => { for (let i = 1; i <= 7; i++) runAndRecord(i); // After renewal: previous has 7 traces, current is empty expect(meta.greatCycle.previousCycleTraces.length).toBe(7); expect(meta.greatCycle.currentCycleTraces.length).toBe(0); }); it('should track renewals completed', () => { expect(meta.greatCycle.renewalsCompleted).toBe(0); for (let i = 1; i <= 7; i++) runAndRecord(i); expect(meta.greatCycle.renewalsCompleted).toBe(1); for (let i = 8; i <= 14; i++) runAndRecord(i); expect(meta.greatCycle.renewalsCompleted).toBe(2); }); it('should increment mycelium maturation on renewal', () => { expect(meta.greatCycle.myceliumMaturation).toBe(0); for (let i = 1; i <= 7; i++) runAndRecord(i); expect(meta.greatCycle.myceliumMaturation).toBeGreaterThan(0); expect(meta.greatCycle.myceliumMaturation).toBeLessThanOrEqual(1.0); }); it('mycelium maturation caps at 1.0', () => { // Simulate many cycles for (let cycle = 0; cycle < 20; cycle++) { for (let run = 1; run <= 7; run++) { runAndRecord(cycle * 7 + run); } } expect(meta.greatCycle.myceliumMaturation).toBeLessThanOrEqual(1.0); }); }); // ─── Mycelium Strengthening on Renewal ─────────────────────────── describe('Mycelium Strengthening on Renewal', () => { it('should strengthen all Mycelium nodes during renewal', () => { const meta = createMetaState(); // Seed some mycelium nodes meta.mycelium.nodes = [ { id: 'element:Na', type: 'element', knowledgeId: 'Na', depositedOnRun: 1, strength: 0.3 }, { id: 'element:O', type: 'element', knowledgeId: 'O', depositedOnRun: 1, strength: 0.5 }, ]; const oldStrengths = meta.mycelium.nodes.map(n => n.strength); performGreatRenewal(meta); for (let i = 0; i < meta.mycelium.nodes.length; i++) { expect(meta.mycelium.nodes[i].strength).toBeGreaterThan(oldStrengths[i]); } }); it('strength should not exceed 1.0', () => { const meta = createMetaState(); meta.mycelium.nodes = [ { id: 'element:Na', type: 'element', knowledgeId: 'Na', depositedOnRun: 1, strength: 0.95 }, ]; performGreatRenewal(meta); expect(meta.mycelium.nodes[0].strength).toBeLessThanOrEqual(1.0); }); }); // ─── World Modifiers ───────────────────────────────────────────── describe('Cycle World Modifiers', () => { it('cycle 1 should have baseline modifiers (~1.0)', () => { const mods = getCycleWorldModifiers(1); expect(mods.elevationScaleMultiplier).toBeCloseTo(1.0, 1); expect(mods.detailScaleMultiplier).toBeCloseTo(1.0, 1); expect(mods.resourceDensityMultiplier).toBeCloseTo(1.0, 1); expect(mods.creatureSpawnMultiplier).toBeCloseTo(1.0, 1); expect(mods.escalationRateMultiplier).toBeCloseTo(1.0, 1); }); it('higher cycles should have higher modifiers', () => { const mod1 = getCycleWorldModifiers(1); const mod3 = getCycleWorldModifiers(3); const mod6 = getCycleWorldModifiers(6); expect(mod3.elevationScaleMultiplier).toBeGreaterThan(mod1.elevationScaleMultiplier); expect(mod6.escalationRateMultiplier).toBeGreaterThan(mod3.escalationRateMultiplier); expect(mod6.creatureSpawnMultiplier).toBeGreaterThan(mod1.creatureSpawnMultiplier); }); it('modifiers should cap at cycle 6', () => { const mod6 = getCycleWorldModifiers(6); const mod10 = getCycleWorldModifiers(10); expect(mod10.elevationScaleMultiplier).toBeCloseTo(mod6.elevationScaleMultiplier, 2); }); }); // ─── Trace Queries ─────────────────────────────────────────────── describe('Trace Queries', () => { let cycle: GreatCycleState; beforeEach(() => { cycle = createGreatCycleState(); cycle.currentCycleTraces = [ makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 20 }), makeTrace(2, 'kinetic-mountains', { tileX: 30, tileY: 40 }), makeTrace(3, 'catalytic-wastes', null), ]; cycle.previousCycleTraces = [ makeTrace(4, 'catalytic-wastes', { tileX: 50, tileY: 60 }), ]; }); function makeTrace( runId: number, biomeId: string, deathPos: { tileX: number; tileY: number } | null, ): RunTrace { return { runId, runInCycle: runId, schoolId: 'alchemist', biomeId, deathPosition: deathPos, phaseReached: RunPhase.Resolution, crisisResolved: false, discoveryCount: 5, keyElements: ['Na'], duration: 60000, worldSeed: runId * 100, }; } it('should filter traces by biome (current + previous)', () => { const wastesTraces = getTracesForBiome(cycle, 'catalytic-wastes'); expect(wastesTraces.length).toBe(3); // 2 current + 1 previous }); it('should return empty for biome with no traces', () => { const forestTraces = getTracesForBiome(cycle, 'verdant-forests'); expect(forestTraces.length).toBe(0); }); it('should filter death traces (non-null death position)', () => { const allTraces = getTracesForBiome(cycle, 'catalytic-wastes'); const deathTraces = getDeathTraces(allTraces); expect(deathTraces.length).toBe(2); // run 1 and run 4 have death positions }); it('isLastRunInCycle should detect 7th run', () => { cycle.runInCycle = 6; expect(isLastRunInCycle(cycle)).toBe(false); cycle.runInCycle = 7; expect(isLastRunInCycle(cycle)).toBe(true); }); }); // ─── Cycle Summary ─────────────────────────────────────────────── describe('Cycle Summary', () => { it('should return formatted summary', () => { const cycle = createGreatCycleState(); cycle.runInCycle = 3; const summary = getCycleSummary(cycle); expect(summary.cycleNumber).toBe(1); expect(summary.runInCycle).toBe(3); expect(summary.totalRuns).toBe(7); expect(summary.theme).toBe('awakening'); expect(summary.isLastRun).toBe(false); }); it('should indicate last run', () => { const cycle = createGreatCycleState(); cycle.runInCycle = 7; const summary = getCycleSummary(cycle); expect(summary.isLastRun).toBe(true); }); }); // ─── Cycle Narrative Data ──────────────────────────────────────── describe('Cycle Narrative Data', () => { it('should have narrative for all 6 themes', () => { const themes = narrativeData.themes; expect(Object.keys(themes).length).toBe(6); expect(themes.awakening).toBeDefined(); expect(themes.doubt).toBeDefined(); expect(themes.realization).toBeDefined(); expect(themes.attempt).toBeDefined(); expect(themes.acceptance).toBeDefined(); expect(themes.synthesis).toBeDefined(); }); it('each theme should have Russian text', () => { for (const [, theme] of Object.entries(narrativeData.themes)) { expect(theme.nameRu).toBeDefined(); expect(theme.nameRu.length).toBeGreaterThan(0); expect(theme.descriptionRu).toBeDefined(); expect(theme.cradleQuoteRu).toBeDefined(); expect(theme.loreFrag.length).toBeGreaterThan(0); for (const frag of theme.loreFrag) { expect(frag.textRu).toBeDefined(); expect(frag.textRu.length).toBeGreaterThan(0); } } }); it('should have renewal messages', () => { expect(narrativeData.renewalMessages.length).toBeGreaterThan(0); for (const msg of narrativeData.renewalMessages) { expect(msg.textRu).toBeDefined(); } }); it('should have trace messages for death and discovery sites', () => { expect(narrativeData.traceMessages.death_site.length).toBeGreaterThan(0); expect(narrativeData.traceMessages.discovery_site.length).toBeGreaterThan(0); }); }); // ─── Full Integration: 2 Complete Cycles ───────────────────────── describe('Full Integration: 2 Complete Cycles', () => { it('should progress through 2 cycles correctly', () => { const meta = createMetaState(); let renewalCount = 0; for (let i = 1; i <= 14; i++) { const run = createRunState(i, 'alchemist', 'catalytic-wastes', i * 100); run.phase = RunPhase.Crisis; run.elapsed = 90000; run.deathPosition = { tileX: i * 5, tileY: i * 3 }; recordDiscovery(run, 'element', `E${i}`); const isRenewal = applyRunResults(meta, run); if (isRenewal) renewalCount++; } expect(renewalCount).toBe(2); expect(meta.greatCycle.cycleNumber).toBe(3); expect(meta.greatCycle.theme).toBe('realization'); expect(meta.greatCycle.runInCycle).toBe(1); expect(meta.greatCycle.renewalsCompleted).toBe(2); expect(meta.greatCycle.previousCycleTraces.length).toBe(7); expect(meta.greatCycle.currentCycleTraces.length).toBe(0); expect(meta.totalRuns).toBe(14); }); }); // ─── RunState Extended Fields ──────────────────────────────────── describe('RunState Extended Fields', () => { it('should include biomeId and worldSeed', () => { const run = createRunState(1, 'alchemist', 'kinetic-mountains', 12345); expect(run.biomeId).toBe('kinetic-mountains'); expect(run.worldSeed).toBe(12345); expect(run.deathPosition).toBeNull(); }); it('should default biomeId to catalytic-wastes', () => { const run = createRunState(1, 'alchemist'); expect(run.biomeId).toBe('catalytic-wastes'); }); it('RunSummary should include biomeId and cycleNumber', () => { const meta = createMetaState(); const run = createRunState(1, 'alchemist', 'verdant-forests', 999); run.phase = RunPhase.Exploration; run.elapsed = 30000; recordDiscovery(run, 'element', 'H'); applyRunResults(meta, run); const lastSummary = meta.runHistory[meta.runHistory.length - 1]; expect(lastSummary.biomeId).toBe('verdant-forests'); expect(lastSummary.cycleNumber).toBe(1); }); });