From 5f78aa14447e4015bf511467b29dfe689a9d21ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=A8=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80?= Date: Thu, 12 Feb 2026 18:44:45 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Great=20Cycle=20system=20=E2=80=94=207-?= =?UTF-8?q?run=20macro=20cycles=20with=20traces=20and=20narrative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/data/cycle-narrative.json | 97 +++++++ src/run/cycle.ts | 220 ++++++++++++++++ src/run/meta.ts | 20 +- src/run/persistence.ts | 6 +- src/run/state.ts | 10 +- src/run/types.ts | 113 ++++++++ tests/great-cycle.test.ts | 469 ++++++++++++++++++++++++++++++++++ 7 files changed, 929 insertions(+), 6 deletions(-) create mode 100644 src/data/cycle-narrative.json create mode 100644 src/run/cycle.ts create mode 100644 tests/great-cycle.test.ts diff --git a/src/data/cycle-narrative.json b/src/data/cycle-narrative.json new file mode 100644 index 0000000..39f9273 --- /dev/null +++ b/src/data/cycle-narrative.json @@ -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": "Почва помнит реакцию, произошедшую здесь." } + ] + } +} diff --git a/src/run/cycle.ts b/src/run/cycle.ts new file mode 100644 index 0000000..3378f4b --- /dev/null +++ b/src/run/cycle.ts @@ -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), + }; +} diff --git a/src/run/meta.ts b/src/run/meta.ts index ba26b39..bce3f74 100644 --- a/src/run/meta.ts +++ b/src/run/meta.ts @@ -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 */ diff --git a/src/run/persistence.ts b/src/run/persistence.ts index 6b545de..1a3a46a 100644 --- a/src/run/persistence.ts +++ b/src/run/persistence.ts @@ -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(), }; } diff --git a/src/run/state.ts b/src/run/state.ts index 11c674b..42fd1d6 100644 --- a/src/run/state.ts +++ b/src/run/state.ts @@ -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(), }, alive: true, + worldSeed, + deathPosition: null, }; } diff --git a/src/run/types.ts b/src/run/types.ts index f8b78c7..3c7c824 100644 --- a/src/run/types.ts +++ b/src/run/types.ts @@ -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 = { + awakening: 'Awakening', + doubt: 'Doubt', + realization: 'Realization', + attempt: 'Attempt', + acceptance: 'Acceptance', + synthesis: 'Synthesis', +}; + +export const CYCLE_THEME_NAMES_RU: Record = { + 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) */ diff --git a/tests/great-cycle.test.ts b/tests/great-cycle.test.ts new file mode 100644 index 0000000..6d6b835 --- /dev/null +++ b/tests/great-cycle.test.ts @@ -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 { + 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); + }); +});