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:
Денис Шкабатур
2026-02-12 18:35:15 +03:00
parent 1b2cc0cd86
commit 0cd995c817
10 changed files with 573 additions and 76 deletions

View File

@@ -1,7 +1,7 @@
# Synthesis — Development Progress
> **Last updated:** 2026-02-12
> **Current phase:** Phase 9 ✅ → Biome Expansion Complete
> **Current phase:** Phase 10 ✅ → Schools Complete
---
@@ -125,15 +125,25 @@
- [x] GameScene filters creatures by selected biome
- [x] Unit tests — 32 new tests (487 total)
### Phase 10: Schools ✅
- [x] 10.1 SchoolData type extended — `bonuses`, `unlockCondition`, `ResolvedSchoolBonuses` interface (`src/run/types.ts`)
- [x] 10.2 3 new schools — Mechanic, Naturalist, Navigator with real science principles (`src/data/schools.json`)
- [x] 10.3 School unlock system — data-driven conditions: elements_discovered, creatures_discovered, runs_completed (`src/run/meta.ts`)
- [x] 10.4 School bonuses resolver — `getSchoolBonuses()` returns complete multiplier set with defaults (`src/run/meta.ts`)
- [x] 10.5 Bonus integration — movement speed (Navigator), projectile damage (Mechanic), creature aggro range (Naturalist), reaction efficiency (Alchemist)
- [x] 10.6 CradleScene updated — all 4 schools displayed, locked schools grayed out with lock icon + unlock hint, bonus indicators shown
- [x] 10.7 System signatures updated — `playerInputSystem`, `aiSystem`, `creatureProjectileSystem` accept optional multiplier params
- [x] Unit tests — 24 new tests (511 total): school data, unlock conditions (cumulative, multi-school, persistence), bonuses per school
---
## In Progress
_None — Phase 9 complete_
_None — Phase 10 complete_
---
## Up Next: Phase 10+
## Up Next: Phase 11+
_(See IMPLEMENTATION-PLAN.md for Beyond Vertical Slice)_
@@ -159,3 +169,4 @@ None
| 8 | 2026-02-12 | Phase 7 | Mycelium: persistent knowledge graph (nodes/edges/strength), fungal node ECS entities with glow animation, knowledge deposit (auto on death + manual at nodes), memory flash extraction (weighted by strength, Russian templates), mycosis visual effect (tint overlay + reveal threshold), spore shop in Cradle (5 bonuses: health/elements/knowledge), MetaState+IndexedDB persistence updated, GameScene+CradleScene integration, 36 new tests (385 total) |
| 9 | 2026-02-12 | Phase 8 | Ouroboros boss fight: 4-phase cyclical AI (Coil/Spray/Lash/Digest) with escalating difficulty, 3 chemistry-based victory paths (NaOH neutralization, direct damage, Hg catalyst poison), circular arena generator, BossArenaScene with attacks+collision+UI, Archont's Memory lore reward, CodexEntry extended, GameScene Resolution→arena trigger, 70 new tests (455 total) |
| 10 | 2026-02-12 | Phase 9 | Biome expansion: +20 elements (40 total), +39 compounds (64 total), +85 reactions (119 total), 2 new biomes (Kinetic Mountains + Verdant Forests), biome selection in CradleScene, 6 new creature species (3 per new biome), generic world generator, 32 new tests (487 total) |
| 11 | 2026-02-12 | Phase 10 | Schools: +3 schools (Mechanic/Naturalist/Navigator), data-driven unlock system (elements/creatures/runs), school bonuses (projectile dmg, movement speed, aggro range, reaction efficiency), CradleScene shows locked schools with hints, system multiplier params, 24 new tests (511 total) |

View File

