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>
This commit is contained in:
Денис Шкабатур
2026-02-12 18:44:45 +03:00
parent 0cd995c817
commit 5f78aa1444
7 changed files with 929 additions and 6 deletions

View File

@@ -0,0 +1,97 @@
{
"themes": {
"awakening": {
"name": "Awakening",
"nameRu": "Пробуждение",
"description": "You are learning the world. Everything is new, mysterious, dangerous.",
"descriptionRu": "Ты познаёшь мир. Всё ново, загадочно, опасно.",
"cradleQuote": "...the first breath of a new cycle...",
"cradleQuoteRu": "...первый вдох нового цикла...",
"loreFrag": [
{ "text": "The world breathes. You are its newest thought.", "textRu": "Мир дышит. Ты — его новейшая мысль." },
{ "text": "Each atom you touch remembers being touched before.", "textRu": "Каждый атом, которого ты касаешься, помнит прежние прикосновения." },
{ "text": "The cycle begins. As it always does. As it always will.", "textRu": "Цикл начинается. Как всегда. Как будет всегда." }
]
},
"doubt": {
"name": "Doubt",
"nameRu": "Сомнение",
"description": "You find traces of past Adepts. Were they... you?",
"descriptionRu": "Ты находишь следы прошлых Адептов. Были ли они... тобой?",
"cradleQuote": "...something stirs in the mycelium. Familiar...",
"cradleQuoteRu": "...что-то шевелится в мицелии. Знакомое...",
"loreFrag": [
{ "text": "Ruins that look like they were built by your hands.", "textRu": "Руины, словно построенные твоими руками." },
{ "text": "The handwriting in this journal... is it yours?", "textRu": "Почерк в этом дневнике... он твой?" },
{ "text": "I have been here before. Or someone exactly like me.", "textRu": "Я уже был здесь. Или кто-то точно такой же, как я." }
]
},
"realization": {
"name": "Realization",
"nameRu": "Осознание",
"description": "You begin to understand the nature of cycles.",
"descriptionRu": "Ты начинаешь понимать природу циклов.",
"cradleQuote": "...the pattern becomes visible...",
"cradleQuoteRu": "...паттерн становится видимым...",
"loreFrag": [
{ "text": "The cycle is not a prison. It is a spiral.", "textRu": "Цикл — не тюрьма. Это спираль." },
{ "text": "Each death is a transformation, not an ending.", "textRu": "Каждая смерть — трансформация, не конец." },
{ "text": "The Mycelium knew all along. It was waiting for you to see.", "textRu": "Мицелий знал с самого начала. Он ждал, пока ты увидишь." }
]
},
"attempt": {
"name": "Attempt",
"nameRu": "Попытка",
"description": "First attempts to transcend the cycle.",
"descriptionRu": "Первые попытки трансцендировать цикл.",
"cradleQuote": "...can the cycle be broken? Or only understood?...",
"cradleQuoteRu": "...можно ли разорвать цикл? Или только понять?...",
"loreFrag": [
{ "text": "The Synthetics tried to break free. They shattered reality instead.", "textRu": "Синтетики пытались вырваться. Вместо этого разбили реальность." },
{ "text": "To transcend is not to escape. It is to encompass.", "textRu": "Трансцендировать — не значит сбежать. Это значит объять." },
{ "text": "The harder you push against the cycle, the tighter it holds.", "textRu": "Чем сильнее давишь на цикл, тем крепче он держит." }
]
},
"acceptance": {
"name": "Acceptance",
"nameRu": "Принятие",
"description": "The cycle is not a prison but a form of being.",
"descriptionRu": "Цикл — не тюрьма, а форма бытия.",
"cradleQuote": "...you are not stuck IN the cycle. You ARE the cycle...",
"cradleQuoteRu": "...ты не застрял В цикле. Ты И ЕСТЬ цикл...",
"loreFrag": [
{ "text": "An atom does not resent its orbit. A wave does not fight its shore.", "textRu": "Атом не злится на свою орбиту. Волна не борется со своим берегом." },
{ "text": "The only way out is through. And through. And through.", "textRu": "Единственный выход — насквозь. И насквозь. И насквозь." },
{ "text": "Every cycle slightly different. Every cycle slightly closer.", "textRu": "Каждый цикл чуть другой. Каждый цикл чуть ближе." }
]
},
"synthesis": {
"name": "Synthesis",
"nameRu": "Синтез",
"description": "All knowledge unifies. The cycle does not break — it transcends.",
"descriptionRu": "Всё знание объединяется. Цикл не ломается — трансцендирует.",
"cradleQuote": "...when all fragments connect, something new emerges...",
"cradleQuoteRu": "...когда все фрагменты соединяются, рождается новое...",
"loreFrag": [
{ "text": "Synthesis is not creation. It is transformation.", "textRu": "Синтез — не создание. Это трансформация." },
{ "text": "You have become what the Synthetics could not: a living cycle.", "textRu": "Ты стал тем, чем Синтетики не смогли: живым циклом." },
{ "text": "The Law of Synthesis: when all fragments connect, the cycle transcends.", "textRu": "Закон Синтеза: когда все фрагменты знания соединяются, цикл трансцендирует." }
]
}
},
"renewalMessages": [
{ "text": "The Great Renewal washes over the world...", "textRu": "Великое Обновление окатывает мир..." },
{ "text": "Seven lives. Seven deaths. The cycle turns.", "textRu": "Семь жизней. Семь смертей. Цикл поворачивается." },
{ "text": "The Mycelium pulses. A new age begins.", "textRu": "Мицелий пульсирует. Начинается новая эпоха." }
],
"traceMessages": {
"death_site": [
{ "text": "Here an adept fell... elements returning to the earth.", "textRu": "Здесь пал адепт... элементы возвращаются в землю." },
{ "text": "A mark of dissolution. Someone ended their cycle here.", "textRu": "Знак растворения. Кто-то завершил свой цикл здесь." }
],
"discovery_site": [
{ "text": "Traces of experimentation linger in this place.", "textRu": "Следы экспериментов витают в этом месте." },
{ "text": "The soil remembers a reaction that happened here.", "textRu": "Почва помнит реакцию, произошедшую здесь." }
]
}
}

