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:
97
src/data/cycle-narrative.json
Normal file
97
src/data/cycle-narrative.json
Normal 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
220
src/run/cycle.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { MetaState, CodexEntry, RunSummary, SchoolData, ResolvedSchoolBonus
|
|||||||
import type { RunState } from './types';
|
import type { RunState } from './types';
|
||||||
import { DEFAULT_SCHOOL_BONUSES } from './types';
|
import { DEFAULT_SCHOOL_BONUSES } from './types';
|
||||||
import { calculateSpores, countDiscoveries } from './state';
|
import { calculateSpores, countDiscoveries } from './state';
|
||||||
|
import { createGreatCycleState, recordRunAndAdvanceCycle } from './cycle';
|
||||||
import schoolsData from '../data/schools.json';
|
import schoolsData from '../data/schools.json';
|
||||||
|
|
||||||
/** Create a fresh meta state (first time playing) */
|
/** Create a fresh meta state (first time playing) */
|
||||||
@@ -23,11 +24,15 @@ export function createMetaState(): MetaState {
|
|||||||
bestRunDiscoveries: 0,
|
bestRunDiscoveries: 0,
|
||||||
runHistory: [],
|
runHistory: [],
|
||||||
mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 },
|
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
|
// 1. Calculate and add spores
|
||||||
const sporesEarned = calculateSpores(run);
|
const sporesEarned = calculateSpores(run);
|
||||||
meta.spores += sporesEarned;
|
meta.spores += sporesEarned;
|
||||||
@@ -72,7 +77,7 @@ export function applyRunResults(meta: MetaState, run: RunState): void {
|
|||||||
meta.bestRunDiscoveries = discoveryCount;
|
meta.bestRunDiscoveries = discoveryCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Add to run history
|
// 4. Add to run history (with cycle info)
|
||||||
const summary: RunSummary = {
|
const summary: RunSummary = {
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
schoolId: run.schoolId,
|
schoolId: run.schoolId,
|
||||||
@@ -81,11 +86,18 @@ export function applyRunResults(meta: MetaState, run: RunState): void {
|
|||||||
discoveries: discoveryCount,
|
discoveries: discoveryCount,
|
||||||
sporesEarned,
|
sporesEarned,
|
||||||
crisisResolved: run.crisisResolved,
|
crisisResolved: run.crisisResolved,
|
||||||
|
biomeId: run.biomeId,
|
||||||
|
cycleNumber: meta.greatCycle.cycleNumber,
|
||||||
};
|
};
|
||||||
meta.runHistory.push(summary);
|
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);
|
checkSchoolUnlocks(meta);
|
||||||
|
|
||||||
|
return isRenewal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a school is unlocked */
|
/** Check if a school is unlocked */
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
* Uses a simple key-value pattern with a single object store.
|
* 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 { createMetaState } from './meta';
|
||||||
|
import { createGreatCycleState } from './cycle';
|
||||||
|
|
||||||
const DB_NAME = 'synthesis-meta';
|
const DB_NAME = 'synthesis-meta';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
@@ -24,6 +25,7 @@ interface SerializedMetaState {
|
|||||||
bestRunDiscoveries: number;
|
bestRunDiscoveries: number;
|
||||||
runHistory: RunSummary[];
|
runHistory: RunSummary[];
|
||||||
mycelium?: MyceliumGraphData;
|
mycelium?: MyceliumGraphData;
|
||||||
|
greatCycle?: GreatCycleState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open (or create) the IndexedDB database */
|
/** Open (or create) the IndexedDB database */
|
||||||
@@ -55,6 +57,7 @@ function serialize(meta: MetaState): SerializedMetaState {
|
|||||||
bestRunDiscoveries: meta.bestRunDiscoveries,
|
bestRunDiscoveries: meta.bestRunDiscoveries,
|
||||||
runHistory: [...meta.runHistory],
|
runHistory: [...meta.runHistory],
|
||||||
mycelium: meta.mycelium,
|
mycelium: meta.mycelium,
|
||||||
|
greatCycle: meta.greatCycle,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +78,7 @@ function deserialize(data: SerializedMetaState): MetaState {
|
|||||||
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
|
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
|
||||||
runHistory: data.runHistory ?? [],
|
runHistory: data.runHistory ?? [],
|
||||||
mycelium: data.mycelium ?? EMPTY_MYCELIUM,
|
mycelium: data.mycelium ?? EMPTY_MYCELIUM,
|
||||||
|
greatCycle: data.greatCycle ?? createGreatCycleState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/** Create a fresh run state for a new run */
|
/** 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 {
|
return {
|
||||||
runId,
|
runId,
|
||||||
schoolId,
|
schoolId,
|
||||||
|
biomeId,
|
||||||
phase: RunPhase.Awakening,
|
phase: RunPhase.Awakening,
|
||||||
phaseTimer: 0,
|
phaseTimer: 0,
|
||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
@@ -31,6 +37,8 @@ export function createRunState(runId: number, schoolId: string): RunState {
|
|||||||
creatures: new Set<string>(),
|
creatures: new Set<string>(),
|
||||||
},
|
},
|
||||||
alive: true,
|
alive: true,
|
||||||
|
worldSeed,
|
||||||
|
deathPosition: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
113
src/run/types.ts
113
src/run/types.ts
@@ -117,6 +117,8 @@ export interface RunState {
|
|||||||
runId: number;
|
runId: number;
|
||||||
/** Selected school id */
|
/** Selected school id */
|
||||||
schoolId: string;
|
schoolId: string;
|
||||||
|
/** Biome being explored this run */
|
||||||
|
biomeId: string;
|
||||||
/** Current phase */
|
/** Current phase */
|
||||||
phase: RunPhase;
|
phase: RunPhase;
|
||||||
/** Time spent in current phase (ms) */
|
/** Time spent in current phase (ms) */
|
||||||
@@ -133,6 +135,10 @@ export interface RunState {
|
|||||||
discoveries: RunDiscoveries;
|
discoveries: RunDiscoveries;
|
||||||
/** Whether the player is alive */
|
/** Whether the player is alive */
|
||||||
alive: boolean;
|
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 {
|
export interface RunDiscoveries {
|
||||||
@@ -168,6 +174,8 @@ export interface MetaState {
|
|||||||
runHistory: RunSummary[];
|
runHistory: RunSummary[];
|
||||||
/** Mycelium knowledge graph (persistent between runs) */
|
/** Mycelium knowledge graph (persistent between runs) */
|
||||||
mycelium: MyceliumGraphData;
|
mycelium: MyceliumGraphData;
|
||||||
|
/** Great cycle state (7-run macro cycles) */
|
||||||
|
greatCycle: GreatCycleState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serializable Mycelium graph data (stored in MetaState) */
|
/** Serializable Mycelium graph data (stored in MetaState) */
|
||||||
@@ -214,6 +222,10 @@ export interface RunSummary {
|
|||||||
discoveries: number;
|
discoveries: number;
|
||||||
sporesEarned: number;
|
sporesEarned: number;
|
||||||
crisisResolved: boolean;
|
crisisResolved: boolean;
|
||||||
|
/** Biome explored (added Phase 11) */
|
||||||
|
biomeId?: string;
|
||||||
|
/** Great cycle number when this run occurred */
|
||||||
|
cycleNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Body Composition (for death animation) ──────────────────────
|
// ─── Body Composition (for death animation) ──────────────────────
|
||||||
@@ -232,6 +244,107 @@ export const BODY_COMPOSITION: { symbol: string; fraction: number }[] = [
|
|||||||
{ symbol: 'Fe', fraction: 0.001 },
|
{ 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 ───────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Phase durations in ms (approximate, can be adjusted) */
|
/** Phase durations in ms (approximate, can be adjusted) */
|
||||||
|
|||||||
469
tests/great-cycle.test.ts
Normal file
469
tests/great-cycle.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user