From 8fa5d12f05ae8b8ca94dd2b356ad2df386eba431 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 16 Apr 2026 09:48:50 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20vampire=20expansion=20chunk=204=20?= =?UTF-8?q?=E2=80=94=20API=20routes=20and=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds six new routes (vampire-boss, vampire-upgrade, vampire-craft, vampire-explore, siring, vampire-awakening) with matching siring and awakening services, and all necessary request/response types. --- apps/api/src/index.ts | 12 + apps/api/src/routes/siring.ts | 188 +++++++++++ apps/api/src/routes/vampireAwakening.ts | 182 ++++++++++ apps/api/src/routes/vampireBoss.ts | 407 +++++++++++++++++++++++ apps/api/src/routes/vampireCraft.ts | 171 ++++++++++ apps/api/src/routes/vampireExplore.ts | 421 ++++++++++++++++++++++++ apps/api/src/routes/vampireUpgrade.ts | 125 +++++++ apps/api/src/services/awakening.ts | 137 ++++++++ apps/api/src/services/siring.ts | 202 ++++++++++++ packages/types/src/index.ts | 20 ++ packages/types/src/interfaces/api.ts | 161 +++++++++ 11 files changed, 2026 insertions(+) create mode 100644 apps/api/src/routes/siring.ts create mode 100644 apps/api/src/routes/vampireAwakening.ts create mode 100644 apps/api/src/routes/vampireBoss.ts create mode 100644 apps/api/src/routes/vampireCraft.ts create mode 100644 apps/api/src/routes/vampireExplore.ts create mode 100644 apps/api/src/routes/vampireUpgrade.ts create mode 100644 apps/api/src/services/awakening.ts create mode 100644 apps/api/src/services/siring.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e599b0e..16e2fbc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,8 +26,14 @@ import { goddessUpgradeRouter } from "./routes/goddessUpgrade.js"; import { leaderboardRouter } from "./routes/leaderboards.js"; import { prestigeRouter } from "./routes/prestige.js"; import { profileRouter } from "./routes/profile.js"; +import { siringRouter } from "./routes/siring.js"; import { timersRouter } from "./routes/timers.js"; import { transcendenceRouter } from "./routes/transcendence.js"; +import { vampireAwakeningRouter } from "./routes/vampireAwakening.js"; +import { vampireBossRouter } from "./routes/vampireBoss.js"; +import { vampireCraftRouter } from "./routes/vampireCraft.js"; +import { vampireExploreRouter } from "./routes/vampireExplore.js"; +import { vampireUpgradeRouter } from "./routes/vampireUpgrade.js"; import { connectGateway } from "./services/gateway.js"; import { logger } from "./services/logger.js"; @@ -60,6 +66,12 @@ app.route("/enlightenment", enlightenmentRouter); app.route("/goddess-upgrade", goddessUpgradeRouter); app.route("/goddess-craft", goddessCraftRouter); app.route("/goddess-explore", goddessExploreRouter); +app.route("/vampire-boss", vampireBossRouter); +app.route("/siring", siringRouter); +app.route("/vampire-awakening", vampireAwakeningRouter); +app.route("/vampire-upgrade", vampireUpgradeRouter); +app.route("/vampire-craft", vampireCraftRouter); +app.route("/vampire-explore", vampireExploreRouter); app.route("/leaderboards", leaderboardRouter); app.route("/profile", profileRouter); app.route("/timers", timersRouter); diff --git a/apps/api/src/routes/siring.ts b/apps/api/src/routes/siring.ts new file mode 100644 index 0000000..cc062dd --- /dev/null +++ b/apps/api/src/routes/siring.ts @@ -0,0 +1,188 @@ +/** + * @file Siring routes handling siring resets and ichor upgrade purchases. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Route handlers require many steps */ +/* eslint-disable max-statements -- Route handlers require many statements */ +/* eslint-disable complexity -- Route handlers have inherent complexity */ +/* eslint-disable stylistic/max-len -- Route logic requires long lines */ +import { Hono } from "hono"; +import { defaultVampireSiringUpgrades } from "../data/vampireSiringUpgrades.js"; +import { prisma } from "../db/client.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { logger } from "../services/logger.js"; +import { + buildPostSiringState, + calculateSiringThreshold, + computeSiringIchorMultipliers, + computeSiringThresholdMultiplier, + isEligibleForSiring, +} from "../services/siring.js"; +import type { HonoEnvironment } from "../types/hono.js"; +import type { + BuySiringUpgradeRequest, + BuySiringUpgradeResponse, + GameState, + SiringResponse, +} from "@elysium/types"; + +const siringRouter = new Hono(); + +siringRouter.use("*", authMiddleware); + +siringRouter.post("/", async(context) => { + try { + const discordId = context.get("discordId"); + + const record = await prisma.gameState.findUnique({ where: { discordId } }); + + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = record.state as unknown as GameState; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + if (!isEligibleForSiring(state)) { + const threshold = calculateSiringThreshold( + state.vampire.siring.count, + computeSiringThresholdMultiplier(state.vampire.siring.purchasedUpgradeIds) * state.vampire.awakening.soulShardsSiringThresholdMultiplier, + ); + return context.json( + { + error: `Not eligible for siring — earn ${threshold.toLocaleString()} total blood first`, + }, + 400, + ); + } + + const { ichorEarned, updatedVampire } = buildPostSiringState(state); + + const updatedSiringCount = updatedVampire.siring.count; + + const updatedState: GameState = { + ...state, + resources: { ...state.resources, blood: 0 }, + vampire: updatedVampire, + }; + + const now = Date.now(); + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: updatedState as object, updatedAt: now }, + where: { discordId }, + }); + + void logger.metric("siring", 1, { discordId, updatedSiringCount }); + + const response: SiringResponse = { + ichorEarned: ichorEarned, + // eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response + newSiringCount: updatedSiringCount, + }; + return context.json(response); + } catch (error) { + void logger.error( + "siring", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +siringRouter.post("/buy-upgrade", async(context) => { + try { + const discordId = context.get("discordId"); + + const body = await context.req.json(); + + const { upgradeId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!upgradeId) { + return context.json({ error: "upgradeId is required" }, 400); + } + + const upgrade = defaultVampireSiringUpgrades.find((siringUpgrade) => { + return siringUpgrade.id === upgradeId; + }); + if (!upgrade) { + return context.json({ error: "Unknown siring upgrade" }, 404); + } + + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = record.state as unknown as GameState; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + const { purchasedUpgradeIds, ichor } = state.vampire.siring; + + if (purchasedUpgradeIds.includes(upgradeId)) { + return context.json({ error: "Upgrade already purchased" }, 400); + } + + if (ichor < upgrade.ichorCost) { + return context.json({ error: "Not enough ichor" }, 400); + } + + const updatedIchor = ichor - upgrade.ichorCost; + + const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ]; + + const updatedMultipliers = computeSiringIchorMultipliers(updatedPurchasedIds); + + const updatedState: GameState = { + ...state, + vampire: { + ...state.vampire, + siring: { + ...state.vampire.siring, + ichor: updatedIchor, + purchasedUpgradeIds: updatedPurchasedIds, + ...updatedMultipliers, + }, + }, + }; + + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: updatedState as object, updatedAt: Date.now() }, + where: { discordId }, + }); + + void logger.metric("siring_upgrade_purchased", 1, { discordId, upgradeId }); + + const response: BuySiringUpgradeResponse = { + ichorBloodMultiplier: updatedMultipliers.ichorBloodMultiplier ?? 1, + ichorCombatMultiplier: updatedMultipliers.ichorCombatMultiplier ?? 1, + ichorRemaining: updatedIchor, + ichorThrallsMultiplier: updatedMultipliers.ichorThrallsMultiplier ?? 1, + purchasedUpgradeIds: updatedPurchasedIds, + }; + return context.json(response); + } catch (error) { + void logger.error( + "siring_buy_upgrade", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { siringRouter }; diff --git a/apps/api/src/routes/vampireAwakening.ts b/apps/api/src/routes/vampireAwakening.ts new file mode 100644 index 0000000..1bc44e5 --- /dev/null +++ b/apps/api/src/routes/vampireAwakening.ts @@ -0,0 +1,182 @@ +/** + * @file Vampire Awakening routes handling awakening resets and soul shard upgrade purchases. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Route handlers require many steps */ +/* eslint-disable max-statements -- Route handlers require many statements */ +/* eslint-disable stylistic/max-len -- Route logic requires long lines */ +import { Hono } from "hono"; +import { defaultVampireAwakeningUpgrades } from "../data/vampireAwakeningUpgrades.js"; +import { prisma } from "../db/client.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { + buildPostAwakeningState, + computeAwakeningMultipliers, + isEligibleForAwakening, +} from "../services/awakening.js"; +import { logger } from "../services/logger.js"; +import type { HonoEnvironment } from "../types/hono.js"; +import type { + AwakeningResponse, + BuyAwakeningUpgradeRequest, + BuyAwakeningUpgradeResponse, + GameState, +} from "@elysium/types"; + +const vampireAwakeningRouter = new Hono(); + +vampireAwakeningRouter.use("*", authMiddleware); + +vampireAwakeningRouter.post("/", async(context) => { + try { + const discordId = context.get("discordId"); + + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = record.state as unknown as GameState; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + if (!isEligibleForAwakening(state)) { + return context.json( + { + error: "Not eligible for awakening — defeat the Eternal Darkness first", + }, + 400, + ); + } + + const { soulShardsEarned, updatedVampire } = buildPostAwakeningState(state); + + const updatedAwakeningCount = updatedVampire.awakening.count; + + const updatedState: GameState = { + ...state, + resources: { ...state.resources, blood: 0, ichor: 0 }, + vampire: updatedVampire, + }; + + const now = Date.now(); + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: updatedState as object, updatedAt: now }, + where: { discordId }, + }); + + void logger.metric("vampire_awakening", 1, { discordId, updatedAwakeningCount }); + + const response: AwakeningResponse = { + // eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response + newAwakeningCount: updatedAwakeningCount, + soulShardsEarned: soulShardsEarned, + }; + return context.json(response); + } catch (error) { + void logger.error( + "vampire_awakening", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +vampireAwakeningRouter.post("/buy-upgrade", async(context) => { + try { + const discordId = context.get("discordId"); + + const body = await context.req.json(); + + const { upgradeId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!upgradeId) { + return context.json({ error: "upgradeId is required" }, 400); + } + + const upgrade = defaultVampireAwakeningUpgrades.find((awakeningUpgrade) => { + return awakeningUpgrade.id === upgradeId; + }); + if (!upgrade) { + return context.json({ error: "Unknown awakening upgrade" }, 404); + } + + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = record.state as unknown as GameState; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + const { purchasedUpgradeIds, soulShards } = state.vampire.awakening; + + if (purchasedUpgradeIds.includes(upgradeId)) { + return context.json({ error: "Upgrade already purchased" }, 400); + } + + if (soulShards < upgrade.cost) { + return context.json({ error: "Not enough soul shards" }, 400); + } + + const updatedSoulShards = soulShards - upgrade.cost; + + const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ]; + + const updatedMultipliers = computeAwakeningMultipliers(updatedPurchasedIds); + + const updatedState: GameState = { + ...state, + vampire: { + ...state.vampire, + awakening: { + ...state.vampire.awakening, + purchasedUpgradeIds: updatedPurchasedIds, + soulShards: updatedSoulShards, + ...updatedMultipliers, + }, + }, + }; + + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: updatedState as object, updatedAt: Date.now() }, + where: { discordId }, + }); + + void logger.metric("vampire_awakening_upgrade_purchased", 1, { discordId, upgradeId }); + + const response: BuyAwakeningUpgradeResponse = { + purchasedUpgradeIds: updatedPurchasedIds, + soulShardsBloodMultiplier: updatedMultipliers.soulShardsBloodMultiplier, + soulShardsCombatMultiplier: updatedMultipliers.soulShardsCombatMultiplier, + soulShardsMetaMultiplier: updatedMultipliers.soulShardsMetaMultiplier, + soulShardsRemaining: updatedSoulShards, + soulShardsSiringIchorMultiplier: updatedMultipliers.soulShardsSiringIchorMultiplier, + soulShardsSiringThresholdMultiplier: updatedMultipliers.soulShardsSiringThresholdMultiplier, + }; + return context.json(response); + } catch (error) { + void logger.error( + "vampire_awakening_buy_upgrade", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { vampireAwakeningRouter }; diff --git a/apps/api/src/routes/vampireBoss.ts b/apps/api/src/routes/vampireBoss.ts new file mode 100644 index 0000000..0e6260b --- /dev/null +++ b/apps/api/src/routes/vampireBoss.ts @@ -0,0 +1,407 @@ +/** + * @file Vampire boss challenge route handling blood 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 */ +/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */ +import { createHmac } from "node:crypto"; +import { + computeVampireSetBonuses, + type GameState, + type VampireBossChallengeResponse, +} from "@elysium/types"; +import { Hono } from "hono"; +import { defaultVampireBosses } from "../data/vampireBosses.js"; +import { defaultVampireEquipmentSets } from "../data/vampireEquipmentSets.js"; +import { defaultVampireExplorationAreas } from "../data/vampireExplorations.js"; +import { prisma } from "../db/client.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { logger } from "../services/logger.js"; +import type { HonoEnvironment } from "../types/hono.js"; + +/** + * Computes the HMAC-SHA256 of data using the given secret. + * @param data - The data string to sign. + * @param secret - The HMAC secret key. + * @returns The hex-encoded HMAC digest. + */ +const computeHmac = (data: string, secret: string): string => { + return createHmac("sha256", secret).update(data). + digest("hex"); +}; + +const vampireBossRouter = new Hono(); + +vampireBossRouter.use("*", authMiddleware); + +const calculateThrallStats = ( + vampire: NonNullable, +): { partyDPS: number; partyMaxHp: number } => { + let globalMultiplier = 1; + for (const upgrade of vampire.upgrades) { + if (upgrade.purchased && upgrade.target === "global") { + globalMultiplier = globalMultiplier * upgrade.multiplier; + } + } + + const ichorCombatMultiplier = vampire.siring.ichorCombatMultiplier ?? 1; + const { soulShardsCombatMultiplier } = vampire.awakening; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 7 -- @preserve */ + const equipmentCombatMultiplier = vampire.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 = vampire.equipment. + filter((item) => { + return item.equipped; + }). + map((item) => { + return item.id; + }); + const { combatMultiplier: setCombatMultiplier } = computeVampireSetBonuses( + equippedItemIds, + defaultVampireEquipmentSets, + ); + + let partyDPS = 0; + let partyMaxHp = 0; + + for (const thrall of vampire.thralls) { + if (thrall.count === 0) { + continue; + } + + let thrallMultiplier = 1; + for (const upgrade of vampire.upgrades) { + if ( + upgrade.purchased + && upgrade.target === "thrall" + && upgrade.thrallId === thrall.id + ) { + thrallMultiplier = thrallMultiplier * upgrade.multiplier; + } + } + + const thrallContribution + = thrall.combatPower + * thrall.count + * thrallMultiplier + * globalMultiplier; + partyDPS = partyDPS + thrallContribution; + + const thrallHp = thrall.level * 50 * thrall.count; + partyMaxHp = partyMaxHp + thrallHp; + } + + const { craftedCombatMultiplier } = vampire.exploration; + + partyDPS = partyDPS + * equipmentCombatMultiplier + * setCombatMultiplier + * ichorCombatMultiplier + * soulShardsCombatMultiplier + * craftedCombatMultiplier; + + return { partyDPS, partyMaxHp }; +}; + +vampireBossRouter.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; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + const { vampire } = state; + + const boss = vampire.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.siringRequirement > vampire.siring.count) { + return context.json({ error: "Siring requirement not met" }, 403); + } + + const { partyDPS, partyMaxHp } = calculateThrallStats(vampire); + + if ( + partyDPS === 0 + || partyMaxHp === 0 + || !Number.isFinite(partyDPS) + || !Number.isFinite(partyMaxHp) + ) { + return context.json( + { error: "Your thralls have no combat power" }, + 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: VampireBossChallengeResponse["rewards"]; + // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss + let casualties: VampireBossChallengeResponse["casualties"]; + + if (won) { + bossHpAtBattleEnd = 0; + bossUpdatedHp = 0; + const bossDamageDealt = bossDPS * timeToKillBoss; + partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt); + + boss.status = "defeated"; + boss.currentHp = 0; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + // eslint-disable-next-line unicorn/consistent-destructuring -- mutation requires direct property access on state.resources + state.resources.blood = (state.resources.blood ?? 0) + boss.bloodReward; + vampire.totalBloodEarned = vampire.totalBloodEarned + boss.bloodReward; + vampire.lifetimeBloodEarned = vampire.lifetimeBloodEarned + boss.bloodReward; + vampire.siring.ichor = vampire.siring.ichor + boss.ichorReward; + vampire.awakening.soulShards = vampire.awakening.soulShards + boss.soulShardsReward; + vampire.lifetimeBossesDefeated = vampire.lifetimeBossesDefeated + 1; + + for (const upgradeId of boss.upgradeRewards) { + const upgrade = vampire.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 = vampire.equipment.find((item) => { + return item.id === equipmentId; + }); + if (equipment) { + equipment.owned = true; + const slotAlreadyEquipped = vampire.equipment.some((item) => { + return item.type === equipment.type && item.equipped; + }); + if (!slotAlreadyEquipped) { + equipment.equipped = true; + } + } + } + + // Unlock next boss in the same zone + const zoneBosses = vampire.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.siringRequirement <= vampire.siring.count + ) { + const nextBossInState = vampire.bosses.find((b) => { + return b.id === nextZoneBoss.id; + }); + if (nextBossInState) { + nextBossInState.status = "available"; + } + } + + // Unlock zones whose conditions are now both satisfied + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + for (const zone of vampire.zones) { + if (zone.status === "unlocked") { + continue; + } + if (zone.unlockBossId !== body.bossId) { + continue; + } + + const questSatisfied + = zone.unlockQuestId === null + || vampire.quests.some((q) => { + return q.id === zone.unlockQuestId && q.status === "completed"; + }); + if (!questSatisfied) { + continue; + } + zone.status = "unlocked"; + + // Unlock exploration areas for the newly unlocked zone + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 9 -- @preserve */ + for (const area of vampire.exploration.areas) { + const areaDefinition = defaultVampireExplorationAreas.find((explorationArea) => { + return explorationArea.id === area.id; + }); + if (areaDefinition?.zoneId === zone.id && area.status === "locked") { + area.status = "available"; + } + } + + const updatedZoneBosses = vampire.bosses.filter((b) => { + return b.zoneId === zone.id; + }); + const [ firstUpdatedBoss ] = updatedZoneBosses; + if ( + firstUpdatedBoss + && firstUpdatedBoss.siringRequirement <= vampire.siring.count + ) { + firstUpdatedBoss.status = "available"; + } + } + + // First-kill ichor bounty — only awarded once + const staticBoss = defaultVampireBosses.find((b) => { + return b.id === body.bossId; + }); + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 7 -- @preserve */ + const bountyIchor + = boss.bountyIchorClaimed === true + ? 0 + : staticBoss?.bountyIchor ?? 0; + if (bountyIchor > 0) { + boss.bountyIchorClaimed = true; + vampire.siring.ichor = vampire.siring.ichor + bountyIchor; + } + + rewards = { + blood: boss.bloodReward, + bountyIchor: bountyIchor, + equipmentIds: boss.equipmentRewards, + ichor: boss.ichorReward, + soulShards: boss.soulShardsReward, + 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; + + const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss); + const casualtyFraction = (1 - victoryProgress) * 0.6; + + casualties = []; + for (const thrall of vampire.thralls) { + if (thrall.count === 0) { + continue; + } + const killed = Math.floor(thrall.count * casualtyFraction); + if (killed > 0) { + thrall.count = Math.max(1, thrall.count - killed); + + casualties.push({ killed: killed, thrallId: thrall.id }); + } + } + } + + 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 secret = process.env.ANTI_CHEAT_SECRET; + const updatedSignature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(state), secret); + + const { bossId } = body; + void logger.metric("vampire_boss_challenge", 1, { bossId, discordId, won }); + + const bossMaxHp = boss.maxHp; + const bossNewHp = bossUpdatedHp; + const response: VampireBossChallengeResponse = { + bossDPS, + bossHpAtBattleEnd, + bossHpBefore, + bossMaxHp, + bossNewHp, + partyDPS, + partyHpRemaining, + partyMaxHp, + won, + }; + if (rewards !== undefined) { + response.rewards = rewards; + } + if (casualties !== undefined) { + response.casualties = casualties; + } + if (updatedSignature !== undefined) { + response.signature = updatedSignature; + } + + return context.json(response); + } catch (error) { + void logger.error( + "vampire_boss_challenge", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { vampireBossRouter }; diff --git a/apps/api/src/routes/vampireCraft.ts b/apps/api/src/routes/vampireCraft.ts new file mode 100644 index 0000000..edc5ac5 --- /dev/null +++ b/apps/api/src/routes/vampireCraft.ts @@ -0,0 +1,171 @@ +/** + * @file Vampire crafting route handling dark recipe crafting. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Route handler requires many steps */ +/* eslint-disable max-statements -- Route handler requires many statements */ +/* eslint-disable complexity -- Route handler has inherent complexity */ +/* eslint-disable stylistic/max-len -- Route logic requires long lines */ +import { Hono } from "hono"; +import { defaultVampireCraftingRecipes } from "../data/vampireCrafting.js"; +import { prisma } from "../db/client.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { logger } from "../services/logger.js"; +import type { HonoEnvironment } from "../types/hono.js"; +import type { + GameState, + VampireCraftRequest, + VampireCraftResponse, +} from "@elysium/types"; + +const vampireCraftRouter = new Hono(); + +vampireCraftRouter.use("*", authMiddleware); + +const recomputeVampireCraftedMultipliers = ( + craftedRecipeIds: Array, +): { + craftedBloodMultiplier: number; + craftedCombatMultiplier: number; + craftedIchorMultiplier: number; +} => { + return { + craftedBloodMultiplier: defaultVampireCraftingRecipes.filter((recipe) => { + return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income"; + }).reduce((mult, recipe) => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + return mult * recipe.bonus.value; + }, 1), + craftedCombatMultiplier: defaultVampireCraftingRecipes.filter((recipe) => { + return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power"; + }).reduce((mult, recipe) => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + return mult * recipe.bonus.value; + }, 1), + craftedIchorMultiplier: defaultVampireCraftingRecipes.filter((recipe) => { + return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income"; + }).reduce((mult, recipe) => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + return mult * recipe.bonus.value; + }, 1), + }; +}; + +vampireCraftRouter.post("/", async(context) => { + try { + const discordId = context.get("discordId"); + + const body = await context.req.json(); + + const { recipeId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!recipeId) { + return context.json({ error: "recipeId is required" }, 400); + } + + const recipe = defaultVampireCraftingRecipes.find((r) => { + return r.id === recipeId; + }); + if (!recipe) { + return context.json({ error: "Unknown recipe" }, 404); + } + + 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; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + if (state.vampire.exploration.craftedRecipeIds.includes(recipeId)) { + return context.json({ error: "Recipe already crafted" }, 400); + } + + // Verify the player has all required dark materials + for (const requirement of recipe.requiredMaterials) { + const material = state.vampire.exploration.materials.find((m) => { + return m.materialId === requirement.materialId; + }); + const quantity = material?.quantity ?? 0; + if (quantity < requirement.quantity) { + return context.json( + { + error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`, + }, + 400, + ); + } + } + + // Deduct dark materials + for (const requirement of recipe.requiredMaterials) { + const material = state.vampire.exploration.materials.find((m) => { + return m.materialId === requirement.materialId; + }); + if (material) { + material.quantity = material.quantity - requirement.quantity; + } + } + + // Add recipe and recompute all multipliers from scratch + state.vampire.exploration.craftedRecipeIds.push(recipeId); + + const updatedMultipliers = recomputeVampireCraftedMultipliers( + state.vampire.exploration.craftedRecipeIds, + ); + state.vampire.exploration.craftedBloodMultiplier = updatedMultipliers.craftedBloodMultiplier; + state.vampire.exploration.craftedIchorMultiplier = updatedMultipliers.craftedIchorMultiplier; + state.vampire.exploration.craftedCombatMultiplier = updatedMultipliers.craftedCombatMultiplier; + + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: state as object, updatedAt: Date.now() }, + where: { discordId }, + }); + + void logger.metric("vampire_recipe_crafted", 1, { discordId, recipeId }); + + const bonusType = recipe.bonus.type; + const bonusValue = recipe.bonus.value; + + const { materials } = state.vampire.exploration; + const { + craftedBloodMultiplier, + craftedIchorMultiplier, + craftedCombatMultiplier, + } = updatedMultipliers; + + const response: VampireCraftResponse = { + bonusType, + bonusValue, + craftedBloodMultiplier, + craftedCombatMultiplier, + craftedIchorMultiplier, + materials, + recipeId, + }; + + return context.json(response); + } catch (error) { + void logger.error( + "vampire_craft", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { vampireCraftRouter }; diff --git a/apps/api/src/routes/vampireExplore.ts b/apps/api/src/routes/vampireExplore.ts new file mode 100644 index 0000000..ac81f73 --- /dev/null +++ b/apps/api/src/routes/vampireExplore.ts @@ -0,0 +1,421 @@ +/** + * @file Vampire exploration routes handling dark area exploration mechanics. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Route handlers require many steps */ +/* eslint-disable max-statements -- Route handlers require many statements */ +/* eslint-disable complexity -- Route handlers have inherent complexity */ +/* eslint-disable max-lines -- Route file requires multiple handlers */ +/* eslint-disable stylistic/max-len -- Route logic requires long lines */ +import { Hono } from "hono"; +import { defaultVampireExplorationAreas } from "../data/vampireExplorations.js"; +import { prisma } from "../db/client.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { logger } from "../services/logger.js"; +import type { HonoEnvironment } from "../types/hono.js"; +import type { + GameState, + VampireExploreClaimableResponse, + VampireExploreCollectEventResult, + VampireExploreCollectRequest, + VampireExploreCollectResponse, + VampireExploreStartRequest, + VampireExploreStartResponse, +} from "@elysium/types"; + +const vampireExploreRouter = new Hono(); + +vampireExploreRouter.use("*", authMiddleware); + +const nothingProbability = 0.2; + +const nothingMessages = [ + "Your thralls searched the shadowy depths but found nothing of value.", + "The cursed area yielded nothing remarkable this time.", + "Your thralls returned empty-handed from the darkness.", + "A wasted hunt — the darkened area proved barren.", + "Nothing to show for the bloodshed. Perhaps next time.", +]; + +/** + * Returns a random "nothing found" message. + * @returns A random message string. + */ +const pickNothingMessage = (): string => { + const index = Math.floor(Math.random() * nothingMessages.length); + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + return nothingMessages[index] ?? nothingMessages[0] ?? ""; +}; + +vampireExploreRouter.get("/claimable", async(context) => { + try { + const discordId = context.get("discordId"); + const areaId = context.req.query("areaId"); + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation + if (!areaId) { + return context.json({ error: "areaId is required" }, 400); + } + + const explorationArea = defaultVampireExplorationAreas.find((a) => { + return a.id === areaId; + }); + if (!explorationArea) { + return context.json({ error: "Unknown exploration area" }, 404); + } + + 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; + + if (!state.vampire) { + const response: VampireExploreClaimableResponse = { claimable: false }; + return context.json(response); + } + + const area = state.vampire.exploration.areas.find((a) => { + return a.id === areaId; + }); + + if (!area || area.status !== "in_progress") { + const response: VampireExploreClaimableResponse = { claimable: false }; + return context.json(response); + } + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const startedAt = area.startedAt ?? 0; + const durationMs = explorationArea.durationSeconds * 1000; + const expiresAt = startedAt + durationMs; + const claimable = Date.now() >= expiresAt; + const response: VampireExploreClaimableResponse = { claimable }; + return context.json(response); + } catch (error) { + void logger.error( + "vampire_explore_claimable", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +vampireExploreRouter.post("/start", async(context) => { + try { + const discordId = context.get("discordId"); + + const body = await context.req.json(); + + const { areaId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!areaId) { + return context.json({ error: "areaId is required" }, 400); + } + + const explorationArea = defaultVampireExplorationAreas.find((a) => { + return a.id === areaId; + }); + if (!explorationArea) { + return context.json({ error: "Unknown exploration area" }, 404); + } + + 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; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + const zone = state.vampire.zones.find((z) => { + return z.id === explorationArea.zoneId; + }); + if (!zone || zone.status !== "unlocked") { + return context.json({ error: "Zone is not unlocked" }, 400); + } + + const area = state.vampire.exploration.areas.find((a) => { + return a.id === areaId; + }); + if (!area) { + return context.json( + { error: "Exploration area not found in state" }, + 404, + ); + } + + const anyInProgress = state.vampire.exploration.areas.some((a) => { + return a.status === "in_progress"; + }); + if (anyInProgress) { + return context.json( + { error: "An exploration is already in progress" }, + 400, + ); + } + + if (area.status === "locked") { + return context.json({ error: "Exploration area is locked" }, 400); + } + + const now = Date.now(); + // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear + const endsAt = now + explorationArea.durationSeconds * 1000; + area.status = "in_progress"; + area.startedAt = now; + area.endsAt = endsAt; + + 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 response: VampireExploreStartResponse = { + areaId, + endsAt, + }; + + return context.json(response); + } catch (error) { + void logger.error( + "vampire_explore_start", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +vampireExploreRouter.post("/collect", async(context) => { + try { + const discordId = context.get("discordId"); + + const body = await context.req.json(); + + const { areaId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!areaId) { + return context.json({ error: "areaId is required" }, 400); + } + + const explorationArea = defaultVampireExplorationAreas.find((a) => { + return a.id === areaId; + }); + if (!explorationArea) { + return context.json({ error: "Unknown exploration area" }, 404); + } + + 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; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + const area = state.vampire.exploration.areas.find((a) => { + return a.id === areaId; + }); + if (!area) { + return context.json({ error: "Exploration area not found" }, 404); + } + + if (area.status !== "in_progress") { + return context.json({ error: "Exploration is not in progress" }, 400); + } + + const now = Date.now(); + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const startedAt = area.startedAt ?? 0; + const durationMs = explorationArea.durationSeconds * 1000; + const expiresAt = startedAt + durationMs; + + if (now < expiresAt) { + return context.json({ error: "Exploration is not yet complete" }, 400); + } + + area.status = "available"; + area.completedOnce = true; + + // 20% chance of finding nothing + if (Math.random() < nothingProbability) { + 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 response: VampireExploreCollectResponse = { + event: null, + foundNothing: true, + materialsFound: [], + nothingMessage: pickNothingMessage(), + }; + return context.json(response); + } + + // Pick a random event + const eventIndex = Math.floor( + Math.random() * explorationArea.events.length, + ); + const event = explorationArea.events[eventIndex]; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (!event) { + return context.json({ error: "No events available" }, 500); + } + + // Apply event effects and build the result summary + let bloodChange = 0; + let ichorChange = 0; + let materialGained: { materialId: string; quantity: number } | null = null; + + if (event.effect.type === "blood_gain") { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + const amount = event.effect.amount ?? 0; + state.resources.blood = (state.resources.blood ?? 0) + amount; + state.vampire.totalBloodEarned = state.vampire.totalBloodEarned + amount; + bloodChange = amount; + } else if (event.effect.type === "blood_loss") { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + const amount = Math.min(state.resources.blood ?? 0, event.effect.amount ?? 0); + state.resources.blood = (state.resources.blood ?? 0) - amount; + bloodChange = -amount; + } else if (event.effect.type === "ichor_gain") { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const amount = event.effect.amount ?? 0; + state.vampire.siring.ichor = state.vampire.siring.ichor + amount; + ichorChange = amount; + } else if (event.effect.type === "dark_material_gain") { + const { materialId } = event.effect; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const quantity = event.effect.quantity ?? 1; + if (materialId !== undefined && materialId !== "") { + const existing = state.vampire.exploration.materials.find((m) => { + return m.materialId === materialId; + }); + if (existing) { + existing.quantity = existing.quantity + quantity; + } else { + state.vampire.exploration.materials.push({ materialId, quantity }); + } + materialGained = { materialId, quantity }; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 13 -- @preserve */ + } + } else if (event.effect.type === "thrall_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 7 -- @preserve */ + const fraction = event.effect.fraction ?? 0.05; + for (const thrall of state.vampire.thralls) { + const lost = Math.floor(thrall.count * fraction); + if (lost > 0) { + thrall.count = Math.max(0, thrall.count - lost); + } + } + } + + let thrallLostCount = 0; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 8 -- @preserve */ + if (event.effect.type === "thrall_loss") { + const fraction = event.effect.fraction ?? 0.05; + for (const thrall of state.vampire.thralls) { + const lost = Math.floor(thrall.count * fraction); + thrallLostCount = thrallLostCount + lost; + } + } + + const eventResult: VampireExploreCollectEventResult = { + bloodChange: bloodChange, + ichorChange: ichorChange, + materialGained: materialGained, + text: event.text, + thrallLostCount: thrallLostCount, + }; + + // Roll for dark material drops from possibleMaterials (weighted random selection) + const materialsFound: Array<{ materialId: string; quantity: number }> = []; + + if (explorationArea.possibleMaterials.length > 0) { + let totalWeight = 0; + for (const materialDrop of explorationArea.possibleMaterials) { + totalWeight = totalWeight + materialDrop.weight; + } + let roll = Math.random() * totalWeight; + + for (const possible of explorationArea.possibleMaterials) { + roll = roll - possible.weight; + if (roll <= 0) { + const maxMinDiff = possible.maxQuantity - possible.minQuantity; + const range = maxMinDiff + 1; + const randomOffset = Math.floor(Math.random() * range); + const quantity = randomOffset + possible.minQuantity; + const { materialId } = possible; + + const existing = state.vampire.exploration.materials.find((m) => { + return m.materialId === materialId; + }); + if (existing) { + existing.quantity = existing.quantity + quantity; + } else { + state.vampire.exploration.materials.push({ materialId, quantity }); + } + + materialsFound.push({ materialId, quantity }); + break; + } + } + } + + 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 response: VampireExploreCollectResponse = { + event: eventResult, + foundNothing: false, + materialsFound: materialsFound, + }; + + return context.json(response); + } catch (error) { + void logger.error( + "vampire_explore_collect", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { vampireExploreRouter }; diff --git a/apps/api/src/routes/vampireUpgrade.ts b/apps/api/src/routes/vampireUpgrade.ts new file mode 100644 index 0000000..3577e86 --- /dev/null +++ b/apps/api/src/routes/vampireUpgrade.ts @@ -0,0 +1,125 @@ +/** + * @file Vampire upgrade purchase route. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Route handler requires many steps */ +/* eslint-disable max-statements -- Route handler requires many statements */ +/* eslint-disable complexity -- Route handler has inherent complexity */ +/* eslint-disable stylistic/max-len -- Route logic requires long lines */ +import { Hono } from "hono"; +import { defaultVampireUpgrades } from "../data/vampireUpgrades.js"; +import { prisma } from "../db/client.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { logger } from "../services/logger.js"; +import type { HonoEnvironment } from "../types/hono.js"; +import type { + BuyVampireUpgradeRequest, + BuyVampireUpgradeResponse, + GameState, +} from "@elysium/types"; + +const vampireUpgradeRouter = new Hono(); + +vampireUpgradeRouter.use("*", authMiddleware); + +vampireUpgradeRouter.post("/buy", async(context) => { + try { + const discordId = context.get("discordId"); + + const body = await context.req.json(); + + const { upgradeId } = body; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation + if (!upgradeId) { + return context.json({ error: "upgradeId is required" }, 400); + } + + const upgradeTemplate = defaultVampireUpgrades.find((vampireUpgrade) => { + return vampireUpgrade.id === upgradeId; + }); + if (!upgradeTemplate) { + return context.json({ error: "Unknown vampire upgrade" }, 404); + } + + const record = await prisma.gameState.findUnique({ where: { discordId } }); + if (!record) { + return context.json({ error: "No save found" }, 404); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */ + const state = record.state as unknown as GameState; + + if (!state.vampire) { + return context.json({ error: "Vampire realm not unlocked" }, 400); + } + + const upgrade = state.vampire.upgrades.find((u) => { + return u.id === upgradeId; + }); + + if (!upgrade) { + return context.json({ error: "Upgrade not found in vampire state" }, 404); + } + + if (!upgrade.unlocked) { + return context.json({ error: "Upgrade is not yet unlocked" }, 400); + } + + if (upgrade.purchased) { + return context.json({ error: "Upgrade already purchased" }, 400); + } + + const currentBlood = state.resources.blood ?? 0; + const currentIchor = state.vampire.siring.ichor; + const currentSoulShards = state.vampire.awakening.soulShards; + + if (currentBlood < upgradeTemplate.costBlood) { + return context.json({ error: "Not enough blood" }, 400); + } + + if (currentIchor < upgradeTemplate.costIchor) { + return context.json({ error: "Not enough ichor" }, 400); + } + + if (currentSoulShards < upgradeTemplate.costSoulShards) { + return context.json({ error: "Not enough soul shards" }, 400); + } + + upgrade.purchased = true; + + const updatedBlood = currentBlood - upgradeTemplate.costBlood; + const updatedIchor = currentIchor - upgradeTemplate.costIchor; + const updatedSoulShards = currentSoulShards - upgradeTemplate.costSoulShards; + + state.resources.blood = updatedBlood; + state.vampire.siring.ichor = updatedIchor; + state.vampire.awakening.soulShards = updatedSoulShards; + + await prisma.gameState.update({ + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ + data: { state: state as object, updatedAt: Date.now() }, + where: { discordId }, + }); + + void logger.metric("vampire_upgrade_purchased", 1, { discordId, upgradeId }); + + const response: BuyVampireUpgradeResponse = { + bloodRemaining: updatedBlood, + ichorRemaining: updatedIchor, + soulShardsRemaining: updatedSoulShards, + }; + return context.json(response); + } catch (error) { + void logger.error( + "vampire_upgrade_buy", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { vampireUpgradeRouter }; diff --git a/apps/api/src/services/awakening.ts b/apps/api/src/services/awakening.ts new file mode 100644 index 0000000..393f36d --- /dev/null +++ b/apps/api/src/services/awakening.ts @@ -0,0 +1,137 @@ +/** + * @file Awakening service handling eligibility checks and post-awakening state building. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/* eslint-disable stylistic/max-len -- Service logic requires long lines */ +import { initialVampireState } from "../data/initialState.js"; +import { defaultVampireAwakeningUpgrades } from "../data/vampireAwakeningUpgrades.js"; +import type { AwakeningData, GameState } from "@elysium/types"; + +/** + * The ID of the final vampire boss whose defeat triggers eligibility for awakening. + */ +const finalVampireBossId = "eternal_darkness"; + +const getCategoryMultiplier = ( + purchasedIds: Array, + category: string, +): number => { + return defaultVampireAwakeningUpgrades.filter((upgrade) => { + return upgrade.category === category && purchasedIds.includes(upgrade.id); + }).reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); +}; + +/** + * Computes all five soul shard multipliers from the purchased awakening upgrade IDs. + * @param purchasedUpgradeIds - The array of purchased awakening upgrade IDs. + * @returns An object containing all five soul shard multiplier values. + */ +const computeAwakeningMultipliers = ( + purchasedUpgradeIds: Array, +): Omit => { + return { + soulShardsBloodMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "blood"), + soulShardsCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"), + soulShardsMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "soulshards_meta"), + soulShardsSiringIchorMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "siring_ichor"), + soulShardsSiringThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "siring_threshold"), + }; +}; + +/** + * Returns true if the player is eligible to awaken: + * the final vampire boss must have been defeated. + * @param state - The current game state. + * @returns Whether the player is eligible for awakening. + */ +const isEligibleForAwakening = (state: GameState): boolean => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (state.vampire === undefined) { + return false; + } + return state.vampire.bosses.some((boss) => { + return boss.id === finalVampireBossId && boss.status === "defeated"; + }); +}; + +/** + * Calculates the soul shards yield from an awakening. + * Formula: MAX(1, FLOOR(SQRT(siringCount) * metaMultiplier)). + * @param siringCount - The number of sirings completed. + * @param metaMultiplier - Multiplier from soul shard meta upgrades applied to yield. + * @returns The soul shards earned. + */ +const calculateSoulShardsYield = ( + siringCount: number, + metaMultiplier: number, +): number => { + return Math.max(1, Math.floor(Math.sqrt(siringCount) * metaMultiplier)); +}; + +/** + * Builds the updated vampire state after an awakening reset. + * Resets the current run including siring data (bosses, quests, thralls, upgrades, zones, siring data). + * Preserves: equipment, achievements, awakening data (updated), eternal sovereignty, lifetime stats. + * @param state - The current game state before awakening. + * @returns The soul shards earned and the updated vampire state. + */ +const buildPostAwakeningState = ( + state: GameState, +): { soulShardsEarned: number; updatedVampire: NonNullable } => { + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure vampire exists */ + const vampire = state.vampire as NonNullable; + + const metaMultiplier = vampire.awakening.soulShardsMetaMultiplier; + const soulShardsEarned = calculateSoulShardsYield(vampire.siring.count, metaMultiplier); + + const updatedCount = vampire.awakening.count + 1; + const updatedSoulShards = vampire.awakening.soulShards + soulShardsEarned; + const updatedPurchasedIds = vampire.awakening.purchasedUpgradeIds; + const updatedMultipliers = computeAwakeningMultipliers(updatedPurchasedIds); + + const updatedAwakening: AwakeningData = { + count: updatedCount, + purchasedUpgradeIds: updatedPurchasedIds, + soulShards: updatedSoulShards, + ...updatedMultipliers, + }; + + const freshVampire = initialVampireState(); + + const updatedVampire: NonNullable = { + ...freshVampire, + achievements: vampire.achievements, + awakening: updatedAwakening, + bosses: freshVampire.bosses.map((b) => { + const existing = vampire.bosses.find((vb) => { + return vb.id === b.id; + }); + return { + ...b, + bountyIchorClaimed: existing?.bountyIchorClaimed ?? false, + }; + }), + equipment: vampire.equipment, + eternalSovereignty: vampire.eternalSovereignty, + lastTickAt: Date.now(), + lifetimeBloodEarned: vampire.lifetimeBloodEarned, + lifetimeBossesDefeated: vampire.lifetimeBossesDefeated, + lifetimeQuestsCompleted: vampire.lifetimeQuestsCompleted, + totalBloodEarned: 0, + }; + + return { soulShardsEarned, updatedVampire }; +}; + +export { + buildPostAwakeningState, + calculateSoulShardsYield, + computeAwakeningMultipliers, + isEligibleForAwakening, +}; diff --git a/apps/api/src/services/siring.ts b/apps/api/src/services/siring.ts new file mode 100644 index 0000000..94d35c1 --- /dev/null +++ b/apps/api/src/services/siring.ts @@ -0,0 +1,202 @@ +/** + * @file Siring service handling eligibility checks and post-siring state building. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Function requires many steps */ +/* eslint-disable stylistic/max-len -- Service logic requires long lines */ +import { initialVampireState } from "../data/initialState.js"; +import { defaultVampireSiringUpgrades } from "../data/vampireSiringUpgrades.js"; +import type { GameState, SiringData } from "@elysium/types"; + +/** + * Base blood threshold for the first siring. + */ +const baseSiringThreshold = 1_000_000; + +/** + * Divisor used in the ichor yield formula. + */ +const ichorYieldDivisor = 50_000; + +/** + * Calculates the blood threshold required for the next siring. + * Formula: BASE * (count + 1)^2 * thresholdMultiplier. + * @param siringCount - The number of sirings completed so far. + * @param thresholdMultiplier - An optional multiplier applied to the threshold. + * @returns The blood amount required to sire. + */ +const calculateSiringThreshold = ( + siringCount: number, + thresholdMultiplier = 1, +): number => { + return ( + baseSiringThreshold + * Math.pow(siringCount + 1, 2) + * thresholdMultiplier + ); +}; + +/** + * Computes the combined threshold multiplier from purchased utility siring upgrades. + * @param purchasedUpgradeIds - The array of purchased siring upgrade IDs. + * @returns The combined threshold multiplier. + */ +const computeSiringThresholdMultiplier = ( + purchasedUpgradeIds: Array, +): number => { + return defaultVampireSiringUpgrades.filter((upgrade) => { + return upgrade.id.startsWith("siring_threshold_") && purchasedUpgradeIds.includes(upgrade.id); + }).reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); +}; + +/** + * Returns true if the player is eligible to sire: + * the total blood earned in the current run must meet the threshold. + * @param state - The current game state. + * @returns Whether the player is eligible for siring. + */ +const isEligibleForSiring = (state: GameState): boolean => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (state.vampire === undefined) { + return false; + } + const { siring, awakening, totalBloodEarned } = state.vampire; + const siringThresholdMultiplier = computeSiringThresholdMultiplier(siring.purchasedUpgradeIds); + const combinedMultiplier = siringThresholdMultiplier * awakening.soulShardsSiringThresholdMultiplier; + const threshold = calculateSiringThreshold(siring.count, combinedMultiplier); + return totalBloodEarned >= threshold; +}; + +/** + * Calculates the ichor yield from a siring. + * Formula: MAX(1, FLOOR(SQRT(totalBloodEarned / divisor) * ichorMultiplier)). + * @param totalBloodEarned - Total blood earned in the current siring run. + * @param ichorMultiplier - Multiplier applied to the ichor yield. + * @returns The ichor earned. + */ +const calculateIchorYield = ( + totalBloodEarned: number, + ichorMultiplier: number, +): number => { + return Math.max(1, Math.floor(Math.sqrt(totalBloodEarned / ichorYieldDivisor) * ichorMultiplier)); +}; + +/** + * Computes the siring production multiplier from the count. + * Each siring adds 25% to the production multiplier. + * @param count - The number of sirings completed. + * @returns The computed production multiplier as a number. + */ +const computeSiringProductionMultiplier = (count: number): number => { + const bonus = count * 0.25; + return 1 + bonus; +}; + +const getCategoryMultiplier = ( + purchasedUpgradeIds: Array, + category: string, +): number => { + return defaultVampireSiringUpgrades.filter((upgrade) => { + return ( + upgrade.category === category + && purchasedUpgradeIds.includes(upgrade.id) + ); + }).reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); +}; + +/** + * Computes all three ichor-upgrade multipliers from the purchased siring upgrade IDs. + * @param purchasedUpgradeIds - The array of purchased siring upgrade IDs. + * @returns An object containing the three ichor multiplier values. + */ +const computeSiringIchorMultipliers = ( + purchasedUpgradeIds: Array, +): Pick => { + return { + ichorBloodMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "blood"), + ichorCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"), + ichorThrallsMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "thralls"), + }; +}; + +/** + * Builds the updated vampire state after a siring reset. + * Resets the current run (bosses, quests, thralls, upgrades, zones, exploration crafting). + * Preserves: equipment, achievements, siring data (updated), awakening, lifetime stats, dark materials. + * @param state - The current game state before siring. + * @returns The ichor earned and the updated vampire state. + */ +const buildPostSiringState = ( + state: GameState, +): { ichorEarned: number; updatedVampire: NonNullable } => { + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure vampire exists */ + const vampire = state.vampire as NonNullable; + + const siringIchorYieldMultiplier = getCategoryMultiplier(vampire.siring.purchasedUpgradeIds, "ichor"); + const awakeningIchorMultiplier = vampire.awakening.soulShardsSiringIchorMultiplier; + const combinedIchorMultiplier = siringIchorYieldMultiplier * awakeningIchorMultiplier; + + const ichorEarned = calculateIchorYield(vampire.totalBloodEarned, combinedIchorMultiplier); + const updatedCount = vampire.siring.count + 1; + const updatedIchor = vampire.siring.ichor + ichorEarned; + const productionMultiplier = computeSiringProductionMultiplier(updatedCount); + + const updatedSiring: SiringData = { + ...vampire.siring, + count: updatedCount, + ichor: updatedIchor, + lastSiredAt: Date.now(), + productionMultiplier: productionMultiplier, + ...computeSiringIchorMultipliers(vampire.siring.purchasedUpgradeIds), + }; + + const freshVampire = initialVampireState(); + + const updatedVampire: NonNullable = { + ...freshVampire, + achievements: vampire.achievements, + awakening: vampire.awakening, + bosses: freshVampire.bosses.map((b) => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 7 -- @preserve */ + const existing = vampire.bosses.find((vb) => { + return vb.id === b.id; + }); + return { + ...b, + bountyIchorClaimed: existing?.bountyIchorClaimed ?? false, + }; + }), + equipment: vampire.equipment, + eternalSovereignty: vampire.eternalSovereignty, + exploration: { + ...freshVampire.exploration, + materials: vampire.exploration.materials, + }, + lastTickAt: Date.now(), + lifetimeBloodEarned: vampire.lifetimeBloodEarned + vampire.totalBloodEarned, + lifetimeBossesDefeated: vampire.lifetimeBossesDefeated, + lifetimeQuestsCompleted: vampire.lifetimeQuestsCompleted, + siring: updatedSiring, + totalBloodEarned: 0, + }; + + return { ichorEarned, updatedVampire }; +}; + +export { + buildPostSiringState, + calculateIchorYield, + calculateSiringThreshold, + computeSiringIchorMultipliers, + computeSiringProductionMultiplier, + computeSiringThresholdMultiplier, + isEligibleForSiring, +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 32b68de..4c1c549 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -47,8 +47,12 @@ export type { ApotheosisRequest, ApotheosisResponse, AuthResponse, + AwakeningRequest, + AwakeningResponse, BossChallengeRequest, BossChallengeResponse, + BuyAwakeningUpgradeRequest, + BuyAwakeningUpgradeResponse, BuyConsecrationUpgradeRequest, BuyConsecrationUpgradeResponse, BuyEchoUpgradeRequest, @@ -59,6 +63,10 @@ export type { BuyGoddessUpgradeResponse, BuyPrestigeUpgradeRequest, BuyPrestigeUpgradeResponse, + BuySiringUpgradeRequest, + BuySiringUpgradeResponse, + BuyVampireUpgradeRequest, + BuyVampireUpgradeResponse, ConsecrationRequest, ConsecrationResponse, CraftRecipeRequest, @@ -93,11 +101,23 @@ export type { PublicProfileResponse, SaveRequest, SaveResponse, + SiringRequest, + SiringResponse, SyncNewContentResponse, TranscendenceRequest, TranscendenceResponse, UpdateProfileRequest, UpdateProfileResponse, + VampireBossChallengeRequest, + VampireBossChallengeResponse, + VampireCraftRequest, + VampireCraftResponse, + VampireExploreClaimableResponse, + VampireExploreCollectEventResult, + VampireExploreCollectRequest, + VampireExploreCollectResponse, + VampireExploreStartRequest, + VampireExploreStartResponse, } from "./interfaces/api.js"; export type { Boss, BossStatus } from "./interfaces/boss.js"; export type { diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index dfeeab9..eb57d76 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -754,14 +754,159 @@ interface GoddessExploreClaimableResponse { claimable: boolean; } +interface VampireBossChallengeRequest { + bossId: string; +} + +interface VampireBossChallengeResponse { + won: boolean; + partyDPS: number; + bossDPS: number; + bossHpBefore: number; + bossMaxHp: number; + bossHpAtBattleEnd: number; + bossNewHp: number; + partyMaxHp: number; + partyHpRemaining: number; + rewards?: { + blood: number; + ichor: number; + soulShards: number; + upgradeIds: Array; + equipmentIds: Array; + bountyIchor: number; + }; + casualties?: Array<{ + thrallId: string; + killed: number; + }>; + + /** + * HMAC-SHA256 signature of the updated state for anti-cheat chain continuity. + */ + signature?: string; +} + +type SiringRequest = Record; + +interface SiringResponse { + + /** + * Ichor earned from this siring. + */ + ichorEarned: number; + + // eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response + newSiringCount: number; +} + +interface BuySiringUpgradeRequest { + upgradeId: string; +} + +interface BuySiringUpgradeResponse { + ichorRemaining: number; + purchasedUpgradeIds: Array; + ichorBloodMultiplier: number; + ichorThrallsMultiplier: number; + ichorCombatMultiplier: number; +} + +type AwakeningRequest = Record; + +interface AwakeningResponse { + + /** + * Soul Shards earned from this awakening. + */ + soulShardsEarned: number; + + // eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response + newAwakeningCount: number; +} + +interface BuyAwakeningUpgradeRequest { + upgradeId: string; +} + +interface BuyAwakeningUpgradeResponse { + soulShardsRemaining: number; + purchasedUpgradeIds: Array; + soulShardsBloodMultiplier: number; + soulShardsCombatMultiplier: number; + soulShardsSiringThresholdMultiplier: number; + soulShardsSiringIchorMultiplier: number; + soulShardsMetaMultiplier: number; +} + +interface BuyVampireUpgradeRequest { + upgradeId: string; +} + +interface BuyVampireUpgradeResponse { + bloodRemaining: number; + ichorRemaining: number; + soulShardsRemaining: number; +} + +interface VampireCraftRequest { + recipeId: string; +} + +interface VampireCraftResponse { + recipeId: string; + bonusType: string; + bonusValue: number; + craftedBloodMultiplier: number; + craftedIchorMultiplier: number; + craftedCombatMultiplier: number; + materials: Array<{ materialId: string; quantity: number }>; +} + +interface VampireExploreStartRequest { + areaId: string; +} + +interface VampireExploreStartResponse { + areaId: string; + endsAt: number; +} + +interface VampireExploreCollectRequest { + areaId: string; +} + +interface VampireExploreCollectEventResult { + text: string; + bloodChange: number; + ichorChange: number; + materialGained: { materialId: string; quantity: number } | null; + thrallLostCount: number; +} + +interface VampireExploreCollectResponse { + foundNothing: boolean; + nothingMessage?: string; + materialsFound: Array<{ materialId: string; quantity: number }>; + event: VampireExploreCollectEventResult | null; +} + +interface VampireExploreClaimableResponse { + claimable: boolean; +} + export type { AboutResponse, ApiError, ApotheosisRequest, ApotheosisResponse, AuthResponse, + AwakeningRequest, + AwakeningResponse, BossChallengeRequest, BossChallengeResponse, + BuyAwakeningUpgradeRequest, + BuyAwakeningUpgradeResponse, BuyConsecrationUpgradeRequest, BuyConsecrationUpgradeResponse, BuyEchoUpgradeRequest, @@ -772,6 +917,10 @@ export type { BuyGoddessUpgradeResponse, BuyPrestigeUpgradeRequest, BuyPrestigeUpgradeResponse, + BuySiringUpgradeRequest, + BuySiringUpgradeResponse, + BuyVampireUpgradeRequest, + BuyVampireUpgradeResponse, ConsecrationRequest, ConsecrationResponse, CraftRecipeRequest, @@ -806,9 +955,21 @@ export type { PublicProfileResponse, SaveRequest, SaveResponse, + SiringRequest, + SiringResponse, SyncNewContentResponse, TranscendenceRequest, TranscendenceResponse, UpdateProfileRequest, UpdateProfileResponse, + VampireBossChallengeRequest, + VampireBossChallengeResponse, + VampireCraftRequest, + VampireCraftResponse, + VampireExploreClaimableResponse, + VampireExploreCollectEventResult, + VampireExploreCollectRequest, + VampireExploreCollectResponse, + VampireExploreStartRequest, + VampireExploreStartResponse, };