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) */