generated from nhcarrigan/template
b85126c345
Resolves #127
442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
/**
|
|
* @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<HonoEnvironment>();
|
|
|
|
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<ExploreStartRequest>();
|
|
|
|
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();
|
|
area.status = "in_progress";
|
|
area.startedAt = 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 },
|
|
});
|
|
|
|
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
|
const endsAt = now + explorationArea.durationSeconds * 1000;
|
|
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<ExploreCollectRequest>();
|
|
|
|
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 };
|