diff --git a/src/boss/ai.ts b/src/boss/ai.ts new file mode 100644 index 0000000..ae5fde1 --- /dev/null +++ b/src/boss/ai.ts @@ -0,0 +1,152 @@ +/** + * Boss AI System — cyclical phase logic for Archon encounters + * + * The boss cycles through 4 phases (Coil → Spray → Lash → Digest), + * each cycle becoming faster. During Digest, the boss is vulnerable. + * Between cycles, the boss regenerates health (self-catalysis). + */ + +import { + BossPhase, + BOSS_PHASE_COUNT, + type BossData, + type BossState, + type BossPhaseEvent, +} from './types'; + +/** Create initial boss state from boss data */ +export function createBossState(boss: BossData): BossState { + const initialDuration = getEffectivePhaseDuration( + { cycleCount: 0 } as BossState, boss, BossPhase.Coil, + ); + + return { + bossId: boss.id, + health: boss.health, + maxHealth: boss.health, + currentPhase: BossPhase.Coil, + phaseTimer: initialDuration, + cycleCount: 0, + catalystStacks: 0, + defeated: false, + victoryMethod: null, + totalDamageDealt: 0, + chemicalDamageDealt: 0, + directDamageDealt: 0, + catalystDamageDealt: 0, + }; +} + +/** + * Get the effective phase duration, accounting for cycle speedup. + * Each completed cycle multiplies duration by phaseSpeedupPerCycle. + * Caps at maxCycles to prevent impossibly short phases. + */ +export function getEffectivePhaseDuration( + state: Pick, + boss: BossData, + phase: BossPhase, +): number { + const baseDuration = boss.phaseDurations[phase]; + const effectiveCycles = Math.min(state.cycleCount, boss.maxCycles); + return baseDuration * Math.pow(boss.phaseSpeedupPerCycle, effectiveCycles); +} + +/** + * Get effective armor value, accounting for catalyst poison stacks. + * During vulnerable phases: uses armorVulnerable as base. + * During normal phases: uses armor as base. + * Catalyst stacks reduce armor further. + */ +export function getEffectiveArmor( + state: BossState, + boss: BossData, +): number { + const isVulnerable = boss.vulnerablePhases.includes(state.currentPhase); + const baseArmor = isVulnerable ? boss.armorVulnerable : boss.armor; + const reduction = state.catalystStacks * boss.catalystArmorReduction; + return Math.max(0, baseArmor - reduction); +} + +/** + * Get effective regeneration rate, accounting for catalyst poison. + * Each catalyst stack permanently reduces regen. + */ +export function getEffectiveRegen( + state: BossState, + boss: BossData, +): number { + const reduction = state.catalystStacks * boss.catalystRegenReduction; + return Math.max(0, boss.regenPerSecond - reduction); +} + +/** + * Check if the boss is currently in a vulnerable phase. + */ +export function isVulnerable(state: BossState, boss: BossData): boolean { + return boss.vulnerablePhases.includes(state.currentPhase); +} + +/** + * Update boss phase timer and cycle through phases. + * Returns events for phase changes, cycle completions, and attacks. + * + * @param state - Mutable boss state + * @param boss - Immutable boss data + * @param delta - Time elapsed in ms + * @returns Array of events that occurred this tick + */ +export function updateBossPhase( + state: BossState, + boss: BossData, + delta: number, +): BossPhaseEvent[] { + if (state.defeated) return []; + + const events: BossPhaseEvent[] = []; + + // Apply regeneration + const regen = getEffectiveRegen(state, boss); + if (regen > 0) { + state.health = Math.min(state.maxHealth, state.health + regen * (delta / 1000)); + } + + // Count down phase timer + state.phaseTimer -= delta; + + // Phase transition + while (state.phaseTimer <= 0) { + const nextPhase = ((state.currentPhase + 1) % BOSS_PHASE_COUNT) as BossPhase; + + // Cycle completion: when Digest ends (wrapping back to Coil) + if (state.currentPhase === BossPhase.Digest) { + state.cycleCount += 1; + events.push({ + type: 'cycle_complete', + phase: nextPhase, + cycleCount: state.cycleCount, + }); + } + + state.currentPhase = nextPhase; + const newDuration = getEffectivePhaseDuration(state, boss, nextPhase); + state.phaseTimer += newDuration; + + events.push({ + type: 'phase_change', + phase: nextPhase, + cycleCount: state.cycleCount, + }); + } + + // Boss attacks during Spray and Lash phases + if (state.currentPhase === BossPhase.Spray || state.currentPhase === BossPhase.Lash) { + events.push({ + type: 'boss_attack', + phase: state.currentPhase, + cycleCount: state.cycleCount, + }); + } + + return events; +} diff --git a/src/boss/arena.ts b/src/boss/arena.ts new file mode 100644 index 0000000..381e982 --- /dev/null +++ b/src/boss/arena.ts @@ -0,0 +1,136 @@ +/** + * Boss Arena Generator — creates a circular arena for Archon encounters + * + * The arena is a special room with: + * - Circular walkable area surrounded by crystal walls + * - Acid pools near the edges (hazards) + * - 4 mineral deposits at cardinal positions (for ammo) + * - Boss spawns at center, player enters from south + * + * Uses the same tile index system as the main world generator. + */ + +import type { BossData, ArenaData } from './types'; +import type { BiomeData } from '../world/types'; + +/** Tile indices matching biomes.json tile order */ +const TILE = { + GROUND: 0, + SCORCHED_EARTH: 1, + ACID_SHALLOW: 2, + ACID_DEEP: 3, + CRYSTAL_FORMATION: 4, + GEYSER: 5, + MINERAL_VEIN: 6, + BEDROCK: 7, +} as const; + +/** + * Generate a circular boss arena. + * + * @param boss - Boss data (determines arena size) + * @param biome - Biome data (for tile size) + * @returns Arena data with grid, spawn positions, and resource locations + */ +export function generateArena(boss: BossData, biome: BiomeData): ArenaData { + const radius = boss.arenaRadius; + const diameter = radius * 2 + 1; + const centerTile = radius; + const tileSize = biome.tileSize; + + // Initialize grid with bedrock (impassable) + const grid: number[][] = []; + for (let y = 0; y < diameter; y++) { + grid[y] = []; + for (let x = 0; x < diameter; x++) { + grid[y][x] = TILE.BEDROCK; + } + } + + const resourcePositions: { x: number; y: number }[] = []; + + // Carve circular arena + for (let y = 0; y < diameter; y++) { + for (let x = 0; x < diameter; x++) { + const dx = x - centerTile; + const dy = y - centerTile; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist <= radius - 1) { + // Inner arena: walkable ground + grid[y][x] = TILE.GROUND; + + // Acid pools ring at 60-75% of radius + if (dist > radius * 0.6 && dist <= radius * 0.75) { + // Place acid in a pattern (not solid ring — alternating) + const angle = Math.atan2(dy, dx); + const sector = Math.floor((angle + Math.PI) / (Math.PI / 4)); + if (sector % 2 === 0) { + grid[y][x] = TILE.ACID_SHALLOW; + } + } + + // Scorched earth ring at 75-90% of radius + if (dist > radius * 0.75 && dist <= radius * 0.9) { + grid[y][x] = TILE.SCORCHED_EARTH; + } + } else if (dist <= radius) { + // Crystal wall border + grid[y][x] = TILE.CRYSTAL_FORMATION; + } + } + } + + // Place 4 mineral deposits at cardinal positions (40% of radius from center) + const mineralDist = Math.round(radius * 0.4); + const cardinalOffsets = [ + { dx: 0, dy: -mineralDist }, // North + { dx: mineralDist, dy: 0 }, // East + { dx: 0, dy: mineralDist }, // South (near player spawn) + { dx: -mineralDist, dy: 0 }, // West + ]; + + for (const offset of cardinalOffsets) { + const mx = centerTile + offset.dx; + const my = centerTile + offset.dy; + if (mx >= 0 && mx < diameter && my >= 0 && my < diameter) { + grid[my][mx] = TILE.MINERAL_VEIN; + resourcePositions.push({ + x: mx * tileSize + tileSize / 2, + y: my * tileSize + tileSize / 2, + }); + } + } + + // Boss spawns at center + const bossSpawnX = centerTile * tileSize + tileSize / 2; + const bossSpawnY = centerTile * tileSize + tileSize / 2; + + // Player spawns at south edge (80% of radius from center) + const playerSpawnX = centerTile * tileSize + tileSize / 2; + const playerSpawnY = (centerTile + Math.round(radius * 0.8)) * tileSize + tileSize / 2; + + return { + grid, + width: diameter, + height: diameter, + bossSpawnX, + bossSpawnY, + playerSpawnX, + playerSpawnY, + resourcePositions, + }; +} + +/** + * Build a set of walkable tile indices for the arena. + * Matches the pattern used in the main world's collision system. + */ +export function buildArenaWalkableSet(): Set { + return new Set([ + TILE.GROUND, + TILE.SCORCHED_EARTH, + TILE.MINERAL_VEIN, + TILE.GEYSER, + ]); +} diff --git a/src/boss/factory.ts b/src/boss/factory.ts new file mode 100644 index 0000000..1f88894 --- /dev/null +++ b/src/boss/factory.ts @@ -0,0 +1,59 @@ +/** + * Boss Entity Factory — creates boss entities in the ECS world + * + * The boss gets: Position, Velocity, Health, SpriteRef, Boss components. + * Rich state (phase, cycle, stacks) is stored in BossState, not ECS. + */ + +import { addEntity, addComponent } from 'bitecs'; +import type { World } from '../ecs/world'; +import { + Position, + Velocity, + Health, + SpriteRef, + Boss, +} from '../ecs/components'; +import type { BossData } from './types'; + +/** + * Create a boss entity at the given position. + * + * @returns Entity ID + */ +export function createBossEntity( + world: World, + boss: BossData, + x: number, + y: number, +): number { + const eid = addEntity(world); + + // Position + addComponent(world, eid, Position); + Position.x[eid] = x; + Position.y[eid] = y; + + // Velocity (boss controls its own movement via AI) + addComponent(world, eid, Velocity); + Velocity.vx[eid] = 0; + Velocity.vy[eid] = 0; + + // Health + addComponent(world, eid, Health); + Health.current[eid] = boss.health; + Health.max[eid] = boss.health; + + // Visual + addComponent(world, eid, SpriteRef); + SpriteRef.color[eid] = parseInt(boss.color.replace('#', ''), 16); + SpriteRef.radius[eid] = boss.radius; + + // Boss tag + addComponent(world, eid, Boss); + Boss.dataIndex[eid] = 0; // Index in bosses.json array (only Ouroboros for now) + Boss.phase[eid] = 0; // BossPhase.Coil + Boss.cycleCount[eid] = 0; + + return eid; +} diff --git a/src/boss/index.ts b/src/boss/index.ts new file mode 100644 index 0000000..e4f9c2c --- /dev/null +++ b/src/boss/index.ts @@ -0,0 +1,19 @@ +/** + * Boss module — Archon encounters + */ + +export { BossPhase, VictoryMethod } from './types'; +export type { + BossData, + BossState, + BossDamageResult, + BossPhaseEvent, + ArenaData, + BossReward, +} from './types'; + +export { createBossState, updateBossPhase, getEffectiveArmor, getEffectiveRegen, isVulnerable, getEffectivePhaseDuration } from './ai'; +export { applyBossDamage, isBossDefeated } from './victory'; +export { generateArena, buildArenaWalkableSet } from './arena'; +export { calculateBossReward, applyBossReward } from './reward'; +export { createBossEntity } from './factory'; diff --git a/src/boss/reward.ts b/src/boss/reward.ts new file mode 100644 index 0000000..099380f --- /dev/null +++ b/src/boss/reward.ts @@ -0,0 +1,86 @@ +/** + * Boss Reward System — Archont's Memory and spore rewards + * + * Defeating a boss grants: + * 1. Spores (currency for meta-progression) + * 2. Archont's Memory — a lore entry added to the Codex + * 3. Victory flavor based on the method used + */ + +import { VictoryMethod, type BossData, type BossState, type BossReward } from './types'; +import type { MetaState, CodexEntry } from '../run/types'; + +/** + * Calculate the reward for defeating a boss. + * + * @param state - Boss state at time of defeat + * @param boss - Boss configuration data + * @returns Reward with spores, lore, and flavor + */ +export function calculateBossReward( + state: BossState, + boss: BossData, +): BossReward { + if (!state.defeated || !state.victoryMethod) { + return { + spores: 0, + loreId: boss.id, + loreText: boss.loreEntry, + loreTextRu: boss.loreEntryRu, + victoryMethod: VictoryMethod.Direct, + }; + } + + // Base reward + bonus for elegant victory methods + let spores = boss.sporeReward; + + switch (state.victoryMethod) { + case VictoryMethod.Chemical: + // Chemical victory: 50% bonus (requires crafting NaOH + timing) + spores = Math.round(spores * 1.5); + break; + case VictoryMethod.Catalytic: + // Catalytic victory: 100% bonus (requires observation + Hg) + spores = Math.round(spores * 2.0); + break; + case VictoryMethod.Direct: + // Direct victory: base reward + break; + } + + return { + spores, + loreId: boss.id, + loreText: boss.loreEntry, + loreTextRu: boss.loreEntryRu, + victoryMethod: state.victoryMethod, + }; +} + +/** + * Apply boss defeat to meta state: add codex entry and spores. + * + * @param meta - Mutable meta state + * @param reward - Boss reward to apply + * @param runId - Current run ID + */ +export function applyBossReward( + meta: MetaState, + reward: BossReward, + runId: number, +): void { + // Add spores + meta.spores += reward.spores; + + // Add boss lore to codex (skip if already exists) + const key = `boss:${reward.loreId}`; + const exists = meta.codex.some(e => `${e.type}:${e.id}` === key); + if (!exists) { + const entry: CodexEntry = { + id: reward.loreId, + type: 'boss' as CodexEntry['type'], + discoveredOnRun: runId, + }; + meta.codex.push(entry); + } +} diff --git a/src/boss/types.ts b/src/boss/types.ts new file mode 100644 index 0000000..808a117 --- /dev/null +++ b/src/boss/types.ts @@ -0,0 +1,202 @@ +/** + * Boss Fight Types — Archon encounters + * + * Ouroboros: The Archon of Cycles — a serpent consuming its own tail. + * The fight is a cyclical pattern-recognition puzzle with 3 victory paths: + * 1. Chemical — NaOH neutralization during acid phase (real acid-base chemistry) + * 2. Direct — projectile damage during vulnerability window + * 3. Catalytic — Hg catalyst poisoning (real: mercury poisons catalytic sites) + */ + +// ─── Boss Phases ───────────────────────────────────────────────── + +/** Cyclical boss attack phases (repeat with escalation) */ +export enum BossPhase { + /** Arena constricts — boss coils, safe zone shrinks */ + Coil = 0, + /** Acid projectile spray — rotating pattern */ + Spray = 1, + /** Tail sweep — sector damage arcs */ + Lash = 2, + /** Self-consumption pause — vulnerability window */ + Digest = 3, +} + +export const BOSS_PHASE_COUNT = 4; + +export const BOSS_PHASE_NAMES: Record = { + [BossPhase.Coil]: 'Coil', + [BossPhase.Spray]: 'Acid Spray', + [BossPhase.Lash]: 'Tail Lash', + [BossPhase.Digest]: 'Digest', +}; + +export const BOSS_PHASE_NAMES_RU: Record = { + [BossPhase.Coil]: 'Сжатие', + [BossPhase.Spray]: 'Кислотный Залп', + [BossPhase.Lash]: 'Удар Хвостом', + [BossPhase.Digest]: 'Переваривание', +}; + +// ─── Victory Methods ───────────────────────────────────────────── + +/** How the boss was defeated (determines reward flavor) */ +export enum VictoryMethod { + /** NaOH acid-base neutralization during Spray phase */ + Chemical = 'chemical', + /** Brute force projectile damage during Digest */ + Direct = 'direct', + /** Hg catalyst poisoning — disrupts self-catalysis */ + Catalytic = 'catalytic', +} + +// ─── Boss Data (from JSON) ────────────────────────────────────── + +/** Boss configuration loaded from bosses.json */ +export interface BossData { + id: string; + name: string; + nameRu: string; + description: string; + descriptionRu: string; + + /** Hex color string for rendering */ + color: string; + /** Base radius in pixels */ + radius: number; + + /** Maximum health points */ + health: number; + /** Damage reduction 0–1 during non-vulnerable phases */ + armor: number; + /** Damage reduction 0–1 during vulnerable phases */ + armorVulnerable: number; + /** HP regeneration per second (self-catalysis) */ + regenPerSecond: number; + /** Base attack damage to player */ + damage: number; + + /** Phase durations in ms: [Coil, Spray, Lash, Digest] */ + phaseDurations: [number, number, number, number]; + /** Duration multiplier per completed cycle (< 1 = faster) */ + phaseSpeedupPerCycle: number; + /** Difficulty caps after this many cycles */ + maxCycles: number; + + /** BossPhase values where boss takes full damage */ + vulnerablePhases: number[]; + /** Compound ID that deals bonus chemical damage */ + chemicalWeakness: string; + /** Damage multiplier for chemical weakness */ + chemicalDamageMultiplier: number; + /** Phases where chemical weakness applies (e.g. Spray for NaOH) */ + chemicalEffectivePhases: number[]; + /** Element symbol that acts as catalyst poison */ + catalystPoison: string; + /** Regen reduction per catalyst stack (HP/s) */ + catalystRegenReduction: number; + /** Armor reduction per catalyst stack (absolute) */ + catalystArmorReduction: number; + /** Maximum catalyst stacks */ + maxCatalystStacks: number; + + /** Arena radius in tiles */ + arenaRadius: number; + + /** Lore text revealed on victory (English) */ + loreEntry: string; + /** Lore text revealed on victory (Russian) */ + loreEntryRu: string; + + /** Spore reward for defeating the boss */ + sporeReward: number; +} + +// ─── Runtime Boss State ────────────────────────────────────────── + +/** Runtime state of the boss fight (stored in scene, not ECS) */ +export interface BossState { + /** Boss data ID */ + bossId: string; + /** Current health */ + health: number; + /** Maximum health */ + maxHealth: number; + /** Current attack phase */ + currentPhase: BossPhase; + /** Time remaining in current phase (ms) */ + phaseTimer: number; + /** Number of completed full cycles (Coil→Spray→Lash→Digest) */ + cycleCount: number; + /** Number of catalyst poison (Hg) applications */ + catalystStacks: number; + /** Whether the boss has been defeated */ + defeated: boolean; + /** How the boss was defeated (null if not yet) */ + victoryMethod: VictoryMethod | null; + /** Running totals by damage type */ + totalDamageDealt: number; + chemicalDamageDealt: number; + directDamageDealt: number; + catalystDamageDealt: number; +} + +// ─── Damage Result ────────────────────────────────────────────── + +/** Result of applying damage to the boss */ +export interface BossDamageResult { + /** Actual damage dealt after armor */ + damageDealt: number; + /** Type of damage */ + damageType: VictoryMethod; + /** Whether this was a killing blow */ + killingBlow: boolean; + /** Whether a catalyst stack was applied */ + catalystApplied: boolean; + /** Feedback message for player */ + message: string; + messageRu: string; +} + +// ─── Boss Phase Event ─────────────────────────────────────────── + +/** Events emitted during boss phase updates */ +export interface BossPhaseEvent { + type: 'phase_change' | 'cycle_complete' | 'boss_attack'; + phase: BossPhase; + cycleCount: number; +} + +// ─── Arena Data ───────────────────────────────────────────────── + +/** Generated arena tile layout */ +export interface ArenaData { + /** Tile grid (same format as WorldData.grid) */ + grid: number[][]; + /** Arena width in tiles */ + width: number; + /** Arena height in tiles */ + height: number; + /** Boss spawn position in pixels */ + bossSpawnX: number; + bossSpawnY: number; + /** Player spawn position in pixels */ + playerSpawnX: number; + playerSpawnY: number; + /** Resource deposit positions in pixels */ + resourcePositions: { x: number; y: number }[]; +} + +// ─── Boss Reward ──────────────────────────────────────────────── + +/** Reward for defeating the boss */ +export interface BossReward { + /** Spores earned */ + spores: number; + /** Lore text for Codex */ + loreId: string; + loreText: string; + loreTextRu: string; + /** Victory method determines flavor */ + victoryMethod: VictoryMethod; +} diff --git a/src/boss/victory.ts b/src/boss/victory.ts new file mode 100644 index 0000000..8a0ca0e --- /dev/null +++ b/src/boss/victory.ts @@ -0,0 +1,178 @@ +/** + * Boss Victory Conditions — 3 paths to defeating an Archon + * + * 1. Chemical: NaOH during Spray phase (acid-base neutralization) + * 2. Direct: Any projectile during Digest vulnerability window + * 3. Catalytic: Hg poison stacks (disrupts self-catalysis) + * + * Real science behind each method: + * - NaOH + HCl → NaCl + H₂O (neutralization of Ouroboros acid) + * - Mercury poisons catalytic sites (real catalysis chemistry) + * - Direct damage exploits the brief self-consumption pause + */ + +import { VictoryMethod, type BossData, type BossState, type BossDamageResult } from './types'; +import { getEffectiveArmor, isVulnerable } from './ai'; + +/** Base damage from a projectile hit */ +const PROJECTILE_BASE_DAMAGE = 15; + +/** Damage dealt by catalyst poison application */ +const CATALYST_DAMAGE = 10; + +/** + * Apply damage to the boss from a projectile hit. + * Determines damage type based on the item used and current boss phase. + * + * @param state - Mutable boss state + * @param boss - Immutable boss data + * @param itemId - The element/compound symbol used (e.g. "NaOH", "Hg", "Fe") + * @returns Damage result with feedback + */ +export function applyBossDamage( + state: BossState, + boss: BossData, + itemId: string, +): BossDamageResult { + if (state.defeated) { + return { + damageDealt: 0, + damageType: VictoryMethod.Direct, + killingBlow: false, + catalystApplied: false, + message: 'Boss already defeated', + messageRu: 'Босс уже побеждён', + }; + } + + const armor = getEffectiveArmor(state, boss); + + // Path 3: Catalyst poison (Hg) + if (itemId === boss.catalystPoison) { + return applyCatalystDamage(state, boss, armor); + } + + // Path 1: Chemical weakness (NaOH during Spray) + if ( + itemId === boss.chemicalWeakness && + boss.chemicalEffectivePhases.includes(state.currentPhase) + ) { + return applyChemicalDamage(state, boss, armor); + } + + // Path 2: Direct damage (any projectile during vulnerable phase) + if (isVulnerable(state, boss)) { + return applyDirectDamage(state, boss, armor); + } + + // Non-vulnerable phase, non-special item → heavily reduced damage + const reducedDamage = Math.max(1, Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor))); + return dealDamage(state, reducedDamage, VictoryMethod.Direct, false, { + message: `Ouroboros absorbs the blow (${reducedDamage} dmg)`, + messageRu: `Уроборос поглощает удар (${reducedDamage} урона)`, + }); +} + +/** Apply chemical damage (NaOH neutralization) */ +function applyChemicalDamage( + state: BossState, + boss: BossData, + armor: number, +): BossDamageResult { + const rawDamage = PROJECTILE_BASE_DAMAGE * boss.chemicalDamageMultiplier; + const damage = Math.max(1, Math.round(rawDamage * (1 - armor))); + + return dealDamage(state, damage, VictoryMethod.Chemical, false, { + message: `Acid neutralized! NaOH + acid → salt + water (${damage} dmg)`, + messageRu: `Кислота нейтрализована! NaOH + кислота → соль + вода (${damage} урона)`, + }); +} + +/** Apply direct projectile damage during vulnerability */ +function applyDirectDamage( + state: BossState, + boss: BossData, + armor: number, +): BossDamageResult { + const damage = Math.max(1, Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor))); + + return dealDamage(state, damage, VictoryMethod.Direct, false, { + message: `Hit during digestion! (${damage} dmg)`, + messageRu: `Попадание во время переваривания! (${damage} урона)`, + }); +} + +/** Apply catalyst poison (Hg) */ +function applyCatalystDamage( + state: BossState, + boss: BossData, + armor: number, +): BossDamageResult { + let catalystApplied = false; + let message: string; + let messageRu: string; + + if (state.catalystStacks < boss.maxCatalystStacks) { + state.catalystStacks += 1; + catalystApplied = true; + message = `Mercury poisons catalytic site! Stack ${state.catalystStacks}/${boss.maxCatalystStacks} — regen and armor permanently reduced`; + messageRu = `Ртуть отравляет каталитический центр! Стак ${state.catalystStacks}/${boss.maxCatalystStacks} — регенерация и броня снижены навсегда`; + } else { + message = `Catalyst fully poisoned (${CATALYST_DAMAGE} dmg)`; + messageRu = `Катализатор полностью отравлен (${CATALYST_DAMAGE} урона)`; + } + + const damage = Math.max(1, Math.round(CATALYST_DAMAGE * (1 - armor))); + + return dealDamage(state, damage, VictoryMethod.Catalytic, catalystApplied, { + message, + messageRu, + }); +} + +/** Common damage application logic */ +function dealDamage( + state: BossState, + damage: number, + damageType: VictoryMethod, + catalystApplied: boolean, + messages: { message: string; messageRu: string }, +): BossDamageResult { + state.health = Math.max(0, state.health - damage); + state.totalDamageDealt += damage; + + // Track damage by type + switch (damageType) { + case VictoryMethod.Chemical: + state.chemicalDamageDealt += damage; + break; + case VictoryMethod.Direct: + state.directDamageDealt += damage; + break; + case VictoryMethod.Catalytic: + state.catalystDamageDealt += damage; + break; + } + + // Check for killing blow + const killingBlow = state.health <= 0; + if (killingBlow) { + state.defeated = true; + state.victoryMethod = damageType; + } + + return { + damageDealt: damage, + damageType, + killingBlow, + catalystApplied, + ...messages, + }; +} + +/** + * Check if the boss is defeated. + */ +export function isBossDefeated(state: BossState): boolean { + return state.defeated; +} diff --git a/src/config.ts b/src/config.ts index 9d49005..8b8a3aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,7 @@ import { GameScene } from './scenes/GameScene'; import { UIScene } from './scenes/UIScene'; import { DeathScene } from './scenes/DeathScene'; import { FractalScene } from './scenes/FractalScene'; +import { BossArenaScene } from './scenes/BossArenaScene'; export const GAME_WIDTH = 1280; export const GAME_HEIGHT = 720; @@ -15,7 +16,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = { height: GAME_HEIGHT, backgroundColor: '#0a0a0a', parent: document.body, - scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene], + scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene, BossArenaScene], physics: { default: 'arcade', arcade: { diff --git a/src/data/bosses.json b/src/data/bosses.json new file mode 100644 index 0000000..57fe428 --- /dev/null +++ b/src/data/bosses.json @@ -0,0 +1,31 @@ +[ + { + "id": "ouroboros", + "name": "Ouroboros", + "nameRu": "Уроборос", + "description": "The Archon of Cycles — a serpent consuming its own tail in endless self-catalysis. Its body is both catalyst and substrate.", + "descriptionRu": "Архонт Циклов — змей, пожирающий собственный хвост в бесконечном самокатализе. Его тело — и катализатор, и субстрат.", + "color": "#cc44ff", + "radius": 20, + "health": 300, + "armor": 0.5, + "armorVulnerable": 0.1, + "regenPerSecond": 5, + "damage": 15, + "phaseDurations": [5000, 8000, 6000, 4000], + "phaseSpeedupPerCycle": 0.9, + "maxCycles": 5, + "vulnerablePhases": [3], + "chemicalWeakness": "NaOH", + "chemicalDamageMultiplier": 3.0, + "chemicalEffectivePhases": [1], + "catalystPoison": "Hg", + "catalystRegenReduction": 2.0, + "catalystArmorReduction": 0.1, + "maxCatalystStacks": 3, + "arenaRadius": 10, + "loreEntry": "The first Archon awakens. The Ouroboros — an eternal cycle of self-consumption. Its body is both catalyst and substrate, endlessly breaking itself down and rebuilding. To break its cycle is to understand catalysis itself: that which accelerates change without being consumed... until a poison finds the active site.", + "loreEntryRu": "Первый Архонт пробуждается. Уроборос — вечный цикл самопоглощения. Его тело — одновременно катализатор и субстрат, бесконечно разрушающее и восстанавливающее себя. Разорвать его цикл — значит постичь суть катализа: то, что ускоряет изменение, не расходуясь... пока яд не найдёт активный центр.", + "sporeReward": 100 + } +] diff --git a/src/ecs/components.ts b/src/ecs/components.ts index d776af2..e572997 100644 --- a/src/ecs/components.ts +++ b/src/ecs/components.ts @@ -84,6 +84,15 @@ export const LifeCycle = { age: [] as number[], // total age in ms }; +// ─── Boss Components ───────────────────────────────────────────── + +/** Boss entity tag + phase info for bridge rendering */ +export const Boss = { + dataIndex: [] as number[], // index in bosses.json array + phase: [] as number[], // BossPhase enum value (synced from BossState) + cycleCount: [] as number[], // completed cycles (synced from BossState) +}; + // ─── Mycelium Components ───────────────────────────────────────── /** Fungal node — a point where the Mycelium surfaces on the map */ diff --git a/src/run/types.ts b/src/run/types.ts index daf978e..132b64f 100644 --- a/src/run/types.ts +++ b/src/run/types.ts @@ -110,7 +110,7 @@ export interface RunDiscoveries { export interface CodexEntry { id: string; - type: 'element' | 'reaction' | 'compound' | 'creature'; + type: 'element' | 'reaction' | 'compound' | 'creature' | 'boss'; discoveredOnRun: number; } diff --git a/src/scenes/BossArenaScene.ts b/src/scenes/BossArenaScene.ts new file mode 100644 index 0000000..5358e07 --- /dev/null +++ b/src/scenes/BossArenaScene.ts @@ -0,0 +1,761 @@ +/** + * BossArenaScene — Archon boss fight in a circular arena + * + * Features: + * - Circular arena tilemap with hazards + * - Boss entity with cyclical phase AI + * - 3 victory paths (chemical, direct, catalytic) + * - Boss health bar and phase indicator + * - Reward on victory → transition to CradleScene + * - Death → normal DeathScene flow + */ + +import Phaser from 'phaser'; +import { createGameWorld, updateTime, type GameWorld } from '../ecs/world'; +import { Health, Position, Boss as BossComponent, Velocity } from '../ecs/components'; +import { movementSystem } from '../ecs/systems/movement'; +import { healthSystem } from '../ecs/systems/health'; +import { removeGameEntity } from '../ecs/factory'; +import { PhaserBridge } from '../ecs/bridge'; +import biomeDataArray from '../data/biomes.json'; +import bossDataArray from '../data/bosses.json'; +import type { BiomeData } from '../world/types'; +import { createWorldTilemap } from '../world/tilemap'; +import { buildWalkableSet } from '../player/collision'; +import { createPlayerEntity } from '../player/factory'; +import { Inventory } from '../player/inventory'; +import { + launchProjectile, + projectileSystem, + type ProjectileData, +} from '../player/projectile'; +import { playerInputSystem } from '../player/input'; +import { tileCollisionSystem } from '../player/collision'; +import { QuickSlots } from '../player/quickslots'; +import type { InputState } from '../player/types'; + +// Boss imports +import type { BossData, BossState, BossPhaseEvent } from '../boss/types'; +import { BossPhase, BOSS_PHASE_NAMES_RU } from '../boss/types'; +import { createBossState, updateBossPhase, getEffectiveArmor, isVulnerable } from '../boss/ai'; +import { applyBossDamage, isBossDefeated } from '../boss/victory'; +import { generateArena, buildArenaWalkableSet } from '../boss/arena'; +import { createBossEntity } from '../boss/factory'; +import { calculateBossReward, applyBossReward } from '../boss/reward'; + +// Run cycle imports +import type { MetaState, RunState } from '../run/types'; +import { query } from 'bitecs'; +import { Projectile } from '../ecs/components'; + +/** Data passed from GameScene to BossArenaScene */ +interface BossArenaInitData { + meta: MetaState; + runState: RunState; + inventoryItems: { id: string; count: number }[]; + quickSlotItems: (string | null)[]; + activeSlot: number; + playerHealth: number; + playerMaxHealth: number; +} + +export class BossArenaScene extends Phaser.Scene { + private gameWorld!: GameWorld; + private bridge!: PhaserBridge; + + // Player + private playerEid!: number; + private inventory!: Inventory; + private quickSlots!: QuickSlots; + private walkableSet!: Set; + private worldGrid!: number[][]; + private tileSize!: number; + private projectileData!: Map; + private keys!: { + W: Phaser.Input.Keyboard.Key; + A: Phaser.Input.Keyboard.Key; + S: Phaser.Input.Keyboard.Key; + D: Phaser.Input.Keyboard.Key; + E: Phaser.Input.Keyboard.Key; + F: Phaser.Input.Keyboard.Key; + ONE: Phaser.Input.Keyboard.Key; + TWO: Phaser.Input.Keyboard.Key; + THREE: Phaser.Input.Keyboard.Key; + FOUR: Phaser.Input.Keyboard.Key; + }; + + // Boss + private bossEid!: number; + private bossData!: BossData; + private bossState!: BossState; + private bossProjectiles: number[] = []; + private bossAttackTimer = 0; + + // Run state + private meta!: MetaState; + private runState!: RunState; + private playerDead = false; + private victoryAchieved = false; + + // UI elements + private bossHealthBar!: Phaser.GameObjects.Graphics; + private bossHealthText!: Phaser.GameObjects.Text; + private phaseText!: Phaser.GameObjects.Text; + private feedbackText!: Phaser.GameObjects.Text; + private feedbackTimer = 0; + private bossGlowGraphics!: Phaser.GameObjects.Graphics; + + // Input debounce + private wasFDown = false; + + constructor() { + super({ key: 'BossArenaScene' }); + } + + init(data: BossArenaInitData): void { + this.meta = data.meta; + this.runState = data.runState; + this.playerDead = false; + this.victoryAchieved = false; + this.bossProjectiles = []; + this.bossAttackTimer = 0; + + // Restore inventory + this.inventory = new Inventory(500, 20); + for (const item of data.inventoryItems) { + for (let i = 0; i < item.count; i++) { + this.inventory.addItem(item.id); + } + } + + // Restore quick slots + this.quickSlots = new QuickSlots(); + for (let i = 0; i < data.quickSlotItems.length; i++) { + this.quickSlots.assign(i, data.quickSlotItems[i]); + } + this.quickSlots.setActive(data.activeSlot); + } + + create(): void { + const biome = biomeDataArray[0] as BiomeData; + this.bossData = bossDataArray[0] as BossData; + this.tileSize = biome.tileSize; + + // 1. Initialize ECS + this.gameWorld = createGameWorld(); + this.bridge = new PhaserBridge(this); + this.projectileData = new Map(); + + // 2. Generate arena + const arena = generateArena(this.bossData, biome); + this.worldGrid = arena.grid; + this.walkableSet = buildArenaWalkableSet(); + + // 3. Create tilemap (reuse world tilemap system) + const arenaWorldData = { + grid: arena.grid, + biome: biome, + seed: 0, + }; + createWorldTilemap(this, arenaWorldData); + + // 4. Create player entity at arena entrance + this.playerEid = createPlayerEntity( + this.gameWorld.world, arena.playerSpawnX, arena.playerSpawnY, + ); + + // Apply player health from GameScene + const initData = this.scene.settings.data as BossArenaInitData; + if (initData?.playerHealth !== undefined) { + Health.current[this.playerEid] = initData.playerHealth; + Health.max[this.playerEid] = initData.playerMaxHealth; + } + + // 5. Create boss entity at center + this.bossEid = createBossEntity( + this.gameWorld.world, this.bossData, arena.bossSpawnX, arena.bossSpawnY, + ); + this.bossState = createBossState(this.bossData); + + // 6. Camera setup + const worldPixelW = arena.width * this.tileSize; + const worldPixelH = arena.height * this.tileSize; + this.cameras.main.setBounds(0, 0, worldPixelW, worldPixelH); + this.cameras.main.setZoom(2.0); // Closer zoom for arena + + // Sync bridge to create sprites, then follow player + this.bridge.sync(this.gameWorld.world); + const playerSprite = this.bridge.getSprite(this.playerEid); + if (playerSprite) { + playerSprite.setDepth(10); + this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1); + } + + // 7. Keyboard input + const keyboard = this.input.keyboard; + if (!keyboard) throw new Error('Keyboard plugin not available'); + this.keys = { + W: keyboard.addKey('W'), + A: keyboard.addKey('A'), + S: keyboard.addKey('S'), + D: keyboard.addKey('D'), + E: keyboard.addKey('E'), + F: keyboard.addKey('F'), + ONE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE), + TWO: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO), + THREE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE), + FOUR: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR), + }; + + // 8. Boss glow graphics (world-space) + this.bossGlowGraphics = this.add.graphics(); + this.bossGlowGraphics.setDepth(5); + + // 9. UI elements (screen-space) + this.createBossUI(); + + // 10. Launch UIScene for player HUD + this.scene.launch('UIScene'); + + // Entry announcement + this.showFeedback('⚔ УРОБОРОС — Архонт Циклов'); + } + + update(_time: number, delta: number): void { + if (this.playerDead || this.victoryAchieved) return; + + // 1. Update world time + updateTime(this.gameWorld, delta); + + // 2. Player input + const input: InputState = { + moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0), + moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0), + interact: this.keys.E.isDown, + }; + playerInputSystem(this.gameWorld.world, input); + + // 3. Movement + movementSystem(this.gameWorld.world, delta); + + // 4. Tile collision (player) + tileCollisionSystem( + this.gameWorld.world, delta, + this.worldGrid, this.tileSize, this.walkableSet, + ); + + // 5. Projectile system + projectileSystem( + this.gameWorld.world, delta, + this.worldGrid, this.tileSize, this.walkableSet, + this.projectileData, + ); + + // 6. Quick slots + if (this.keys.ONE.isDown) this.quickSlots.setActive(0); + if (this.keys.TWO.isDown) this.quickSlots.setActive(1); + if (this.keys.THREE.isDown) this.quickSlots.setActive(2); + if (this.keys.FOUR.isDown) this.quickSlots.setActive(3); + + // 7. Throw projectile (F key) + const isFDown = this.keys.F.isDown; + const justPressedF = isFDown && !this.wasFDown; + this.wasFDown = isFDown; + if (justPressedF) { + this.tryLaunchProjectile(); + } + + // 8. Boss AI phase update + const bossEvents = updateBossPhase(this.bossState, this.bossData, delta); + this.handleBossEvents(bossEvents, delta); + + // Sync boss ECS component with runtime state + BossComponent.phase[this.bossEid] = this.bossState.currentPhase; + BossComponent.cycleCount[this.bossEid] = this.bossState.cycleCount; + + // 9. Boss attack behavior + this.updateBossAttacks(delta); + + // 10. Check projectile → boss collision + this.checkProjectileBossCollision(); + + // 11. Health system + const dead = healthSystem(this.gameWorld.world); + let playerDied = false; + for (const eid of dead) { + if (eid === this.playerEid) { + playerDied = true; + continue; + } + if (eid === this.bossEid) { + continue; // Boss death handled by victory system + } + removeGameEntity(this.gameWorld.world, eid); + } + + if (playerDied) { + this.onPlayerDeath(); + return; + } + + // 12. Check victory + if (isBossDefeated(this.bossState)) { + this.onBossDefeated(); + return; + } + + // 13. Render sync + this.bridge.sync(this.gameWorld.world); + + // 14. Update boss visuals + this.updateBossVisuals(delta); + + // 15. Update UI + this.updateBossUI(); + + // 16. Feedback text fade + if (this.feedbackTimer > 0) { + this.feedbackTimer -= delta; + if (this.feedbackTimer <= 500) { + this.feedbackText.setAlpha(this.feedbackTimer / 500); + } + if (this.feedbackTimer <= 0) { + this.feedbackText.setAlpha(0); + } + } + + // 17. Push state to registry for UIScene + this.registry.set('health', Health.current[this.playerEid] ?? 100); + this.registry.set('healthMax', Health.max[this.playerEid] ?? 100); + this.registry.set('quickSlots', this.quickSlots.getAll()); + this.registry.set('activeSlot', this.quickSlots.activeIndex); + this.registry.set('invWeight', this.inventory.getTotalWeight()); + this.registry.set('invMaxWeight', this.inventory.maxWeight); + this.registry.set('invSlots', this.inventory.slotCount); + const counts = new Map(); + for (const item of this.inventory.getItems()) { + counts.set(item.id, item.count); + } + this.registry.set('invCounts', counts); + } + + // ─── Boss Attack Logic ───────────────────────────────────────── + + private handleBossEvents(events: BossPhaseEvent[], _delta: number): void { + for (const event of events) { + if (event.type === 'phase_change') { + const phaseName = BOSS_PHASE_NAMES_RU[event.phase]; + this.showFeedback(`Фаза: ${phaseName}`); + } + if (event.type === 'cycle_complete') { + this.showFeedback(`Цикл ${event.cycleCount} завершён — Уроборос ускоряется!`); + } + } + } + + private updateBossAttacks(delta: number): void { + const phase = this.bossState.currentPhase; + const bx = Position.x[this.bossEid]; + const by = Position.y[this.bossEid]; + const px = Position.x[this.playerEid]; + const py = Position.y[this.playerEid]; + + switch (phase) { + case BossPhase.Coil: { + // Boss slowly moves toward player + const dx = px - bx; + const dy = py - by; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 30) { + const speed = 20 + this.bossState.cycleCount * 5; + Velocity.vx[this.bossEid] = (dx / dist) * speed; + Velocity.vy[this.bossEid] = (dy / dist) * speed; + } else { + Velocity.vx[this.bossEid] = 0; + Velocity.vy[this.bossEid] = 0; + // Close-range damage + this.bossAttackTimer -= delta; + if (this.bossAttackTimer <= 0) { + Health.current[this.playerEid] = Math.max( + 0, (Health.current[this.playerEid] ?? 0) - this.bossData.damage * 0.5, + ); + this.bossAttackTimer = 1500; + } + } + break; + } + + case BossPhase.Spray: { + // Boss stays still, shoots acid projectiles in rotating pattern + Velocity.vx[this.bossEid] = 0; + Velocity.vy[this.bossEid] = 0; + + this.bossAttackTimer -= delta; + if (this.bossAttackTimer <= 0) { + const sprayCount = 4 + this.bossState.cycleCount; + const baseAngle = (Date.now() / 1000) * 2; // Rotating + for (let i = 0; i < sprayCount; i++) { + const angle = baseAngle + (i * 2 * Math.PI / sprayCount); + const targetX = bx + Math.cos(angle) * 200; + const targetY = by + Math.sin(angle) * 200; + this.spawnBossProjectile(bx, by, targetX, targetY); + } + this.bossAttackTimer = 1500 - this.bossState.cycleCount * 150; + } + break; + } + + case BossPhase.Lash: { + // Boss sweeps tail — area damage if player is in range + angle + Velocity.vx[this.bossEid] = 0; + Velocity.vy[this.bossEid] = 0; + + this.bossAttackTimer -= delta; + if (this.bossAttackTimer <= 0) { + const dx = px - bx; + const dy = py - by; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 80 + this.bossState.cycleCount * 10) { + const damage = this.bossData.damage * (1 + this.bossState.cycleCount * 0.2); + Health.current[this.playerEid] = Math.max( + 0, (Health.current[this.playerEid] ?? 0) - damage, + ); + this.showFeedback(`Удар хвостом! (${Math.round(damage)} урона)`); + } + this.bossAttackTimer = 2000 - this.bossState.cycleCount * 200; + } + break; + } + + case BossPhase.Digest: { + // Boss is immobile and vulnerable + Velocity.vx[this.bossEid] = 0; + Velocity.vy[this.bossEid] = 0; + break; + } + } + } + + private spawnBossProjectile(fromX: number, fromY: number, toX: number, toY: number): void { + launchProjectile( + this.gameWorld.world, + this.projectileData, + fromX, fromY, + toX, toY, + '__boss_acid__', + ); + } + + // ─── Projectile → Boss Collision ─────────────────────────────── + + private checkProjectileBossCollision(): void { + const projEntities = query(this.gameWorld.world, [Position, Projectile]); + const bx = Position.x[this.bossEid]; + const by = Position.y[this.bossEid]; + const hitRadiusSq = (this.bossData.radius + 5) * (this.bossData.radius + 5); + + for (const projEid of projEntities) { + const projInfo = this.projectileData.get(projEid); + if (!projInfo || projInfo.itemId === '__boss_acid__') continue; // Skip boss projectiles + + const dx = Position.x[projEid] - bx; + const dy = Position.y[projEid] - by; + const distSq = dx * dx + dy * dy; + + if (distSq <= hitRadiusSq) { + // Hit the boss! + const result = applyBossDamage(this.bossState, this.bossData, projInfo.itemId); + + // Sync health to ECS + Health.current[this.bossEid] = this.bossState.health; + + // Remove projectile + removeGameEntity(this.gameWorld.world, projEid); + this.projectileData.delete(projEid); + + // Show feedback + this.showFeedback(result.messageRu); + } + } + + // Check boss projectile → player collision + const px = Position.x[this.playerEid]; + const py = Position.y[this.playerEid]; + const playerHitRadiusSq = 15 * 15; + + for (const projEid of projEntities) { + const projInfo = this.projectileData.get(projEid); + if (!projInfo || projInfo.itemId !== '__boss_acid__') continue; + + const dx = Position.x[projEid] - px; + const dy = Position.y[projEid] - py; + const distSq = dx * dx + dy * dy; + + if (distSq <= playerHitRadiusSq) { + // Boss projectile hits player + const damage = this.bossData.damage * 0.7; + Health.current[this.playerEid] = Math.max( + 0, (Health.current[this.playerEid] ?? 0) - damage, + ); + removeGameEntity(this.gameWorld.world, projEid); + this.projectileData.delete(projEid); + } + } + } + + // ─── Boss Visual Effects ─────────────────────────────────────── + + private updateBossVisuals(_delta: number): void { + const bx = Position.x[this.bossEid]; + const by = Position.y[this.bossEid]; + + this.bossGlowGraphics.clear(); + + // Phase-based glow + const phase = this.bossState.currentPhase; + let glowColor = 0xcc44ff; + let glowAlpha = 0.15; + let glowRadius = this.bossData.radius * 2; + + switch (phase) { + case BossPhase.Coil: + glowColor = 0xff4444; // Red — danger + glowAlpha = 0.2; + break; + case BossPhase.Spray: + glowColor = 0x88ff00; // Acid green + glowAlpha = 0.25; + glowRadius = this.bossData.radius * 2.5; + break; + case BossPhase.Lash: + glowColor = 0xffaa00; // Orange — warning + glowAlpha = 0.2; + break; + case BossPhase.Digest: + glowColor = 0x4488ff; // Blue — vulnerable + glowAlpha = 0.3; + glowRadius = this.bossData.radius * 1.5; + break; + } + + // Pulsing glow + const pulse = 0.7 + 0.3 * Math.sin(Date.now() / 300); + this.bossGlowGraphics.fillStyle(glowColor, glowAlpha * pulse); + this.bossGlowGraphics.fillCircle(bx, by, glowRadius); + + // Inner glow + this.bossGlowGraphics.fillStyle(glowColor, glowAlpha * pulse * 1.5); + this.bossGlowGraphics.fillCircle(bx, by, glowRadius * 0.5); + + // Catalyst poison visual: darkening stacks + if (this.bossState.catalystStacks > 0) { + const poisonAlpha = 0.1 * this.bossState.catalystStacks; + this.bossGlowGraphics.fillStyle(0x888888, poisonAlpha); + this.bossGlowGraphics.fillCircle(bx, by, this.bossData.radius * 1.2); + } + } + + // ─── UI ──────────────────────────────────────────────────────── + + private createBossUI(): void { + const cam = this.cameras.main; + + // Boss health bar background + this.bossHealthBar = this.add.graphics(); + this.bossHealthBar.setScrollFactor(0); + this.bossHealthBar.setDepth(100); + + // Boss name + health text + this.bossHealthText = this.add.text(cam.width / 2, 20, '', { + fontSize: '14px', + color: '#cc44ff', + fontFamily: 'monospace', + backgroundColor: '#000000cc', + padding: { x: 8, y: 4 }, + }); + this.bossHealthText.setScrollFactor(0); + this.bossHealthText.setOrigin(0.5, 0); + this.bossHealthText.setDepth(101); + + // Phase indicator + this.phaseText = this.add.text(cam.width / 2, 55, '', { + fontSize: '12px', + color: '#ffdd44', + fontFamily: 'monospace', + backgroundColor: '#000000aa', + padding: { x: 6, y: 2 }, + }); + this.phaseText.setScrollFactor(0); + this.phaseText.setOrigin(0.5, 0); + this.phaseText.setDepth(101); + + // Feedback text (center) + this.feedbackText = this.add.text(cam.width / 2, cam.height - 60, '', { + fontSize: '14px', + color: '#ffdd44', + fontFamily: 'monospace', + backgroundColor: '#000000cc', + padding: { x: 8, y: 4 }, + align: 'center', + }); + this.feedbackText.setScrollFactor(0); + this.feedbackText.setOrigin(0.5); + this.feedbackText.setDepth(101); + this.feedbackText.setAlpha(0); + } + + private updateBossUI(): void { + const cam = this.cameras.main; + const healthPct = this.bossState.health / this.bossState.maxHealth; + + // Health bar + const barWidth = 300; + const barHeight = 8; + const barX = (cam.width - barWidth) / 2; + const barY = 42; + + this.bossHealthBar.clear(); + // Background + this.bossHealthBar.fillStyle(0x333333, 0.8); + this.bossHealthBar.fillRect(barX, barY, barWidth, barHeight); + // Fill + const fillColor = healthPct > 0.5 ? 0xcc44ff : healthPct > 0.25 ? 0xff8800 : 0xff2222; + this.bossHealthBar.fillStyle(fillColor, 1); + this.bossHealthBar.fillRect(barX, barY, barWidth * healthPct, barHeight); + // Border + this.bossHealthBar.lineStyle(1, 0xffffff, 0.3); + this.bossHealthBar.strokeRect(barX, barY, barWidth, barHeight); + + // Health text + const healthStr = `УРОБОРОС — ${Math.ceil(this.bossState.health)}/${this.bossState.maxHealth}`; + this.bossHealthText.setText(healthStr); + + // Phase text + const phaseName = BOSS_PHASE_NAMES_RU[this.bossState.currentPhase]; + const cycleStr = this.bossState.cycleCount > 0 ? ` | Цикл ${this.bossState.cycleCount}` : ''; + const catalystStr = this.bossState.catalystStacks > 0 + ? ` | ☠ Яд: ${this.bossState.catalystStacks}/${this.bossData.maxCatalystStacks}` + : ''; + const vulnStr = isVulnerable(this.bossState, this.bossData) ? ' | ★ УЯЗВИМ' : ''; + this.phaseText.setText(`${phaseName}${vulnStr}${cycleStr}${catalystStr}`); + + // Phase color + switch (this.bossState.currentPhase) { + case BossPhase.Coil: + this.phaseText.setColor('#ff4444'); + break; + case BossPhase.Spray: + this.phaseText.setColor('#88ff00'); + break; + case BossPhase.Lash: + this.phaseText.setColor('#ffaa00'); + break; + case BossPhase.Digest: + this.phaseText.setColor('#4488ff'); + break; + } + } + + // ─── Victory & Death ─────────────────────────────────────────── + + private onBossDefeated(): void { + this.victoryAchieved = true; + + // Calculate reward + const reward = calculateBossReward(this.bossState, this.bossData); + applyBossReward(this.meta, reward, this.runState.runId); + + // Show victory message + const cam = this.cameras.main; + const victoryText = this.add.text(cam.width / 2, cam.height / 2, '', { + fontSize: '20px', + color: '#cc44ff', + fontFamily: 'monospace', + backgroundColor: '#000000ee', + padding: { x: 16, y: 12 }, + align: 'center', + wordWrap: { width: 500 }, + }); + victoryText.setScrollFactor(0); + victoryText.setOrigin(0.5); + victoryText.setDepth(200); + + const methodNames: Record = { + chemical: 'Алхимическая победа (NaOH)', + direct: 'Прямая победа', + catalytic: 'Каталитическая победа (Hg)', + }; + const method = this.bossState.victoryMethod ?? 'direct'; + const methodName = methodNames[method] ?? method; + + victoryText.setText( + `★ УРОБОРОС ПОВЕРЖЕН ★\n\n${methodName}\n+${reward.spores} спор\n\nАрхонтова Память добавлена в Кодекс`, + ); + + // Stop UIScene + this.scene.stop('UIScene'); + + // Transition after delay + this.time.delayedCall(4000, () => { + this.cameras.main.fadeOut(1500, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('DeathScene', { + meta: this.meta, + runState: this.runState, + }); + }); + }); + } + + private onPlayerDeath(): void { + this.playerDead = true; + this.runState.alive = false; + + this.scene.stop('UIScene'); + + this.cameras.main.fadeOut(2000, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('DeathScene', { + meta: this.meta, + runState: this.runState, + }); + }); + } + + // ─── Projectile Launch ───────────────────────────────────────── + + private tryLaunchProjectile(): void { + const itemId = this.quickSlots.getActive(); + if (!itemId || !this.inventory.hasItem(itemId)) return; + + const removed = this.inventory.removeItem(itemId, 1); + if (removed === 0) return; + + const pointer = this.input.activePointer; + const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y); + + launchProjectile( + this.gameWorld.world, + this.projectileData, + Position.x[this.playerEid], + Position.y[this.playerEid], + worldPoint.x, + worldPoint.y, + itemId, + ); + + // Clear quick slot if empty + if (!this.inventory.hasItem(itemId)) { + const slotIdx = this.quickSlots.getAll().indexOf(itemId); + if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null); + } + } + + // ─── Feedback ────────────────────────────────────────────────── + + private showFeedback(message: string): void { + this.feedbackText.setText(message); + this.feedbackText.setAlpha(1); + this.feedbackTimer = 2500; + } +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index e5e33c6..952edf9 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -740,8 +740,8 @@ export class GameScene extends Phaser.Scene { advancePhase(this.runState); // → Crisis this.triggerCrisis(); } else if (phase === RunPhase.Resolution) { - // Run complete — could transition to a victory scene - // For now, just keep playing + // Resolution phase complete → enter boss arena + this.enterBossArena(); } } @@ -842,6 +842,39 @@ export class GameScene extends Phaser.Scene { } } + /** Transition to the boss arena (triggered when Resolution phase ends) */ + private enterBossArena(): void { + if (this.playerDead) return; + + // Prevent re-entry + this.playerDead = true; // Reuse flag to stop updates + + // Auto-deposit discoveries before leaving + if (!this.hasDepositedThisRun) { + depositKnowledge(this.meta.mycelium, this.runState); + this.hasDepositedThisRun = true; + } + + this.showInteractionFeedback('collected', '⚔ Вход в арену Уробороса...'); + + // Fade out and transition + this.time.delayedCall(1500, () => { + this.scene.stop('UIScene'); + this.cameras.main.fadeOut(1000, 0, 0, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('BossArenaScene', { + meta: this.meta, + runState: this.runState, + inventoryItems: this.inventory.getItems(), + quickSlotItems: this.quickSlots.getAll(), + activeSlot: this.quickSlots.activeIndex, + playerHealth: Health.current[this.playerEid] ?? 100, + playerMaxHealth: Health.max[this.playerEid] ?? 100, + }); + }); + }); + } + private showInteractionFeedback(type: string, itemId?: string): void { let msg = ''; switch (type) { diff --git a/tests/boss.test.ts b/tests/boss.test.ts new file mode 100644 index 0000000..a701bef --- /dev/null +++ b/tests/boss.test.ts @@ -0,0 +1,758 @@ +/** + * Boss System Tests — Phase 8: First Archont (Ouroboros) + * + * Tests cover: + * - Boss state creation and initialization + * - Phase cycling (Coil → Spray → Lash → Digest → repeat) + * - Phase speedup per cycle + * - Damage mechanics (vulnerability windows, armor) + * - Chemical damage (NaOH during Spray) + * - Catalyst poison (Hg stacks, regen/armor reduction) + * - Victory detection and method determination + * - Arena generation (circular layout, features) + * - Reward calculation (spores, lore) + * - Boss entity factory (ECS) + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createWorld, addEntity, addComponent, query } from 'bitecs'; +import type { World } from '../src/ecs/world'; +import { Position, Health, SpriteRef, Boss } from '../src/ecs/components'; + +// Boss system imports +import { + createBossState, + updateBossPhase, + getEffectiveArmor, + getEffectiveRegen, + isVulnerable, + getEffectivePhaseDuration, +} from '../src/boss/ai'; +import { + applyBossDamage, + isBossDefeated, +} from '../src/boss/victory'; +import { + generateArena, + buildArenaWalkableSet, +} from '../src/boss/arena'; +import { + calculateBossReward, + applyBossReward, +} from '../src/boss/reward'; +import { createBossEntity } from '../src/boss/factory'; + +// Types +import { + BossPhase, + VictoryMethod, + type BossData, + type BossState, +} from '../src/boss/types'; +import type { BiomeData } from '../src/world/types'; +import { createMetaState } from '../src/run/meta'; + +// Load boss data +import bossDataArray from '../src/data/bosses.json'; +import biomeDataArray from '../src/data/biomes.json'; + +const ouroboros = bossDataArray[0] as BossData; +const biome = biomeDataArray[0] as BiomeData; + +// ─── Boss State Creation ───────────────────────────────────────── + +describe('Boss State Creation', () => { + it('creates initial boss state from data', () => { + const state = createBossState(ouroboros); + expect(state.bossId).toBe('ouroboros'); + expect(state.health).toBe(300); + expect(state.maxHealth).toBe(300); + expect(state.currentPhase).toBe(BossPhase.Coil); + expect(state.cycleCount).toBe(0); + expect(state.catalystStacks).toBe(0); + expect(state.defeated).toBe(false); + expect(state.victoryMethod).toBeNull(); + }); + + it('starts with phase timer set to first phase duration', () => { + const state = createBossState(ouroboros); + expect(state.phaseTimer).toBe(ouroboros.phaseDurations[0]); // 5000ms + }); + + it('starts with zero damage counters', () => { + const state = createBossState(ouroboros); + expect(state.totalDamageDealt).toBe(0); + expect(state.chemicalDamageDealt).toBe(0); + expect(state.directDamageDealt).toBe(0); + expect(state.catalystDamageDealt).toBe(0); + }); +}); + +// ─── Phase Cycling ─────────────────────────────────────────────── + +describe('Boss Phase Cycling', () => { + let state: BossState; + + beforeEach(() => { + state = createBossState(ouroboros); + }); + + it('stays in current phase while timer has not expired', () => { + updateBossPhase(state, ouroboros, 1000); + expect(state.currentPhase).toBe(BossPhase.Coil); + expect(state.phaseTimer).toBeGreaterThan(0); + }); + + it('transitions from Coil to Spray when timer expires', () => { + updateBossPhase(state, ouroboros, 5000); // Full Coil duration + expect(state.currentPhase).toBe(BossPhase.Spray); + }); + + it('transitions through full cycle: Coil → Spray → Lash → Digest', () => { + // Coil (5s) → Spray + updateBossPhase(state, ouroboros, 5000); + expect(state.currentPhase).toBe(BossPhase.Spray); + + // Spray (8s) → Lash + updateBossPhase(state, ouroboros, 8000); + expect(state.currentPhase).toBe(BossPhase.Lash); + + // Lash (6s) → Digest + updateBossPhase(state, ouroboros, 6000); + expect(state.currentPhase).toBe(BossPhase.Digest); + }); + + it('completes a full cycle and increments cycleCount', () => { + // Full cycle: 5000 + 8000 + 6000 + 4000 = 23000ms + const events = []; + events.push(...updateBossPhase(state, ouroboros, 5000)); // Coil → Spray + events.push(...updateBossPhase(state, ouroboros, 8000)); // Spray → Lash + events.push(...updateBossPhase(state, ouroboros, 6000)); // Lash → Digest + events.push(...updateBossPhase(state, ouroboros, 4000)); // Digest → Coil (cycle 1) + + expect(state.currentPhase).toBe(BossPhase.Coil); + expect(state.cycleCount).toBe(1); + + const cycleEvent = events.find(e => e.type === 'cycle_complete'); + expect(cycleEvent).toBeDefined(); + expect(cycleEvent?.cycleCount).toBe(1); + }); + + it('emits phase_change events', () => { + const events = updateBossPhase(state, ouroboros, 5000); // Coil → Spray + const phaseChange = events.find(e => e.type === 'phase_change'); + expect(phaseChange).toBeDefined(); + expect(phaseChange?.phase).toBe(BossPhase.Spray); + }); + + it('emits boss_attack during Spray phase', () => { + updateBossPhase(state, ouroboros, 5000); // → Spray + const events = updateBossPhase(state, ouroboros, 100); // Tick in Spray + const attack = events.find(e => e.type === 'boss_attack'); + expect(attack).toBeDefined(); + expect(attack?.phase).toBe(BossPhase.Spray); + }); + + it('emits boss_attack during Lash phase', () => { + updateBossPhase(state, ouroboros, 5000 + 8000); // → Lash + const events = updateBossPhase(state, ouroboros, 100); // Tick in Lash + const attack = events.find(e => e.type === 'boss_attack'); + expect(attack).toBeDefined(); + expect(attack?.phase).toBe(BossPhase.Lash); + }); + + it('does not emit boss_attack during Coil or Digest', () => { + // During Coil + const coilEvents = updateBossPhase(state, ouroboros, 100); + expect(coilEvents.filter(e => e.type === 'boss_attack')).toHaveLength(0); + + // Advance to Digest + updateBossPhase(state, ouroboros, 4900 + 8000 + 6000); // Coil → Spray → Lash → Digest + const digestEvents = updateBossPhase(state, ouroboros, 100); + expect(digestEvents.filter(e => e.type === 'boss_attack')).toHaveLength(0); + }); + + it('does not update when defeated', () => { + state.defeated = true; + const events = updateBossPhase(state, ouroboros, 10000); + expect(events).toHaveLength(0); + }); +}); + +// ─── Phase Speedup ─────────────────────────────────────────────── + +describe('Boss Phase Speedup', () => { + it('returns base duration for cycle 0', () => { + const duration = getEffectivePhaseDuration( + { cycleCount: 0 } as BossState, + ouroboros, + BossPhase.Coil, + ); + expect(duration).toBe(5000); + }); + + it('reduces duration by speedup factor per cycle', () => { + // Cycle 1: 5000 * 0.9 = 4500 + const duration = getEffectivePhaseDuration( + { cycleCount: 1 } as BossState, + ouroboros, + BossPhase.Coil, + ); + expect(duration).toBeCloseTo(4500); + }); + + it('caps speedup at maxCycles', () => { + // Cycle 5 (maxCycles): 5000 * 0.9^5 = 2952.45 + const duration5 = getEffectivePhaseDuration( + { cycleCount: 5 } as BossState, + ouroboros, + BossPhase.Coil, + ); + // Cycle 10 (should cap at 5): same + const duration10 = getEffectivePhaseDuration( + { cycleCount: 10 } as BossState, + ouroboros, + BossPhase.Coil, + ); + expect(duration10).toBeCloseTo(duration5); + }); + + it('applies speedup to all phases', () => { + const state = { cycleCount: 2 } as BossState; + const coil = getEffectivePhaseDuration(state, ouroboros, BossPhase.Coil); + const spray = getEffectivePhaseDuration(state, ouroboros, BossPhase.Spray); + const lash = getEffectivePhaseDuration(state, ouroboros, BossPhase.Lash); + const digest = getEffectivePhaseDuration(state, ouroboros, BossPhase.Digest); + + expect(coil).toBeCloseTo(5000 * 0.81); // 0.9^2 + expect(spray).toBeCloseTo(8000 * 0.81); + expect(lash).toBeCloseTo(6000 * 0.81); + expect(digest).toBeCloseTo(4000 * 0.81); + }); +}); + +// ─── Armor & Regeneration ──────────────────────────────────────── + +describe('Boss Armor and Regeneration', () => { + let state: BossState; + + beforeEach(() => { + state = createBossState(ouroboros); + }); + + it('has full armor during non-vulnerable phases', () => { + state.currentPhase = BossPhase.Coil; + expect(getEffectiveArmor(state, ouroboros)).toBe(0.5); + }); + + it('has reduced armor during vulnerable phases', () => { + state.currentPhase = BossPhase.Digest; + expect(getEffectiveArmor(state, ouroboros)).toBe(0.1); + }); + + it('has full regen rate with 0 catalyst stacks', () => { + expect(getEffectiveRegen(state, ouroboros)).toBe(5); + }); + + it('reduces regen per catalyst stack', () => { + state.catalystStacks = 1; + expect(getEffectiveRegen(state, ouroboros)).toBe(3); // 5 - 1*2 = 3 + }); + + it('regen does not go below 0', () => { + state.catalystStacks = 3; + expect(getEffectiveRegen(state, ouroboros)).toBe(0); // 5 - 3*2 = -1 → 0 + }); + + it('reduces armor per catalyst stack', () => { + state.catalystStacks = 2; + state.currentPhase = BossPhase.Coil; + expect(getEffectiveArmor(state, ouroboros)).toBe(0.3); // 0.5 - 2*0.1 + }); + + it('armor does not go below 0', () => { + state.catalystStacks = 10; // Way over max stacks + state.currentPhase = BossPhase.Digest; + expect(getEffectiveArmor(state, ouroboros)).toBe(0); // 0.1 - 10*0.1 → 0 + }); + + it('identifies vulnerable phases correctly', () => { + state.currentPhase = BossPhase.Digest; + expect(isVulnerable(state, ouroboros)).toBe(true); + + state.currentPhase = BossPhase.Coil; + expect(isVulnerable(state, ouroboros)).toBe(false); + + state.currentPhase = BossPhase.Spray; + expect(isVulnerable(state, ouroboros)).toBe(false); + + state.currentPhase = BossPhase.Lash; + expect(isVulnerable(state, ouroboros)).toBe(false); + }); + + it('applies regeneration over time', () => { + state.health = 250; // Damaged + updateBossPhase(state, ouroboros, 2000); // 2 seconds → 10 HP regen + expect(state.health).toBeCloseTo(260); + }); + + it('does not regenerate past max health', () => { + state.health = 298; + updateBossPhase(state, ouroboros, 2000); // Would regen 10, capped at 300 + expect(state.health).toBe(300); + }); + + it('regeneration is reduced by catalyst stacks', () => { + state.health = 250; + state.catalystStacks = 2; // regen = 5 - 2*2 = 1 HP/s + updateBossPhase(state, ouroboros, 2000); // 2 seconds → 2 HP regen + expect(state.health).toBeCloseTo(252); + }); +}); + +// ─── Damage: Direct (Victory Path 2) ──────────────────────────── + +describe('Boss Damage — Direct', () => { + let state: BossState; + + beforeEach(() => { + state = createBossState(ouroboros); + }); + + it('deals full damage during Digest (vulnerable) phase', () => { + state.currentPhase = BossPhase.Digest; + const result = applyBossDamage(state, ouroboros, 'Fe'); + // 15 * (1 - 0.1) = 13.5 → 14 rounded + expect(result.damageDealt).toBe(14); + expect(result.damageType).toBe(VictoryMethod.Direct); + }); + + it('deals reduced damage during non-vulnerable phases', () => { + state.currentPhase = BossPhase.Coil; + const result = applyBossDamage(state, ouroboros, 'Fe'); + // 15 * (1 - 0.5) = 7.5 → 8 rounded + expect(result.damageDealt).toBe(8); + }); + + it('tracks direct damage dealt', () => { + state.currentPhase = BossPhase.Digest; + applyBossDamage(state, ouroboros, 'Fe'); + expect(state.directDamageDealt).toBeGreaterThan(0); + expect(state.totalDamageDealt).toBeGreaterThan(0); + }); + + it('does not deal damage to defeated boss', () => { + state.defeated = true; + const result = applyBossDamage(state, ouroboros, 'Fe'); + expect(result.damageDealt).toBe(0); + }); +}); + +// ─── Damage: Chemical (Victory Path 1) ────────────────────────── + +describe('Boss Damage — Chemical (NaOH)', () => { + let state: BossState; + + beforeEach(() => { + state = createBossState(ouroboros); + }); + + it('deals multiplied damage with NaOH during Spray phase', () => { + state.currentPhase = BossPhase.Spray; + const result = applyBossDamage(state, ouroboros, 'NaOH'); + // 15 * 3.0 * (1 - 0.5) = 22.5 → 23 rounded + expect(result.damageDealt).toBe(23); + expect(result.damageType).toBe(VictoryMethod.Chemical); + }); + + it('treats NaOH as normal projectile outside effective phases', () => { + state.currentPhase = BossPhase.Coil; // Not a chemical-effective phase + const result = applyBossDamage(state, ouroboros, 'NaOH'); + // Normal reduced damage: 15 * (1 - 0.5) = 8 + expect(result.damageDealt).toBe(8); + expect(result.damageType).toBe(VictoryMethod.Direct); + }); + + it('tracks chemical damage separately', () => { + state.currentPhase = BossPhase.Spray; + applyBossDamage(state, ouroboros, 'NaOH'); + expect(state.chemicalDamageDealt).toBeGreaterThan(0); + expect(state.directDamageDealt).toBe(0); + }); +}); + +// ─── Damage: Catalytic Poison (Victory Path 3) ────────────────── + +describe('Boss Damage — Catalytic Poison (Hg)', () => { + let state: BossState; + + beforeEach(() => { + state = createBossState(ouroboros); + }); + + it('applies catalyst stack on first Hg hit', () => { + const result = applyBossDamage(state, ouroboros, 'Hg'); + expect(result.catalystApplied).toBe(true); + expect(state.catalystStacks).toBe(1); + expect(result.damageType).toBe(VictoryMethod.Catalytic); + }); + + it('caps at maxCatalystStacks', () => { + state.catalystStacks = 3; // Already at max + const result = applyBossDamage(state, ouroboros, 'Hg'); + expect(result.catalystApplied).toBe(false); + expect(state.catalystStacks).toBe(3); + }); + + it('deals damage on every Hg application', () => { + const result = applyBossDamage(state, ouroboros, 'Hg'); + expect(result.damageDealt).toBeGreaterThan(0); + }); + + it('tracks catalyst damage separately', () => { + applyBossDamage(state, ouroboros, 'Hg'); + expect(state.catalystDamageDealt).toBeGreaterThan(0); + expect(state.chemicalDamageDealt).toBe(0); + expect(state.directDamageDealt).toBe(0); + }); + + it('Hg works in any phase (not phase-dependent)', () => { + for (const phase of [BossPhase.Coil, BossPhase.Spray, BossPhase.Lash, BossPhase.Digest]) { + const s = createBossState(ouroboros); + s.currentPhase = phase; + const result = applyBossDamage(s, ouroboros, 'Hg'); + expect(result.catalystApplied).toBe(true); + expect(result.damageType).toBe(VictoryMethod.Catalytic); + } + }); +}); + +// ─── Victory Detection ─────────────────────────────────────────── + +describe('Boss Victory Detection', () => { + let state: BossState; + + beforeEach(() => { + state = createBossState(ouroboros); + }); + + it('detects killing blow', () => { + state.health = 5; + state.currentPhase = BossPhase.Digest; + const result = applyBossDamage(state, ouroboros, 'Fe'); + expect(result.killingBlow).toBe(true); + expect(state.defeated).toBe(true); + }); + + it('sets victoryMethod based on killing blow type', () => { + state.health = 5; + state.currentPhase = BossPhase.Spray; + applyBossDamage(state, ouroboros, 'NaOH'); + expect(state.victoryMethod).toBe(VictoryMethod.Chemical); + }); + + it('sets Catalytic victory on Hg killing blow', () => { + state.health = 3; + applyBossDamage(state, ouroboros, 'Hg'); + expect(state.victoryMethod).toBe(VictoryMethod.Catalytic); + }); + + it('isBossDefeated returns true when defeated', () => { + expect(isBossDefeated(state)).toBe(false); + state.health = 1; + state.currentPhase = BossPhase.Digest; + applyBossDamage(state, ouroboros, 'Fe'); + expect(isBossDefeated(state)).toBe(true); + }); + + it('health does not go below 0', () => { + state.health = 1; + state.currentPhase = BossPhase.Digest; + applyBossDamage(state, ouroboros, 'Fe'); + expect(state.health).toBe(0); + }); +}); + +// ─── Arena Generation ──────────────────────────────────────────── + +describe('Arena Generation', () => { + it('generates a grid of correct size', () => { + const arena = generateArena(ouroboros, biome); + const diameter = ouroboros.arenaRadius * 2 + 1; + expect(arena.width).toBe(diameter); + expect(arena.height).toBe(diameter); + expect(arena.grid.length).toBe(diameter); + expect(arena.grid[0].length).toBe(diameter); + }); + + it('has walkable ground in the center', () => { + const arena = generateArena(ouroboros, biome); + const center = ouroboros.arenaRadius; + // Center tile should be walkable ground (0) + expect(arena.grid[center][center]).toBe(0); + }); + + it('has crystal wall border', () => { + const arena = generateArena(ouroboros, biome); + const r = ouroboros.arenaRadius; + // Top edge should be bedrock (7) or crystal (4) + // Very top-left corner is outside circle → bedrock + expect(arena.grid[0][0]).toBe(7); + }); + + it('has boss spawn at center', () => { + const arena = generateArena(ouroboros, biome); + const center = ouroboros.arenaRadius; + const tileSize = biome.tileSize; + expect(arena.bossSpawnX).toBe(center * tileSize + tileSize / 2); + expect(arena.bossSpawnY).toBe(center * tileSize + tileSize / 2); + }); + + it('has player spawn south of center', () => { + const arena = generateArena(ouroboros, biome); + expect(arena.playerSpawnY).toBeGreaterThan(arena.bossSpawnY); + }); + + it('places 4 resource deposits', () => { + const arena = generateArena(ouroboros, biome); + expect(arena.resourcePositions).toHaveLength(4); + }); + + it('resource positions are within arena bounds', () => { + const arena = generateArena(ouroboros, biome); + const maxPixel = arena.width * biome.tileSize; + for (const pos of arena.resourcePositions) { + expect(pos.x).toBeGreaterThan(0); + expect(pos.x).toBeLessThan(maxPixel); + expect(pos.y).toBeGreaterThan(0); + expect(pos.y).toBeLessThan(maxPixel); + } + }); + + it('has acid tiles in the arena', () => { + const arena = generateArena(ouroboros, biome); + let acidCount = 0; + for (const row of arena.grid) { + for (const tile of row) { + if (tile === 2) acidCount++; // ACID_SHALLOW + } + } + expect(acidCount).toBeGreaterThan(0); + }); + + it('buildArenaWalkableSet includes expected tiles', () => { + const walkable = buildArenaWalkableSet(); + expect(walkable.has(0)).toBe(true); // GROUND + expect(walkable.has(1)).toBe(true); // SCORCHED_EARTH + expect(walkable.has(6)).toBe(true); // MINERAL_VEIN + expect(walkable.has(7)).toBe(false); // BEDROCK (not walkable) + expect(walkable.has(4)).toBe(false); // CRYSTAL (not walkable) + }); +}); + +// ─── Reward System ─────────────────────────────────────────────── + +describe('Boss Reward System', () => { + it('calculates base reward for direct victory', () => { + const state = createBossState(ouroboros); + state.defeated = true; + state.victoryMethod = VictoryMethod.Direct; + const reward = calculateBossReward(state, ouroboros); + expect(reward.spores).toBe(100); // Base + expect(reward.loreId).toBe('ouroboros'); + expect(reward.victoryMethod).toBe(VictoryMethod.Direct); + }); + + it('gives 50% bonus for chemical victory', () => { + const state = createBossState(ouroboros); + state.defeated = true; + state.victoryMethod = VictoryMethod.Chemical; + const reward = calculateBossReward(state, ouroboros); + expect(reward.spores).toBe(150); // 100 * 1.5 + }); + + it('gives 100% bonus for catalytic victory', () => { + const state = createBossState(ouroboros); + state.defeated = true; + state.victoryMethod = VictoryMethod.Catalytic; + const reward = calculateBossReward(state, ouroboros); + expect(reward.spores).toBe(200); // 100 * 2.0 + }); + + it('returns no spores if boss not defeated', () => { + const state = createBossState(ouroboros); + const reward = calculateBossReward(state, ouroboros); + expect(reward.spores).toBe(0); + }); + + it('includes lore text in reward', () => { + const state = createBossState(ouroboros); + state.defeated = true; + state.victoryMethod = VictoryMethod.Direct; + const reward = calculateBossReward(state, ouroboros); + expect(reward.loreText.length).toBeGreaterThan(0); + expect(reward.loreTextRu.length).toBeGreaterThan(0); + }); + + it('applyBossReward adds spores to meta', () => { + const meta = createMetaState(); + const initialSpores = meta.spores; + const state = createBossState(ouroboros); + state.defeated = true; + state.victoryMethod = VictoryMethod.Direct; + const reward = calculateBossReward(state, ouroboros); + applyBossReward(meta, reward, 1); + expect(meta.spores).toBe(initialSpores + reward.spores); + }); + + it('applyBossReward adds lore to codex', () => { + const meta = createMetaState(); + const state = createBossState(ouroboros); + state.defeated = true; + state.victoryMethod = VictoryMethod.Direct; + const reward = calculateBossReward(state, ouroboros); + applyBossReward(meta, reward, 1); + const bossEntry = meta.codex.find(e => e.id === 'ouroboros'); + expect(bossEntry).toBeDefined(); + expect(bossEntry?.type).toBe('boss'); + expect(bossEntry?.discoveredOnRun).toBe(1); + }); + + it('does not add duplicate codex entries', () => { + const meta = createMetaState(); + const state = createBossState(ouroboros); + state.defeated = true; + state.victoryMethod = VictoryMethod.Direct; + const reward = calculateBossReward(state, ouroboros); + applyBossReward(meta, reward, 1); + applyBossReward(meta, reward, 2); + const bossEntries = meta.codex.filter(e => e.id === 'ouroboros'); + expect(bossEntries).toHaveLength(1); + }); +}); + +// ─── Boss Entity Factory ───────────────────────────────────────── + +describe('Boss Entity Factory', () => { + let world: World; + + beforeEach(() => { + world = createWorld(); + }); + + it('creates an entity with Position component', () => { + const eid = createBossEntity(world, ouroboros, 400, 300); + expect(Position.x[eid]).toBe(400); + expect(Position.y[eid]).toBe(300); + }); + + it('creates an entity with Health component', () => { + const eid = createBossEntity(world, ouroboros, 400, 300); + expect(Health.current[eid]).toBe(300); + expect(Health.max[eid]).toBe(300); + }); + + it('creates an entity with SpriteRef component', () => { + const eid = createBossEntity(world, ouroboros, 400, 300); + expect(SpriteRef.color[eid]).toBe(0xcc44ff); + expect(SpriteRef.radius[eid]).toBe(20); + }); + + it('creates an entity with Boss component', () => { + const eid = createBossEntity(world, ouroboros, 400, 300); + expect(Boss.dataIndex[eid]).toBe(0); + expect(Boss.phase[eid]).toBe(0); // BossPhase.Coil + expect(Boss.cycleCount[eid]).toBe(0); + }); + + it('entity is queryable by Boss component', () => { + const eid = createBossEntity(world, ouroboros, 400, 300); + const bosses = query(world, [Boss]); + expect(bosses).toContain(eid); + }); +}); + +// ─── Integration: Full Boss Fight ──────────────────────────────── + +describe('Boss Fight Integration', () => { + it('can defeat boss with direct damage during Digest windows', () => { + const state = createBossState(ouroboros); + let totalCycles = 0; + + // Simulate multiple cycles of hitting during Digest + // In a 4s Digest window, player can throw ~10 projectiles + while (!state.defeated && totalCycles < 30) { + // Advance through Coil → Spray → Lash → Digest + updateBossPhase(state, ouroboros, 5000); // Coil (+ regen) + updateBossPhase(state, ouroboros, 8000); // Spray (+ regen) + updateBossPhase(state, ouroboros, 6000); // Lash (+ regen) + + // Now in Digest — rapid-fire attacks (10 per 4s window is realistic) + for (let i = 0; i < 10 && !state.defeated; i++) { + state.currentPhase = BossPhase.Digest; // Ensure in Digest + applyBossDamage(state, ouroboros, 'Fe'); + } + + updateBossPhase(state, ouroboros, 4000); // Complete Digest + totalCycles++; + } + + expect(state.defeated).toBe(true); + expect(state.victoryMethod).toBe(VictoryMethod.Direct); + // Direct path is slow but viable (boss regens 5 HP/s between windows) + expect(totalCycles).toBeLessThan(30); + }); + + it('can defeat boss faster with NaOH during Spray', () => { + const state = createBossState(ouroboros); + let hits = 0; + + // Advance to Spray phase and keep hitting with NaOH + state.currentPhase = BossPhase.Spray; + + while (!state.defeated && hits < 50) { + applyBossDamage(state, ouroboros, 'NaOH'); + hits++; + } + + expect(state.defeated).toBe(true); + expect(state.victoryMethod).toBe(VictoryMethod.Chemical); + expect(hits).toBeLessThan(20); // NaOH should be efficient + }); + + it('Hg makes boss progressively weaker', () => { + const state = createBossState(ouroboros); + + // Apply 3 Hg stacks + for (let i = 0; i < 3; i++) { + applyBossDamage(state, ouroboros, 'Hg'); + } + + expect(state.catalystStacks).toBe(3); + // Regen should be 0 (5 - 3*2 = -1 → 0) + expect(getEffectiveRegen(state, ouroboros)).toBe(0); + // Armor should be reduced: 0.5 - 3*0.1 = 0.2 + expect(getEffectiveArmor(state, ouroboros)).toBeCloseTo(0.2); + }); + + it('catalyst poison + direct damage is a viable strategy', () => { + const state = createBossState(ouroboros); + + // Apply 3 Hg stacks to maximize weakness + for (let i = 0; i < 3; i++) { + applyBossDamage(state, ouroboros, 'Hg'); + } + + // Now kill with direct damage during Digest + state.currentPhase = BossPhase.Digest; + let hits = 0; + while (!state.defeated && hits < 50) { + applyBossDamage(state, ouroboros, 'Fe'); + hits++; + } + + expect(state.defeated).toBe(true); + // Victory method should be Direct (killing blow was Fe) + expect(state.victoryMethod).toBe(VictoryMethod.Direct); + }); +});