phase 8: Ouroboros boss fight — pattern puzzle with 3 victory paths
First Archon encounter: a cyclical pattern-recognition puzzle. Boss AI: 4-phase cycle (Coil → Spray → Lash → Digest) with escalating difficulty (10% faster per cycle, caps at 5 cycles). Victory paths (all based on real chemistry): - Chemical: NaOH during Spray phase (acid-base neutralization, 3x dmg) - Direct: any projectile during Digest vulnerability window - Catalytic: Hg poison stacks (mercury poisons catalytic sites, reduces regen+armor permanently) New files: src/boss/ (types, ai, victory, arena, factory, reward), src/data/bosses.json, src/scenes/BossArenaScene.ts, tests/boss.test.ts Extended: ECS Boss component, CodexEntry 'boss' type, GameScene triggers arena on Resolution phase completion. 70 new tests (455 total), all passing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
758
tests/boss.test.ts
Normal file
758
tests/boss.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user