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:
23
src/data/schools.json
Normal file
23
src/data/schools.json
Normal 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
85
src/run/crisis.ts
Normal 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.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;
|
||||
}
|
||||
101
src/run/meta.ts
Normal file
101
src/run/meta.ts
Normal 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
121
src/run/persistence.ts
Normal 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
93
src/run/state.ts
Normal 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
183
src/run/types.ts
Normal 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
400
tests/run-cycle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user