From c7f1728590edacd00d912e08eba5dc791eb966ff Mon Sep 17 00:00:00 2001 From: JesseRWeigel Date: Mon, 16 Mar 2026 23:51:05 -0400 Subject: [PATCH] feat: add boss mechanics with rank-specific abilities and phases Each boss rank now has unique combat mechanics: - E (Goblin): Frenzied Strikes when wounded (+30% damage) - D (Orc): Bloodlust stacking damage, Berserk at 25% HP (2x damage) - C (Dark Elf): Shadow Cloak dodge (30% miss), Backstab double hit - B (Troll): Stone Skin defense, Rock Throw burst, Regeneration at 25% - A (Dragon Knight): Dragonfire on phase change, Dragon Scales, Sword Dance - S (Void Lord): Void Rift weakening, Reality Tear, Corruption stacks, Desperation invulnerability Mechanics integrate into combat tick loop via processBossMechanics() which returns damage/defense multipliers and combat log messages. Bosses now have 4 phases (100-75%, 75-50%, 50-25%, <25%) that trigger phase-based abilities. 21 new tests for bossMechanics.ts. 236 total tests passing. Closes #13 Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/hooks/useCombatEngine.ts | 31 ++- client/src/lib/game/bossMechanics.test.ts | 162 ++++++++++++++ client/src/lib/game/bossMechanics.ts | 248 ++++++++++++++++++++++ client/src/lib/game/gateSystem.ts | 4 +- client/src/lib/game/types.ts | 12 ++ 5 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 client/src/lib/game/bossMechanics.test.ts create mode 100644 client/src/lib/game/bossMechanics.ts diff --git a/client/src/hooks/useCombatEngine.ts b/client/src/hooks/useCombatEngine.ts index e42ba0b..af08879 100644 --- a/client/src/hooks/useCombatEngine.ts +++ b/client/src/hooks/useCombatEngine.ts @@ -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"; @@ -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, diff --git a/client/src/lib/game/bossMechanics.test.ts b/client/src/lib/game/bossMechanics.test.ts new file mode 100644 index 0000000..c3b2af3 --- /dev/null +++ b/client/src/lib/game/bossMechanics.test.ts @@ -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 { + 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([]); + }); + }); +}); diff --git a/client/src/lib/game/bossMechanics.ts b/client/src/lib/game/bossMechanics.ts new file mode 100644 index 0000000..e407d49 --- /dev/null +++ b/client/src/lib/game/bossMechanics.ts @@ -0,0 +1,248 @@ +import type { Boss, BossMechanic } from "./types"; + +/** + * Boss mechanics definitions per rank. + * Each mechanic modifies combat via damage/defense multipliers and messages. + */ + +export interface MechanicResult { + bossDmgMult: number; + playerDmgMult: number; + bossDefMult: number; + bossHeal: number; + messages: string[]; +} + +// Rank-specific boss mechanics +const RANK_MECHANICS: Record = { + E: [ + { id: "frenzy", name: "Frenzied Strikes", description: "Attacks faster when wounded", trigger: "hp_threshold", triggerValue: 40 }, + ], + D: [ + { id: "bloodlust", name: "Bloodlust", description: "Gains strength as the fight continues", trigger: "every_n_ticks", triggerValue: 4 }, + { id: "berserk", name: "Berserk", description: "Doubles attack power when near death", trigger: "hp_threshold", triggerValue: 25 }, + ], + C: [ + { id: "shadow_cloak", name: "Shadow Cloak", description: "Chance to dodge incoming attacks", trigger: "random_chance", triggerValue: 30 }, + { id: "backstab", name: "Backstab", description: "Strikes twice every few turns", trigger: "every_n_ticks", triggerValue: 4 }, + ], + B: [ + { id: "stone_skin", name: "Stone Skin", description: "Reduces all incoming damage", trigger: "every_n_ticks", triggerValue: 1 }, + { id: "rock_throw", name: "Rock Throw", description: "Hurls a boulder for massive damage", trigger: "every_n_ticks", triggerValue: 5 }, + { id: "regeneration", name: "Regeneration", description: "Heals when critically wounded", trigger: "hp_threshold", triggerValue: 25 }, + ], + A: [ + { id: "dragonfire", name: "Dragonfire Breath", description: "Unleashes fire at each phase", trigger: "phase", triggerValue: 1 }, + { id: "dragon_scales", name: "Dragon Scales", description: "Defense increases each phase", trigger: "phase", triggerValue: 1 }, + { id: "sword_dance", name: "Sword Dance", description: "Attacks twice periodically", trigger: "every_n_ticks", triggerValue: 5 }, + ], + S: [ + { id: "void_rift", name: "Void Rift", description: "Weakens the hunter's attacks", trigger: "random_chance", triggerValue: 25 }, + { id: "reality_tear", name: "Reality Tear", description: "Devastating phase transition attack", trigger: "phase", triggerValue: 1 }, + { id: "corruption", name: "Corruption", description: "Stacking debuff that weakens the hunter", trigger: "every_n_ticks", triggerValue: 3 }, + { id: "desperation", name: "Desperation", description: "Becomes invulnerable briefly before a final strike", trigger: "hp_threshold", triggerValue: 10 }, + ], +}; + +// Flavor messages for mechanic activations +const MECHANIC_MESSAGES: Record = { + frenzy: ["The Goblin's eyes glow red — frenzied strikes!", "Wounded and desperate, it attacks wildly!"], + bloodlust: ["The Orc roars with bloodlust — growing stronger!", "Blood fury courses through its veins!"], + berserk: ["The Orc enters a berserker rage! Attack power doubled!", "BERSERK MODE — the Orc's muscles bulge with fury!"], + shadow_cloak: ["The Dark Elf vanishes into shadows — attack missed!", "Your blade cuts through an afterimage!"], + backstab: ["The Dark Elf strikes from the shadows — double hit!", "A flash of blades — two strikes in rapid succession!"], + stone_skin: ["The Troll's rocky hide absorbs some damage.", "Your weapon scrapes against stone skin."], + rock_throw: ["The Troll hurls a massive boulder!", "A thrown rock slams into you with tremendous force!"], + regeneration: ["The Troll's wounds begin to close — regenerating!", "Green energy pulses as the Troll heals!"], + dragonfire: ["The Dragon Knight unleashes dragonfire breath!", "Flames engulf the battlefield!"], + dragon_scales: ["Dragon scales harden — defense increased!", "Shimmering scales deflect your attacks!"], + sword_dance: ["The Dragon Knight executes a devastating sword dance!", "A flurry of strikes — two attacks in one!"], + void_rift: ["Reality warps — your attacks weaken!", "The Void distorts space around you!"], + reality_tear: ["The Void Lord tears reality apart — devastating!", "Space itself shatters around you!"], + corruption: ["Dark energy seeps into you — corruption spreading!", "The Void's corruption weakens your resolve!"], + desperation: ["The Void Lord becomes intangible — invulnerable!", "You cannot touch what exists beyond reality!"], +}; + +/** + * Initialize boss with rank-appropriate mechanics. + */ +export function initBossMechanics(boss: Boss, rank: string): Boss { + const mechanics = (RANK_MECHANICS[rank] || []).map(m => ({ ...m, activated: false })); + return { + ...boss, + mechanics, + phase: 0, + mechanicState: { corruptionStacks: 0, berserkTicks: 0, desperationTicks: 0, regenTicks: 0 }, + }; +} + +/** + * Get the current boss phase based on HP percentage. + * Phase 0: 100-75%, Phase 1: 75-50%, Phase 2: 50-25%, Phase 3: <25% + */ +export function getBossPhase(boss: Boss): number { + const pct = (boss.hp / boss.maxHp) * 100; + if (pct > 75) return 0; + if (pct > 50) return 1; + if (pct > 25) return 2; + return 3; +} + +function pickMsg(id: string): string { + const msgs = MECHANIC_MESSAGES[id]; + if (!msgs || msgs.length === 0) return ""; + return msgs[Math.floor(Math.random() * msgs.length)]; +} + +/** + * Process boss mechanics for a single combat tick. + * Returns multiplier adjustments and combat log messages. + */ +export function processBossMechanics( + boss: Boss, + tick: number, + previousPhase: number, +): MechanicResult { + const result: MechanicResult = { + bossDmgMult: 1.0, + playerDmgMult: 1.0, + bossDefMult: 1.0, + bossHeal: 0, + messages: [], + }; + + if (!boss.mechanics || boss.mechanics.length === 0) return result; + + const hpPct = (boss.hp / boss.maxHp) * 100; + const currentPhase = getBossPhase(boss); + const phaseChanged = currentPhase > previousPhase; + const state = boss.mechanicState || {}; + + for (const mechanic of boss.mechanics) { + let triggered = false; + + switch (mechanic.trigger) { + case "hp_threshold": + triggered = hpPct <= mechanic.triggerValue; + break; + case "every_n_ticks": + triggered = tick > 0 && tick % mechanic.triggerValue === 0; + break; + case "random_chance": + triggered = Math.random() * 100 < mechanic.triggerValue; + break; + case "phase": + triggered = phaseChanged; + break; + } + + if (!triggered) continue; + + // Apply mechanic effects + switch (mechanic.id) { + // E-Rank: Goblin Warrior + case "frenzy": + result.bossDmgMult *= 1.3; + if (!mechanic.activated) { + result.messages.push(pickMsg("frenzy")); + mechanic.activated = true; + } + break; + + // D-Rank: Orc Berserker + case "bloodlust": + result.bossDmgMult *= 1.1; + result.messages.push(pickMsg("bloodlust")); + break; + case "berserk": + result.bossDmgMult *= 2.0; + if (!mechanic.activated) { + result.messages.push(pickMsg("berserk")); + mechanic.activated = true; + } + break; + + // C-Rank: Dark Elf Assassin + case "shadow_cloak": + result.playerDmgMult *= 0; // dodge — player's attack misses + result.messages.push(pickMsg("shadow_cloak")); + break; + case "backstab": + result.bossDmgMult *= 2.0; + result.messages.push(pickMsg("backstab")); + break; + + // B-Rank: Troll Chieftain + case "stone_skin": + result.bossDefMult *= 1.15; + // Only message occasionally + if (tick % 5 === 0) result.messages.push(pickMsg("stone_skin")); + break; + case "rock_throw": + result.bossDmgMult *= 1.8; + result.messages.push(pickMsg("rock_throw")); + break; + case "regeneration": { + const regenTicks = (state.regenTicks || 0); + if (regenTicks < 3) { + result.bossHeal = Math.floor(boss.maxHp * 0.05); + state.regenTicks = regenTicks + 1; + result.messages.push(pickMsg("regeneration")); + } + break; + } + + // A-Rank: Dragon Knight + case "dragonfire": + result.bossDmgMult *= 1.5; + result.messages.push(pickMsg("dragonfire")); + break; + case "dragon_scales": + result.bossDefMult *= 1.2; + result.messages.push(pickMsg("dragon_scales")); + break; + case "sword_dance": + result.bossDmgMult *= 2.0; + result.messages.push(pickMsg("sword_dance")); + break; + + // S-Rank: Void Lord + case "void_rift": + result.playerDmgMult *= 0.6; + result.messages.push(pickMsg("void_rift")); + break; + case "reality_tear": + result.bossDmgMult *= 2.5; + result.messages.push(pickMsg("reality_tear")); + break; + case "corruption": { + const stacks = Math.min((state.corruptionStacks || 0) + 1, 5); + state.corruptionStacks = stacks; + result.playerDmgMult *= (1 - stacks * 0.08); + result.messages.push(pickMsg("corruption")); + break; + } + case "desperation": { + const despTicks = state.desperationTicks || 0; + if (despTicks < 2) { + result.playerDmgMult *= 0; // invulnerable + state.desperationTicks = despTicks + 1; + result.messages.push(pickMsg("desperation")); + } else if (despTicks === 2) { + result.bossDmgMult *= 3.0; // massive strike after invuln + state.desperationTicks = despTicks + 1; + } + break; + } + } + } + + return result; +} + +/** + * Get a summary of boss mechanics for UI display. + */ +export function getBossMechanicsSummary(boss: Boss): string[] { + return (boss.mechanics || []).map(m => `${m.name}: ${m.description}`); +} diff --git a/client/src/lib/game/gateSystem.ts b/client/src/lib/game/gateSystem.ts index 12e43ee..40751fc 100644 --- a/client/src/lib/game/gateSystem.ts +++ b/client/src/lib/game/gateSystem.ts @@ -1,4 +1,5 @@ import { rand, uid } from "@/lib/game/gameUtils"; +import { initBossMechanics } from "@/lib/game/bossMechanics"; export const RANKS = ["E", "D", "C", "B", "A", "S"] as const; export const RANK_COLORS = { E: "bg-green-600", D: "bg-blue-600", C: "bg-purple-600", B: "bg-red-600", A: "bg-orange-600", S: "bg-yellow-600" }; @@ -31,7 +32,8 @@ const GATE_NAMES: Record = { export function gatePowerForRank(rankIdx: number) { return Math.round(Math.pow(1.7, rankIdx) * 30 + rankIdx * 20); } export function makeBoss(rankIdx: number): Boss { const base = gatePowerForRank(rankIdx); const rank = RANKS[rankIdx]; const hp = Math.floor(base * 8 + rand(-25, 25)); - return { name: MONSTER_DATA[rank].name, maxHp: hp, hp, atk: Math.floor(base * 0.8 + rand(-5, 5)), def: Math.floor(base * 0.3 + rand(-3, 3)) }; + const boss: Boss = { name: MONSTER_DATA[rank].name, maxHp: hp, hp, atk: Math.floor(base * 0.8 + rand(-5, 5)), def: Math.floor(base * 0.3 + rand(-3, 3)) }; + return initBossMechanics(boss, rank); } export function makeGate(rankIdx: number, usedNames?: Set): Gate { const rank = RANKS[rankIdx]; const namePool = GATE_NAMES[rank] ?? GATE_NAMES.E; const unused = namePool.filter((n) => !usedNames?.has(n)); const source = unused.length ? unused : namePool; diff --git a/client/src/lib/game/types.ts b/client/src/lib/game/types.ts index 8c453f4..f906e5d 100644 --- a/client/src/lib/game/types.ts +++ b/client/src/lib/game/types.ts @@ -1,11 +1,23 @@ // Types and interfaces extracted from HuntersPath.tsx +export interface BossMechanic { + id: string; + name: string; + description: string; + trigger: "phase" | "hp_threshold" | "every_n_ticks" | "random_chance"; + triggerValue: number; + activated?: boolean; +} + export interface Boss { name: string; maxHp: number; hp: number; atk: number; def: number; + mechanics?: BossMechanic[]; + phase?: number; + mechanicState?: Record; } export interface DungeonModifier {