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:
17
PROGRESS.md
17
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) |
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 рана"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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