generated from nhcarrigan/template
8fa5d12f05
Adds six new routes (vampire-boss, vampire-upgrade, vampire-craft, vampire-explore, siring, vampire-awakening) with matching siring and awakening services, and all necessary request/response types.
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
/**
|
|
* @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<HonoEnvironment>();
|
|
|
|
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<VampireExploreStartRequest>();
|
|
|
|
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<VampireExploreCollectRequest>();
|
|
|
|
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 };
|