phase 10: schools — Mechanic, Naturalist, Navigator with unlock system and bonuses
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>
This commit is contained in:
@@ -23,6 +23,8 @@ import {
|
||||
isSchoolUnlocked,
|
||||
getCodexEntries,
|
||||
getCodexCount,
|
||||
checkSchoolUnlocks,
|
||||
getSchoolBonuses,
|
||||
} from '../src/run/meta';
|
||||
import {
|
||||
createCrisisState,
|
||||
@@ -37,8 +39,8 @@ import {
|
||||
describe('School Data', () => {
|
||||
const schools = schoolsData as SchoolData[];
|
||||
|
||||
it('should have at least one school (Alchemist)', () => {
|
||||
expect(schools.length).toBeGreaterThanOrEqual(1);
|
||||
it('should have exactly 4 schools', () => {
|
||||
expect(schools.length).toBe(4);
|
||||
});
|
||||
|
||||
it('Alchemist has correct starting elements', () => {
|
||||
@@ -47,6 +49,24 @@ describe('School Data', () => {
|
||||
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) {
|
||||
@@ -83,6 +103,50 @@ describe('School Data', () => {
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -353,6 +417,169 @@ describe('Crisis: Chemical Plague', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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', () => {
|
||||
|
||||
Reference in New Issue
Block a user