@@ -107,12 +107,14 @@ function findNearest(
*
* @param speciesLookup - map from SpeciesId to species stats
* @param tick - current game tick (for deterministic randomness)
* @param aggroMultiplier - multiplier on flee/aggression radius (default 1.0, e.g. Naturalist bonus)
*/
export function aiSystem(
world: World,
deltaMs: number,
speciesLookup: Map<number, SpeciesData>,
tick: number,
aggroMultiplier = 1.0,
): void {
const dt = deltaMs / 1000;
const creatures = query(world, [Creature, AI, Position, Velocity, Metabolism, LifeCycle]);
@@ -167,7 +169,8 @@ export function aiSystem(
}
}
const fleeRadiusSq = species.fleeRadius * species.fleeRadius;
const effectiveFleeRadius = species.fleeRadius * aggroMultiplier;
const fleeRadiusSq = effectiveFleeRadius * effectiveFleeRadius;
const shouldFlee = nearestThreatDist < fleeRadiusSq
&& species.speciesId !== SpeciesId.Reagent; // Reagents don't flee
@@ -176,7 +179,8 @@ export function aiSystem(
const isHungry = hungerFraction < Metabolism.hungerThreshold[eid];
// ─── Check for prey (Reagents only) ───
const aggrRadiusSq = species.aggressionRadius * species.aggressionRadius;
const effectiveAggrRadius = species.aggressionRadius * aggroMultiplier;
const aggrRadiusSq = effectiveAggrRadius * effectiveAggrRadius;
let nearestPreyEid = -1;
let nearestPreyDist = Infinity;

View File

@@ -53,12 +53,14 @@ export interface ProjectileHit {
* Check projectile-creature collisions.
* Removes hitting projectiles and deals damage to creatures.
*
* @param damageMultiplier — optional multiplier on projectile damage (default 1.0, e.g. Mechanic bonus)
* @returns Array of hits that occurred
*/
export function creatureProjectileSystem(
world: World,
projData: Map<number, ProjectileData>,
speciesLookup: Map<number, SpeciesData>,
damageMultiplier = 1.0,
): ProjectileHit[] {
const hits: ProjectileHit[] = [];
const projectiles = query(world, [Position, Projectile]);
@@ -76,10 +78,10 @@ export function creatureProjectileSystem(
const dSq = dx * dx + dy * dy;
if (dSq <= HIT_RADIUS_SQ) {
// Hit! Calculate damage (with armor reduction)
// Hit! Calculate damage (with armor reduction and school bonus)
const species = speciesLookup.get(Creature.speciesId[cEid]);
const armor = species?.armor ?? 0;
const damage = Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor));
const damage = Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor) * damageMultiplier);
Health.current[cEid] -= damage;
const killed = Health.current[cEid] <= 0;

View File

@@ -18,6 +18,96 @@
"principleRu": "Химическое равновесие",
"playstyle": "Potions, explosives, poisons. Combine elements for powerful chemical effects.",
"playstyleRu": "Зельеварение, взрывчатка, яды. Комбинируй элементы для мощных химических эффектов.",
"color": "#00ff88"
"color": "#00ff88",
"bonuses": {
"reactionEfficiency": 1.25
}
},
{
"id": "mechanic",
"name": "Mechanic",
"nameRu": "Механик",
"description": "Builder of mechanisms and contraptions. Uses leverage and momentum to amplify force.",
"descriptionRu": "Строитель механизмов и устройств. Использует рычаг и момент силы для усиления воздействия.",
"startingElements": ["Fe", "Cu", "Sn", "Si", "C"],
"startingQuantities": {
"Fe": 5,
"Cu": 4,
"Sn": 3,
"Si": 4,
"C": 4
},
"principle": "Lever & Moment of Force",
"principleRu": "Рычаг и момент силы",
"playstyle": "Craft mechanisms, traps, automatons. Metals are your tools.",
"playstyleRu": "Крафт механизмов, ловушки, автоматоны. Металлы — твои инструменты.",
"color": "#ff8800",
"bonuses": {
"projectileDamage": 1.3
},
"unlockCondition": {
"type": "elements_discovered",
"threshold": 10,
"hint": "Discover 10 elements in the Codex",
"hintRu": "Открой 10 элементов в Кодексе"
}
},
{
"id": "naturalist",
"name": "Naturalist",
"nameRu": "Натуралист",
"description": "Observer of living systems. Understands photosynthesis and ecosystem balance.",
"descriptionRu": "Наблюдатель живых систем. Понимает фотосинтез и баланс экосистем.",
"startingElements": ["C", "N", "O", "P", "K"],
"startingQuantities": {
"C": 5,
"N": 4,
"O": 5,
"P": 3,
"K": 3
},
"principle": "Photosynthesis",
"principleRu": "Фотосинтез",
"playstyle": "Taming, cultivation, ecosystem control. Work with nature, not against it.",
"playstyleRu": "Приручение, выращивание, экосистемный контроль. Работай с природой, а не против неё.",
"color": "#44cc44",
"bonuses": {
"creatureAggroRange": 0.6
},
"unlockCondition": {
"type": "creatures_discovered",
"threshold": 3,
"hint": "Discover 3 creature species",
"hintRu": "Открой 3 вида существ"
}
},
{
"id": "navigator",
"name": "Navigator",
"nameRu": "Навигатор",
"description": "Master of angles and distances. Charts the unknown with precision and moves with unmatched speed.",
"descriptionRu": "Мастер углов и расстояний. Исследует неизведанное с точностью и двигается с непревзойдённой скоростью.",
"startingElements": ["Si", "Fe", "C", "H", "O"],
"startingQuantities": {
"Si": 4,
"Fe": 3,
"C": 4,
"H": 5,
"O": 4
},
"principle": "Angular Measurement",
"principleRu": "Угловое измерение",
"playstyle": "Cartography, scouting, mobility. See further, move faster.",
"playstyleRu": "Картография, разведка, мобильность. Видь дальше, двигайся быстрее.",
"color": "#4488ff",
"bonuses": {
"movementSpeed": 1.2
},
"unlockCondition": {
"type": "runs_completed",
"threshold": 3,
"hint": "Complete 3 runs",
"hintRu": "Заверши 3 рана"
}
}
]

