Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions client/src/hooks/useCombatEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { clamp, rand, pick, fmt } from "@/lib/game/gameUtils";
import { playerPower, spiritUpkeep, calcBindingChance, gainExpGoldFromGate } from "@/lib/game/gameLogic";
import { RANKS, DUNGEON_MODIFIERS, generateGatePool, rollDrop } from "@/lib/game/gateSystem";
import { processBossMechanics, getBossPhase } from "@/lib/game/bossMechanics";
import { updateStatsAfterCombat, type GameStats, type CombatOutcome } from "@/lib/game/statsTracker";
import { PLAYER_ATTACK_MSGS, BOSS_ATTACK_MSGS, BOSS_BLOCK_MSGS, CRIT_MSGS, SPIRIT_ABILITY_MSGS, BOSS_PHASE_MSGS, BOSS_DIALOGUE, FIRST_CLEAR_TEXT } from "@/lib/game/constants";
import type { Player, Gate, RunningState, CombatResult, Boss } from "@/lib/game/types";
Expand Down Expand Up @@ -150,15 +151,39 @@ export function useCombatEngine({
bossDmgMult = result.bossDmgMult;
}

// Player attack - increased base damage (with spirit bonuses and dungeon modifiers)
// Apply boss mechanics
const previousPhase = boss.phase ?? 0;
const tempBoss = { ...boss, hp: hpEnemy };
const mechResult = processBossMechanics(tempBoss, tick, previousPhase);
playerDmgMult *= mechResult.playerDmgMult;
bossDmgMult *= mechResult.bossDmgMult;
const effectiveBossDef = boss.def * mechResult.bossDefMult;

// Apply boss healing from mechanics (e.g., Troll regeneration)
if (mechResult.bossHeal > 0) {
hpEnemy = clamp(hpEnemy + mechResult.bossHeal, 0, boss.maxHp);
}

// Update boss phase and mechanic state
const newPhase = getBossPhase({ ...boss, hp: hpEnemy });
if (newPhase !== previousPhase || tempBoss.mechanicState !== boss.mechanicState) {
boss = { ...boss, phase: newPhase, mechanicState: tempBoss.mechanicState };
}

// Add mechanic messages to combat log
if (mechResult.messages.length > 0) {
setCombatLog((log) => [...log, ...mechResult.messages].slice(-12));
}

// Player attack - increased base damage (with spirit bonuses, dungeon modifiers, and boss mechanics)
const dmgPlayer = Math.max(
1,
Math.floor(pPower * 1.2 * playerDmgMult - boss.def * 0.3 + rand(0, 6))
Math.floor(pPower * 1.2 * playerDmgMult - effectiveBossDef * 0.3 + rand(0, 6))
);
const oldHpEnemy = hpEnemy;
hpEnemy = clamp(hpEnemy - dmgPlayer, 0, boss.maxHp);

// Boss attack - slightly reduced boss damage (with spirit block chance and dungeon modifiers)
// Boss attack - with spirit block chance, dungeon modifiers, and boss mechanics
const blocked = spiritBlockChance > 0 && Math.random() < spiritBlockChance;
const dmgBoss = blocked ? 0 : Math.max(
0,
Expand Down
162 changes: 162 additions & 0 deletions client/src/lib/game/bossMechanics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
initBossMechanics,
getBossPhase,
processBossMechanics,
getBossMechanicsSummary,
} from "./bossMechanics";
import type { Boss } from "./types";

function makeBoss(hp: number, maxHp: number, overrides?: Partial<Boss>): Boss {
return { name: "Test Boss", hp, maxHp, hp, atk: 50, def: 20, ...overrides };
}

describe("bossMechanics", () => {
afterEach(() => vi.restoreAllMocks());

describe("initBossMechanics", () => {
it("adds mechanics for each rank", () => {
for (const rank of ["E", "D", "C", "B", "A", "S"]) {
const boss = initBossMechanics(makeBoss(100, 100), rank);
expect(boss.mechanics).toBeDefined();
expect(boss.mechanics!.length).toBeGreaterThan(0);
expect(boss.phase).toBe(0);
expect(boss.mechanicState).toBeDefined();
}
});

it("E-rank has 1 mechanic (frenzy)", () => {
const boss = initBossMechanics(makeBoss(100, 100), "E");
expect(boss.mechanics).toHaveLength(1);
expect(boss.mechanics![0].id).toBe("frenzy");
});

it("S-rank has 4 mechanics", () => {
const boss = initBossMechanics(makeBoss(100, 100), "S");
expect(boss.mechanics).toHaveLength(4);
});

it("marks all mechanics as not activated", () => {
const boss = initBossMechanics(makeBoss(100, 100), "D");
boss.mechanics!.forEach(m => expect(m.activated).toBe(false));
});
});

describe("getBossPhase", () => {
it("returns phase 0 above 75% HP", () => {
expect(getBossPhase(makeBoss(80, 100))).toBe(0);
expect(getBossPhase(makeBoss(100, 100))).toBe(0);
});

it("returns phase 1 between 50-75% HP", () => {
expect(getBossPhase(makeBoss(75, 100))).toBe(1);
expect(getBossPhase(makeBoss(60, 100))).toBe(1);
});

it("returns phase 2 between 25-50% HP", () => {
expect(getBossPhase(makeBoss(50, 100))).toBe(2);
expect(getBossPhase(makeBoss(30, 100))).toBe(2);
});

it("returns phase 3 below 25% HP", () => {
expect(getBossPhase(makeBoss(25, 100))).toBe(3);
expect(getBossPhase(makeBoss(1, 100))).toBe(3);
});
});

describe("processBossMechanics", () => {
it("returns neutral result for boss with no mechanics", () => {
const boss = makeBoss(100, 100);
const result = processBossMechanics(boss, 1, 0);
expect(result.bossDmgMult).toBe(1.0);
expect(result.playerDmgMult).toBe(1.0);
expect(result.bossDefMult).toBe(1.0);
expect(result.bossHeal).toBe(0);
expect(result.messages).toEqual([]);
});

it("E-rank frenzy triggers below 40% HP", () => {
const boss = initBossMechanics(makeBoss(30, 100), "E");
const result = processBossMechanics(boss, 1, 0);
expect(result.bossDmgMult).toBeGreaterThan(1.0);
expect(result.messages.length).toBeGreaterThan(0);
});

it("E-rank frenzy does not trigger above 40% HP", () => {
const boss = initBossMechanics(makeBoss(50, 100), "E");
const result = processBossMechanics(boss, 1, 0);
expect(result.bossDmgMult).toBe(1.0);
});

it("D-rank bloodlust triggers on tick intervals", () => {
const boss = initBossMechanics(makeBoss(100, 100), "D");
const result = processBossMechanics(boss, 4, 0);
expect(result.bossDmgMult).toBeGreaterThan(1.0);
});

it("D-rank berserk triggers below 25% HP", () => {
const boss = initBossMechanics(makeBoss(20, 100), "D");
const result = processBossMechanics(boss, 1, 0);
expect(result.bossDmgMult).toBe(2.0);
});

it("C-rank shadow cloak can cause player miss", () => {
vi.spyOn(Math, "random").mockReturnValue(0.1); // 10% < 30% threshold
const boss = initBossMechanics(makeBoss(100, 100), "C");
const result = processBossMechanics(boss, 1, 0);
expect(result.playerDmgMult).toBe(0);
});

it("B-rank stone skin increases defense", () => {
const boss = initBossMechanics(makeBoss(100, 100), "B");
const result = processBossMechanics(boss, 1, 0);
expect(result.bossDefMult).toBeGreaterThan(1.0);
});

it("B-rank regeneration heals below 25% HP", () => {
const boss = initBossMechanics(makeBoss(20, 100), "B");
const result = processBossMechanics(boss, 1, 0);
expect(result.bossHeal).toBeGreaterThan(0);
});

it("B-rank regeneration only heals 3 times", () => {
const boss = initBossMechanics(makeBoss(20, 100), "B");
for (let i = 0; i < 3; i++) {
processBossMechanics(boss, i + 1, 3);
}
const result4 = processBossMechanics(boss, 4, 3);
expect(result4.bossHeal).toBe(0);
});

it("A-rank dragonfire triggers on phase change", () => {
const boss = initBossMechanics(makeBoss(74, 100), "A");
const result = processBossMechanics(boss, 1, 0); // phase changed 0→1
expect(result.bossDmgMult).toBeGreaterThan(1.0);
expect(result.messages.length).toBeGreaterThan(0);
});

it("S-rank corruption stacks up to 5", () => {
const boss = initBossMechanics(makeBoss(100, 100), "S");
// Corruption triggers every 3 ticks
for (let i = 1; i <= 18; i++) {
if (i % 3 === 0) {
processBossMechanics(boss, i, 0);
}
}
expect(boss.mechanicState!.corruptionStacks).toBeLessThanOrEqual(5);
});
});

describe("getBossMechanicsSummary", () => {
it("returns mechanic descriptions", () => {
const boss = initBossMechanics(makeBoss(100, 100), "A");
const summary = getBossMechanicsSummary(boss);
expect(summary.length).toBe(3);
expect(summary[0]).toContain("Dragonfire");
});

it("returns empty for boss without mechanics", () => {
expect(getBossMechanicsSummary(makeBoss(100, 100))).toEqual([]);
});
});
});
Loading
Loading