/** * @file Exploration routes handling 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 */ import { Hono } from "hono"; import { defaultExplorations } from "../data/explorations.js"; import { initialExploration } from "../data/initialState.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 { ExploreClaimableResponse, ExploreCollectEventResult, ExploreCollectRequest, ExploreCollectResponse, ExploreStartRequest, ExploreStartResponse, GameState, } from "@elysium/types"; const exploreRouter = new Hono(); exploreRouter.use("*", authMiddleware); const nothingProbability = 0.2; const nothingMessages = [ "Your scouts searched thoroughly but found nothing of value.", "The area yielded nothing remarkable this time.", "Your scouts returned empty-handed.", "A wasted journey — the area proved barren.", "Nothing to show for the effort. Perhaps next time.", ]; /** * Returns a random "nothing found" message. * V8 ignore next 2 -- @preserve. * @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] ?? ""; }; exploreRouter.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 = defaultExplorations.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.exploration) { const response: ExploreClaimableResponse = { claimable: false }; return context.json(response); } const area = state.exploration.areas.find((a) => { return a.id === areaId; }); if (!area || area.status !== "in_progress") { const response: ExploreClaimableResponse = { 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: ExploreClaimableResponse = { claimable }; return context.json(response); } catch (error) { void logger.error( "explore_claimable", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); exploreRouter.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 = defaultExplorations.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; // Backfill exploration state for old saves that predate this feature if (!state.exploration) { state.exploration = structuredClone(initialExploration); // Unlock areas for zones already unlocked in this save for (const area of state.exploration.areas) { const areaData = defaultExplorations.find((areaItem) => { return areaItem.id === area.id; }); // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 3 -- @preserve */ if (!areaData) { continue; } const zone = state.zones.find((z) => { return z.id === areaData.zoneId; }); if (zone?.status === "unlocked") { area.status = "available"; } } } const zone = state.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.exploration.areas.find((a) => { return a.id === areaId; }); if (!area) { return context.json( { error: "Exploration area not found in state" }, 404, ); } const anyInProgress = state.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: ExploreStartResponse = { areaId, endsAt, }; return context.json(response); } catch (error) { void logger.error( "explore_start", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); exploreRouter.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 = defaultExplorations.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.exploration) { return context.json({ error: "No exploration state found" }, 400); } const area = state.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: ExploreCollectResponse = { 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 goldChange = 0; let essenceChange = 0; let materialGained: { materialId: string; quantity: number } | null = null; if (event.effect.type === "gold_gain") { // Gold gain — amount may be undefined in edge cases // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const amount = event.effect.amount ?? 0; state.resources.gold = state.resources.gold + amount; state.player.totalGoldEarned = state.player.totalGoldEarned + amount; goldChange = amount; } else if (event.effect.type === "gold_loss") { // Gold loss — amount may be undefined in edge cases // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const amount = Math.min(state.resources.gold, event.effect.amount ?? 0); state.resources.gold = state.resources.gold - amount; goldChange = -amount; } else if (event.effect.type === "essence_gain") { // Essence gain — amount may be undefined in edge cases // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ const amount = event.effect.amount ?? 0; state.resources.essence = state.resources.essence + amount; essenceChange = amount; } else if (event.effect.type === "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.exploration.materials.find((m) => { return m.materialId === materialId; }); if (existing) { existing.quantity = existing.quantity + quantity; } else { state.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 === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above // Adventurer loss — fraction and loop are defensive // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 8 -- @preserve */ const fraction = event.effect.fraction ?? 0.05; for (const adventurer of state.adventurers) { const lost = Math.floor(adventurer.count * fraction); if (lost > 0) { adventurer.count = Math.max(0, adventurer.count - lost); } } } // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next 8 -- @preserve */ let adventurerLostCount = 0; if (event.effect.type === "adventurer_loss") { const fraction = event.effect.fraction ?? 0.05; for (const adv of state.adventurers) { const lost = Math.floor(adv.count * fraction); adventurerLostCount = adventurerLostCount + lost; } } const eventResult: ExploreCollectEventResult = { adventurerLostCount: adventurerLostCount, essenceChange: essenceChange, goldChange: goldChange, materialGained: materialGained, text: event.text, }; // Roll for 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.exploration.materials.find((m) => { return m.materialId === materialId; }); if (existing) { existing.quantity = existing.quantity + quantity; } else { state.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: ExploreCollectResponse = { event: eventResult, foundNothing: false, materialsFound: materialsFound, }; return context.json(response); } catch (error) { void logger.error( "explore_collect", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); export { exploreRouter };