220
src/run/cycle.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Great Cycle System — 7-run macro cycles with traces between runs
*
* GDD spec (Section IV):
* - Every 7 runs = 1 Great Cycle
* - After 7th run → Great Renewal: world changes, lore unlocks, Mycelium matures
* - Previous 7 runs leave concrete traces: ruins, consequences, mutated creatures
* - Each cycle has a narrative theme (Awakening → Doubt → Realization → ...)
*
* Three Laws of Arcana:
* 1. Law of Return — everything that was, will be again, but never exactly the same
* 2. Law of Trace — nothing disappears without a trace
* 3. Law of Synthesis — when all knowledge fragments connect, the cycle transcends
*/
import type {
GreatCycleState,
RunTrace,
RunState,
MetaState,
CycleTheme,
CycleWorldModifiers,
} from './types';
import { CYCLE_THEMES, RUNS_PER_CYCLE, RunPhase } from './types';
import { countDiscoveries } from './state';
// ─── Initialization ──────────────────────────────────────────────
/** Create initial great cycle state (first time playing) */
export function createGreatCycleState(): GreatCycleState {
return {
cycleNumber: 1,
runInCycle: 1,
theme: 'awakening',
currentCycleTraces: [],
previousCycleTraces: [],
renewalsCompleted: 0,
myceliumMaturation: 0,
};
}
// ─── Theme Resolution ────────────────────────────────────────────
/** Get the cycle theme for a given cycle number (1-based) */
export function getCycleTheme(cycleNumber: number): CycleTheme {
if (cycleNumber < 1) return 'awakening';
const idx = Math.min(cycleNumber - 1, CYCLE_THEMES.length - 1);
return CYCLE_THEMES[idx];
}
// ─── Run Trace Recording ─────────────────────────────────────────
/**
* Create a RunTrace from a completed run state.
* Called when a run ends (death or boss victory).
*/
export function createRunTrace(
run: RunState,
cycleState: GreatCycleState,
): RunTrace {
// Extract up to 5 key element discoveries for trace markers
const keyElements = Array.from(run.discoveries.elements).slice(0, 5);
return {
runId: run.runId,
runInCycle: cycleState.runInCycle,
schoolId: run.schoolId,
biomeId: run.biomeId,
deathPosition: run.deathPosition,
phaseReached: run.phase,
crisisResolved: run.crisisResolved,
discoveryCount: countDiscoveries(run),
keyElements,
duration: run.elapsed,
worldSeed: run.worldSeed,
};
}
/**
* Record a completed run's trace and advance the cycle.
* Returns true if this run triggers a Great Renewal (every 7th run).
*/
export function recordRunAndAdvanceCycle(
meta: MetaState,
run: RunState,
): boolean {
const cycle = meta.greatCycle;
const trace = createRunTrace(run, cycle);
// Add trace to current cycle
cycle.currentCycleTraces.push(trace);
// Check if this completes a great cycle
const isRenewal = cycle.runInCycle >= RUNS_PER_CYCLE;
if (isRenewal) {
// Great Renewal!
performGreatRenewal(meta);
} else {
// Advance to next run within the cycle
cycle.runInCycle += 1;
}
return isRenewal;
}
// ─── Great Renewal ───────────────────────────────────────────────
/**
* Perform the Great Renewal — transitions between great cycles.
*
* GDD spec:
* - World generation fundamentally changes
* - New lore layer unlocks
* - Mycelium "matures" — opens new capabilities
* - Previous 7 runs' traces become "previous cycle" traces
*/
export function performGreatRenewal(meta: MetaState): void {
const cycle = meta.greatCycle;
// Move current traces to previous (only keep last cycle's traces)
cycle.previousCycleTraces = [...cycle.currentCycleTraces];
cycle.currentCycleTraces = [];
// Advance cycle number
cycle.cycleNumber += 1;
cycle.runInCycle = 1;
cycle.theme = getCycleTheme(cycle.cycleNumber);
cycle.renewalsCompleted += 1;
// Mycelium maturation: each renewal strengthens the network
cycle.myceliumMaturation = Math.min(
cycle.myceliumMaturation + 0.15,
1.0,
);
// Strengthen all Mycelium nodes on renewal
strengthenMyceliumOnRenewal(meta);
}
/**
* Strengthen Mycelium nodes during Great Renewal.
* All nodes gain strength proportional to the maturation level.
*/
function strengthenMyceliumOnRenewal(meta: MetaState): void {
const bonus = 0.1 + meta.greatCycle.myceliumMaturation * 0.1;
for (const node of meta.mycelium.nodes) {
node.strength = Math.min(1.0, node.strength + bonus);
}
}
// ─── World Modifiers ─────────────────────────────────────────────
/**
* Get world generation modifiers based on cycle number.
* Higher cycles = more varied/challenging worlds.
*
* GDD: "Fundamentally changes generation (new biome types, different proportions)"
*/
export function getCycleWorldModifiers(cycleNumber: number): CycleWorldModifiers {
// Cycle 1 = baseline, each subsequent cycle adds variation
const t = Math.min((cycleNumber - 1) / 5, 1.0); // 0→1 over 5 cycles
return {
elevationScaleMultiplier: 1.0 + t * 0.3, // terrain becomes more varied
detailScaleMultiplier: 1.0 + t * 0.2, // more detail features
resourceDensityMultiplier: 1.0 + t * 0.15, // slightly more resources
creatureSpawnMultiplier: 1.0 + t * 0.25, // more creatures
escalationRateMultiplier: 1.0 + t * 0.2, // faster escalation
};
}
// ─── Trace Queries ───────────────────────────────────────────────
/**
* Get all traces from the current and previous cycle for a given biome.
* Used by world generation to place ruins/markers.
*/
export function getTracesForBiome(
cycle: GreatCycleState,
biomeId: string,
): RunTrace[] {
const currentBiome = cycle.currentCycleTraces.filter(t => t.biomeId === biomeId);
const previousBiome = cycle.previousCycleTraces.filter(t => t.biomeId === biomeId);
return [...currentBiome, ...previousBiome];
}
/**
* Get traces that left death markers (for ruin placement).
* Returns traces with valid death positions.
*/
export function getDeathTraces(traces: RunTrace[]): RunTrace[] {
return traces.filter(t => t.deathPosition !== null);
}
/**
* Check if the current run is the last in the cycle (7th).
*/
export function isLastRunInCycle(cycle: GreatCycleState): boolean {
return cycle.runInCycle >= RUNS_PER_CYCLE;
}
/**
* Get a human-readable cycle summary for display.
*/
export function getCycleSummary(cycle: GreatCycleState): {
cycleNumber: number;
runInCycle: number;
totalRuns: number;
theme: CycleTheme;
isLastRun: boolean;
} {
return {
cycleNumber: cycle.cycleNumber,
runInCycle: cycle.runInCycle,
totalRuns: RUNS_PER_CYCLE,
theme: cycle.theme,
isLastRun: isLastRunInCycle(cycle),
};
}

