diff --git a/PROGRESS.md b/PROGRESS.md index ada6e1c..0ae3b96 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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) | diff --git a/src/creatures/ai.ts b/src/creatures/ai.ts index 12238e1..11fea81 100644 --- a/src/creatures/ai.ts +++ b/src/creatures/ai.ts @@ -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, 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; diff --git a/src/creatures/interaction.ts b/src/creatures/interaction.ts index 07426e1..acebdbb 100644 --- a/src/creatures/interaction.ts +++ b/src/creatures/interaction.ts @@ -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, speciesLookup: Map, + 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; diff --git a/src/data/schools.json b/src/data/schools.json index cc2b79d..61dd73a 100644 --- a/src/data/schools.json +++ b/src/data/schools.json @@ -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 рана" + } } ] diff --git a/src/player/input.ts b/src/player/input.ts index d7aec22..2635213 100644 --- a/src/player/input.ts +++ b/src/player/input.ts @@ -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; diff --git a/src/run/meta.ts b/src/run/meta.ts index f2722f6..ba26b39 100644 --- a/src/run/meta.ts +++ b/src/run/meta.ts @@ -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, + }; +} diff --git a/src/run/types.ts b/src/run/types.ts index 132b64f..f8b78c7 100644 --- a/src/run/types.ts +++ b/src/run/types.ts @@ -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; + /** 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 */ diff --git a/src/scenes/CradleScene.ts b/src/scenes/CradleScene.ts index 45d0701..2d7a0f5 100644 --- a/src/scenes/CradleScene.ts +++ b/src/scenes/CradleScene.ts @@ -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 = { + 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); } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index cbc46d9..0c47144 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -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 diff --git a/tests/run-cycle.test.ts b/tests/run-cycle.test.ts index 692c435..90127dc 100644 --- a/tests/run-cycle.test.ts +++ b/tests/run-cycle.test.ts @@ -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', () => {