Files
synthesis/tests/run-cycle.test.ts
Денис Шкабатур 5b7dbb4df3 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>
2026-02-12 15:10:05 +03:00

401 lines
13 KiB
TypeScript

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