Files
elysium/apps/api/src/routes/consecration.ts
T
hikari 0d36b255ee feat: goddess API routes, services, and tests (chunk 4)
Add six new goddess-mode API routes (boss fight, consecration,
enlightenment, upgrade purchase, crafting, exploration) alongside
matching service modules and full test suites at 100% coverage.
2026-04-13 15:48:35 -07:00

189 lines
5.9 KiB
TypeScript

/**
* @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<HonoEnvironment>();
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<BuyConsecrationUpgradeRequest>();
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 };