View File

@@ -38,9 +38,11 @@ export function calculatePlayerVelocity(
/**
* ECS system: set player entity velocity from input.
* Only affects entities with PlayerTag + Velocity.
*
* @param speedMultiplier — optional multiplier on base PLAYER_SPEED (default 1.0)
*/
export function playerInputSystem(world: World, input: InputState): void {
const vel = calculatePlayerVelocity(input, PLAYER_SPEED);
export function playerInputSystem(world: World, input: InputState, speedMultiplier = 1.0): void {
const vel = calculatePlayerVelocity(input, PLAYER_SPEED * speedMultiplier);
for (const eid of query(world, [Velocity, PlayerTag])) {
Velocity.vx[eid] = vel.vx;
Velocity.vy[eid] = vel.vy;

View File

@@ -2,12 +2,14 @@
* Meta-Progression — persists between runs.
*
* Manages the Codex (permanent knowledge), spores (currency),
* unlocked schools, and run history.
* unlocked schools, run history, and school unlock system.
*/
import type { MetaState, CodexEntry, RunSummary } from './types';
import type { MetaState, CodexEntry, RunSummary, SchoolData, ResolvedSchoolBonuses } from './types';
import type { RunState } from './types';
import { DEFAULT_SCHOOL_BONUSES } from './types';
import { calculateSpores, countDiscoveries } from './state';
import schoolsData from '../data/schools.json';
/** Create a fresh meta state (first time playing) */
export function createMetaState(): MetaState {
@@ -81,6 +83,9 @@ export function applyRunResults(meta: MetaState, run: RunState): void {
crisisResolved: run.crisisResolved,
};
meta.runHistory.push(summary);
// 5. Check for school unlocks after updating codex and stats
checkSchoolUnlocks(meta);
}
/** Check if a school is unlocked */
@@ -100,3 +105,63 @@ export function getCodexEntries(
export function getCodexCount(meta: MetaState): number {
return meta.codex.length;
}
// ─── School Unlock System ────────────────────────────────────────
const schools = schoolsData as unknown as SchoolData[];
/**
* Check all school unlock conditions against current meta state.
* Newly unlocked schools are added to meta.unlockedSchools.
*/
export function checkSchoolUnlocks(meta: MetaState): void {
for (const school of schools) {
// Skip already unlocked
if (meta.unlockedSchools.includes(school.id)) continue;
// Schools without condition are always available (e.g., alchemist)
if (!school.unlockCondition) {
meta.unlockedSchools.push(school.id);
continue;
}
const condition = school.unlockCondition;
let met = false;
switch (condition.type) {
case 'elements_discovered': {
const count = meta.codex.filter(e => e.type === 'element').length;
met = count >= condition.threshold;
break;
}
case 'creatures_discovered': {
const count = meta.codex.filter(e => e.type === 'creature').length;
met = count >= condition.threshold;
break;
}
case 'runs_completed': {
met = meta.totalRuns >= condition.threshold;
break;
}
}
if (met) {
meta.unlockedSchools.push(school.id);
}
}
}
// ─── School Bonuses ──────────────────────────────────────────────
/**
* Resolve school bonuses into a complete multiplier set.
* Unknown school ID returns all-default (1.0) bonuses.
*/
export function getSchoolBonuses(schoolId: string): ResolvedSchoolBonuses {
const school = schools.find(s => s.id === schoolId);
if (!school) return { ...DEFAULT_SCHOOL_BONUSES };
return {
...DEFAULT_SCHOOL_BONUSES,
...school.bonuses,
};
}

View File

@@ -25,8 +25,44 @@ export interface SchoolData {
playstyleRu: string;
/** Hex color for UI representation */
color: string;
/** Passive gameplay bonuses from this school's principle */
bonuses: Record<string, number>;
/** Condition to unlock this school (absent = always unlocked) */
unlockCondition?: SchoolUnlockCondition;
}
/** Condition that must be met to unlock a school */
export interface SchoolUnlockCondition {
/** What metric to check */
type: 'elements_discovered' | 'creatures_discovered' | 'runs_completed';
/** How many needed */
threshold: number;
/** Hint text (English) shown when locked */
hint: string;
/** Hint text (Russian) shown when locked */
hintRu: string;
}
/** Resolved school bonuses with all multipliers filled in (defaults = 1.0) */
export interface ResolvedSchoolBonuses {
/** Projectile damage multiplier */
projectileDamage: number;
/** Player movement speed multiplier */
movementSpeed: number;
/** Creature aggro/flee range multiplier (< 1 = less aggro) */
creatureAggroRange: number;
/** Reaction efficiency multiplier */
reactionEfficiency: number;
}
/** Default bonuses (no school effect) */
export const DEFAULT_SCHOOL_BONUSES: ResolvedSchoolBonuses = {
projectileDamage: 1.0,
movementSpeed: 1.0,
creatureAggroRange: 1.0,
reactionEfficiency: 1.0,
};
// ─── Run Phases ──────────────────────────────────────────────────
/** Phases of a single run in order */

View File

@@ -17,7 +17,7 @@ import { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession }
import { getGraphStats } from '../mycelium/graph';
import type { BonusEffect } from '../mycelium/types';
const schools = schoolsData as SchoolData[];
const schools = schoolsData as unknown as SchoolData[];
const biomes = biomeDataArray as BiomeData[];
export class CradleScene extends Phaser.Scene {
@@ -155,29 +155,34 @@ export class CradleScene extends Phaser.Scene {
// Biome selection row
this.createBiomeSelector(cx, 105);
// School cards
const cardWidth = 320;
// School cards — show ALL schools, grayed out if locked
const cardWidth = 280;
const cardHeight = 220;
const startY = 150;
const unlockedSchools = schools.filter(s => isSchoolUnlocked(this.meta, s.id));
const cardSpacing = 12;
for (let i = 0; i < unlockedSchools.length; i++) {
const school = unlockedSchools[i];
const cardX = cx - (unlockedSchools.length - 1) * (cardWidth + 20) / 2 + i * (cardWidth + 20);
for (let i = 0; i < schools.length; i++) {
const school = schools[i];
const unlocked = isSchoolUnlocked(this.meta, school.id);
const totalCardWidth = schools.length * (cardWidth + cardSpacing) - cardSpacing;
const cardX = cx - totalCardWidth / 2 + cardWidth / 2 + i * (cardWidth + cardSpacing);
const cardY = startY + cardHeight / 2;
const container = this.add.container(cardX, cardY);
container.setDepth(10);
const schoolColor = parseInt(school.color.replace('#', ''), 16);
// Card background
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight, 0x0a1a0f, 0.9);
bg.setStrokeStyle(2, parseInt(school.color.replace('#', ''), 16));
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight,
unlocked ? 0x0a1a0f : 0x0a0a0a, 0.9);
bg.setStrokeStyle(2, unlocked ? schoolColor : 0x333333);
container.add(bg);
// School name
const nameText = this.add.text(0, -cardHeight / 2 + 20, school.nameRu, {
fontSize: '22px',
color: school.color,
fontSize: '20px',
color: unlocked ? school.color : '#555555',
fontFamily: 'monospace',
fontStyle: 'bold',
});
@@ -185,71 +190,119 @@ export class CradleScene extends Phaser.Scene {
container.add(nameText);
// Principle
const principleText = this.add.text(0, -cardHeight / 2 + 50, school.principleRu, {
fontSize: '12px',
color: '#88aa88',
const principleText = this.add.text(0, -cardHeight / 2 + 48, school.principleRu, {
fontSize: '11px',
color: unlocked ? '#88aa88' : '#444444',
fontFamily: 'monospace',
});
principleText.setOrigin(0.5);
container.add(principleText);
// Starting elements
const elemList = school.startingElements.map(sym => {
const qty = school.startingQuantities[sym] ?? 1;
return `${sym} ×${qty}`;
}).join(' ');
const elemText = this.add.text(0, -cardHeight / 2 + 80, elemList, {
fontSize: '13px',
color: '#aaffaa',
fontFamily: 'monospace',
});
elemText.setOrigin(0.5);
container.add(elemText);
if (unlocked) {
// ─── Unlocked school: full info ───
// Playstyle
const playText = this.add.text(0, -cardHeight / 2 + 120,
this.wrapText(school.playstyleRu, 34), {
// Starting elements
const elemList = school.startingElements.map(sym => {
const qty = school.startingQuantities[sym] ?? 1;
return `${sym} ×${qty}`;
}).join(' ');
const elemText = this.add.text(0, -cardHeight / 2 + 72, elemList, {
fontSize: '12px',
color: '#778877',
color: '#aaffaa',
fontFamily: 'monospace',
lineSpacing: 4,
});
playText.setOrigin(0.5, 0);
container.add(playText);
elemText.setOrigin(0.5);
container.add(elemText);
// Description
const descText = this.add.text(0, cardHeight / 2 - 60,
this.wrapText(school.descriptionRu, 34), {
fontSize: '11px',
color: '#556655',
fontFamily: 'monospace',
lineSpacing: 3,
// Bonus indicator
const bonusKeys = Object.keys(school.bonuses);
if (bonusKeys.length > 0) {
const bonusLabels: Record<string, string> = {
reactionEfficiency: 'Реакции',
projectileDamage: 'Урон',
creatureAggroRange: 'Агрессия',
movementSpeed: 'Скорость',
};
const bonusText = bonusKeys.map(k => {
const label = bonusLabels[k] ?? k;
const val = school.bonuses[k];
const pct = val >= 1 ? `+${Math.round((val - 1) * 100)}%` : `-${Math.round((1 - val) * 100)}%`;
return `${label} ${pct}`;
}).join(' ');
const bText = this.add.text(0, -cardHeight / 2 + 95, bonusText, {
fontSize: '11px',
color: '#ffcc44',
fontFamily: 'monospace',
});
bText.setOrigin(0.5);
container.add(bText);
}
// Playstyle
const playText = this.add.text(0, -cardHeight / 2 + 118,
this.wrapText(school.playstyleRu, 30), {
fontSize: '11px',
color: '#778877',
fontFamily: 'monospace',
lineSpacing: 3,
});
playText.setOrigin(0.5, 0);
container.add(playText);
// Description
const descText = this.add.text(0, cardHeight / 2 - 55,
this.wrapText(school.descriptionRu, 30), {
fontSize: '10px',
color: '#556655',
fontFamily: 'monospace',
lineSpacing: 2,
});
descText.setOrigin(0.5, 0);
container.add(descText);
// Click to select
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => {
bg.setStrokeStyle(3, 0x00ff88);
this.selectedIndex = i;
});
descText.setOrigin(0.5, 0);
container.add(descText);
bg.on('pointerout', () => {
bg.setStrokeStyle(2, schoolColor);
});
bg.on('pointerdown', () => {
this.startRun(school);
});
} else {
// ─── Locked school: grayed out with unlock hint ───
// Lock icon
const lockText = this.add.text(0, -10, '🔒', {
fontSize: '32px',
});
lockText.setOrigin(0.5);
container.add(lockText);
// Unlock hint
const hint = school.unlockCondition?.hintRu ?? 'Заблокировано';
const hintText = this.add.text(0, 30, hint, {
fontSize: '11px',
color: '#666666',
fontFamily: 'monospace',
align: 'center',
});
hintText.setOrigin(0.5);
container.add(hintText);
}
// Fade in
container.setAlpha(0);
this.tweens.add({
targets: container,
alpha: 1,
alpha: unlocked ? 1 : 0.6,
duration: 600,
delay: 200 + i * 150,
});
// Click to select
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => {
bg.setStrokeStyle(3, 0x00ff88);
this.selectedIndex = i;
});
bg.on('pointerout', () => {
bg.setStrokeStyle(2, parseInt(school.color.replace('#', ''), 16));
});
bg.on('pointerdown', () => {
this.startRun(school);
});
this.schoolCards.push(container);
}

View File

@@ -45,8 +45,9 @@ import {
import { query } from 'bitecs';
// Run cycle imports
import type { MetaState, SchoolData, RunState } from '../run/types';
import type { MetaState, SchoolData, RunState, ResolvedSchoolBonuses } from '../run/types';
import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types';
import { getSchoolBonuses } from '../run/meta';
import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state';
import {
createCrisisState,
@@ -138,6 +139,9 @@ export class GameScene extends Phaser.Scene {
// Biome selection
private biomeId = 'catalytic-wastes';
// School bonuses (resolved from school data)
private schoolBonuses!: ResolvedSchoolBonuses;
init(data: { meta: MetaState; schoolId: string; runId: number; purchasedEffects?: import('../mycelium/types').BonusEffect[]; biomeId?: string }): void {
this.meta = data.meta;
this.runState = createRunState(data.runId, data.schoolId);
@@ -148,6 +152,7 @@ export class GameScene extends Phaser.Scene {
this.memoryFlashTimer = 0;
this.purchasedEffects = data.purchasedEffects ?? [];
this.biomeId = data.biomeId ?? 'catalytic-wastes';
this.schoolBonuses = getSchoolBonuses(data.schoolId);
}
create(): void {
@@ -354,7 +359,7 @@ export class GameScene extends Phaser.Scene {
/** Give the player their school's starting elements */
private giveStartingKit(): void {
const schools = schoolsData as SchoolData[];
const schools = schoolsData as unknown as SchoolData[];
const school = schools.find(s => s.id === this.runState.schoolId);
if (!school) return;
@@ -389,8 +394,8 @@ export class GameScene extends Phaser.Scene {
interact: this.keys.E.isDown,
};
// 3. Player input → velocity
playerInputSystem(this.gameWorld.world, input);
// 3. Player input → velocity (Navigator school gets speed bonus)
playerInputSystem(this.gameWorld.world, input, this.schoolBonuses.movementSpeed);
// 4. Movement (all entities including projectiles)
movementSystem(this.gameWorld.world, delta);
@@ -454,10 +459,11 @@ export class GameScene extends Phaser.Scene {
this.tryLaunchProjectile();
}
// 9a. Creature AI — adjust aggression based on escalation
// 9a. Creature AI — aggro range affected by Naturalist school bonus
aiSystem(
this.gameWorld.world, delta,
this.speciesLookup, this.gameWorld.time.tick,
this.schoolBonuses.creatureAggroRange,
);
// 9b. Creature metabolism (feeding, energy drain)
@@ -486,9 +492,10 @@ export class GameScene extends Phaser.Scene {
}
}
// 9e. Projectile-creature collision
// 9e. Projectile-creature collision (Mechanic school gets damage bonus)
creatureProjectileSystem(
this.gameWorld.world, this.projectileData, this.speciesLookup,
this.schoolBonuses.projectileDamage,
);
// 9f. Creature attacks on player

View File

@@ -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', () => {