/** * @file Vampire exploration routes handling dark area exploration mechanics. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-statements -- Route handlers require many statements */ /* eslint-disable complexity -- Route handlers have inherent complexity */ /* eslint-disable max-lines -- Route file requires multiple handlers */ /* eslint-disable stylistic/max-len -- Route logic requires long lines */ import { Hono } from "hono"; import { defaultVampireExplorationAreas } from "../data/vampireExplorations.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; import type { GameState, VampireExploreClaimableResponse, VampireExploreCollectEventResult, VampireExploreCollectRequest, VampireExploreCollectResponse, VampireExploreStartRequest, VampireExploreStartResponse, } from "@elysium/types"; const vampireExploreRouter = new Hono(); vampireExploreRouter.use("*", authMiddleware); const nothingProbability = 0.2; const nothingMessages = [ "Your thralls searched the shadowy depths but found nothing of value.", "The cursed area yielded nothing remarkable this time.", "Your thralls returned empty-handed from the darkness.", "A wasted hunt — the darkened area proved barren.", "Nothing to show for the bloodshed. Perhaps next time.", ]; /** * Returns a random "nothing found" message. * @returns A random message string. */ const pickNothingMessage = (): string => { const index = Math.floor(Math.random() * nothingMessages.length); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ return nothingMessages[index] ?? nothingMessages[0] ?? ""; }; vampireExploreRouter.get("/claimable", async(context) => { try { const discordId = context.get("discordId"); const areaId = context.req.query("areaId"); // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation if (!areaId) { return context.json({ error: "areaId is required" }, 400); } const explorationArea = defaultVampireExplorationAreas.find((a) => { return a.id === areaId; }); if (!explorationArea) { return context.json({ error: "Unknown exploration area" }, 404); } const record = await prisma.gameState.findUnique({ where: { discordId } }); if (!record) { return context.json({ error: "No save found" }, 404); } const rawState: unknown = record.state; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ const state = rawState as GameState; if (!state.vampire) { const response: VampireExploreClaimableResponse = { claimable: false }; return context.json(response); } const area = state.vampire.exploration.areas.find((a) => { return a.id === areaId; }); if (!area || area.status !== "in_progress") { const response: VampireExploreClaimableResponse = { claimable: false }; return context.json(response); } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const startedAt = area.startedAt ?? 0; const durationMs = explorationArea.durationSeconds * 1000; const expiresAt = startedAt + durationMs; const claimable = Date.now() >= expiresAt; const response: VampireExploreClaimableResponse = { claimable }; return context.json(response); } catch (error) { void logger.error( "vampire_explore_claimable", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); vampireExploreRouter.post("/start", async(context) => { try { const discordId = context.get("discordId"); const body = await context.req.json(); const { areaId } = body; // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation if (!areaId) { return context.json({ error: "areaId is required" }, 400); } const explorationArea = defaultVampireExplorationAreas.find((a) => { return a.id === areaId; }); if (!explorationArea) { return context.json({ error: "Unknown exploration area" }, 404); } const record = await prisma.gameState.findUnique({ where: { discordId } }); if (!record) { return context.json({ error: "No save found" }, 404); } const rawState: unknown = record.state; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ const state = rawState as GameState; if (!state.vampire) { return context.json({ error: "Vampire realm not unlocked" }, 400); } const zone = state.vampire.zones.find((z) => { return z.id === explorationArea.zoneId; }); if (!zone || zone.status !== "unlocked") { return context.json({ error: "Zone is not unlocked" }, 400); } const area = state.vampire.exploration.areas.find((a) => { return a.id === areaId; }); if (!area) { return context.json( { error: "Exploration area not found in state" }, 404, ); } const anyInProgress = state.vampire.exploration.areas.some((a) => { return a.status === "in_progress"; }); if (anyInProgress) { return context.json( { error: "An exploration is already in progress" }, 400, ); } if (area.status === "locked") { return context.json({ error: "Exploration area is locked" }, 400); } const now = Date.now(); // eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear const endsAt = now + explorationArea.durationSeconds * 1000; area.status = "in_progress"; area.startedAt = now; area.endsAt = endsAt; await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: now }, where: { discordId }, }); const response: VampireExploreStartResponse = { areaId, endsAt, }; return context.json(response); } catch (error) { void logger.error( "vampire_explore_start", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); vampireExploreRouter.post("/collect", async(context) => { try { const discordId = context.get("discordId"); const body = await context.req.json(); const { areaId } = body; // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation if (!areaId) { return context.json({ error: "areaId is required" }, 400); } const explorationArea = defaultVampireExplorationAreas.find((a) => { return a.id === areaId; }); if (!explorationArea) { return context.json({ error: "Unknown exploration area" }, 404); } const record = await prisma.gameState.findUnique({ where: { discordId } }); if (!record) { return context.json({ error: "No save found" }, 404); } const rawState: unknown = record.state; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ const state = rawState as GameState; if (!state.vampire) { return context.json({ error: "Vampire realm not unlocked" }, 400); } const area = state.vampire.exploration.areas.find((a) => { return a.id === areaId; }); if (!area) { return context.json({ error: "Exploration area not found" }, 404); } if (area.status !== "in_progress") { return context.json({ error: "Exploration is not in progress" }, 400); } const now = Date.now(); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const startedAt = area.startedAt ?? 0; const durationMs = explorationArea.durationSeconds * 1000; const expiresAt = startedAt + durationMs; if (now < expiresAt) { return context.json({ error: "Exploration is not yet complete" }, 400); } area.status = "available"; area.completedOnce = true; // 20% chance of finding nothing if (Math.random() < nothingProbability) { await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: now }, where: { discordId }, }); const response: VampireExploreCollectResponse = { event: null, foundNothing: true, materialsFound: [], nothingMessage: pickNothingMessage(), }; return context.json(response); } // Pick a random event const eventIndex = Math.floor( Math.random() * explorationArea.events.length, ); const event = explorationArea.events[eventIndex]; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 3 -- @preserve */ if (!event) { return context.json({ error: "No events available" }, 500); } // Apply event effects and build the result summary let bloodChange = 0; let ichorChange = 0; let materialGained: { materialId: string; quantity: number } | null = null; if (event.effect.type === "blood_gain") { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 2 -- @preserve */ const amount = event.effect.amount ?? 0; state.resources.blood = (state.resources.blood ?? 0) + amount; state.vampire.totalBloodEarned = state.vampire.totalBloodEarned + amount; bloodChange = amount; } else if (event.effect.type === "blood_loss") { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 2 -- @preserve */ const amount = Math.min(state.resources.blood ?? 0, event.effect.amount ?? 0); state.resources.blood = (state.resources.blood ?? 0) - amount; bloodChange = -amount; } else if (event.effect.type === "ichor_gain") { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const amount = event.effect.amount ?? 0; state.vampire.siring.ichor = state.vampire.siring.ichor + amount; ichorChange = amount; } else if (event.effect.type === "dark_material_gain") { const { materialId } = event.effect; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const quantity = event.effect.quantity ?? 1; if (materialId !== undefined && materialId !== "") { const existing = state.vampire.exploration.materials.find((m) => { return m.materialId === materialId; }); if (existing) { existing.quantity = existing.quantity + quantity; } else { state.vampire.exploration.materials.push({ materialId, quantity }); } materialGained = { materialId, quantity }; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 13 -- @preserve */ } } else if (event.effect.type === "thrall_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 7 -- @preserve */ const fraction = event.effect.fraction ?? 0.05; for (const thrall of state.vampire.thralls) { const lost = Math.floor(thrall.count * fraction); if (lost > 0) { thrall.count = Math.max(0, thrall.count - lost); } } } let thrallLostCount = 0; // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 8 -- @preserve */ if (event.effect.type === "thrall_loss") { const fraction = event.effect.fraction ?? 0.05; for (const thrall of state.vampire.thralls) { const lost = Math.floor(thrall.count * fraction); thrallLostCount = thrallLostCount + lost; } } const eventResult: VampireExploreCollectEventResult = { bloodChange: bloodChange, ichorChange: ichorChange, materialGained: materialGained, text: event.text, thrallLostCount: thrallLostCount, }; // Roll for dark material drops from possibleMaterials (weighted random selection) const materialsFound: Array<{ materialId: string; quantity: number }> = []; if (explorationArea.possibleMaterials.length > 0) { let totalWeight = 0; for (const materialDrop of explorationArea.possibleMaterials) { totalWeight = totalWeight + materialDrop.weight; } let roll = Math.random() * totalWeight; for (const possible of explorationArea.possibleMaterials) { roll = roll - possible.weight; if (roll <= 0) { const maxMinDiff = possible.maxQuantity - possible.minQuantity; const range = maxMinDiff + 1; const randomOffset = Math.floor(Math.random() * range); const quantity = randomOffset + possible.minQuantity; const { materialId } = possible; const existing = state.vampire.exploration.materials.find((m) => { return m.materialId === materialId; }); if (existing) { existing.quantity = existing.quantity + quantity; } else { state.vampire.exploration.materials.push({ materialId, quantity }); } materialsFound.push({ materialId, quantity }); break; } } } await prisma.gameState.update({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: now }, where: { discordId }, }); const response: VampireExploreCollectResponse = { event: eventResult, foundNothing: false, materialsFound: materialsFound, }; return context.json(response); } catch (error) { void logger.error( "vampire_explore_collect", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); export { vampireExploreRouter };