Add 3 new schools with real scientific principles (Lever & Moment, Photosynthesis, Angular Measurement). Data-driven unlock conditions check codex elements, creature discoveries, and completed runs. Each school provides passive gameplay bonuses (projectile damage, movement speed, creature aggro range, reaction efficiency) applied through system multiplier parameters. CradleScene shows all 4 schools with locked ones grayed out and showing unlock hints. 24 new tests (511 total). Co-authored-by: Cursor <cursoragent@cursor.com>
628 lines
21 KiB
TypeScript
628 lines
21 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,
|
|
checkSchoolUnlocks,
|
|
getSchoolBonuses,
|
|
} 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 exactly 4 schools', () => {
|
|
expect(schools.length).toBe(4);
|
|
});
|
|
|
|
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('Mechanic has correct starting elements', () => {
|
|
const mechanic = schools.find(s => s.id === 'mechanic');
|
|
expect(mechanic).toBeDefined();
|
|
expect(mechanic!.startingElements).toEqual(['Fe', 'Cu', 'Sn', 'Si', 'C']);
|
|
});
|
|
|
|
it('Naturalist has correct starting elements', () => {
|
|
const naturalist = schools.find(s => s.id === 'naturalist');
|
|
expect(naturalist).toBeDefined();
|
|
expect(naturalist!.startingElements).toEqual(['C', 'N', 'O', 'P', 'K']);
|
|
});
|
|
|
|
it('Navigator has correct starting elements', () => {
|
|
const navigator = schools.find(s => s.id === 'navigator');
|
|
expect(navigator).toBeDefined();
|
|
expect(navigator!.startingElements).toEqual(['Si', 'Fe', 'C', 'H', 'O']);
|
|
});
|
|
|
|
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}$/);
|
|
}
|
|
});
|
|
|
|
it('each school has unique id', () => {
|
|
const ids = schools.map(s => s.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it('each school has unique color', () => {
|
|
const colors = schools.map(s => s.color);
|
|
expect(new Set(colors).size).toBe(colors.length);
|
|
});
|
|
|
|
it('each school has bonuses defined', () => {
|
|
for (const school of schools) {
|
|
expect(school.bonuses).toBeDefined();
|
|
expect(Object.keys(school.bonuses).length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('locked schools have unlock conditions', () => {
|
|
for (const school of schools) {
|
|
if (school.id === 'alchemist') {
|
|
expect(school.unlockCondition).toBeUndefined();
|
|
} else {
|
|
expect(school.unlockCondition).toBeDefined();
|
|
expect(school.unlockCondition!.threshold).toBeGreaterThan(0);
|
|
expect(school.unlockCondition!.hint.length).toBeGreaterThan(0);
|
|
expect(school.unlockCondition!.hintRu.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('each school has a real scientific principle', () => {
|
|
const alchemist = schools.find(s => s.id === 'alchemist')!;
|
|
expect(alchemist.principle).toBe('Chemical Equilibrium');
|
|
|
|
const mechanic = schools.find(s => s.id === 'mechanic')!;
|
|
expect(mechanic.principle).toBe('Lever & Moment of Force');
|
|
|
|
const naturalist = schools.find(s => s.id === 'naturalist')!;
|
|
expect(naturalist.principle).toBe('Photosynthesis');
|
|
|
|
const navigator = schools.find(s => s.id === 'navigator')!;
|
|
expect(navigator.principle).toBe('Angular Measurement');
|
|
});
|
|
});
|
|
|
|
// ─── 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);
|
|
});
|
|
});
|
|
|
|
// ─── School Unlock System ────────────────────────────────────────
|
|
|
|
describe('School Unlock System', () => {
|
|
let meta: MetaState;
|
|
|
|
beforeEach(() => {
|
|
meta = createMetaState();
|
|
});
|
|
|
|
it('only alchemist is unlocked at start', () => {
|
|
expect(meta.unlockedSchools).toEqual(['alchemist']);
|
|
});
|
|
|
|
it('mechanic unlocks after discovering 10 elements', () => {
|
|
const run = createRunState(1, 'alchemist');
|
|
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
|
|
recordDiscovery(run, 'element', sym);
|
|
}
|
|
applyRunResults(meta, run);
|
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
|
});
|
|
|
|
it('mechanic does NOT unlock with fewer than 10 elements', () => {
|
|
const run = createRunState(1, 'alchemist');
|
|
for (const sym of ['H', 'O', 'C', 'Na', 'S']) {
|
|
recordDiscovery(run, 'element', sym);
|
|
}
|
|
applyRunResults(meta, run);
|
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false);
|
|
});
|
|
|
|
it('naturalist unlocks after discovering 3 creatures', () => {
|
|
const run = createRunState(1, 'alchemist');
|
|
recordDiscovery(run, 'creature', 'crystallid');
|
|
recordDiscovery(run, 'creature', 'acidophile');
|
|
recordDiscovery(run, 'creature', 'reagent');
|
|
applyRunResults(meta, run);
|
|
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(true);
|
|
});
|
|
|
|
it('naturalist does NOT unlock with fewer than 3 creatures', () => {
|
|
const run = createRunState(1, 'alchemist');
|
|
recordDiscovery(run, 'creature', 'crystallid');
|
|
recordDiscovery(run, 'creature', 'acidophile');
|
|
applyRunResults(meta, run);
|
|
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(false);
|
|
});
|
|
|
|
it('navigator unlocks after 3 completed runs', () => {
|
|
for (let i = 1; i <= 3; i++) {
|
|
const run = createRunState(i, 'alchemist');
|
|
recordDiscovery(run, 'element', `elem${i}`);
|
|
applyRunResults(meta, run);
|
|
}
|
|
expect(isSchoolUnlocked(meta, 'navigator')).toBe(true);
|
|
});
|
|
|
|
it('navigator does NOT unlock with only 2 runs', () => {
|
|
for (let i = 1; i <= 2; i++) {
|
|
const run = createRunState(i, 'alchemist');
|
|
recordDiscovery(run, 'element', `elem${i}`);
|
|
applyRunResults(meta, run);
|
|
}
|
|
expect(isSchoolUnlocked(meta, 'navigator')).toBe(false);
|
|
});
|
|
|
|
it('unlocks persist across subsequent runs', () => {
|
|
// Unlock mechanic with 10 element discoveries
|
|
const run1 = createRunState(1, 'alchemist');
|
|
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
|
|
recordDiscovery(run1, 'element', sym);
|
|
}
|
|
applyRunResults(meta, run1);
|
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
|
|
|
// Next run with no discoveries — mechanic still unlocked
|
|
const run2 = createRunState(2, 'alchemist');
|
|
applyRunResults(meta, run2);
|
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
|
});
|
|
|
|
it('multiple schools can unlock in the same run', () => {
|
|
const run = createRunState(1, 'alchemist');
|
|
// 10 elements → mechanic
|
|
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
|
|
recordDiscovery(run, 'element', sym);
|
|
}
|
|
// 3 creatures → naturalist
|
|
recordDiscovery(run, 'creature', 'crystallid');
|
|
recordDiscovery(run, 'creature', 'acidophile');
|
|
recordDiscovery(run, 'creature', 'reagent');
|
|
|
|
applyRunResults(meta, run);
|
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
|
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(true);
|
|
});
|
|
|
|
it('cumulative codex entries across runs unlock schools', () => {
|
|
// Run 1: discover 6 elements
|
|
const run1 = createRunState(1, 'alchemist');
|
|
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe']) {
|
|
recordDiscovery(run1, 'element', sym);
|
|
}
|
|
applyRunResults(meta, run1);
|
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false);
|
|
|
|
// Run 2: discover 4 more → total 10
|
|
const run2 = createRunState(2, 'alchemist');
|
|
for (const sym of ['Cu', 'Si', 'Sn', 'N']) {
|
|
recordDiscovery(run2, 'element', sym);
|
|
}
|
|
applyRunResults(meta, run2);
|
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
|
});
|
|
|
|
it('checkSchoolUnlocks can be called standalone', () => {
|
|
// Manually populate codex with 10 elements
|
|
for (let i = 0; i < 10; i++) {
|
|
meta.codex.push({ id: `elem${i}`, type: 'element', discoveredOnRun: 1 });
|
|
}
|
|
checkSchoolUnlocks(meta);
|
|
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── School Bonuses ──────────────────────────────────────────────
|
|
|
|
describe('School Bonuses', () => {
|
|
it('alchemist has reaction efficiency bonus', () => {
|
|
const bonus = getSchoolBonuses('alchemist');
|
|
expect(bonus.reactionEfficiency).toBe(1.25);
|
|
expect(bonus.projectileDamage).toBe(1.0);
|
|
expect(bonus.movementSpeed).toBe(1.0);
|
|
expect(bonus.creatureAggroRange).toBe(1.0);
|
|
});
|
|
|
|
it('mechanic has projectile damage bonus', () => {
|
|
const bonus = getSchoolBonuses('mechanic');
|
|
expect(bonus.projectileDamage).toBe(1.3);
|
|
expect(bonus.movementSpeed).toBe(1.0);
|
|
});
|
|
|
|
it('naturalist has creature aggro reduction', () => {
|
|
const bonus = getSchoolBonuses('naturalist');
|
|
expect(bonus.creatureAggroRange).toBe(0.6);
|
|
expect(bonus.projectileDamage).toBe(1.0);
|
|
});
|
|
|
|
it('navigator has movement speed bonus', () => {
|
|
const bonus = getSchoolBonuses('navigator');
|
|
expect(bonus.movementSpeed).toBe(1.2);
|
|
expect(bonus.creatureAggroRange).toBe(1.0);
|
|
});
|
|
|
|
it('unknown school returns all-default bonuses', () => {
|
|
const bonus = getSchoolBonuses('nonexistent');
|
|
expect(bonus.projectileDamage).toBe(1.0);
|
|
expect(bonus.movementSpeed).toBe(1.0);
|
|
expect(bonus.creatureAggroRange).toBe(1.0);
|
|
expect(bonus.reactionEfficiency).toBe(1.0);
|
|
});
|
|
});
|
|
|
|
// ─── 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);
|
|
});
|
|
});
|