diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 411cda0..e599b0e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,11 +12,17 @@ import { aboutRouter } from "./routes/about.js"; import { apotheosisRouter } from "./routes/apotheosis.js"; import { authRouter } from "./routes/auth.js"; import { bossRouter } from "./routes/boss.js"; +import { consecrationRouter } from "./routes/consecration.js"; import { craftRouter } from "./routes/craft.js"; import { debugRouter } from "./routes/debug.js"; +import { enlightenmentRouter } from "./routes/enlightenment.js"; import { exploreRouter } from "./routes/explore.js"; import { frontendRouter } from "./routes/frontend.js"; import { gameRouter } from "./routes/game.js"; +import { goddessBossRouter } from "./routes/goddessBoss.js"; +import { goddessCraftRouter } from "./routes/goddessCraft.js"; +import { goddessExploreRouter } from "./routes/goddessExplore.js"; +import { goddessUpgradeRouter } from "./routes/goddessUpgrade.js"; import { leaderboardRouter } from "./routes/leaderboards.js"; import { prestigeRouter } from "./routes/prestige.js"; import { profileRouter } from "./routes/profile.js"; @@ -48,6 +54,12 @@ app.route("/craft", craftRouter); app.route("/prestige", prestigeRouter); app.route("/transcendence", transcendenceRouter); app.route("/apotheosis", apotheosisRouter); +app.route("/goddess-boss", goddessBossRouter); +app.route("/consecration", consecrationRouter); +app.route("/enlightenment", enlightenmentRouter); +app.route("/goddess-upgrade", goddessUpgradeRouter); +app.route("/goddess-craft", goddessCraftRouter); +app.route("/goddess-explore", goddessExploreRouter); app.route("/leaderboards", leaderboardRouter); app.route("/profile", profileRouter); app.route("/timers", timersRouter); diff --git a/apps/api/src/routes/consecration.ts b/apps/api/src/routes/consecration.ts new file mode 100644 index 0000000..762004b --- /dev/null +++ b/apps/api/src/routes/consecration.ts @@ -0,0 +1,188 @@ +/** + * @file Consecration routes handling consecration resets and divinity 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 { defaultConsecrationUpgrades } from "../data/goddessConsecrationUpgrades.js"; +import { prisma } from "../db/client.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { + buildPostConsecrationState, + calculateConsecrationThreshold, + computeConsecrationDivinityMultipliers, + isEligibleForConsecration, +} from "../services/consecration.js"; +import { logger } from "../services/logger.js"; +import type { HonoEnvironment } from "../types/hono.js"; +import type { + BuyConsecrationUpgradeRequest, + ConsecrationResponse, + GameState, +} from "@elysium/types"; + +const consecrationRouter = new Hono(); + +consecrationRouter.use("*", authMiddleware); + +consecrationRouter.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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + if (!isEligibleForConsecration(state)) { + const thresholdMultiplier + = state.goddess.enlightenment.stardustConsecrationThresholdMultiplier; + const required = calculateConsecrationThreshold( + state.goddess.consecration.count, + thresholdMultiplier, + ); + return context.json( + { + error: `Not eligible for consecration — earn ${required.toLocaleString()} total prayers first`, + }, + 400, + ); + } + + const { divinityEarned, updatedGoddess } = buildPostConsecrationState(state); + + const updatedConsecrationCount = updatedGoddess.consecration.count; + + const updatedState: GameState = { + ...state, + goddess: updatedGoddess, + resources: { + ...state.resources, + prayers: 0, + }, + }; + + 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("consecration", 1, { discordId, updatedConsecrationCount }); + + const response: ConsecrationResponse = { + divinityEarned: divinityEarned, + // eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client + newConsecrationCount: updatedConsecrationCount, + }; + return context.json(response); + } catch (error) { + void logger.error( + "consecration", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +consecrationRouter.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 = defaultConsecrationUpgrades.find((consecrationUpgrade) => { + return consecrationUpgrade.id === upgradeId; + }); + if (!upgrade) { + return context.json({ error: "Unknown consecration 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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + const { purchasedUpgradeIds, divinity } = state.goddess.consecration; + + if (purchasedUpgradeIds.includes(upgradeId)) { + return context.json({ error: "Upgrade already purchased" }, 400); + } + + if (divinity < upgrade.divinityCost) { + return context.json({ error: "Not enough divinity" }, 400); + } + + const updatedDivinity = divinity - upgrade.divinityCost; + + const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ]; + + const updatedMultipliers = computeConsecrationDivinityMultipliers(updatedPurchasedIds); + + const updatedState: GameState = { + ...state, + goddess: { + ...state.goddess, + consecration: { + ...state.goddess.consecration, + divinity: updatedDivinity, + + 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("consecration_upgrade_purchased", 1, { discordId, upgradeId }); + return context.json({ + divinityRemaining: updatedDivinity, + + purchasedUpgradeIds: updatedPurchasedIds, + ...updatedMultipliers, + }); + } catch (error) { + void logger.error( + "consecration_buy_upgrade", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { consecrationRouter }; diff --git a/apps/api/src/routes/enlightenment.ts b/apps/api/src/routes/enlightenment.ts new file mode 100644 index 0000000..659c4d5 --- /dev/null +++ b/apps/api/src/routes/enlightenment.ts @@ -0,0 +1,180 @@ +/** + * @file Enlightenment routes handling enlightenment resets and stardust 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 { defaultEnlightenmentUpgrades } from "../data/goddessEnlightenmentUpgrades.js"; +import { prisma } from "../db/client.js"; +import { authMiddleware } from "../middleware/auth.js"; +import { + buildPostEnlightenmentState, + computeEnlightenmentMultipliers, + isEligibleForEnlightenment, +} from "../services/enlightenment.js"; +import { logger } from "../services/logger.js"; +import type { HonoEnvironment } from "../types/hono.js"; +import type { + BuyEnlightenmentUpgradeRequest, + EnlightenmentResponse, + GameState, +} from "@elysium/types"; + +const enlightenmentRouter = new Hono(); + +enlightenmentRouter.use("*", authMiddleware); + +enlightenmentRouter.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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + if (!isEligibleForEnlightenment(state)) { + return context.json( + { + error: "Not eligible for enlightenment — defeat the Divine Heart Sovereign first", + }, + 400, + ); + } + + const { stardustEarned, updatedGoddess } = buildPostEnlightenmentState(state); + + const updatedEnlightenmentCount = updatedGoddess.enlightenment.count; + + const updatedState: GameState = { + ...state, + goddess: updatedGoddess, + resources: { + ...state.resources, + prayers: 0, + }, + }; + + 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("enlightenment", 1, { discordId, updatedEnlightenmentCount }); + + const response: EnlightenmentResponse = { + // eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client + newEnlightenmentCount: updatedEnlightenmentCount, + stardustEarned: stardustEarned, + }; + return context.json(response); + } catch (error) { + void logger.error( + "enlightenment", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +enlightenmentRouter.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 = defaultEnlightenmentUpgrades.find((enlightenmentUpgrade) => { + return enlightenmentUpgrade.id === upgradeId; + }); + if (!upgrade) { + return context.json({ error: "Unknown enlightenment 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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + const { purchasedUpgradeIds, stardust } = state.goddess.enlightenment; + + if (purchasedUpgradeIds.includes(upgradeId)) { + return context.json({ error: "Upgrade already purchased" }, 400); + } + + if (stardust < upgrade.cost) { + return context.json({ error: "Not enough stardust" }, 400); + } + + const updatedStardust = stardust - upgrade.cost; + + const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ]; + + const updatedMultipliers = computeEnlightenmentMultipliers(updatedPurchasedIds); + + const updatedState: GameState = { + ...state, + goddess: { + ...state.goddess, + enlightenment: { + ...state.goddess.enlightenment, + + purchasedUpgradeIds: updatedPurchasedIds, + stardust: updatedStardust, + ...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("enlightenment_upgrade_purchased", 1, { discordId, upgradeId }); + return context.json({ + + purchasedUpgradeIds: updatedPurchasedIds, + stardustRemaining: updatedStardust, + ...updatedMultipliers, + }); + } catch (error) { + void logger.error( + "enlightenment_buy_upgrade", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { enlightenmentRouter }; diff --git a/apps/api/src/routes/goddessBoss.ts b/apps/api/src/routes/goddessBoss.ts new file mode 100644 index 0000000..d53aa14 --- /dev/null +++ b/apps/api/src/routes/goddessBoss.ts @@ -0,0 +1,415 @@ +/** + * @file Goddess boss challenge route handling divine 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 { + computeGoddessSetBonuses, + type GameState, + type GoddessBossChallengeResponse, +} from "@elysium/types"; +import { Hono } from "hono"; +import { defaultGoddessBosses } from "../data/goddessBosses.js"; +import { defaultGoddessEquipmentSets } from "../data/goddessEquipmentSets.js"; +import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.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 goddessBossRouter = new Hono(); + +goddessBossRouter.use("*", authMiddleware); + +const calculateDiscipleStats = ( + goddess: NonNullable, +): { partyDPS: number; partyMaxHp: number } => { + let globalMultiplier = 1; + for (const upgrade of goddess.upgrades) { + if (upgrade.purchased && upgrade.target === "global") { + globalMultiplier = globalMultiplier * upgrade.multiplier; + } + } + + // Apply consecration production multiplier as a combat boost + const consecrationCombatMultiplier + = goddess.consecration.divinityCombatMultiplier ?? 1; + const { stardustCombatMultiplier } = goddess.enlightenment; + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 7 -- @preserve */ + const equipmentCombatMultiplier = goddess.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 = goddess.equipment. + filter((item) => { + return item.equipped; + }). + map((item) => { + return item.id; + }); + const { combatMultiplier: setCombatMultiplier } = computeGoddessSetBonuses( + equippedItemIds, + defaultGoddessEquipmentSets, + ); + + let partyDPS = 0; + let partyMaxHp = 0; + + for (const disciple of goddess.disciples) { + if (disciple.count === 0) { + continue; + } + + let discipleMultiplier = 1; + for (const upgrade of goddess.upgrades) { + if ( + upgrade.purchased + && upgrade.target === "disciple" + && upgrade.discipleId === disciple.id + ) { + discipleMultiplier = discipleMultiplier * upgrade.multiplier; + } + } + + const discipleContribution + = disciple.combatPower + * disciple.count + * discipleMultiplier + * globalMultiplier; + partyDPS = partyDPS + discipleContribution; + + const discipleHp = disciple.level * 50 * disciple.count; + partyMaxHp = partyMaxHp + discipleHp; + } + + const { craftedCombatMultiplier } = goddess.exploration; + + partyDPS = partyDPS + * equipmentCombatMultiplier + * setCombatMultiplier + * consecrationCombatMultiplier + * stardustCombatMultiplier + * craftedCombatMultiplier; + + return { partyDPS, partyMaxHp }; +}; + +goddessBossRouter.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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + const { goddess } = state; + + const boss = goddess.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.consecrationRequirement > goddess.consecration.count) { + return context.json({ error: "Consecration requirement not met" }, 403); + } + + const { partyDPS, partyMaxHp } = calculateDiscipleStats(goddess); + + if ( + partyDPS === 0 + || partyMaxHp === 0 + || !Number.isFinite(partyDPS) + || !Number.isFinite(partyMaxHp) + ) { + return context.json( + { error: "Your disciples 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: GoddessBossChallengeResponse["rewards"]; + // eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss + let casualties: GoddessBossChallengeResponse["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.prayers = (state.resources.prayers ?? 0) + boss.prayersReward; + goddess.totalPrayersEarned + = goddess.totalPrayersEarned + boss.prayersReward; + goddess.lifetimePrayersEarned + = goddess.lifetimePrayersEarned + boss.prayersReward; + goddess.consecration.divinity + = goddess.consecration.divinity + boss.divinityReward; + goddess.enlightenment.stardust + = goddess.enlightenment.stardust + boss.stardustReward; + goddess.lifetimeBossesDefeated + = goddess.lifetimeBossesDefeated + 1; + + for (const upgradeId of boss.upgradeRewards) { + const upgrade = goddess.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 = goddess.equipment.find((item) => { + return item.id === equipmentId; + }); + if (equipment) { + equipment.owned = true; + const slotAlreadyEquipped = goddess.equipment.some((item) => { + return item.type === equipment.type && item.equipped; + }); + if (!slotAlreadyEquipped) { + equipment.equipped = true; + } + } + } + + // Unlock next boss in the same zone + const zoneBosses = goddess.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.consecrationRequirement <= goddess.consecration.count + ) { + const nextBossInState = goddess.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 goddess.zones) { + if (zone.status === "unlocked") { + continue; + } + if (zone.unlockBossId !== body.bossId) { + continue; + } + + const questSatisfied + = zone.unlockQuestId === null + || goddess.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 goddess.exploration.areas) { + const areaDefinition = defaultGoddessExplorationAreas.find((explorationArea) => { + return explorationArea.id === area.id; + }); + if (areaDefinition?.zoneId === zone.id && area.status === "locked") { + area.status = "available"; + } + } + + const updatedZoneBosses = goddess.bosses.filter((b) => { + return b.zoneId === zone.id; + }); + const [ firstUpdatedBoss ] = updatedZoneBosses; + if ( + firstUpdatedBoss + && firstUpdatedBoss.consecrationRequirement <= goddess.consecration.count + ) { + firstUpdatedBoss.status = "available"; + } + } + + // First-kill divinity bounty — only awarded once + const staticBoss = defaultGoddessBosses.find((b) => { + return b.id === body.bossId; + }); + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 7 -- @preserve */ + const bountyDivinity + = boss.bountyDivinityClaimed === true + ? 0 + : staticBoss?.bountyDivinity ?? 0; + if (bountyDivinity > 0) { + boss.bountyDivinityClaimed = true; + } + goddess.consecration.divinity + = goddess.consecration.divinity + bountyDivinity; + + rewards = { + bountyDivinity: bountyDivinity, + divinity: boss.divinityReward, + equipmentIds: boss.equipmentRewards, + prayers: boss.prayersReward, + stardust: boss.stardustReward, + 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 disciple of goddess.disciples) { + if (disciple.count === 0) { + continue; + } + const killed = Math.floor(disciple.count * casualtyFraction); + if (killed > 0) { + disciple.count = Math.max(1, disciple.count - killed); + + casualties.push({ discipleId: disciple.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 secret = process.env.ANTI_CHEAT_SECRET; + const updatedSignature = secret === undefined + ? undefined + : computeHmac(JSON.stringify(state), secret); + + const { bossId } = body; + void logger.metric("goddess_boss_challenge", 1, { bossId, discordId, won }); + + const bossMaxHp = boss.maxHp; + const bossNewHp = bossUpdatedHp; + const response: GoddessBossChallengeResponse = { + 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( + "goddess_boss_challenge", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { goddessBossRouter }; diff --git a/apps/api/src/routes/goddessCraft.ts b/apps/api/src/routes/goddessCraft.ts new file mode 100644 index 0000000..dc8c74a --- /dev/null +++ b/apps/api/src/routes/goddessCraft.ts @@ -0,0 +1,173 @@ +/** + * @file Goddess crafting route handling divine 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 { defaultGoddessCraftingRecipes } from "../data/goddessCrafting.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 { + GoddessCraftRequest, + GoddessCraftResponse, + GameState, +} from "@elysium/types"; + +const goddessCraftRouter = new Hono(); + +goddessCraftRouter.use("*", authMiddleware); + +const recomputeGoddessCraftedMultipliers = ( + craftedRecipeIds: Array, +): { + craftedPrayersMultiplier: number; + craftedDivinityMultiplier: number; + craftedCombatMultiplier: number; +} => { + return { + craftedCombatMultiplier: defaultGoddessCraftingRecipes.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), + craftedDivinityMultiplier: defaultGoddessCraftingRecipes.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), + craftedPrayersMultiplier: defaultGoddessCraftingRecipes.filter((recipe) => { + return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income"; + }).reduce((mult, recipe) => { + return mult * recipe.bonus.value; + }, 1), + }; +}; + +goddessCraftRouter.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 = defaultGoddessCraftingRecipes.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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + if (state.goddess.exploration.craftedRecipeIds.includes(recipeId)) { + return context.json({ error: "Recipe already crafted" }, 400); + } + + // Verify the player has all required sacred materials + for (const requirement of recipe.requiredMaterials) { + const material = state.goddess.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 sacred materials + for (const requirement of recipe.requiredMaterials) { + const material = state.goddess.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.goddess.exploration.craftedRecipeIds.push(recipeId); + + const updatedMultipliers = recomputeGoddessCraftedMultipliers( + state.goddess.exploration.craftedRecipeIds, + ); + state.goddess.exploration.craftedPrayersMultiplier + = updatedMultipliers.craftedPrayersMultiplier; + state.goddess.exploration.craftedDivinityMultiplier + = updatedMultipliers.craftedDivinityMultiplier; + state.goddess.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("goddess_recipe_crafted", 1, { discordId, recipeId }); + + const bonusType = recipe.bonus.type; + const bonusValue = recipe.bonus.value; + + const { materials } = state.goddess.exploration; + const { + craftedPrayersMultiplier, + craftedDivinityMultiplier, + craftedCombatMultiplier, + } = updatedMultipliers; + + const response: GoddessCraftResponse = { + bonusType, + bonusValue, + craftedCombatMultiplier, + craftedDivinityMultiplier, + craftedPrayersMultiplier, + materials, + recipeId, + }; + + return context.json(response); + } catch (error) { + void logger.error( + "goddess_craft", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { goddessCraftRouter }; diff --git a/apps/api/src/routes/goddessExplore.ts b/apps/api/src/routes/goddessExplore.ts new file mode 100644 index 0000000..2ec150b --- /dev/null +++ b/apps/api/src/routes/goddessExplore.ts @@ -0,0 +1,418 @@ +/** + * @file Goddess exploration routes handling divine 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 { defaultGoddessExplorationAreas } from "../data/goddessExplorations.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 { + GoddessExploreClaimableResponse, + GoddessExploreCollectEventResult, + GoddessExploreCollectRequest, + GoddessExploreCollectResponse, + GoddessExploreStartRequest, + GoddessExploreStartResponse, + GameState, +} from "@elysium/types"; + +const goddessExploreRouter = new Hono(); + +goddessExploreRouter.use("*", authMiddleware); + +const nothingProbability = 0.2; + +const nothingMessages = [ + "Your disciples searched every corner of the divine realm but found nothing of value.", + "The sacred area yielded nothing remarkable this time.", + "Your disciples returned empty-handed from the divine realm.", + "A wasted journey — the sacred area proved barren.", + "Nothing to show for the devotion. 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] ?? ""; +}; + +goddessExploreRouter.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 = defaultGoddessExplorationAreas.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.goddess) { + const response: GoddessExploreClaimableResponse = { claimable: false }; + return context.json(response); + } + + const area = state.goddess.exploration.areas.find((a) => { + return a.id === areaId; + }); + + if (!area || area.status !== "in_progress") { + const response: GoddessExploreClaimableResponse = { 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: GoddessExploreClaimableResponse = { claimable }; + return context.json(response); + } catch (error) { + void logger.error( + "goddess_explore_claimable", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +goddessExploreRouter.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 = defaultGoddessExplorationAreas.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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + const zone = state.goddess.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.goddess.exploration.areas.find((a) => { + return a.id === areaId; + }); + if (!area) { + return context.json( + { error: "Exploration area not found in state" }, + 404, + ); + } + + const anyInProgress = state.goddess.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: GoddessExploreStartResponse = { + areaId, + endsAt, + }; + + return context.json(response); + } catch (error) { + void logger.error( + "goddess_explore_start", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +goddessExploreRouter.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 = defaultGoddessExplorationAreas.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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + const area = state.goddess.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: GoddessExploreCollectResponse = { + 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 prayersChange = 0; + let materialGained: { materialId: string; quantity: number } | null = null; + + if (event.effect.type === "prayers_gain") { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + const amount = event.effect.amount ?? 0; + state.resources.prayers = (state.resources.prayers ?? 0) + amount; + state.goddess.totalPrayersEarned = state.goddess.totalPrayersEarned + amount; + prayersChange = amount; + } else if (event.effect.type === "prayers_loss") { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + const amount = Math.min(state.resources.prayers ?? 0, event.effect.amount ?? 0); + state.resources.prayers = (state.resources.prayers ?? 0) - amount; + prayersChange = -amount; + } else if (event.effect.type === "divinity_gain") { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + const amount = event.effect.amount ?? 0; + state.goddess.consecration.divinity = state.goddess.consecration.divinity + amount; + } else if (event.effect.type === "sacred_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.goddess.exploration.materials.find((m) => { + return m.materialId === materialId; + }); + if (existing) { + existing.quantity = existing.quantity + quantity; + } else { + state.goddess.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 === "disciple_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 disciple of state.goddess.disciples) { + const lost = Math.floor(disciple.count * fraction); + if (lost > 0) { + disciple.count = Math.max(0, disciple.count - lost); + } + } + } + + let discipleLostCount = 0; + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 8 -- @preserve */ + if (event.effect.type === "disciple_loss") { + const fraction = event.effect.fraction ?? 0.05; + for (const disciple of state.goddess.disciples) { + const lost = Math.floor(disciple.count * fraction); + discipleLostCount = discipleLostCount + lost; + } + } + + const eventResult: GoddessExploreCollectEventResult = { + discipleLostCount: discipleLostCount, + materialGained: materialGained, + prayersChange: prayersChange, + text: event.text, + }; + + // Roll for sacred 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.goddess.exploration.materials.find((m) => { + return m.materialId === materialId; + }); + if (existing) { + existing.quantity = existing.quantity + quantity; + } else { + state.goddess.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: GoddessExploreCollectResponse = { + event: eventResult, + foundNothing: false, + materialsFound: materialsFound, + }; + + return context.json(response); + } catch (error) { + void logger.error( + "goddess_explore_collect", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { goddessExploreRouter }; diff --git a/apps/api/src/routes/goddessUpgrade.ts b/apps/api/src/routes/goddessUpgrade.ts new file mode 100644 index 0000000..a147efb --- /dev/null +++ b/apps/api/src/routes/goddessUpgrade.ts @@ -0,0 +1,125 @@ +/** + * @file Goddess 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 { defaultGoddessUpgrades } from "../data/goddessUpgrades.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 { + BuyGoddessUpgradeRequest, + BuyGoddessUpgradeResponse, + GameState, +} from "@elysium/types"; + +const goddessUpgradeRouter = new Hono(); + +goddessUpgradeRouter.use("*", authMiddleware); + +goddessUpgradeRouter.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 = defaultGoddessUpgrades.find((goddessUpgrade) => { + return goddessUpgrade.id === upgradeId; + }); + if (!upgradeTemplate) { + return context.json({ error: "Unknown goddess 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.goddess) { + return context.json({ error: "Goddess realm not unlocked" }, 400); + } + + const upgrade = state.goddess.upgrades.find((u) => { + return u.id === upgradeId; + }); + + if (!upgrade) { + return context.json({ error: "Upgrade not found in goddess 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 currentPrayers = state.resources.prayers ?? 0; + const currentDivinity = state.goddess.consecration.divinity; + const currentStardust = state.goddess.enlightenment.stardust; + + if (currentPrayers < upgradeTemplate.costPrayers) { + return context.json({ error: "Not enough prayers" }, 400); + } + + if (currentDivinity < upgradeTemplate.costDivinity) { + return context.json({ error: "Not enough divinity" }, 400); + } + + if (currentStardust < upgradeTemplate.costStardust) { + return context.json({ error: "Not enough stardust" }, 400); + } + + upgrade.purchased = true; + + const updatedPrayers = currentPrayers - upgradeTemplate.costPrayers; + const updatedDivinity = currentDivinity - upgradeTemplate.costDivinity; + const updatedStardust = currentStardust - upgradeTemplate.costStardust; + + state.resources.prayers = updatedPrayers; + state.goddess.consecration.divinity = updatedDivinity; + state.goddess.enlightenment.stardust = updatedStardust; + + 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("goddess_upgrade_purchased", 1, { discordId, upgradeId }); + + const response: BuyGoddessUpgradeResponse = { + divinityRemaining: updatedDivinity, + prayersRemaining: updatedPrayers, + stardustRemaining: updatedStardust, + }; + return context.json(response); + } catch (error) { + void logger.error( + "goddess_upgrade_buy", + error instanceof Error + ? error + : new Error(String(error)), + ); + return context.json({ error: "Internal server error" }, 500); + } +}); + +export { goddessUpgradeRouter }; diff --git a/apps/api/src/services/consecration.ts b/apps/api/src/services/consecration.ts new file mode 100644 index 0000000..84e0a57 --- /dev/null +++ b/apps/api/src/services/consecration.ts @@ -0,0 +1,201 @@ +/** + * @file Consecration service handling eligibility checks and post-consecration 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 { defaultConsecrationUpgrades } from "../data/goddessConsecrationUpgrades.js"; +import { initialGoddessState } from "../data/initialState.js"; +import type { ConsecrationData, GameState } from "@elysium/types"; + +/** + * Base prayers threshold for the first consecration. + */ +const baseConsecrationThreshold = 50_000; + +/** + * Divisor used in the divinity yield formula. + */ +const divinityYieldDivisor = 1000; + +/** + * Calculates the prayers threshold required for the next consecration. + * Formula: BASE * (count + 1)^2 * thresholdMultiplier. + * @param consecrationCount - The number of consecrations completed so far. + * @param thresholdMultiplier - An optional stardust-upgrade multiplier applied to the threshold. + * @returns The prayers amount required to consecrate. + */ +const calculateConsecrationThreshold = ( + consecrationCount: number, + thresholdMultiplier = 1, +): number => { + return ( + baseConsecrationThreshold + * Math.pow(consecrationCount + 1, 2) + * thresholdMultiplier + ); +}; + +/** + * Returns true if the player is eligible to consecrate: + * the total prayers earned in the current run must meet the threshold. + * @param state - The current game state. + * @returns Whether the player is eligible for consecration. + */ +const isEligibleForConsecration = (state: GameState): boolean => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (state.goddess === undefined) { + return false; + } + const thresholdMultiplier + = state.goddess.enlightenment.stardustConsecrationThresholdMultiplier; + const threshold = calculateConsecrationThreshold( + state.goddess.consecration.count, + thresholdMultiplier, + ); + return state.goddess.totalPrayersEarned >= threshold; +}; + +/** + * Calculates the divinity yield from a consecration. + * Formula: MAX(1, FLOOR(SQRT(totalPrayersEarned / divisor) * divinityMultiplier)). + * @param totalPrayersEarned - Total prayers earned in the current consecration run. + * @param divinityMultiplier - Multiplier from stardust upgrades applied to divinity yield. + * @returns The divinity earned. + */ +const calculateDivinityYield = ( + totalPrayersEarned: number, + divinityMultiplier: number, +): number => { + return Math.max( + 1, + Math.floor( + Math.sqrt(totalPrayersEarned / divinityYieldDivisor) * divinityMultiplier, + ), + ); +}; + +/** + * Computes the consecration production multiplier from the count. + * Each consecration adds 25% to the production multiplier. + * @param count - The number of consecrations completed. + * @returns The computed production multiplier as a number. + */ +const computeConsecrationProductionMultiplier = (count: number): number => { + const bonus = count * 0.25; + return 1 + bonus; +}; + +const getCategoryMultiplier = ( + purchasedUpgradeIds: Array, + category: string, +): number => { + return defaultConsecrationUpgrades.filter((upgrade) => { + return ( + upgrade.category === category + && purchasedUpgradeIds.includes(upgrade.id) + ); + }).reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); +}; + +/** + * Computes all three divinity-upgrade multipliers from the purchased upgrade IDs. + * @param purchasedUpgradeIds - The array of purchased consecration upgrade IDs. + * @returns An object containing the three divinity multiplier values. + */ +const computeConsecrationDivinityMultipliers = ( + purchasedUpgradeIds: Array, +): Pick< + ConsecrationData, + | "divinityCombatMultiplier" + | "divinityDisciplesMultiplier" + | "divinityPrayersMultiplier" +> => { + return { + divinityCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"), + divinityDisciplesMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "disciples"), + divinityPrayersMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prayers"), + }; +}; + +/** + * Builds the updated goddess state after a consecration reset. + * Resets the current run (bosses, quests, disciples, upgrades, zones, exploration crafting). + * Preserves: equipment, achievements, consecration data (updated), enlightenment, lifetime stats, sacred materials. + * @param state - The current game state before consecration. + * @returns The divinity earned and the updated goddess state. + */ +const buildPostConsecrationState = ( + state: GameState, +): { divinityEarned: number; updatedGoddess: NonNullable } => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure goddess exists + const goddess = state.goddess as NonNullable; + + const divinityMultiplier + = goddess.enlightenment.stardustConsecrationDivinityMultiplier; + const divinityEarned = calculateDivinityYield( + goddess.totalPrayersEarned, + divinityMultiplier, + ); + + const updatedCount = goddess.consecration.count + 1; + const updatedDivinity = goddess.consecration.divinity + divinityEarned; + const productionMultiplier = computeConsecrationProductionMultiplier(updatedCount); + + const updatedConsecration: ConsecrationData = { + ...goddess.consecration, + count: updatedCount, + divinity: updatedDivinity, + lastConsecratedAt: Date.now(), + productionMultiplier: productionMultiplier, + ...computeConsecrationDivinityMultipliers( + goddess.consecration.purchasedUpgradeIds, + ), + }; + + const freshGoddess = initialGoddessState(); + + const updatedGoddess: NonNullable = { + ...freshGoddess, + achievements: goddess.achievements, + bosses: freshGoddess.bosses.map((b) => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 7 -- @preserve */ + const existing = goddess.bosses.find((gb) => { + return gb.id === b.id; + }); + return { + ...b, + bountyDivinityClaimed: existing?.bountyDivinityClaimed ?? false, + }; + }), + consecration: updatedConsecration, + enlightenment: goddess.enlightenment, + equipment: goddess.equipment, + exploration: { + ...freshGoddess.exploration, + materials: goddess.exploration.materials, + }, + lastTickAt: Date.now(), + lifetimeBossesDefeated: goddess.lifetimeBossesDefeated, + lifetimePrayersEarned: goddess.lifetimePrayersEarned + goddess.totalPrayersEarned, + lifetimeQuestsCompleted: goddess.lifetimeQuestsCompleted, + totalPrayersEarned: 0, + }; + + return { divinityEarned, updatedGoddess }; +}; + +export { + buildPostConsecrationState, + calculateConsecrationThreshold, + calculateDivinityYield, + computeConsecrationDivinityMultipliers, + computeConsecrationProductionMultiplier, + isEligibleForConsecration, +}; diff --git a/apps/api/src/services/enlightenment.ts b/apps/api/src/services/enlightenment.ts new file mode 100644 index 0000000..7bc797d --- /dev/null +++ b/apps/api/src/services/enlightenment.ts @@ -0,0 +1,137 @@ +/** + * @file Enlightenment service handling eligibility checks and post-enlightenment state building. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable stylistic/max-len -- Service logic requires long lines */ +import { defaultEnlightenmentUpgrades } from "../data/goddessEnlightenmentUpgrades.js"; +import { initialGoddessState } from "../data/initialState.js"; +import type { EnlightenmentData, GameState } from "@elysium/types"; + +/** + * ID of the final goddess boss — must be defeated to unlock Enlightenment. + */ +const finalGoddessBossId = "divine_heart_sovereign"; + +const getCategoryMultiplier = ( + purchasedIds: Array, + category: string, +): number => { + return defaultEnlightenmentUpgrades.filter((upgrade) => { + return upgrade.category === category && purchasedIds.includes(upgrade.id); + }).reduce((mult, upgrade) => { + return mult * upgrade.multiplier; + }, 1); +}; + +/** + * Computes all five stardust multipliers from the purchased enlightenment upgrade IDs. + * @param purchasedUpgradeIds - The array of purchased enlightenment upgrade IDs. + * @returns An object containing all five stardust multiplier values. + */ +const computeEnlightenmentMultipliers = ( + purchasedUpgradeIds: Array, +): Omit => { + return { + stardustCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"), + stardustConsecrationDivinityMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "consecration_divinity"), + stardustConsecrationThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "consecration_threshold"), + stardustMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "stardust_meta"), + stardustPrayersMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prayers"), + }; +}; + +/** + * Returns true when the player is eligible for Enlightenment: + * they must have defeated the final goddess boss at least once. + * @param state - The current game state. + * @returns Whether the player is eligible for Enlightenment. + */ +const isEligibleForEnlightenment = (state: GameState): boolean => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (state.goddess === undefined) { + return false; + } + return state.goddess.bosses.some((boss) => { + return boss.id === finalGoddessBossId && boss.status === "defeated"; + }); +}; + +/** + * Calculates the stardust yield from an Enlightenment. + * Formula: MAX(1, FLOOR(SQRT(consecrationCount) * metaMultiplier)). + * @param consecrationCount - The number of consecrations completed before this Enlightenment. + * @param metaMultiplier - Multiplier from prior enlightenment upgrades applied to stardust yield. + * @returns The stardust earned. + */ +const calculateStardustYield = ( + consecrationCount: number, + metaMultiplier: number, +): number => { + return Math.max(1, Math.floor(Math.sqrt(consecrationCount) * metaMultiplier)); +}; + +/** + * Builds the updated goddess state after an Enlightenment — a full goddess reset. + * Wipes everything including consecration, preserving only equipment, achievements, and enlightenment data. + * @param state - The current game state before enlightenment. + * @returns The stardust earned and the updated goddess state. + */ +const buildPostEnlightenmentState = ( + state: GameState, +): { stardustEarned: number; updatedGoddess: NonNullable } => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure goddess exists + const goddess = state.goddess as NonNullable; + + const metaMultiplier = goddess.enlightenment.stardustMetaMultiplier; + const stardustEarned = calculateStardustYield( + goddess.consecration.count, + metaMultiplier, + ); + + const updatedCount = goddess.enlightenment.count + 1; + const updatedStardust = goddess.enlightenment.stardust + stardustEarned; + const updatedPurchasedIds = goddess.enlightenment.purchasedUpgradeIds; + const updatedMultipliers = computeEnlightenmentMultipliers(updatedPurchasedIds); + + const updatedEnlightenment: EnlightenmentData = { + count: updatedCount, + purchasedUpgradeIds: updatedPurchasedIds, + stardust: updatedStardust, + ...updatedMultipliers, + }; + + const freshGoddess = initialGoddessState(); + + const updatedGoddess: NonNullable = { + ...freshGoddess, + achievements: goddess.achievements, + bosses: freshGoddess.bosses.map((b) => { + const existing = goddess.bosses.find((gb) => { + return gb.id === b.id; + }); + return { + ...b, + bountyDivinityClaimed: existing?.bountyDivinityClaimed ?? false, + }; + }), + enlightenment: updatedEnlightenment, + equipment: goddess.equipment, + lastTickAt: Date.now(), + lifetimeBossesDefeated: goddess.lifetimeBossesDefeated, + lifetimePrayersEarned: goddess.lifetimePrayersEarned, + lifetimeQuestsCompleted: goddess.lifetimeQuestsCompleted, + totalPrayersEarned: 0, + }; + + return { stardustEarned, updatedGoddess }; +}; + +export { + buildPostEnlightenmentState, + calculateStardustYield, + computeEnlightenmentMultipliers, + isEligibleForEnlightenment, +}; diff --git a/apps/api/test/routes/consecration.spec.ts b/apps/api/test/routes/consecration.spec.ts new file mode 100644 index 0000000..c1388ab --- /dev/null +++ b/apps/api/test/routes/consecration.spec.ts @@ -0,0 +1,295 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-lines -- Test suites naturally have many cases */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type { GameState } from "@elysium/types"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + gameState: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("../../src/middleware/auth.js", () => ({ + authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise) => { + c.set("discordId", "test_discord_id"); + await next(); + }), +})); + +vi.mock("../../src/services/logger.js", () => ({ + logger: { + error: vi.fn().mockResolvedValue(undefined), + metric: vi.fn().mockResolvedValue(undefined), + }, +})); + +const DISCORD_ID = "test_discord_id"; + +const makeConsecration = (overrides: Record = {}) => ({ + count: 0, + divinity: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [] as string[], + ...overrides, +}); + +const makeEnlightenment = (overrides: Record = {}) => ({ + count: 0, + stardust: 0, + purchasedUpgradeIds: [] as string[], + stardustPrayersMultiplier: 1, + stardustCombatMultiplier: 1, + stardustConsecrationThresholdMultiplier: 1, + stardustConsecrationDivinityMultiplier: 1, + stardustMetaMultiplier: 1, + ...overrides, +}); + +const makeGoddessExploration = (overrides: Record = {}) => ({ + areas: [] as Array<{ id: string; status: string }>, + materials: [] as Array<{ materialId: string; quantity: number }>, + craftedRecipeIds: [] as string[], + craftedPrayersMultiplier: 1, + craftedDivinityMultiplier: 1, + craftedCombatMultiplier: 1, + ...overrides, +}); + +/** + * Creates a minimal GoddessState that has met the first consecration threshold (50 000 prayers). + */ +const makeGoddessStateEligible = (overrides: Record = {}) => ({ + zones: [] as Array<{ id: string; status: string }>, + bosses: [] as Array<{ id: string; status: string }>, + quests: [] as Array<{ id: string; status: string }>, + disciples: [] as Array<{ id: string; count: number }>, + equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record }>, + upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number }>, + achievements: [] as Array<{ id: string; completed: boolean }>, + consecration: makeConsecration(), + enlightenment: makeEnlightenment(), + exploration: makeGoddessExploration(), + totalPrayersEarned: 50_000, // Meets base threshold for count=0 + lifetimePrayersEarned: 50_000, + lifetimeBossesDefeated: 0, + lifetimeQuestsCompleted: 0, + baseClickPower: 1, + lastTickAt: 0, + ...overrides, +}); + +/** + * Creates a minimal GoddessState that has NOT met the consecration threshold. + */ +const makeGoddessStateIneligible = (overrides: Record = {}) => ({ + ...makeGoddessStateEligible(), + totalPrayersEarned: 0, + ...overrides, +}); + +const makeState = (overrides: Partial = {}): GameState => ({ + player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, + resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 }, + adventurers: [], + upgrades: [], + quests: [], + bosses: [], + equipment: [], + achievements: [], + zones: [], + exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 }, + companions: { unlockedCompanionIds: [], activeCompanionId: null }, + prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + baseClickPower: 1, + lastTickAt: 0, + schemaVersion: 1, + goddess: makeGoddessStateEligible() as GameState["goddess"], + ...overrides, +} as GameState); + +describe("consecration route", () => { + let app: Hono; + let prisma: { gameState: { findUnique: ReturnType; update: ReturnType } }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { consecrationRouter } = await import("../../src/routes/consecration.js"); + const { prisma: p } = await import("../../src/db/client.js"); + prisma = p as typeof prisma; + app = new Hono(); + app.route("/consecration", consecrationRouter); + }); + + const post = (path: string, body?: Record) => + app.fetch(new Request(`http://localhost/consecration${path}`, { + method: "POST", + headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + })); + + describe("POST /", () => { + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await post(""); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess realm is not unlocked", async () => { + const state = makeState({ goddess: undefined }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post(""); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Goddess realm not unlocked"); + }); + + it("returns 400 with threshold message when not eligible", async () => { + const state = makeState({ + goddess: makeGoddessStateIneligible() as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post(""); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toMatch(/Not eligible for consecration/u); + expect(body.error).toMatch(/50,000/u); + }); + + it("returns 200 with divinityEarned and newConsecrationCount on success", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await post(""); + expect(res.status).toBe(200); + const body = await res.json() as { divinityEarned: number; newConsecrationCount: number }; + expect(body.newConsecrationCount).toBe(1); + expect(body.divinityEarned).toBeGreaterThanOrEqual(1); + }); + + it("applies the threshold multiplier when checking eligibility", async () => { + // threshold multiplier of 2 means we need 100 000 prayers for count=0 but only have 50 000 + const state = makeState({ + goddess: makeGoddessStateIneligible({ + totalPrayersEarned: 50_000, + enlightenment: makeEnlightenment({ stardustConsecrationThresholdMultiplier: 2 }), + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post(""); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + // threshold should be 100 000 with multiplier 2 + expect(body.error).toMatch(/100,000/u); + }); + + it("returns 500 when database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post(""); + expect(res.status).toBe(500); + }); + + it("returns 500 when database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post(""); + expect(res.status).toBe(500); + }); + }); + + describe("POST /buy-upgrade", () => { + it("returns 400 when upgradeId is missing", async () => { + const res = await post("/buy-upgrade", {}); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("upgradeId is required"); + }); + + it("returns 404 for an unknown upgrade", async () => { + const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade_id" }); + expect(res.status).toBe(404); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Unknown consecration upgrade"); + }); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" }); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess realm is not unlocked", async () => { + const state = makeState({ goddess: undefined }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Goddess realm not unlocked"); + }); + + it("returns 400 when upgrade is already purchased", async () => { + const state = makeState({ + goddess: makeGoddessStateEligible({ + consecration: makeConsecration({ + divinity: 100, + purchasedUpgradeIds: [ "divine_prayers_1" ], + }), + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Upgrade already purchased"); + }); + + it("returns 400 when not enough divinity", async () => { + // divine_prayers_1 costs 5 divinity — give the player 0 + const state = makeState({ + goddess: makeGoddessStateEligible({ + consecration: makeConsecration({ divinity: 0 }), + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Not enough divinity"); + }); + + it("returns 200 with updated multipliers on successful purchase", async () => { + // divine_prayers_1 costs 5 divinity and is in the "prayers" category + const state = makeState({ + goddess: makeGoddessStateEligible({ + consecration: makeConsecration({ divinity: 100 }), + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" }); + expect(res.status).toBe(200); + const body = await res.json() as { + divinityRemaining: number; + purchasedUpgradeIds: string[]; + divinityPrayersMultiplier: number; + divinityDisciplesMultiplier: number; + divinityCombatMultiplier: number; + }; + expect(body.divinityRemaining).toBe(95); // 100 - 5 + expect(body.purchasedUpgradeIds).toContain("divine_prayers_1"); + expect(body.divinityPrayersMultiplier).toBeGreaterThan(1); + }); + + it("returns 500 when database throws an Error during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" }); + expect(res.status).toBe(500); + }); + + it("returns 500 when database throws a non-Error value during buy-upgrade", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" }); + expect(res.status).toBe(500); + }); + }); +}); diff --git a/apps/api/test/routes/enlightenment.spec.ts b/apps/api/test/routes/enlightenment.spec.ts new file mode 100644 index 0000000..a38d169 --- /dev/null +++ b/apps/api/test/routes/enlightenment.spec.ts @@ -0,0 +1,239 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-lines -- Test suites naturally have many cases */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type { GameState } from "@elysium/types"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + gameState: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("../../src/middleware/auth.js", () => ({ + authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise) => { + c.set("discordId", "test_discord_id"); + await next(); + }), +})); + +const DISCORD_ID = "test_discord_id"; +// stardust_prayers_1 costs 2 stardust +const TEST_UPGRADE_ID = "stardust_prayers_1"; + +const makeGoddessState = (): NonNullable => ({ + zones: [{ id: "goddess_celestial_garden", name: "Celestial Garden", description: "", emoji: "🌸", status: "unlocked", unlockBossId: null, unlockQuestId: null }], + bosses: [ + { + id: "divine_heart_sovereign", + name: "Divine Heart Sovereign", + description: "", + status: "defeated", + maxHp: 1000, + currentHp: 0, + damagePerSecond: 10, + prayersReward: 100, + divinityReward: 1, + stardustReward: 1, + upgradeRewards: [], + equipmentRewards: [], + consecrationRequirement: 0, + zoneId: "goddess_celestial_garden", + bountyDivinity: 5, + }, + ], + quests: [], + disciples: [], + equipment: [], + upgrades: [], + achievements: [], + consecration: { + count: 0, + divinity: 10, + productionMultiplier: 1, + purchasedUpgradeIds: [], + }, + enlightenment: { + count: 0, + stardust: 10, + purchasedUpgradeIds: [], + stardustPrayersMultiplier: 1, + stardustCombatMultiplier: 1, + stardustConsecrationThresholdMultiplier: 1, + stardustConsecrationDivinityMultiplier: 1, + stardustMetaMultiplier: 1, + }, + exploration: { + areas: [], + materials: [], + craftedRecipeIds: [], + craftedPrayersMultiplier: 1, + craftedDivinityMultiplier: 1, + craftedCombatMultiplier: 1, + }, + totalPrayersEarned: 0, + lifetimePrayersEarned: 0, + lifetimeBossesDefeated: 0, + lifetimeQuestsCompleted: 0, + baseClickPower: 1, + lastTickAt: 0, +}); + +const makeState = (overrides: Partial = {}): GameState => ({ + player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, + resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 }, + adventurers: [], + upgrades: [], + quests: [], + bosses: [], + equipment: [], + achievements: [], + zones: [], + exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 }, + companions: { unlockedCompanionIds: [], activeCompanionId: null }, + prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + baseClickPower: 1, + lastTickAt: 0, + schemaVersion: 1, + ...overrides, +} as GameState); + +describe("enlightenment route", () => { + let app: Hono; + let prisma: { gameState: { findUnique: ReturnType; update: ReturnType } }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { enlightenmentRouter } = await import("../../src/routes/enlightenment.js"); + const { prisma: p } = await import("../../src/db/client.js"); + prisma = p as typeof prisma; + app = new Hono(); + app.route("/enlightenment", enlightenmentRouter); + }); + + const post = (path: string, body?: Record) => + app.fetch(new Request(`http://localhost/enlightenment${path}`, { + method: "POST", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + })); + + describe("POST /", () => { + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await post(""); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess is undefined", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post(""); + expect(res.status).toBe(400); + }); + + it("returns 400 when not eligible (final boss not defeated)", async () => { + const goddess = makeGoddessState(); + // Override final boss to available (not defeated) + goddess.bosses[0]!.status = "available"; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post(""); + expect(res.status).toBe(400); + }); + + it("returns 200 with stardustEarned and newEnlightenmentCount on success", async () => { + const goddess = makeGoddessState(); + goddess.consecration.count = 4; // sqrt(4)*1 = 2 stardust + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await post(""); + expect(res.status).toBe(200); + const body = await res.json() as { stardustEarned: number; newEnlightenmentCount: number }; + expect(body.newEnlightenmentCount).toBe(1); + expect(body.stardustEarned).toBeGreaterThanOrEqual(1); + }); + + it("returns 500 when the database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post(""); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post(""); + expect(res.status).toBe(500); + }); + }); + + describe("POST /buy-upgrade", () => { + it("returns 400 when upgradeId is missing", async () => { + const res = await post("/buy-upgrade", {}); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown upgrade", async () => { + const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade" }); + expect(res.status).toBe(404); + }); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess is undefined", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when upgrade is already purchased", async () => { + const goddess = makeGoddessState(); + goddess.enlightenment.purchasedUpgradeIds = [TEST_UPGRADE_ID]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when not enough stardust", async () => { + const goddess = makeGoddessState(); + goddess.enlightenment.stardust = 0; // stardust_prayers_1 costs 2 + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 200 with updated multipliers on success", async () => { + const goddess = makeGoddessState(); + goddess.enlightenment.stardust = 10; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { stardustRemaining: number; purchasedUpgradeIds: string[] }; + expect(body.stardustRemaining).toBe(8); // 10 - 2 + expect(body.purchasedUpgradeIds).toContain(TEST_UPGRADE_ID); + }); + + it("returns 500 when the database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(500); + }); + }); +}); diff --git a/apps/api/test/routes/goddessBoss.spec.ts b/apps/api/test/routes/goddessBoss.spec.ts new file mode 100644 index 0000000..7c0ae6a --- /dev/null +++ b/apps/api/test/routes/goddessBoss.spec.ts @@ -0,0 +1,560 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-lines -- Test suites naturally have many cases */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type { GameState } from "@elysium/types"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + gameState: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("../../src/middleware/auth.js", () => ({ + authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise) => { + c.set("discordId", "test_discord_id"); + await next(); + }), +})); + +vi.mock("../../src/services/logger.js", () => ({ + logger: { + error: vi.fn().mockResolvedValue(undefined), + metric: vi.fn().mockResolvedValue(undefined), + }, +})); + +const DISCORD_ID = "test_discord_id"; + +const makeConsecration = (overrides: Record = {}) => ({ + count: 0, + divinity: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [] as string[], + ...overrides, +}); + +const makeEnlightenment = (overrides: Record = {}) => ({ + count: 0, + stardust: 0, + purchasedUpgradeIds: [] as string[], + stardustPrayersMultiplier: 1, + stardustCombatMultiplier: 1, + stardustConsecrationThresholdMultiplier: 1, + stardustConsecrationDivinityMultiplier: 1, + stardustMetaMultiplier: 1, + ...overrides, +}); + +const makeGoddessExploration = (overrides: Record = {}) => ({ + areas: [] as Array<{ id: string; status: string }>, + materials: [] as Array<{ materialId: string; quantity: number }>, + craftedRecipeIds: [] as string[], + craftedPrayersMultiplier: 1, + craftedDivinityMultiplier: 1, + craftedCombatMultiplier: 1, + ...overrides, +}); + +const makeGoddessBoss = (overrides: Record = {}) => ({ + id: "test_goddess_boss", + name: "Test Goddess Boss", + description: "A test boss", + status: "available", + maxHp: 100, + currentHp: 100, + damagePerSecond: 1, + prayersReward: 50, + divinityReward: 2, + stardustReward: 0, + upgradeRewards: [] as string[], + equipmentRewards: [] as string[], + consecrationRequirement: 0, + zoneId: "test_goddess_zone", + bountyDivinity: 5, + ...overrides, +}); + +const makeDisciple = (overrides: Record = {}) => ({ + id: "test_disciple", + name: "Test Disciple", + class: "oracle" as const, + level: 10, + baseCost: 100, + prayersPerSecond: 1, + divinityPerSecond: 0, + combatPower: 10_000, + count: 1, + unlocked: true, + ...overrides, +}); + +const makeGoddessState = (overrides: Record = {}) => ({ + zones: [] as Array<{ id: string; status: string; unlockBossId: string | null; unlockQuestId: string | null }>, + bosses: [ makeGoddessBoss() ], + quests: [] as Array<{ id: string; status: string }>, + disciples: [ makeDisciple() ], + equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record }>, + upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number; discipleId?: string }>, + achievements: [] as Array<{ id: string; completed: boolean }>, + consecration: makeConsecration(), + enlightenment: makeEnlightenment(), + exploration: makeGoddessExploration(), + totalPrayersEarned: 0, + lifetimePrayersEarned: 0, + lifetimeBossesDefeated: 0, + lifetimeQuestsCompleted: 0, + baseClickPower: 1, + lastTickAt: 0, + ...overrides, +}); + +const makeState = (overrides: Partial = {}): GameState => ({ + player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, + resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 }, + adventurers: [], + upgrades: [], + quests: [], + bosses: [], + equipment: [], + achievements: [], + zones: [], + exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 }, + companions: { unlockedCompanionIds: [], activeCompanionId: null }, + prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + baseClickPower: 1, + lastTickAt: 0, + schemaVersion: 1, + goddess: makeGoddessState() as GameState["goddess"], + ...overrides, +} as GameState); + +describe("goddessBoss route", () => { + let app: Hono; + let prisma: { gameState: { findUnique: ReturnType; update: ReturnType } }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { goddessBossRouter } = await import("../../src/routes/goddessBoss.js"); + const { prisma: p } = await import("../../src/db/client.js"); + prisma = p as typeof prisma; + app = new Hono(); + app.route("/goddess-boss", goddessBossRouter); + }); + + const challenge = (body: Record) => + app.fetch(new Request("http://localhost/goddess-boss/challenge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + + it("returns 400 when bossId is missing", async () => { + const res = await challenge({}); + expect(res.status).toBe(400); + }); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess realm is not unlocked", async () => { + const state = makeState({ goddess: undefined }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Goddess realm not unlocked"); + }); + + it("returns 404 when boss is not found in goddess state", async () => { + const state = makeState({ + goddess: makeGoddessState({ bosses: [] }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(404); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Boss not found"); + }); + + it("returns 400 when boss status is defeated", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ status: "defeated" }) ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Boss is not currently available"); + }); + + it("returns 400 when boss status is locked", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ status: "locked" }) ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(400); + }); + + it("accepts in_progress boss status", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ status: "in_progress" }) ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + }); + + it("returns 403 when consecration requirement is not met", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ consecrationRequirement: 3 }) ], + consecration: makeConsecration({ count: 0 }), + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(403); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Consecration requirement not met"); + }); + + it("returns 400 when party has no combat power (all disciples have count 0)", async () => { + const state = makeState({ + goddess: makeGoddessState({ + disciples: [ makeDisciple({ count: 0 }) ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(400); + const body = await res.json() as { error: string }; + expect(body.error).toBe("Your disciples have no combat power"); + }); + + it("returns 400 when party has no combat power (disciples array is empty)", async () => { + const state = makeState({ + goddess: makeGoddessState({ + disciples: [], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(400); + }); + + it("returns 200 with rewards when party wins", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1, level: 10 }) ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean; rewards: { prayers: number; divinity: number } }; + expect(body.won).toBe(true); + expect(body.rewards.prayers).toBe(50); + expect(body.rewards.divinity).toBe(2); + }); + + it("returns 200 with bountyDivinity when first kill", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ currentHp: 50, maxHp: 50, damagePerSecond: 1, id: "celestial_sprite" }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "celestial_sprite" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } }; + expect(body.won).toBe(true); + expect(body.rewards.bountyDivinity).toBeGreaterThan(0); + }); + + it("returns 0 bountyDivinity when already claimed", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ id: "celestial_sprite", bountyDivinityClaimed: true }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "celestial_sprite" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } }; + expect(body.won).toBe(true); + expect(body.rewards.bountyDivinity).toBe(0); + }); + + it("unlocks upgrade rewards on win", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ upgradeRewards: [ "test_upgrade" ] }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + upgrades: [ { id: "test_upgrade", purchased: false, unlocked: false, target: "global", multiplier: 1 } ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean; rewards: { upgradeIds: string[] } }; + expect(body.won).toBe(true); + expect(body.rewards.upgradeIds).toContain("test_upgrade"); + }); + + it("unlocks next zone boss when boss is defeated", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ + makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }), + makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 0 }), + ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("does not unlock next boss if consecration requirement not met", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ + makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }), + makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 5 }), + ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + consecration: makeConsecration({ count: 0 }), + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("unlocks goddess zone when boss and quest conditions are both met", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + zones: [ + { id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" }, + ], + quests: [ { id: "test_quest", status: "completed" } ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("does not unlock zone when quest condition is not satisfied", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + zones: [ + { id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" }, + ], + quests: [ { id: "test_quest", status: "active" } ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("unlocks zone when unlockQuestId is null", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + zones: [ + { id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: null }, + ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("skips zone that is already unlocked", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + zones: [ + { id: "test_goddess_zone", status: "unlocked", unlockBossId: "test_goddess_boss", unlockQuestId: null }, + ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("skips zone whose unlockBossId does not match the defeated boss", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ], + disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ], + zones: [ + { id: "other_zone", status: "locked", unlockBossId: "different_boss", unlockQuestId: null }, + ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("applies global upgrade multiplier to party DPS", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ], + disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ], + upgrades: [ { id: "global_u", purchased: true, target: "global", multiplier: 100_000 } ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("applies disciple-specific upgrade multiplier", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ], + disciples: [ makeDisciple({ id: "test_disciple", combatPower: 1, count: 1, level: 10 }) ], + upgrades: [ + { id: "disciple_u", purchased: true, target: "disciple", multiplier: 100_000, discipleId: "test_disciple" }, + ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(true); + }); + + it("skips unpurchased upgrades", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ makeGoddessBoss({ currentHp: 100_000, damagePerSecond: 1 }) ], + disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ], + upgrades: [ + { id: "not_bought", purchased: false, target: "global", multiplier: 100_000 }, + ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean }; + expect(body.won).toBe(false); + }); + + it("returns 200 with casualties when party loses", async () => { + const state = makeState({ + goddess: makeGoddessState({ + bosses: [ + makeGoddessBoss({ currentHp: 1_000_000, maxHp: 1_000_000, damagePerSecond: 1_000_000 }), + ], + disciples: [ + makeDisciple({ combatPower: 1, count: 10, level: 1 }), + makeDisciple({ id: "zero_disciple", combatPower: 0, count: 0, level: 1 }), + ], + }) as GameState["goddess"], + }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { won: boolean; casualties: Array<{ discipleId: string }> }; + expect(body.won).toBe(false); + expect(Array.isArray(body.casualties)).toBe(true); + }); + + it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => { + process.env.ANTI_CHEAT_SECRET = "test_secret"; + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { signature: string | undefined }; + expect(body.signature).toBeDefined(); + delete process.env.ANTI_CHEAT_SECRET; + }); + + it("omits signature when ANTI_CHEAT_SECRET is not set", async () => { + delete process.env.ANTI_CHEAT_SECRET; + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(200); + const body = await res.json() as { signature: string | undefined }; + expect(body.signature).toBeUndefined(); + }); + + it("returns 500 when the database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await challenge({ bossId: "test_goddess_boss" }); + expect(res.status).toBe(500); + }); +}); diff --git a/apps/api/test/routes/goddessCraft.spec.ts b/apps/api/test/routes/goddessCraft.spec.ts new file mode 100644 index 0000000..8b7a062 --- /dev/null +++ b/apps/api/test/routes/goddessCraft.spec.ts @@ -0,0 +1,193 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-lines -- Test suites naturally have many cases */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type { GameState } from "@elysium/types"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + gameState: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("../../src/middleware/auth.js", () => ({ + authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise) => { + c.set("discordId", "test_discord_id"); + await next(); + }), +})); + +const DISCORD_ID = "test_discord_id"; +// prayer_amplifier requires: divine_petal×3, prayer_crystal×2; bonus: gold_income 1.1 +const TEST_RECIPE_ID = "prayer_amplifier"; + +const makeGoddessState = (): NonNullable => ({ + zones: [], + bosses: [], + quests: [], + disciples: [], + equipment: [], + upgrades: [], + achievements: [], + consecration: { + count: 0, + divinity: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [], + }, + enlightenment: { + count: 0, + stardust: 0, + purchasedUpgradeIds: [], + stardustPrayersMultiplier: 1, + stardustCombatMultiplier: 1, + stardustConsecrationThresholdMultiplier: 1, + stardustConsecrationDivinityMultiplier: 1, + stardustMetaMultiplier: 1, + }, + exploration: { + areas: [], + materials: [ + { materialId: "divine_petal", quantity: 5 }, + { materialId: "prayer_crystal", quantity: 5 }, + ], + craftedRecipeIds: [], + craftedPrayersMultiplier: 1, + craftedDivinityMultiplier: 1, + craftedCombatMultiplier: 1, + }, + totalPrayersEarned: 0, + lifetimePrayersEarned: 0, + lifetimeBossesDefeated: 0, + lifetimeQuestsCompleted: 0, + baseClickPower: 1, + lastTickAt: 0, +}); + +const makeState = (overrides: Partial = {}): GameState => ({ + player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, + resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 }, + adventurers: [], + upgrades: [], + quests: [], + bosses: [], + equipment: [], + achievements: [], + zones: [], + exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 }, + companions: { unlockedCompanionIds: [], activeCompanionId: null }, + prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + baseClickPower: 1, + lastTickAt: 0, + schemaVersion: 1, + ...overrides, +} as GameState); + +describe("goddessCraft route", () => { + let app: Hono; + let prisma: { gameState: { findUnique: ReturnType; update: ReturnType } }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { goddessCraftRouter } = await import("../../src/routes/goddessCraft.js"); + const { prisma: p } = await import("../../src/db/client.js"); + prisma = p as typeof prisma; + app = new Hono(); + app.route("/goddess-craft", goddessCraftRouter); + }); + + const post = (body: Record) => + app.fetch(new Request("http://localhost/goddess-craft", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + + it("returns 400 when recipeId is missing", async () => { + const res = await post({}); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown recipe", async () => { + const res = await post({ recipeId: "nonexistent_recipe" }); + expect(res.status).toBe(404); + }); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess is undefined", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when recipe is already crafted", async () => { + const goddess = makeGoddessState(); + goddess.exploration.craftedRecipeIds = [TEST_RECIPE_ID]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when not enough material (first requirement)", async () => { + const goddess = makeGoddessState(); + goddess.exploration.materials = [ + { materialId: "divine_petal", quantity: 1 }, // needs 3 + { materialId: "prayer_crystal", quantity: 5 }, + ]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when material is completely absent", async () => { + const goddess = makeGoddessState(); + goddess.exploration.materials = []; // neither material present + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 200 with updated multipliers and materials on success", async () => { + const goddess = makeGoddessState(); + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { + recipeId: string; + bonusType: string; + bonusValue: number; + craftedPrayersMultiplier: number; + craftedDivinityMultiplier: number; + craftedCombatMultiplier: number; + materials: Array<{ materialId: string; quantity: number }>; + }; + expect(body.recipeId).toBe(TEST_RECIPE_ID); + expect(body.bonusType).toBe("gold_income"); + expect(body.bonusValue).toBe(1.1); + expect(body.craftedPrayersMultiplier).toBeGreaterThan(1); + }); + + it("returns 500 when the database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post({ recipeId: TEST_RECIPE_ID }); + expect(res.status).toBe(500); + }); +}); diff --git a/apps/api/test/routes/goddessExplore.spec.ts b/apps/api/test/routes/goddessExplore.spec.ts new file mode 100644 index 0000000..98775ae --- /dev/null +++ b/apps/api/test/routes/goddessExplore.spec.ts @@ -0,0 +1,619 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-lines -- Test suites naturally have many cases */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type { GameState, GoddessExplorationArea } from "@elysium/types"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + gameState: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("../../src/middleware/auth.js", () => ({ + authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise) => { + c.set("discordId", "test_discord_id"); + await next(); + }), +})); + +// Custom test areas exercising event types not present in the real data +const PRAYERS_LOSS_AREA: GoddessExplorationArea = { + description: "Test area for prayers_loss events", + durationSeconds: 1, + events: [ + { effect: { amount: 100, type: "prayers_loss" }, id: "test_prayers_loss", text: "You lost some prayers." }, + ], + id: "test_prayers_loss_area", + name: "Test Prayers Loss Area", + possibleMaterials: [], + zoneId: "goddess_celestial_garden", +}; + +const DIVINITY_GAIN_AREA: GoddessExplorationArea = { + description: "Test area for divinity_gain events", + durationSeconds: 1, + events: [ + { effect: { amount: 10, type: "divinity_gain" }, id: "test_divinity_gain", text: "You gained divinity." }, + ], + id: "test_divinity_gain_area", + name: "Test Divinity Gain Area", + possibleMaterials: [], + zoneId: "goddess_celestial_garden", +}; + +vi.mock("../../src/data/goddessExplorations.js", async (importOriginal) => { + const original = await importOriginal(); + return { + defaultGoddessExplorationAreas: [ + ...original.defaultGoddessExplorationAreas, + PRAYERS_LOSS_AREA, + DIVINITY_GAIN_AREA, + ], + }; +}); + +const DISCORD_ID = "test_discord_id"; +// garden_glade: zoneId=goddess_celestial_garden, durationSeconds=30 +// events[0]: prayers_gain 50; events[1]: disciple_loss 0.05 +// possibleMaterials: divine_petal(weight 5), prayer_crystal(weight 3) — total 8 +const TEST_AREA_ID = "garden_glade"; +const TEST_ZONE_ID = "goddess_celestial_garden"; + +// celestial_meadow: durationSeconds=60 +// events[0]: prayers_gain 200; events[1]: sacred_material_gain celestial_dust qty 2 +const MATERIAL_AREA_ID = "celestial_meadow"; + +const makeGoddessState = (areaId: string, zoneStatus: "unlocked" | "locked" = "unlocked"): NonNullable => ({ + zones: [ + { + id: TEST_ZONE_ID, + name: "Celestial Garden", + description: "", + emoji: "🌸", + status: zoneStatus, + unlockBossId: null, + unlockQuestId: null, + }, + ], + bosses: [], + quests: [], + disciples: [ + { id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID }, + ], + equipment: [], + upgrades: [], + achievements: [], + consecration: { + count: 0, + divinity: 50, + productionMultiplier: 1, + purchasedUpgradeIds: [], + }, + enlightenment: { + count: 0, + stardust: 0, + purchasedUpgradeIds: [], + stardustPrayersMultiplier: 1, + stardustCombatMultiplier: 1, + stardustConsecrationThresholdMultiplier: 1, + stardustConsecrationDivinityMultiplier: 1, + stardustMetaMultiplier: 1, + }, + exploration: { + areas: [ + { id: areaId, status: "available" }, + ], + materials: [], + craftedRecipeIds: [], + craftedPrayersMultiplier: 1, + craftedDivinityMultiplier: 1, + craftedCombatMultiplier: 1, + }, + totalPrayersEarned: 0, + lifetimePrayersEarned: 0, + lifetimeBossesDefeated: 0, + lifetimeQuestsCompleted: 0, + baseClickPower: 1, + lastTickAt: 0, +}); + +const makeState = (overrides: Partial = {}): GameState => ({ + player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, + resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 }, + adventurers: [], + upgrades: [], + quests: [], + bosses: [], + equipment: [], + achievements: [], + zones: [], + exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 }, + companions: { unlockedCompanionIds: [], activeCompanionId: null }, + prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + baseClickPower: 1, + lastTickAt: 0, + schemaVersion: 1, + ...overrides, +} as GameState); + +/** Builds a state with the area in_progress and startedAt in the past so it's complete. */ +const makeCompletedAreaState = ( + areaId: string, + extraMaterials: Array<{ materialId: string; quantity: number }> = [], + extraPrayers = 0, +): GameState => { + const goddess = makeGoddessState(areaId); + goddess.exploration.areas = [{ id: areaId, status: "in_progress", startedAt: 0 }]; + goddess.exploration.materials = extraMaterials; + return makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: extraPrayers } }); +}; + +describe("goddessExplore route", () => { + let app: Hono; + let prisma: { gameState: { findUnique: ReturnType; update: ReturnType } }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { goddessExploreRouter } = await import("../../src/routes/goddessExplore.js"); + const { prisma: p } = await import("../../src/db/client.js"); + prisma = p as typeof prisma; + app = new Hono(); + app.route("/goddess-explore", goddessExploreRouter); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const getClaimable = (areaId?: string) => { + const url = areaId === undefined + ? "http://localhost/goddess-explore/claimable" + : `http://localhost/goddess-explore/claimable?areaId=${areaId}`; + return app.fetch(new Request(url)); + }; + + const postStart = (body: Record) => + app.fetch(new Request("http://localhost/goddess-explore/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + + const postCollect = (body: Record) => + app.fetch(new Request("http://localhost/goddess-explore/collect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + + describe("GET /claimable", () => { + it("returns 400 when areaId is missing", async () => { + const res = await getClaimable(); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown area", async () => { + const res = await getClaimable("nonexistent_area"); + expect(res.status).toBe(404); + }); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(404); + }); + + it("returns claimable=false when goddess is undefined", async () => { + const state = makeState(); // no goddess + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(false); + }); + + it("returns claimable=false when area is not in_progress", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + // area status is "available" (not in_progress) + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(false); + }); + + it("returns claimable=false when area not found in state", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + goddess.exploration.areas = []; // area missing entirely + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(false); + }); + + it("returns claimable=false when exploration is not yet complete", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + // startedAt = now → not complete yet (duration is 30s) + goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(false); + }); + + it("returns claimable=true when exploration is complete", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + // startedAt = 0 → expired long ago + goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(200); + const body = await res.json() as { claimable: boolean }; + expect(body.claimable).toBe(true); + }); + + it("returns 500 when the database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await getClaimable(TEST_AREA_ID); + expect(res.status).toBe(500); + }); + }); + + describe("POST /start", () => { + it("returns 400 when areaId is missing", async () => { + const res = await postStart({}); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown area", async () => { + const res = await postStart({ areaId: "nonexistent_area" }); + expect(res.status).toBe(404); + }); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess is undefined", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when zone is not unlocked", async () => { + const goddess = makeGoddessState(TEST_AREA_ID, "locked"); + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when zone is not found in goddess state", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + goddess.zones = []; // zone missing + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(400); + }); + + it("returns 404 when area is not found in state", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + goddess.exploration.areas = []; // area missing + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(404); + }); + + it("returns 400 when another exploration is already in progress", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + goddess.exploration.areas = [ + { id: TEST_AREA_ID, status: "available" }, + { id: "other_area", status: "in_progress" }, + ]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when area is locked", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "locked" }]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(400); + }); + + it("returns 200 with areaId and endsAt on success", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { areaId: string; endsAt: number }; + expect(body.areaId).toBe(TEST_AREA_ID); + expect(body.endsAt).toBeGreaterThan(Date.now()); + }); + + it("returns 500 when the database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await postStart({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(500); + }); + }); + + describe("POST /collect", () => { + it("returns 400 when areaId is missing", async () => { + const res = await postCollect({}); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown area", async () => { + const res = await postCollect({ areaId: "nonexistent_area" }); + expect(res.status).toBe(404); + }); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess is undefined", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(400); + }); + + it("returns 404 when area is not found in state", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + goddess.exploration.areas = []; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(404); + }); + + it("returns 400 when area is not in_progress", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + // area is "available", not "in_progress" + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when exploration is not yet complete", async () => { + const goddess = makeGoddessState(TEST_AREA_ID); + // startedAt = now → still in progress for 30s + goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(400); + }); + + it("returns foundNothing=true when Math.random is below 0.2 (nothing path)", async () => { + vi.spyOn(Math, "random").mockReturnValue(0.1); // < 0.2 → nothing + const state = makeCompletedAreaState(TEST_AREA_ID); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { foundNothing: boolean; nothingMessage: string; materialsFound: unknown[] }; + expect(body.foundNothing).toBe(true); + expect(typeof body.nothingMessage).toBe("string"); + }); + + it("applies prayers_gain event and returns prayersChange > 0", async () => { + const mockRandom = vi.spyOn(Math, "random"); + // garden_glade has 2 events: [prayers_gain(0), disciple_loss(1)] + // Call 1: nothing check 0.5 ≥ 0.2 → proceed + // Call 2: event index Math.floor(0.1 * 2) = 0 → prayers_gain + // Call 3: possibleMaterials roll (total weight 8): 0 * 8 = 0, 0 - 5 = -5 ≤ 0 → divine_petal + // Call 4: quantity Math.floor(0 * (3-1+1)) + 1 = 0 + 1 = 1 + mockRandom + .mockReturnValueOnce(0.5) + .mockReturnValueOnce(0.1) + .mockReturnValueOnce(0) + .mockReturnValueOnce(0); + const state = makeCompletedAreaState(TEST_AREA_ID); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { foundNothing: boolean; event: { prayersChange: number }; materialsFound: Array<{ materialId: string }> }; + expect(body.foundNothing).toBe(false); + expect(body.event.prayersChange).toBeGreaterThan(0); + }); + + it("applies sacred_material_gain event and pushes new material", async () => { + const mockRandom = vi.spyOn(Math, "random"); + // celestial_meadow: events[1] = sacred_material_gain celestial_dust qty 2 + // index 1: Math.floor(0.6 * 2) = 1 + // possibleMaterials: [divine_petal(4), celestial_dust(3)] total 7 + // Call 3: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal (new material) + // Call 4: Math.floor(0 * (4-2+1)) + 2 = 0 + 2 = 2 + mockRandom + .mockReturnValueOnce(0.5) // nothing check: proceed + .mockReturnValueOnce(0.6) // event index: Math.floor(0.6 * 2) = 1 → sacred_material_gain + .mockReturnValueOnce(0) // possibleMaterials roll: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal + .mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 2 = 2 + const state = makeCompletedAreaState(MATERIAL_AREA_ID); // no materials in state + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: MATERIAL_AREA_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } }; materialsFound: Array<{ materialId: string }> }; + expect(body.event.materialGained?.materialId).toBe("celestial_dust"); + }); + + it("increments existing material quantity on sacred_material_gain event", async () => { + const mockRandom = vi.spyOn(Math, "random"); + // Same as above — celestial_meadow events[1] = sacred_material_gain celestial_dust + mockRandom + .mockReturnValueOnce(0.5) // nothing check: proceed + .mockReturnValueOnce(0.6) // event: sacred_material_gain + .mockReturnValueOnce(0) // possibleMaterials roll → divine_petal + .mockReturnValueOnce(0); // quantity → 2 + const state = makeCompletedAreaState(MATERIAL_AREA_ID, [ + { materialId: "celestial_dust", quantity: 5 }, // pre-existing + ]); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: MATERIAL_AREA_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } }; + expect(body.event.materialGained?.materialId).toBe("celestial_dust"); + expect(body.event.materialGained?.quantity).toBeGreaterThan(0); + }); + + it("returns materialsFound with new material from possibleMaterials when none pre-existing", async () => { + const mockRandom = vi.spyOn(Math, "random"); + // prayers_gain event, then roll for divine_petal (new) + mockRandom + .mockReturnValueOnce(0.5) // nothing check: proceed + .mockReturnValueOnce(0.1) // event: prayers_gain (index 0) + .mockReturnValueOnce(0) // possibleMaterials roll → divine_petal (first, weight 5) + .mockReturnValueOnce(0); // quantity → min (1) + const state = makeCompletedAreaState(TEST_AREA_ID); // no materials + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { materialsFound: Array<{ materialId: string; quantity: number }> }; + expect(body.materialsFound.length).toBeGreaterThan(0); + expect(body.materialsFound[0]?.materialId).toBe("divine_petal"); + }); + + it("increments existing possibleMaterial quantity when material is already in state", async () => { + const mockRandom = vi.spyOn(Math, "random"); + mockRandom + .mockReturnValueOnce(0.5) // nothing check: proceed + .mockReturnValueOnce(0.1) // event: prayers_gain (index 0) + .mockReturnValueOnce(0) // possibleMaterials roll → divine_petal + .mockReturnValueOnce(0); // quantity → 1 + const state = makeCompletedAreaState(TEST_AREA_ID, [ + { materialId: "divine_petal", quantity: 10 }, // pre-existing + ]); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { materialsFound: Array<{ materialId: string }> }; + expect(body.materialsFound.some((m) => { + return m.materialId === "divine_petal"; + })).toBe(true); + }); + + it("applies disciple_loss event and reduces disciple counts", async () => { + const mockRandom = vi.spyOn(Math, "random"); + // garden_glade events[1] = disciple_loss fraction 0.05 + // Call 1: nothing check 0.5 ≥ 0.2 → proceed + // Call 2: event index Math.floor(0.6 * 2) = 1 → disciple_loss + // possibleMaterials: total weight 8; call 3: 0.9 * 8 = 7.2; 7.2 - 5 = 2.2 > 0; 2.2 - 3 = -0.8 ≤ 0 → prayer_crystal + // Call 4: quantity for prayer_crystal: Math.floor(0 * (2-1+1)) + 1 = 1 + mockRandom + .mockReturnValueOnce(0.5) // nothing check: proceed + .mockReturnValueOnce(0.6) // event: Math.floor(0.6 * 2) = 1 → disciple_loss + .mockReturnValueOnce(0.9) // possibleMaterials roll → prayer_crystal + .mockReturnValueOnce(0); // quantity → 1 + const goddess = makeGoddessState(TEST_AREA_ID); + goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }]; + // Need disciples with non-zero count so lost > 0 triggers + goddess.disciples = [ + { id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID }, + ]; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(200); + }); + + it("applies prayers_loss event and returns negative prayersChange", async () => { + const mockRandom = vi.spyOn(Math, "random"); + // test_prayers_loss_area has 1 event: prayers_loss 100 + // Call 1: nothing check 0.5 ≥ 0.2 → proceed + // Call 2: event index Math.floor(0.1 * 1) = 0 → prayers_loss + // No possibleMaterials, so no further calls needed + mockRandom + .mockReturnValueOnce(0.5) + .mockReturnValueOnce(0.1); + const goddess = makeGoddessState(PRAYERS_LOSS_AREA.id); + goddess.exploration.areas = [{ id: PRAYERS_LOSS_AREA.id, status: "in_progress", startedAt: 0 }]; + const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: PRAYERS_LOSS_AREA.id }); + expect(res.status).toBe(200); + const body = await res.json() as { event: { prayersChange: number }; foundNothing: boolean }; + expect(body.foundNothing).toBe(false); + expect(body.event.prayersChange).toBeLessThanOrEqual(0); + }); + + it("applies divinity_gain event and increases divinity", async () => { + const mockRandom = vi.spyOn(Math, "random"); + // test_divinity_gain_area has 1 event: divinity_gain 10 + // Call 1: nothing check 0.5 ≥ 0.2 → proceed + // Call 2: event index Math.floor(0.1 * 1) = 0 → divinity_gain + // No possibleMaterials + mockRandom + .mockReturnValueOnce(0.5) + .mockReturnValueOnce(0.1); + const goddess = makeGoddessState(DIVINITY_GAIN_AREA.id); + goddess.exploration.areas = [{ id: DIVINITY_GAIN_AREA.id, status: "in_progress", startedAt: 0 }]; + const initialDivinity = goddess.consecration.divinity; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await postCollect({ areaId: DIVINITY_GAIN_AREA.id }); + expect(res.status).toBe(200); + // Verify state was saved with updated divinity + const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as { + data: { state: { goddess: { consecration: { divinity: number } } } }; + }; + expect(updateArg.data.state.goddess.consecration.divinity).toBe(initialDivinity + 10); + }); + + it("returns 500 when the database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await postCollect({ areaId: TEST_AREA_ID }); + expect(res.status).toBe(500); + }); + }); +}); diff --git a/apps/api/test/routes/goddessUpgrade.spec.ts b/apps/api/test/routes/goddessUpgrade.spec.ts new file mode 100644 index 0000000..0f95a46 --- /dev/null +++ b/apps/api/test/routes/goddessUpgrade.spec.ts @@ -0,0 +1,255 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +/* eslint-disable max-lines -- Test suites naturally have many cases */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type { GameState } from "@elysium/types"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + gameState: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("../../src/middleware/auth.js", () => ({ + authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise) => { + c.set("discordId", "test_discord_id"); + await next(); + }), +})); + +const DISCORD_ID = "test_discord_id"; +// prayer_offering_1 costs 50 prayers, 0 divinity, 0 stardust; unlocked: true +const TEST_UPGRADE_ID = "prayer_offering_1"; + +const makeGoddessState = (): NonNullable => ({ + zones: [], + bosses: [], + quests: [], + disciples: [], + equipment: [], + upgrades: [ + { + id: TEST_UPGRADE_ID, + name: "Morning Offering I", + description: "", + target: "prayers", + multiplier: 1.25, + costPrayers: 50, + costDivinity: 0, + costStardust: 0, + purchased: false, + unlocked: true, + }, + ], + achievements: [], + consecration: { + count: 0, + divinity: 100, + productionMultiplier: 1, + purchasedUpgradeIds: [], + }, + enlightenment: { + count: 0, + stardust: 100, + purchasedUpgradeIds: [], + stardustPrayersMultiplier: 1, + stardustCombatMultiplier: 1, + stardustConsecrationThresholdMultiplier: 1, + stardustConsecrationDivinityMultiplier: 1, + stardustMetaMultiplier: 1, + }, + exploration: { + areas: [], + materials: [], + craftedRecipeIds: [], + craftedPrayersMultiplier: 1, + craftedDivinityMultiplier: 1, + craftedCombatMultiplier: 1, + }, + totalPrayersEarned: 0, + lifetimePrayersEarned: 0, + lifetimeBossesDefeated: 0, + lifetimeQuestsCompleted: 0, + baseClickPower: 1, + lastTickAt: 0, +}); + +const makeState = (overrides: Partial = {}): GameState => ({ + player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" }, + resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 }, + adventurers: [], + upgrades: [], + quests: [], + bosses: [], + equipment: [], + achievements: [], + zones: [], + exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 }, + companions: { unlockedCompanionIds: [], activeCompanionId: null }, + prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + baseClickPower: 1, + lastTickAt: 0, + schemaVersion: 1, + ...overrides, +} as GameState); + +describe("goddessUpgrade route", () => { + let app: Hono; + let prisma: { gameState: { findUnique: ReturnType; update: ReturnType } }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { goddessUpgradeRouter } = await import("../../src/routes/goddessUpgrade.js"); + const { prisma: p } = await import("../../src/db/client.js"); + prisma = p as typeof prisma; + app = new Hono(); + app.route("/goddess-upgrade", goddessUpgradeRouter); + }); + + const post = (body: Record) => + app.fetch(new Request("http://localhost/goddess-upgrade/buy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + + it("returns 400 when upgradeId is missing", async () => { + const res = await post({}); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown upgrade", async () => { + const res = await post({ upgradeId: "nonexistent_upgrade" }); + expect(res.status).toBe(404); + }); + + it("returns 404 when no save is found", async () => { + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(404); + }); + + it("returns 400 when goddess is undefined", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 404 when upgrade is not found in goddess state", async () => { + const goddess = makeGoddessState(); + goddess.upgrades = []; // no upgrades in state + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(404); + }); + + it("returns 400 when upgrade is not yet unlocked", async () => { + const goddess = makeGoddessState(); + goddess.upgrades[0]!.unlocked = false; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when upgrade is already purchased", async () => { + const goddess = makeGoddessState(); + goddess.upgrades[0]!.purchased = true; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when prayers is undefined (treats as 0)", async () => { + const goddess = makeGoddessState(); + // Omitting prayers entirely exercises the `?? 0` fallback on line 75 + const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when not enough prayers", async () => { + const goddess = makeGoddessState(); + const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 10 } }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(400); + }); + + it("returns 400 when not enough divinity", async () => { + // prayer_offering_3 costs 1 divinity + const upgradeId = "prayer_offering_3"; + const goddess = makeGoddessState(); + goddess.upgrades.push({ + id: upgradeId, + name: "Morning Offering III", + description: "", + target: "prayers", + multiplier: 2, + costPrayers: 1000, + costDivinity: 1, + costStardust: 0, + purchased: false, + unlocked: true, + }); + goddess.consecration.divinity = 0; // need 1 but have 0 + const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 5000 } }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ upgradeId }); + expect(res.status).toBe(400); + }); + + it("returns 400 when not enough stardust", async () => { + // divine_spark_2 is in defaultGoddessUpgrades with costStardust: 1, costDivinity: 100, costPrayers: 500_000 + const upgradeId = "divine_spark_2"; + const goddess = makeGoddessState(); + goddess.upgrades.push({ + id: upgradeId, + name: "Divine Spark II", + description: "", + target: "prayers", + multiplier: 25, + costPrayers: 500_000, + costDivinity: 100, + costStardust: 1, + purchased: false, + unlocked: true, + }); + goddess.consecration.divinity = 100; // enough divinity + goddess.enlightenment.stardust = 0; // NOT enough stardust (need 1) + const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 500_000 } }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + const res = await post({ upgradeId }); + expect(res.status).toBe(400); + }); + + it("returns 200 with remaining resources on success", async () => { + const goddess = makeGoddessState(); + const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(200); + const body = await res.json() as { prayersRemaining: number; divinityRemaining: number; stardustRemaining: number }; + expect(body.prayersRemaining).toBe(150); // 200 - 50 + expect(body.divinityRemaining).toBe(100); // no divinity cost + expect(body.stardustRemaining).toBe(100); // no stardust cost + }); + + it("returns 500 when the database throws an Error", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(500); + }); + + it("returns 500 when the database throws a non-Error value", async () => { + vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error"); + const res = await post({ upgradeId: TEST_UPGRADE_ID }); + expect(res.status).toBe(500); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 3abf55e..846e221 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -10,11 +10,7 @@ export default defineConfig({ "src/db/client.ts", "src/index.ts", "src/data/materials.ts", - // Goddess expansion data files — excluded until goddess routes import them in a later chunk - "src/data/goddessConsecrationUpgrades.ts", - "src/data/goddessCrafting.ts", - "src/data/goddessEnlightenmentUpgrades.ts", - "src/data/goddessEquipmentSets.ts", + // Goddess materials data file — not directly imported by any route (referenced by ID strings only) "src/data/goddessMaterials.ts", ], thresholds: { diff --git a/goddess-todo.md b/goddess-todo.md index 8332db2..b5e0a95 100644 --- a/goddess-todo.md +++ b/goddess-todo.md @@ -33,18 +33,22 @@ Branch: `feat/goddess` - NOTE: All data files excluded from coverage until Chunk 4 routes import them - Lint ✅ · Build ✅ · Tests ✅ (100% coverage) -## Chunk 3 — Sync / Sanitize -- [ ] Update `validateAndSanitize` to inject goddess state defaults for existing saves -- [ ] Update force-sync (`syncNewContent`) to inject missing goddess fields -- [ ] Add apotheosis unlock flag handling +## Chunk 3 — Sync / Sanitize ✅ COMPLETE +- [x] Update `validateAndSanitize` to inject goddess state defaults for existing saves +- [x] Update force-sync (`syncNewContent`) to inject missing goddess fields +- [x] Add apotheosis unlock flag handling +- Lint ✅ · Build ✅ · Tests ✅ (100% coverage) -## Chunk 4 — API Routes -- [ ] Goddess boss fight route -- [ ] Consecration (goddess prestige) route -- [ ] Enlightenment (goddess transcendence) route -- [ ] Goddess upgrade purchase route -- [ ] Goddess crafting route -- [ ] Goddess exploration route +## Chunk 4 — API Routes ✅ COMPLETE +- [x] Goddess boss fight route (`goddessBoss.ts`) +- [x] Consecration (goddess prestige) route (`consecration.ts`) +- [x] Enlightenment (goddess transcendence) route (`enlightenment.ts`) +- [x] Goddess upgrade purchase route (`goddessUpgrade.ts`) +- [x] Goddess crafting route (`goddessCraft.ts`) +- [x] Goddess exploration route (`goddessExplore.ts`) +- [x] Services: `consecration.ts`, `enlightenment.ts` +- [x] Tests for all 6 routes (100% coverage) +- Lint ✅ · Build ✅ · Tests ✅ (100% coverage) ## Chunk 5 — UI: Resource Bar + Mode/Tab Nav - [ ] Add goddess currencies to resource bar dropdown (greyed pre-apotheosis) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a2f2f51..6bcf8e6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,12 +49,22 @@ export type { AuthResponse, BossChallengeRequest, BossChallengeResponse, + BuyConsecrationUpgradeRequest, + BuyConsecrationUpgradeResponse, BuyEchoUpgradeRequest, BuyEchoUpgradeResponse, + BuyEnlightenmentUpgradeRequest, + BuyEnlightenmentUpgradeResponse, + BuyGoddessUpgradeRequest, + BuyGoddessUpgradeResponse, BuyPrestigeUpgradeRequest, BuyPrestigeUpgradeResponse, + ConsecrationRequest, + ConsecrationResponse, CraftRecipeRequest, CraftRecipeResponse, + EnlightenmentRequest, + EnlightenmentResponse, ExploreClaimableResponse, ExploreCollectEventResult, ExploreCollectRequest, @@ -63,6 +73,16 @@ export type { ExploreStartResponse, ForceUnlocksResponse, GiteaRelease, + GoddessBossChallengeRequest, + GoddessBossChallengeResponse, + GoddessCraftRequest, + GoddessCraftResponse, + GoddessExploreClaimableResponse, + GoddessExploreCollectEventResult, + GoddessExploreCollectRequest, + GoddessExploreCollectResponse, + GoddessExploreStartRequest, + GoddessExploreStartResponse, LeaderboardCategory, LeaderboardEntry, LeaderboardResponse, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 0f94b5c..dfeeab9 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -564,12 +564,196 @@ interface SyncNewContentResponse { */ zonesPatched: number; + /** + * Number of goddess achievements added to the save. + */ + goddessAchievementsAdded: number; + + /** + * Number of goddess bosses added to the save. + */ + goddessBossesAdded: number; + + /** + * Number of goddess disciples added to the save. + */ + goddessDiscipesAdded: number; + + /** + * Number of goddess equipment items added to the save. + */ + goddessEquipmentAdded: number; + + /** + * Number of goddess exploration areas added to the save. + */ + goddessExplorationAreasAdded: number; + + /** + * Number of goddess quests added to the save. + */ + goddessQuestsAdded: number; + + /** + * Number of goddess upgrades added to the save. + */ + goddessUpgradesAdded: number; + + /** + * Number of goddess zones added to the save. + */ + goddessZonesAdded: number; + /** * HMAC-SHA256 signature of the updated state for anti-cheat chain continuity. */ signature?: string; } +interface GoddessBossChallengeRequest { + bossId: string; +} + +interface GoddessBossChallengeResponse { + won: boolean; + partyDPS: number; + bossDPS: number; + bossHpBefore: number; + bossMaxHp: number; + bossHpAtBattleEnd: number; + bossNewHp: number; + partyMaxHp: number; + partyHpRemaining: number; + rewards?: { + prayers: number; + divinity: number; + stardust: number; + upgradeIds: Array; + equipmentIds: Array; + + /** + * One-time divinity bounty awarded for defeating this boss for the very first time. + */ + bountyDivinity: number; + }; + casualties?: Array<{ + discipleId: string; + killed: number; + }>; + + /** + * HMAC-SHA256 signature of the updated state for anti-cheat chain continuity. + */ + signature?: string; +} + +type ConsecrationRequest = Record; + +interface ConsecrationResponse { + + /** + * Divinity earned from this consecration. + */ + divinityEarned: number; + + // eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client + newConsecrationCount: number; +} + +interface BuyConsecrationUpgradeRequest { + upgradeId: string; +} + +interface BuyConsecrationUpgradeResponse { + divinityRemaining: number; + purchasedUpgradeIds: Array; + divinityPrayersMultiplier: number; + divinityDisciplesMultiplier: number; + divinityCombatMultiplier: number; +} + +type EnlightenmentRequest = Record; + +interface EnlightenmentResponse { + + /** + * Stardust earned from this enlightenment. + */ + stardustEarned: number; + + // eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client + newEnlightenmentCount: number; +} + +interface BuyEnlightenmentUpgradeRequest { + upgradeId: string; +} + +interface BuyEnlightenmentUpgradeResponse { + stardustRemaining: number; + purchasedUpgradeIds: Array; + stardustPrayersMultiplier: number; + stardustCombatMultiplier: number; + stardustConsecrationThresholdMultiplier: number; + stardustConsecrationDivinityMultiplier: number; + stardustMetaMultiplier: number; +} + +interface BuyGoddessUpgradeRequest { + upgradeId: string; +} + +interface BuyGoddessUpgradeResponse { + prayersRemaining: number; + divinityRemaining: number; + stardustRemaining: number; +} + +interface GoddessCraftRequest { + recipeId: string; +} + +interface GoddessCraftResponse { + recipeId: string; + bonusType: string; + bonusValue: number; + craftedPrayersMultiplier: number; + craftedDivinityMultiplier: number; + craftedCombatMultiplier: number; + materials: Array<{ materialId: string; quantity: number }>; +} + +interface GoddessExploreStartRequest { + areaId: string; +} + +interface GoddessExploreStartResponse { + areaId: string; + endsAt: number; +} + +interface GoddessExploreCollectRequest { + areaId: string; +} + +interface GoddessExploreCollectEventResult { + text: string; + prayersChange: number; + discipleLostCount: number; + materialGained: { materialId: string; quantity: number } | null; +} + +interface GoddessExploreCollectResponse { + foundNothing: boolean; + nothingMessage?: string; + materialsFound: Array<{ materialId: string; quantity: number }>; + event: GoddessExploreCollectEventResult | null; +} + +interface GoddessExploreClaimableResponse { + claimable: boolean; +} + export type { AboutResponse, ApiError, @@ -578,12 +762,22 @@ export type { AuthResponse, BossChallengeRequest, BossChallengeResponse, + BuyConsecrationUpgradeRequest, + BuyConsecrationUpgradeResponse, BuyEchoUpgradeRequest, BuyEchoUpgradeResponse, + BuyEnlightenmentUpgradeRequest, + BuyEnlightenmentUpgradeResponse, + BuyGoddessUpgradeRequest, + BuyGoddessUpgradeResponse, BuyPrestigeUpgradeRequest, BuyPrestigeUpgradeResponse, + ConsecrationRequest, + ConsecrationResponse, CraftRecipeRequest, CraftRecipeResponse, + EnlightenmentRequest, + EnlightenmentResponse, ExploreClaimableResponse, ExploreCollectEventResult, ExploreCollectRequest, @@ -592,6 +786,16 @@ export type { ExploreStartResponse, ForceUnlocksResponse, GiteaRelease, + GoddessBossChallengeRequest, + GoddessBossChallengeResponse, + GoddessCraftRequest, + GoddessCraftResponse, + GoddessExploreClaimableResponse, + GoddessExploreCollectEventResult, + GoddessExploreCollectRequest, + GoddessExploreCollectResponse, + GoddessExploreStartRequest, + GoddessExploreStartResponse, LeaderboardCategory, LeaderboardEntry, LeaderboardResponse,