View File

@@ -9,6 +9,7 @@ import type { MetaState, CodexEntry, RunSummary, SchoolData, ResolvedSchoolBonus
import type { RunState } from './types';
import { DEFAULT_SCHOOL_BONUSES } from './types';
import { calculateSpores, countDiscoveries } from './state';
import { createGreatCycleState, recordRunAndAdvanceCycle } from './cycle';
import schoolsData from '../data/schools.json';
/** Create a fresh meta state (first time playing) */
@@ -23,11 +24,15 @@ export function createMetaState(): MetaState {
bestRunDiscoveries: 0,
runHistory: [],
mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 },
greatCycle: createGreatCycleState(),
};
}
/** Apply run results to meta state after a run ends (death or completion) */
export function applyRunResults(meta: MetaState, run: RunState): void {
/**
* Apply run results to meta state after a run ends (death or completion).
* Returns true if a Great Renewal was triggered.
*/
export function applyRunResults(meta: MetaState, run: RunState): boolean {
// 1. Calculate and add spores
const sporesEarned = calculateSpores(run);
meta.spores += sporesEarned;
@@ -72,7 +77,7 @@ export function applyRunResults(meta: MetaState, run: RunState): void {
meta.bestRunDiscoveries = discoveryCount;
}
// 4. Add to run history
// 4. Add to run history (with cycle info)
const summary: RunSummary = {
runId: run.runId,
schoolId: run.schoolId,
@@ -81,11 +86,18 @@ export function applyRunResults(meta: MetaState, run: RunState): void {
discoveries: discoveryCount,
sporesEarned,
crisisResolved: run.crisisResolved,
biomeId: run.biomeId,
cycleNumber: meta.greatCycle.cycleNumber,
};
meta.runHistory.push(summary);
// 5. Check for school unlocks after updating codex and stats
// 5. Record run trace and advance the great cycle
const isRenewal = recordRunAndAdvanceCycle(meta, run);
// 6. Check for school unlocks after updating codex and stats
checkSchoolUnlocks(meta);
return isRenewal;
}
/** Check if a school is unlocked */

View File

@@ -5,8 +5,9 @@
* Uses a simple key-value pattern with a single object store.
*/
import type { MetaState, CodexEntry, RunSummary, MyceliumGraphData } from './types';
import type { MetaState, CodexEntry, RunSummary, MyceliumGraphData, GreatCycleState } from './types';
import { createMetaState } from './meta';
import { createGreatCycleState } from './cycle';
const DB_NAME = 'synthesis-meta';
const DB_VERSION = 1;
@@ -24,6 +25,7 @@ interface SerializedMetaState {
bestRunDiscoveries: number;
runHistory: RunSummary[];
mycelium?: MyceliumGraphData;
greatCycle?: GreatCycleState;
}
/** Open (or create) the IndexedDB database */
@@ -55,6 +57,7 @@ function serialize(meta: MetaState): SerializedMetaState {
bestRunDiscoveries: meta.bestRunDiscoveries,
runHistory: [...meta.runHistory],
mycelium: meta.mycelium,
greatCycle: meta.greatCycle,
};
}
@@ -75,6 +78,7 @@ function deserialize(data: SerializedMetaState): MetaState {
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
runHistory: data.runHistory ?? [],
mycelium: data.mycelium ?? EMPTY_MYCELIUM,
greatCycle: data.greatCycle ?? createGreatCycleState(),
};
}

