Files
synthesis/tests/great-cycle.test.ts
Денис Шкабатур 5f78aa1444 feat: Great Cycle system — 7-run macro cycles with traces and narrative
Core engine for the Great Cycle mechanic (GDD Section IV):
- GreatCycleState tracking: cycle number, run within cycle, theme
- RunTrace recording: death position, school, biome, discoveries per run
- Cycle advancement: auto-advance to next cycle after 7 runs
- Great Renewal: resets traces, advances theme, strengthens Mycelium
- 6 narrative themes (Awakening→Doubt→Realization→Attempt→Acceptance→Synthesis)
- Cycle world modifiers: terrain/resources/creatures scale with cycle
- Narrative data JSON with Russian/English lore fragments per theme
- MetaState + persistence + RunState extended with cycle fields
- 38 new tests (549 total), all passing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:44:45 +03:00

470 lines
17 KiB
TypeScript

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