feat: vampire expansion chunk 4 — API routes and services

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.
This commit is contained in:
2026-04-16 09:48:50 -07:00
committed by Naomi Carrigan
parent 7f43dc725e
commit 8fa5d12f05
11 changed files with 2026 additions and 0 deletions
+421
View File
@@ -0,0 +1,421 @@
/**
* @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 };