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 <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 15:10:05 +03:00
parent 22e6c6bcee
commit 5b7dbb4df3
7 changed files with 1006 additions and 0 deletions

23
src/data/schools.json Normal file
View File

@@ -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"
}
]

85
src/run/crisis.ts Normal file
View File

@@ -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.01.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;
}

101
src/run/meta.ts Normal file
View File

@@ -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;
}

121
src/run/persistence.ts Normal file
View File

@@ -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<IDBDatabase> {
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<void> {
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<MetaState> {
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<void> {
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();
});
}

93
src/run/state.ts Normal file
View File

@@ -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<string>(),
reactions: new Set<string>(),
compounds: new Set<string>(),
creatures: new Set<string>(),
},
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<DiscoveryType, keyof RunDiscoveries> = {
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;
}

183
src/run/types.ts Normal file
View File

@@ -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<string, number>;
/** 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, string> = {
[RunPhase.Awakening]: 'Awakening',
[RunPhase.Exploration]: 'Exploration',
[RunPhase.Escalation]: 'Escalation',
[RunPhase.Crisis]: 'Crisis',
[RunPhase.Resolution]: 'Resolution',
};
export const RUN_PHASE_NAMES_RU: Record<RunPhase, string> = {
[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<string>;
reactions: Set<string>;
compounds: Set<string>;
creatures: Set<string>;
}
// ─── 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, number> = {
[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,
};

400
tests/run-cycle.test.ts Normal file
View File

@@ -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);
});
});