/** * @file Boss challenge route handling combat mechanics. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Boss handler requires many steps */ /* eslint-disable max-statements -- Boss handler requires many statements */ /* eslint-disable complexity -- Boss handler has inherent complexity */ /* eslint-disable stylistic/max-len -- Long lines in combat logic */ import { computeSetBonuses, getActiveCompanionBonus, type BossChallengeResponse, type GameState, } from "@elysium/types"; import { Hono } from "hono"; import { defaultBosses } from "../data/bosses.js"; import { defaultEquipmentSets } from "../data/equipmentSets.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; const bossRouter = new Hono(); bossRouter.use("*", authMiddleware); const calculatePartyStats = ( state: GameState, ): { partyDPS: number; partyMaxHp: number } => { let globalMultiplier = 1; for (const upgrade of state.upgrades) { if (upgrade.purchased && upgrade.target === "global") { globalMultiplier = globalMultiplier * upgrade.multiplier; } } // eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear const prestigeMultiplier = 1 + state.prestige.count * 0.1; // Apply equipped weapon's combat bonus // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 7 -- @preserve */ const equipmentCombatMultiplier = state.equipment. filter((item) => { return item.equipped && item.bonus.combatMultiplier !== undefined; }). reduce((mult, item) => { return mult * (item.bonus.combatMultiplier ?? 1); }, 1); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 7 -- @preserve */ const equippedItemIds = state.equipment. filter((item) => { return item.equipped; }). map((item) => { return item.id; }); const { combatMultiplier: setCombatMultiplier } = computeSetBonuses( equippedItemIds, defaultEquipmentSets, ); let partyDPS = 0; let partyMaxHp = 0; for (const adventurer of state.adventurers) { if (adventurer.count === 0) { continue; } let adventurerMultiplier = 1; for (const upgrade of state.upgrades) { if ( upgrade.purchased && upgrade.target === "adventurer" && upgrade.adventurerId === adventurer.id ) { adventurerMultiplier = adventurerMultiplier * upgrade.multiplier; } } const adventurerContribution = adventurer.combatPower * adventurer.count * adventurerMultiplier * globalMultiplier * prestigeMultiplier; partyDPS = partyDPS + adventurerContribution; const adventurerHp = adventurer.level * 50 * adventurer.count; partyMaxHp = partyMaxHp + adventurerHp; } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 12 -- @preserve */ const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1; const craftedCombatMultiplier = state.exploration?.craftedCombatMultiplier ?? 1; const companionBonus = getActiveCompanionBonus( state.companions?.activeCompanionId ?? null, state.companions?.unlockedCompanionIds ?? [], ); const companionCombatMult = companionBonus?.type === "bossDamage" ? 1 + companionBonus.value : 1; partyDPS = partyDPS * equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier * craftedCombatMultiplier * companionCombatMult; return { partyDPS, partyMaxHp }; }; bossRouter.post("/challenge", async(context) => { try { const discordId = context.get("discordId"); const body = await context.req.json<{ bossId: string }>(); // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation if (!body.bossId) { return context.json({ error: "Invalid request body" }, 400); } const record = await prisma.gameState.findUnique({ where: { discordId } }); if (!record) { return context.json({ error: "No save found" }, 404); } const rawState: unknown = record.state; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ const state = rawState as GameState; const boss = state.bosses.find((b) => { return b.id === body.bossId; }); if (!boss) { return context.json({ error: "Boss not found" }, 404); } if (boss.status !== "available" && boss.status !== "in_progress") { return context.json({ error: "Boss is not currently available" }, 400); } if (boss.prestigeRequirement > state.prestige.count) { return context.json({ error: "Prestige requirement not met" }, 403); } const { partyDPS, partyMaxHp } = calculatePartyStats(state); if ( partyDPS === 0 || partyMaxHp === 0 || !Number.isFinite(partyDPS) || !Number.isFinite(partyMaxHp) ) { return context.json( { error: "Your party has no adventurers ready to fight" }, 400, ); } const bossHpBefore = boss.currentHp; const bossDPS = boss.damagePerSecond; const timeToKillBoss = bossHpBefore / partyDPS; const timeToKillParty = partyMaxHp / bossDPS; const won = timeToKillBoss <= timeToKillParty; // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches let partyHpRemaining: number; // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches let bossHpAtBattleEnd: number; // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches let bossUpdatedHp: number; // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss let rewards: BossChallengeResponse["rewards"]; // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss let casualties: BossChallengeResponse["casualties"]; if (won) { bossHpAtBattleEnd = 0; bossUpdatedHp = 0; const bossDamageDealt = bossDPS * timeToKillBoss; partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt); boss.status = "defeated"; boss.currentHp = 0; state.resources.gold = state.resources.gold + boss.goldReward; state.resources.essence = state.resources.essence + boss.essenceReward; state.resources.crystals = state.resources.crystals + boss.crystalReward; state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward; for (const upgradeId of boss.upgradeRewards) { const upgrade = state.upgrades.find((u) => { return u.id === upgradeId; }); if (upgrade) { upgrade.unlocked = true; } } // Grant equipment rewards — auto-equip if the slot is currently empty // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 14 -- @preserve */ for (const equipmentId of boss.equipmentRewards) { const equipment = state.equipment.find((item) => { return item.id === equipmentId; }); if (equipment) { equipment.owned = true; const slotAlreadyEquipped = state.equipment.some((item) => { return item.type === equipment.type && item.equipped; }); if (!slotAlreadyEquipped) { equipment.equipped = true; } } } // Unlock next boss in the same zone (zone-based sequential progression) const zoneBosses = state.bosses.filter((b) => { return b.zoneId === boss.zoneId; }); const zoneIndex = zoneBosses.findIndex((b) => { return b.id === body.bossId; }); const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1); if ( nextZoneBoss && nextZoneBoss.prestigeRequirement <= state.prestige.count ) { const nextBossInState = state.bosses.find((b) => { return b.id === nextZoneBoss.id; }); if (nextBossInState) { nextBossInState.status = "available"; } } /* * Unlock any zone whose unlock conditions are now both satisfied * (final boss defeated AND final quest completed) */ // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ for (const zone of state.zones) { if (zone.status === "unlocked") { continue; } if (zone.unlockBossId !== body.bossId) { continue; } // Boss condition just became satisfied — check the quest condition too const questSatisfied = zone.unlockQuestId === null || state.quests.some((q) => { return q.id === zone.unlockQuestId && q.status === "completed"; }); if (!questSatisfied) { continue; } zone.status = "unlocked"; const updatedZoneBosses = state.bosses.filter((b) => { return b.zoneId === zone.id; }); const [ firstUpdatedBoss ] = updatedZoneBosses; if ( firstUpdatedBoss && firstUpdatedBoss.prestigeRequirement <= state.prestige.count ) { firstUpdatedBoss.status = "available"; } } // Update daily boss challenge progress if (state.dailyChallenges) { const { crystalsAwarded, updatedChallenges } = updateChallengeProgress( state.dailyChallenges, "bossesDefeated", 1, ); state.dailyChallenges = updatedChallenges; state.resources.crystals = state.resources.crystals + crystalsAwarded; } // First-kill bounty — look up authoritative bounty from static data const staticBoss = defaultBosses.find((b) => { return b.id === body.bossId; }); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const bountyRunestones = staticBoss?.bountyRunestones ?? 0; state.prestige.runestones = state.prestige.runestones + bountyRunestones; rewards = { bountyRunestones: bountyRunestones, crystals: boss.crystalReward, equipmentIds: boss.equipmentRewards, essence: boss.essenceReward, gold: boss.goldReward, upgradeIds: boss.upgradeRewards, }; } else { const partyDamageDealt = partyDPS * timeToKillParty; bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt); bossUpdatedHp = boss.maxHp; partyHpRemaining = 0; boss.status = "available"; boss.currentHp = boss.maxHp; // How close was the party to winning? (0 = hopeless, 1 = nearly won) const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss); // Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched) const casualtyFraction = (1 - victoryProgress) * 0.6; casualties = []; for (const adventurer of state.adventurers) { if (adventurer.count === 0) { continue; } const killed = Math.floor(adventurer.count * casualtyFraction); if (killed > 0) { adventurer.count = Math.max(1, adventurer.count - killed); casualties.push({ adventurerId: adventurer.id, killed: killed }); } } } const now = Date.now(); await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: now }, where: { discordId }, }); const { bossId } = body; void logger.metric("boss_challenge", 1, { bossId, discordId, won }); const bossMaxHp = boss.maxHp; const bossNewHp = bossUpdatedHp; const response: BossChallengeResponse = { bossDPS, bossHpAtBattleEnd, bossHpBefore, bossMaxHp, bossNewHp, partyDPS, partyHpRemaining, partyMaxHp, won, }; if (rewards !== undefined) { response.rewards = rewards; } if (casualties !== undefined) { response.casualties = casualties; } return context.json(response); } catch (error) { void logger.error( "boss_challenge", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); export { bossRouter };