From 5b7dbb4df34c7bf64b4fa1815115cdfe8448e854 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 15:10:05 +0300 Subject: [PATCH] phase 6: school data, run state, meta-progression, crisis system - Add school data (Alchemist: H, O, C, Na, S, Fe starting kit) - Add run cycle types: phases, escalation, crisis, body composition - Implement run state management (create, advance phase, discoveries, spores) - Implement meta-progression (codex, spore accumulation, run history) - Implement crisis system (Chemical Plague with neutralization) - Add IndexedDB persistence for meta state - 42 new tests (335 total) Co-authored-by: Cursor --- src/data/schools.json | 23 +++ src/run/crisis.ts | 85 +++++++++ src/run/meta.ts | 101 ++++++++++ src/run/persistence.ts | 121 ++++++++++++ src/run/state.ts | 93 ++++++++++ src/run/types.ts | 183 ++++++++++++++++++ tests/run-cycle.test.ts | 400 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1006 insertions(+) create mode 100644 src/data/schools.json create mode 100644 src/run/crisis.ts create mode 100644 src/run/meta.ts create mode 100644 src/run/persistence.ts create mode 100644 src/run/state.ts create mode 100644 src/run/types.ts create mode 100644 tests/run-cycle.test.ts diff --git a/src/data/schools.json b/src/data/schools.json new file mode 100644 index 0000000..cc2b79d --- /dev/null +++ b/src/data/schools.json @@ -0,0 +1,23 @@ +[ + { + "id": "alchemist", + "name": "Alchemist", + "nameRu": "Алхимик", + "description": "Master of chemical transformations. Starts with reactive elements and knowledge of chemical equilibrium.", + "descriptionRu": "Мастер химических превращений. Начинает с реактивными элементами и знанием химического равновесия.", + "startingElements": ["H", "O", "C", "Na", "S", "Fe"], + "startingQuantities": { + "H": 5, + "O": 5, + "C": 3, + "Na": 3, + "S": 2, + "Fe": 2 + }, + "principle": "Chemical Equilibrium", + "principleRu": "Химическое равновесие", + "playstyle": "Potions, explosives, poisons. Combine elements for powerful chemical effects.", + "playstyleRu": "Зельеварение, взрывчатка, яды. Комбинируй элементы для мощных химических эффектов.", + "color": "#00ff88" + } +] diff --git a/src/run/crisis.ts b/src/run/crisis.ts new file mode 100644 index 0000000..c7248ca --- /dev/null +++ b/src/run/crisis.ts @@ -0,0 +1,85 @@ +/** + * Crisis System — Chemical Plague + * + * When escalation reaches its threshold, a crisis activates. + * The Chemical Plague poisons the atmosphere — player must craft + * the correct neutralizing compound to stop it. + */ + +import type { CrisisConfig } from './types'; + +// ─── Chemical Plague Configuration ─────────────────────────────── + +export const CHEMICAL_PLAGUE: CrisisConfig = { + type: 'chemical-plague', + name: 'Chemical Plague', + nameRu: 'Химическая Чума', + description: 'A chain reaction is poisoning the atmosphere. Neutralize it with the right compound!', + descriptionRu: 'Цепная реакция отравляет атмосферу. Нейтрализуй правильным соединением!', + triggerThreshold: 0.8, + neutralizer: 'CaO', // Calcium oxide (quicklime) — absorbs acid gases + neutralizeAmount: 3, // need 3 units to fully neutralize +}; + +// ─── Crisis State ──────────────────────────────────────────────── + +export interface CrisisState { + config: CrisisConfig; + active: boolean; + /** Progress 0.0–1.0 — at 1.0 the world is fully poisoned */ + progress: number; + resolved: boolean; + /** How many neutralizer units have been applied */ + neutralizeApplied: number; +} + +/** Damage growth rate per second during active crisis */ +const CRISIS_DAMAGE_RATE = 0.01; // reaches 1.0 in ~100 seconds + +/** Create a new crisis state from config */ +export function createCrisisState(config: CrisisConfig): CrisisState { + return { + config, + active: true, + progress: 0, + resolved: false, + neutralizeApplied: 0, + }; +} + +/** Advance crisis damage over time */ +export function applyCrisisDamage(crisis: CrisisState, deltaMs: number): void { + if (!crisis.active || crisis.resolved) return; + const deltaSec = deltaMs / 1000; + crisis.progress = Math.min(1.0, crisis.progress + CRISIS_DAMAGE_RATE * deltaSec); +} + +/** Attempt to neutralize the crisis with a compound. Returns true if correct compound. */ +export function attemptNeutralize( + crisis: CrisisState, + compoundId: string, + amount: number, +): boolean { + if (compoundId !== crisis.config.neutralizer) return false; + if (crisis.resolved) return true; + + crisis.neutralizeApplied += amount; + + // Reduce progress proportionally + const reductionPerUnit = 1.0 / crisis.config.neutralizeAmount; + crisis.progress = Math.max(0, crisis.progress - amount * reductionPerUnit); + + // Check if fully neutralized + if (crisis.neutralizeApplied >= crisis.config.neutralizeAmount) { + crisis.resolved = true; + crisis.active = false; + crisis.progress = 0; + } + + return true; +} + +/** Check if crisis has been resolved */ +export function isCrisisResolved(crisis: CrisisState): boolean { + return crisis.resolved; +} diff --git a/src/run/meta.ts b/src/run/meta.ts new file mode 100644 index 0000000..1934006 --- /dev/null +++ b/src/run/meta.ts @@ -0,0 +1,101 @@ +/** + * Meta-Progression — persists between runs. + * + * Manages the Codex (permanent knowledge), spores (currency), + * unlocked schools, and run history. + */ + +import type { MetaState, CodexEntry, RunSummary } from './types'; +import type { RunState } from './types'; +import { calculateSpores, countDiscoveries } from './state'; + +/** Create a fresh meta state (first time playing) */ +export function createMetaState(): MetaState { + return { + spores: 0, + codex: [], + totalRuns: 0, + totalDeaths: 0, + unlockedSchools: ['alchemist'], + bestRunTime: 0, + bestRunDiscoveries: 0, + runHistory: [], + }; +} + +/** Apply run results to meta state after a run ends (death or completion) */ +export function applyRunResults(meta: MetaState, run: RunState): void { + // 1. Calculate and add spores + const sporesEarned = calculateSpores(run); + meta.spores += sporesEarned; + + // 2. Add new codex entries (skip duplicates) + const existingIds = new Set(meta.codex.map(e => `${e.type}:${e.id}`)); + + for (const elemId of run.discoveries.elements) { + const key = `element:${elemId}`; + if (!existingIds.has(key)) { + meta.codex.push({ id: elemId, type: 'element', discoveredOnRun: run.runId }); + } + } + for (const reactionId of run.discoveries.reactions) { + const key = `reaction:${reactionId}`; + if (!existingIds.has(key)) { + meta.codex.push({ id: reactionId, type: 'reaction', discoveredOnRun: run.runId }); + } + } + for (const compoundId of run.discoveries.compounds) { + const key = `compound:${compoundId}`; + if (!existingIds.has(key)) { + meta.codex.push({ id: compoundId, type: 'compound', discoveredOnRun: run.runId }); + } + } + for (const creatureId of run.discoveries.creatures) { + const key = `creature:${creatureId}`; + if (!existingIds.has(key)) { + meta.codex.push({ id: creatureId, type: 'creature', discoveredOnRun: run.runId }); + } + } + + // 3. Update stats + meta.totalRuns += 1; + meta.totalDeaths += 1; // every run ends in death (for now) + + const discoveryCount = countDiscoveries(run); + if (run.elapsed > meta.bestRunTime) { + meta.bestRunTime = run.elapsed; + } + if (discoveryCount > meta.bestRunDiscoveries) { + meta.bestRunDiscoveries = discoveryCount; + } + + // 4. Add to run history + const summary: RunSummary = { + runId: run.runId, + schoolId: run.schoolId, + duration: run.elapsed, + phase: run.phase, + discoveries: discoveryCount, + sporesEarned, + crisisResolved: run.crisisResolved, + }; + meta.runHistory.push(summary); +} + +/** Check if a school is unlocked */ +export function isSchoolUnlocked(meta: MetaState, schoolId: string): boolean { + return meta.unlockedSchools.includes(schoolId); +} + +/** Get codex entries filtered by type */ +export function getCodexEntries( + meta: MetaState, + type: CodexEntry['type'], +): CodexEntry[] { + return meta.codex.filter(e => e.type === type); +} + +/** Get total codex entry count */ +export function getCodexCount(meta: MetaState): number { + return meta.codex.length; +} diff --git a/src/run/persistence.ts b/src/run/persistence.ts new file mode 100644 index 0000000..9dc3f89 --- /dev/null +++ b/src/run/persistence.ts @@ -0,0 +1,121 @@ +/** + * Persistence — IndexedDB storage for meta-progression. + * + * Saves/loads MetaState between browser sessions. + * Uses a simple key-value pattern with a single object store. + */ + +import type { MetaState, CodexEntry, RunSummary } from './types'; +import { createMetaState } from './meta'; + +const DB_NAME = 'synthesis-meta'; +const DB_VERSION = 1; +const STORE_NAME = 'meta'; +const META_KEY = 'metaState'; + +/** Serializable version of MetaState (no Sets, plain objects) */ +interface SerializedMetaState { + spores: number; + codex: CodexEntry[]; + totalRuns: number; + totalDeaths: number; + unlockedSchools: string[]; + bestRunTime: number; + bestRunDiscoveries: number; + runHistory: RunSummary[]; +} + +/** Open (or create) the IndexedDB database */ +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +/** Serialize MetaState to plain JSON-safe object */ +function serialize(meta: MetaState): SerializedMetaState { + return { + spores: meta.spores, + codex: [...meta.codex], + totalRuns: meta.totalRuns, + totalDeaths: meta.totalDeaths, + unlockedSchools: [...meta.unlockedSchools], + bestRunTime: meta.bestRunTime, + bestRunDiscoveries: meta.bestRunDiscoveries, + runHistory: [...meta.runHistory], + }; +} + +/** Deserialize from plain object back to MetaState */ +function deserialize(data: SerializedMetaState): MetaState { + return { + spores: data.spores ?? 0, + codex: data.codex ?? [], + totalRuns: data.totalRuns ?? 0, + totalDeaths: data.totalDeaths ?? 0, + unlockedSchools: data.unlockedSchools ?? ['alchemist'], + bestRunTime: data.bestRunTime ?? 0, + bestRunDiscoveries: data.bestRunDiscoveries ?? 0, + runHistory: data.runHistory ?? [], + }; +} + +/** Save meta state to IndexedDB */ +export async function saveMetaState(meta: MetaState): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const request = store.put(serialize(meta), META_KEY); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); +} + +/** Load meta state from IndexedDB. Returns fresh state if nothing saved. */ +export async function loadMetaState(): Promise { + try { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.get(META_KEY); + + request.onsuccess = () => { + const data = request.result as SerializedMetaState | undefined; + resolve(data ? deserialize(data) : createMetaState()); + }; + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); + } catch { + // If IndexedDB is unavailable, return fresh state + return createMetaState(); + } +} + +/** Clear all saved data (for debug/reset) */ +export async function clearMetaState(): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const request = store.delete(META_KEY); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); +} diff --git a/src/run/state.ts b/src/run/state.ts new file mode 100644 index 0000000..11c674b --- /dev/null +++ b/src/run/state.ts @@ -0,0 +1,93 @@ +/** + * Run State Management — creates and updates the state of a single run. + * + * A "run" is one cycle: Awakening → Exploration → Escalation → Crisis → Resolution. + * Each run tracks discoveries, escalation level, and crisis status. + */ + +import { + type RunState, + type RunDiscoveries, + RunPhase, + ESCALATION_RATE, + SPORE_REWARDS, +} from './types'; + +/** Create a fresh run state for a new run */ +export function createRunState(runId: number, schoolId: string): RunState { + return { + runId, + schoolId, + phase: RunPhase.Awakening, + phaseTimer: 0, + elapsed: 0, + escalation: 0, + crisisActive: false, + crisisResolved: false, + discoveries: { + elements: new Set(), + reactions: new Set(), + compounds: new Set(), + creatures: new Set(), + }, + alive: true, + }; +} + +/** Advance to the next phase. Does nothing if already at Resolution. */ +export function advancePhase(state: RunState): void { + if (state.phase < RunPhase.Resolution) { + state.phase = (state.phase + 1) as RunPhase; + state.phaseTimer = 0; + } +} + +/** Update escalation level based on elapsed delta (ms). Only active during Escalation and Crisis. */ +export function updateEscalation(state: RunState, deltaMs: number): void { + if (state.phase < RunPhase.Escalation) return; + if (state.phase > RunPhase.Crisis) return; + + const deltaSec = deltaMs / 1000; + state.escalation = Math.min(1.0, state.escalation + ESCALATION_RATE * deltaSec); +} + +/** Discovery type names used in API (singular) */ +export type DiscoveryType = 'element' | 'reaction' | 'compound' | 'creature'; + +/** Map singular type to plural key in RunDiscoveries */ +const DISCOVERY_KEY: Record = { + element: 'elements', + reaction: 'reactions', + compound: 'compounds', + creature: 'creatures', +}; + +/** Record a discovery during the current run */ +export function recordDiscovery( + state: RunState, + type: DiscoveryType, + id: string, +): void { + state.discoveries[DISCOVERY_KEY[type]].add(id); +} + +/** Count total unique discoveries in a run */ +export function countDiscoveries(state: RunState): number { + return ( + state.discoveries.elements.size + + state.discoveries.reactions.size + + state.discoveries.compounds.size + + state.discoveries.creatures.size + ); +} + +/** Calculate spores earned from a run */ +export function calculateSpores(state: RunState): number { + let total = 0; + total += state.discoveries.elements.size * SPORE_REWARDS.element; + total += state.discoveries.reactions.size * SPORE_REWARDS.reaction; + total += state.discoveries.compounds.size * SPORE_REWARDS.compound; + total += state.discoveries.creatures.size * SPORE_REWARDS.creature; + if (state.crisisResolved) total += SPORE_REWARDS.crisisResolved; + return total; +} diff --git a/src/run/types.ts b/src/run/types.ts new file mode 100644 index 0000000..defef74 --- /dev/null +++ b/src/run/types.ts @@ -0,0 +1,183 @@ +/** + * Run Cycle Types — schools, run phases, meta-progression + * + * Defines the structure of a single run (birth → death → rebirth) + * and the persistent meta-progression between runs. + */ + +// ─── Schools ───────────────────────────────────────────────────── + +export interface SchoolData { + id: string; + name: string; + nameRu: string; + description: string; + descriptionRu: string; + /** Starting element symbols (looked up in element registry) */ + startingElements: string[]; + /** Quantity of each starting element */ + startingQuantities: Record; + /** Scientific principle the school teaches */ + principle: string; + principleRu: string; + /** Play style description */ + playstyle: string; + playstyleRu: string; + /** Hex color for UI representation */ + color: string; +} + +// ─── Run Phases ────────────────────────────────────────────────── + +/** Phases of a single run in order */ +export enum RunPhase { + Awakening = 0, + Exploration = 1, + Escalation = 2, + Crisis = 3, + Resolution = 4, +} + +/** Human-readable names for RunPhase */ +export const RUN_PHASE_NAMES: Record = { + [RunPhase.Awakening]: 'Awakening', + [RunPhase.Exploration]: 'Exploration', + [RunPhase.Escalation]: 'Escalation', + [RunPhase.Crisis]: 'Crisis', + [RunPhase.Resolution]: 'Resolution', +}; + +export const RUN_PHASE_NAMES_RU: Record = { + [RunPhase.Awakening]: 'Пробуждение', + [RunPhase.Exploration]: 'Исследование', + [RunPhase.Escalation]: 'Эскалация', + [RunPhase.Crisis]: 'Кризис', + [RunPhase.Resolution]: 'Развязка', +}; + +// ─── Crisis Types ──────────────────────────────────────────────── + +export type CrisisType = 'chemical-plague'; + +export interface CrisisConfig { + type: CrisisType; + name: string; + nameRu: string; + description: string; + descriptionRu: string; + /** Escalation threshold (0-1) at which crisis triggers */ + triggerThreshold: number; + /** Compound that neutralizes the crisis */ + neutralizer: string; + /** Amount needed to neutralize */ + neutralizeAmount: number; +} + +// ─── Run State ─────────────────────────────────────────────────── + +/** State of the current run */ +export interface RunState { + /** Unique run identifier */ + runId: number; + /** Selected school id */ + schoolId: string; + /** Current phase */ + phase: RunPhase; + /** Time spent in current phase (ms) */ + phaseTimer: number; + /** Total run elapsed time (ms) */ + elapsed: number; + /** Escalation level 0.0 – 1.0 */ + escalation: number; + /** Whether crisis has been triggered */ + crisisActive: boolean; + /** Whether crisis has been resolved */ + crisisResolved: boolean; + /** Discoveries made this run */ + discoveries: RunDiscoveries; + /** Whether the player is alive */ + alive: boolean; +} + +export interface RunDiscoveries { + elements: Set; + reactions: Set; + compounds: Set; + creatures: Set; +} + +// ─── Meta-Progression (persists between runs) ──────────────────── + +export interface CodexEntry { + id: string; + type: 'element' | 'reaction' | 'compound' | 'creature'; + discoveredOnRun: number; +} + +export interface MetaState { + /** Total spores earned across all runs */ + spores: number; + /** All codex entries (permanent knowledge) */ + codex: CodexEntry[]; + /** Total runs completed */ + totalRuns: number; + /** Total deaths */ + totalDeaths: number; + /** Unlocked school ids */ + unlockedSchools: string[]; + /** Best run statistics */ + bestRunTime: number; + bestRunDiscoveries: number; + /** Run history summaries */ + runHistory: RunSummary[]; +} + +export interface RunSummary { + runId: number; + schoolId: string; + duration: number; + phase: RunPhase; + discoveries: number; + sporesEarned: number; + crisisResolved: boolean; +} + +// ─── Body Composition (for death animation) ────────────────────── + +/** Real elemental composition of the human body (simplified) */ +export const BODY_COMPOSITION: { symbol: string; fraction: number }[] = [ + { symbol: 'O', fraction: 0.65 }, + { symbol: 'C', fraction: 0.18 }, + { symbol: 'H', fraction: 0.10 }, + { symbol: 'N', fraction: 0.03 }, + { symbol: 'Ca', fraction: 0.015 }, + { symbol: 'P', fraction: 0.01 }, + { symbol: 'S', fraction: 0.003 }, + { symbol: 'Na', fraction: 0.002 }, + { symbol: 'K', fraction: 0.002 }, + { symbol: 'Fe', fraction: 0.001 }, +]; + +// ─── Constants ─────────────────────────────────────────────────── + +/** Phase durations in ms (approximate, can be adjusted) */ +export const PHASE_DURATIONS: Record = { + [RunPhase.Awakening]: 0, // ends when player leaves Cradle + [RunPhase.Exploration]: 180_000, // 3 minutes + [RunPhase.Escalation]: 120_000, // 2 minutes + [RunPhase.Crisis]: 0, // ends when resolved or player dies + [RunPhase.Resolution]: 60_000, // 1 minute +}; + +/** Escalation growth rate per second (0→1 over Escalation phase) */ +export const ESCALATION_RATE = 0.005; + +/** Spores awarded per discovery type */ +export const SPORE_REWARDS = { + element: 5, + reaction: 10, + compound: 8, + creature: 15, + crisisResolved: 50, + runCompleted: 20, +}; diff --git a/tests/run-cycle.test.ts b/tests/run-cycle.test.ts new file mode 100644 index 0000000..692c435 --- /dev/null +++ b/tests/run-cycle.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import schoolsData from '../src/data/schools.json'; +import elementsData from '../src/data/elements.json'; +import type { SchoolData, RunState, MetaState, CrisisConfig } from '../src/run/types'; +import { + RunPhase, + RUN_PHASE_NAMES, + BODY_COMPOSITION, + SPORE_REWARDS, + PHASE_DURATIONS, + ESCALATION_RATE, +} from '../src/run/types'; +import { + createRunState, + advancePhase, + updateEscalation, + recordDiscovery, + calculateSpores, +} from '../src/run/state'; +import { + createMetaState, + applyRunResults, + isSchoolUnlocked, + getCodexEntries, + getCodexCount, +} from '../src/run/meta'; +import { + createCrisisState, + applyCrisisDamage, + attemptNeutralize, + isCrisisResolved, + CHEMICAL_PLAGUE, +} from '../src/run/crisis'; + +// ─── School Data ───────────────────────────────────────────────── + +describe('School Data', () => { + const schools = schoolsData as SchoolData[]; + + it('should have at least one school (Alchemist)', () => { + expect(schools.length).toBeGreaterThanOrEqual(1); + }); + + it('Alchemist has correct starting elements', () => { + const alchemist = schools.find(s => s.id === 'alchemist'); + expect(alchemist).toBeDefined(); + expect(alchemist!.startingElements).toEqual(['H', 'O', 'C', 'Na', 'S', 'Fe']); + }); + + it('all starting elements exist in element registry', () => { + const symbols = new Set(elementsData.map(e => e.symbol)); + for (const school of schools) { + for (const elem of school.startingElements) { + expect(symbols.has(elem), `Element ${elem} not found in registry`).toBe(true); + } + } + }); + + it('starting quantities match starting elements', () => { + for (const school of schools) { + for (const elem of school.startingElements) { + expect( + school.startingQuantities[elem], + `Missing quantity for ${elem} in school ${school.id}`, + ).toBeGreaterThan(0); + } + } + }); + + it('each school has bilingual fields', () => { + for (const school of schools) { + expect(school.name.length).toBeGreaterThan(0); + expect(school.nameRu.length).toBeGreaterThan(0); + expect(school.description.length).toBeGreaterThan(0); + expect(school.descriptionRu.length).toBeGreaterThan(0); + expect(school.principle.length).toBeGreaterThan(0); + expect(school.principleRu.length).toBeGreaterThan(0); + } + }); + + it('each school has a valid hex color', () => { + for (const school of schools) { + expect(school.color).toMatch(/^#[0-9a-fA-F]{6}$/); + } + }); +}); + +// ─── Run State ─────────────────────────────────────────────────── + +describe('Run State', () => { + let state: RunState; + + beforeEach(() => { + state = createRunState(1, 'alchemist'); + }); + + it('creates initial state in Awakening phase', () => { + expect(state.runId).toBe(1); + expect(state.schoolId).toBe('alchemist'); + expect(state.phase).toBe(RunPhase.Awakening); + expect(state.elapsed).toBe(0); + expect(state.escalation).toBe(0); + expect(state.crisisActive).toBe(false); + expect(state.alive).toBe(true); + }); + + it('advances phases in order', () => { + advancePhase(state); + expect(state.phase).toBe(RunPhase.Exploration); + + advancePhase(state); + expect(state.phase).toBe(RunPhase.Escalation); + + advancePhase(state); + expect(state.phase).toBe(RunPhase.Crisis); + + advancePhase(state); + expect(state.phase).toBe(RunPhase.Resolution); + }); + + it('does not advance past Resolution', () => { + state.phase = RunPhase.Resolution; + advancePhase(state); + expect(state.phase).toBe(RunPhase.Resolution); + }); + + it('resets phase timer on advance', () => { + state.phaseTimer = 5000; + advancePhase(state); + expect(state.phaseTimer).toBe(0); + }); + + it('records element discovery', () => { + recordDiscovery(state, 'element', 'Na'); + expect(state.discoveries.elements.has('Na')).toBe(true); + }); + + it('records reaction discovery', () => { + recordDiscovery(state, 'reaction', 'Na+Cl'); + expect(state.discoveries.reactions.has('Na+Cl')).toBe(true); + }); + + it('records compound discovery', () => { + recordDiscovery(state, 'compound', 'NaCl'); + expect(state.discoveries.compounds.has('NaCl')).toBe(true); + }); + + it('records creature discovery', () => { + recordDiscovery(state, 'creature', 'crystallid'); + expect(state.discoveries.creatures.has('crystallid')).toBe(true); + }); + + it('does not duplicate discoveries', () => { + recordDiscovery(state, 'element', 'Na'); + recordDiscovery(state, 'element', 'Na'); + expect(state.discoveries.elements.size).toBe(1); + }); + + it('calculates spores from discoveries', () => { + recordDiscovery(state, 'element', 'Na'); + recordDiscovery(state, 'element', 'Cl'); + recordDiscovery(state, 'reaction', 'Na+Cl'); + recordDiscovery(state, 'compound', 'NaCl'); + recordDiscovery(state, 'creature', 'crystallid'); + + const spores = calculateSpores(state); + const expected = 2 * SPORE_REWARDS.element + + 1 * SPORE_REWARDS.reaction + + 1 * SPORE_REWARDS.compound + + 1 * SPORE_REWARDS.creature; + expect(spores).toBe(expected); + }); + + it('adds crisis resolved bonus to spores', () => { + recordDiscovery(state, 'element', 'Na'); + state.crisisResolved = true; + const spores = calculateSpores(state); + expect(spores).toBe(SPORE_REWARDS.element + SPORE_REWARDS.crisisResolved); + }); +}); + +// ─── Escalation ────────────────────────────────────────────────── + +describe('Escalation', () => { + let state: RunState; + + beforeEach(() => { + state = createRunState(1, 'alchemist'); + state.phase = RunPhase.Escalation; + }); + + it('increases escalation over time', () => { + updateEscalation(state, 1000); // 1 second + expect(state.escalation).toBeCloseTo(ESCALATION_RATE); + }); + + it('clamps escalation to 1.0', () => { + updateEscalation(state, 300_000); // 5 minutes + expect(state.escalation).toBeLessThanOrEqual(1.0); + }); + + it('does not escalate during Exploration', () => { + state.phase = RunPhase.Exploration; + updateEscalation(state, 10_000); + expect(state.escalation).toBe(0); + }); + + it('continues escalation during Crisis', () => { + state.phase = RunPhase.Crisis; + state.escalation = 0.5; + updateEscalation(state, 1000); + expect(state.escalation).toBeGreaterThan(0.5); + }); +}); + +// ─── Meta-Progression ──────────────────────────────────────────── + +describe('Meta-Progression', () => { + let meta: MetaState; + + beforeEach(() => { + meta = createMetaState(); + }); + + it('creates empty meta state', () => { + expect(meta.spores).toBe(0); + expect(meta.codex).toEqual([]); + expect(meta.totalRuns).toBe(0); + expect(meta.totalDeaths).toBe(0); + expect(meta.unlockedSchools).toEqual(['alchemist']); + }); + + it('alchemist is unlocked by default', () => { + expect(isSchoolUnlocked(meta, 'alchemist')).toBe(true); + }); + + it('other schools are locked by default', () => { + expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false); + }); + + it('applies run results — adds spores', () => { + const run = createRunState(1, 'alchemist'); + recordDiscovery(run, 'element', 'Na'); + recordDiscovery(run, 'element', 'Cl'); + + applyRunResults(meta, run); + expect(meta.spores).toBe(2 * SPORE_REWARDS.element); + expect(meta.totalRuns).toBe(1); + expect(meta.totalDeaths).toBe(1); + }); + + it('applies run results — adds codex entries', () => { + const run = createRunState(1, 'alchemist'); + recordDiscovery(run, 'element', 'Na'); + recordDiscovery(run, 'reaction', 'Na+Cl'); + + applyRunResults(meta, run); + expect(meta.codex.length).toBe(2); + expect(getCodexEntries(meta, 'element')).toHaveLength(1); + expect(getCodexEntries(meta, 'reaction')).toHaveLength(1); + }); + + it('does not duplicate codex entries across runs', () => { + const run1 = createRunState(1, 'alchemist'); + recordDiscovery(run1, 'element', 'Na'); + applyRunResults(meta, run1); + + const run2 = createRunState(2, 'alchemist'); + recordDiscovery(run2, 'element', 'Na'); + applyRunResults(meta, run2); + + expect(getCodexCount(meta)).toBe(1); + }); + + it('accumulates spores across runs', () => { + const run1 = createRunState(1, 'alchemist'); + recordDiscovery(run1, 'element', 'Na'); + applyRunResults(meta, run1); + + const run2 = createRunState(2, 'alchemist'); + recordDiscovery(run2, 'element', 'Cl'); + applyRunResults(meta, run2); + + expect(meta.spores).toBe(2 * SPORE_REWARDS.element); + expect(meta.totalRuns).toBe(2); + }); + + it('stores run history summaries', () => { + const run = createRunState(1, 'alchemist'); + run.phase = RunPhase.Escalation; + run.elapsed = 120_000; + recordDiscovery(run, 'element', 'Na'); + recordDiscovery(run, 'compound', 'NaCl'); + + applyRunResults(meta, run); + expect(meta.runHistory).toHaveLength(1); + expect(meta.runHistory[0].schoolId).toBe('alchemist'); + expect(meta.runHistory[0].discoveries).toBe(2); + expect(meta.runHistory[0].duration).toBe(120_000); + }); +}); + +// ─── Crisis: Chemical Plague ───────────────────────────────────── + +describe('Crisis: Chemical Plague', () => { + it('defines chemical plague config', () => { + expect(CHEMICAL_PLAGUE.type).toBe('chemical-plague'); + expect(CHEMICAL_PLAGUE.neutralizer).toBeDefined(); + expect(CHEMICAL_PLAGUE.neutralizeAmount).toBeGreaterThan(0); + }); + + it('creates crisis state', () => { + const crisis = createCrisisState(CHEMICAL_PLAGUE); + expect(crisis.active).toBe(true); + expect(crisis.progress).toBe(0); + expect(crisis.resolved).toBe(false); + }); + + it('applies crisis damage over time', () => { + const crisis = createCrisisState(CHEMICAL_PLAGUE); + applyCrisisDamage(crisis, 1000); + expect(crisis.progress).toBeGreaterThan(0); + }); + + it('crisis progress clamps at 1.0', () => { + const crisis = createCrisisState(CHEMICAL_PLAGUE); + applyCrisisDamage(crisis, 999_999); + expect(crisis.progress).toBeLessThanOrEqual(1.0); + }); + + it('neutralize reduces progress', () => { + const crisis = createCrisisState(CHEMICAL_PLAGUE); + crisis.progress = 0.5; + const result = attemptNeutralize(crisis, CHEMICAL_PLAGUE.neutralizer, 1); + expect(result).toBe(true); + expect(crisis.progress).toBeLessThan(0.5); + }); + + it('wrong compound does not neutralize', () => { + const crisis = createCrisisState(CHEMICAL_PLAGUE); + crisis.progress = 0.5; + const result = attemptNeutralize(crisis, 'WrongCompound', 1); + expect(result).toBe(false); + expect(crisis.progress).toBe(0.5); + }); + + it('sufficient neutralization resolves crisis', () => { + const crisis = createCrisisState(CHEMICAL_PLAGUE); + crisis.progress = 0.1; + attemptNeutralize(crisis, CHEMICAL_PLAGUE.neutralizer, CHEMICAL_PLAGUE.neutralizeAmount); + expect(isCrisisResolved(crisis)).toBe(true); + expect(crisis.resolved).toBe(true); + }); +}); + +// ─── Body Composition ──────────────────────────────────────────── + +describe('Body Composition', () => { + it('fractions sum to approximately 1.0', () => { + const sum = BODY_COMPOSITION.reduce((acc, e) => acc + e.fraction, 0); + // Allow some margin since we simplified (real body has trace elements) + expect(sum).toBeGreaterThan(0.85); + expect(sum).toBeLessThanOrEqual(1.0); + }); + + it('all body elements exist in element registry', () => { + const symbols = new Set(elementsData.map(e => e.symbol)); + for (const entry of BODY_COMPOSITION) { + expect(symbols.has(entry.symbol), `${entry.symbol} not in registry`).toBe(true); + } + }); + + it('oxygen is the largest fraction', () => { + const oxygen = BODY_COMPOSITION.find(e => e.symbol === 'O'); + expect(oxygen).toBeDefined(); + expect(oxygen!.fraction).toBe(0.65); + }); +}); + +// ─── Run Phase Names ───────────────────────────────────────────── + +describe('Run Phase Names', () => { + it('has names for all phases', () => { + expect(RUN_PHASE_NAMES[RunPhase.Awakening]).toBe('Awakening'); + expect(RUN_PHASE_NAMES[RunPhase.Exploration]).toBe('Exploration'); + expect(RUN_PHASE_NAMES[RunPhase.Escalation]).toBe('Escalation'); + expect(RUN_PHASE_NAMES[RunPhase.Crisis]).toBe('Crisis'); + expect(RUN_PHASE_NAMES[RunPhase.Resolution]).toBe('Resolution'); + }); + + it('phases are numbered 0-4', () => { + expect(RunPhase.Awakening).toBe(0); + expect(RunPhase.Resolution).toBe(4); + }); + + it('phase durations are defined', () => { + expect(PHASE_DURATIONS[RunPhase.Exploration]).toBeGreaterThan(0); + expect(PHASE_DURATIONS[RunPhase.Escalation]).toBeGreaterThan(0); + }); +});