View File

@@ -14,10 +14,16 @@ import {
} from './types';
/** Create a fresh run state for a new run */
export function createRunState(runId: number, schoolId: string): RunState {
export function createRunState(
runId: number,
schoolId: string,
biomeId: string = 'catalytic-wastes',
worldSeed: number = Date.now(),
): RunState {
return {
runId,
schoolId,
biomeId,
phase: RunPhase.Awakening,
phaseTimer: 0,
elapsed: 0,
@@ -31,6 +37,8 @@ export function createRunState(runId: number, schoolId: string): RunState {
creatures: new Set<string>(),
},
alive: true,
worldSeed,
deathPosition: null,
};
}

View File

@@ -117,6 +117,8 @@ export interface RunState {
runId: number;
/** Selected school id */
schoolId: string;
/** Biome being explored this run */
biomeId: string;
/** Current phase */
phase: RunPhase;
/** Time spent in current phase (ms) */
@@ -133,6 +135,10 @@ export interface RunState {
discoveries: RunDiscoveries;
/** Whether the player is alive */
alive: boolean;
/** World seed for this run */
worldSeed: number;
/** Player death position in tile coords (set on death) */
deathPosition: { tileX: number; tileY: number } | null;
}
export interface RunDiscoveries {
@@ -168,6 +174,8 @@ export interface MetaState {
runHistory: RunSummary[];
/** Mycelium knowledge graph (persistent between runs) */
mycelium: MyceliumGraphData;
/** Great cycle state (7-run macro cycles) */
greatCycle: GreatCycleState;
}
/** Serializable Mycelium graph data (stored in MetaState) */
@@ -214,6 +222,10 @@ export interface RunSummary {
discoveries: number;
sporesEarned: number;
crisisResolved: boolean;
/** Biome explored (added Phase 11) */
biomeId?: string;
/** Great cycle number when this run occurred */
cycleNumber?: number;
}
// ─── Body Composition (for death animation) ──────────────────────
@@ -232,6 +244,107 @@ export const BODY_COMPOSITION: { symbol: string; fraction: number }[] = [
{ symbol: 'Fe', fraction: 0.001 },
];
// ─── Great Cycle (every 7 runs) ──────────────────────────────────
/** Narrative themes for each great cycle (GDD Section IV) */
export type CycleTheme =
| 'awakening' // Cycle 1 (runs 1-7): learning the world
| 'doubt' // Cycle 2 (runs 8-14): finding traces of past adepts
| 'realization' // Cycle 3 (runs 15-21): understanding the nature of cycles
| 'attempt' // Cycle 4 (runs 22-28): first attempts to transcend
| 'acceptance' // Cycle 5 (runs 29-35): cycle is not a prison
| 'synthesis'; // Cycle 6+ (runs 36+): unifying all knowledge
/** Human-readable names for CycleTheme */
export const CYCLE_THEME_NAMES: Record<CycleTheme, string> = {
awakening: 'Awakening',
doubt: 'Doubt',
realization: 'Realization',
attempt: 'Attempt',
acceptance: 'Acceptance',
synthesis: 'Synthesis',
};
export const CYCLE_THEME_NAMES_RU: Record<CycleTheme, string> = {
awakening: 'Пробуждение',
doubt: 'Сомнение',
realization: 'Осознание',
attempt: 'Попытка',
acceptance: 'Принятие',
synthesis: 'Синтез',
};
/** Ordered themes by cycle number (0-indexed, cycles beyond 6 repeat 'synthesis') */
export const CYCLE_THEMES: CycleTheme[] = [
'awakening',
'doubt',
'realization',
'attempt',
'acceptance',
'synthesis',
];
/** A trace left by a completed run within a great cycle */
export interface RunTrace {
/** Run ID this trace belongs to */
runId: number;
/** Position within the great cycle (1-7) */
runInCycle: number;
/** School used this run */
schoolId: string;
/** Biome explored */
biomeId: string;
/** Death location (tile coords), null if run completed via boss victory */
deathPosition: { tileX: number; tileY: number } | null;
/** Phase reached when run ended */
phaseReached: RunPhase;
/** Was the crisis resolved? */
crisisResolved: boolean;
/** Number of unique discoveries */
discoveryCount: number;
/** Key element discoveries (up to 5 symbols for trace markers) */
keyElements: string[];
/** Duration of the run in ms */
duration: number;
/** World seed used for this run */
worldSeed: number;
}
/** Persistent great cycle state (stored in MetaState) */
export interface GreatCycleState {
/** Current great cycle number (1-based) */
cycleNumber: number;
/** Current run within the cycle (1-7, resets on renewal) */
runInCycle: number;
/** Current cycle's theme */
theme: CycleTheme;
/** Traces from runs in the CURRENT cycle (cleared on renewal) */
currentCycleTraces: RunTrace[];
/** Traces from the PREVIOUS cycle (for cross-cycle references) */
previousCycleTraces: RunTrace[];
/** Total great renewals completed */
renewalsCompleted: number;
/** Mycelium maturation level (increases each renewal) */
myceliumMaturation: number;
}
/** Number of runs per great cycle */
export const RUNS_PER_CYCLE = 7;
/** World generation modifiers based on cycle number */
export interface CycleWorldModifiers {
/** Elevation noise scale multiplier (world shape variation) */
elevationScaleMultiplier: number;
/** Detail noise scale multiplier */
detailScaleMultiplier: number;
/** Resource density multiplier */
resourceDensityMultiplier: number;
/** Creature spawn rate multiplier */
creatureSpawnMultiplier: number;
/** Escalation rate multiplier (difficulty) */
escalationRateMultiplier: number;
}
// ─── Constants ───────────────────────────────────────────────────
/** Phase durations in ms (approximate, can be adjusted) */

469
tests/great-cycle.test.ts Normal file
View File

@@ -0,0 +1,469 @@
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);
});
});