generated from nhcarrigan/template
a36c8e72a5
## Summary
- Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections
- Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary`
- Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html`
- Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override
## Test plan
- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process
- [ ] Verify frontend errors appear in the `/api/fe/error` backend log
- [ ] Verify Open Graph tags render correctly when sharing the URL
- [ ] Verify Plausible analytics fires on page load
- [ ] Verify Tree-Nation badge renders in the sidebar
- [ ] Verify sidebar stays fixed while the main content scrolls on desktop
- [ ] Verify mobile layout is unaffected
✨ This issue was created with help from Hikari~ 🌸
Reviewed-on: #44
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
/**
|
|
* @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<HonoEnvironment>();
|
|
|
|
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 };
|