generated from nhcarrigan/template
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:
@@ -26,8 +26,14 @@ import { goddessUpgradeRouter } from "./routes/goddessUpgrade.js";
|
|||||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||||
import { prestigeRouter } from "./routes/prestige.js";
|
import { prestigeRouter } from "./routes/prestige.js";
|
||||||
import { profileRouter } from "./routes/profile.js";
|
import { profileRouter } from "./routes/profile.js";
|
||||||
|
import { siringRouter } from "./routes/siring.js";
|
||||||
import { timersRouter } from "./routes/timers.js";
|
import { timersRouter } from "./routes/timers.js";
|
||||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||||
|
import { vampireAwakeningRouter } from "./routes/vampireAwakening.js";
|
||||||
|
import { vampireBossRouter } from "./routes/vampireBoss.js";
|
||||||
|
import { vampireCraftRouter } from "./routes/vampireCraft.js";
|
||||||
|
import { vampireExploreRouter } from "./routes/vampireExplore.js";
|
||||||
|
import { vampireUpgradeRouter } from "./routes/vampireUpgrade.js";
|
||||||
import { connectGateway } from "./services/gateway.js";
|
import { connectGateway } from "./services/gateway.js";
|
||||||
import { logger } from "./services/logger.js";
|
import { logger } from "./services/logger.js";
|
||||||
|
|
||||||
@@ -60,6 +66,12 @@ app.route("/enlightenment", enlightenmentRouter);
|
|||||||
app.route("/goddess-upgrade", goddessUpgradeRouter);
|
app.route("/goddess-upgrade", goddessUpgradeRouter);
|
||||||
app.route("/goddess-craft", goddessCraftRouter);
|
app.route("/goddess-craft", goddessCraftRouter);
|
||||||
app.route("/goddess-explore", goddessExploreRouter);
|
app.route("/goddess-explore", goddessExploreRouter);
|
||||||
|
app.route("/vampire-boss", vampireBossRouter);
|
||||||
|
app.route("/siring", siringRouter);
|
||||||
|
app.route("/vampire-awakening", vampireAwakeningRouter);
|
||||||
|
app.route("/vampire-upgrade", vampireUpgradeRouter);
|
||||||
|
app.route("/vampire-craft", vampireCraftRouter);
|
||||||
|
app.route("/vampire-explore", vampireExploreRouter);
|
||||||
app.route("/leaderboards", leaderboardRouter);
|
app.route("/leaderboards", leaderboardRouter);
|
||||||
app.route("/profile", profileRouter);
|
app.route("/profile", profileRouter);
|
||||||
app.route("/timers", timersRouter);
|
app.route("/timers", timersRouter);
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* @file Siring routes handling siring resets and ichor upgrade purchases.
|
||||||
|
* @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 stylistic/max-len -- Route logic requires long lines */
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { defaultVampireSiringUpgrades } from "../data/vampireSiringUpgrades.js";
|
||||||
|
import { prisma } from "../db/client.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
import {
|
||||||
|
buildPostSiringState,
|
||||||
|
calculateSiringThreshold,
|
||||||
|
computeSiringIchorMultipliers,
|
||||||
|
computeSiringThresholdMultiplier,
|
||||||
|
isEligibleForSiring,
|
||||||
|
} from "../services/siring.js";
|
||||||
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
import type {
|
||||||
|
BuySiringUpgradeRequest,
|
||||||
|
BuySiringUpgradeResponse,
|
||||||
|
GameState,
|
||||||
|
SiringResponse,
|
||||||
|
} from "@elysium/types";
|
||||||
|
|
||||||
|
const siringRouter = new Hono<HonoEnvironment>();
|
||||||
|
|
||||||
|
siringRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
|
siringRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
|
if (!state.vampire) {
|
||||||
|
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEligibleForSiring(state)) {
|
||||||
|
const threshold = calculateSiringThreshold(
|
||||||
|
state.vampire.siring.count,
|
||||||
|
computeSiringThresholdMultiplier(state.vampire.siring.purchasedUpgradeIds) * state.vampire.awakening.soulShardsSiringThresholdMultiplier,
|
||||||
|
);
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: `Not eligible for siring — earn ${threshold.toLocaleString()} total blood first`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ichorEarned, updatedVampire } = buildPostSiringState(state);
|
||||||
|
|
||||||
|
const updatedSiringCount = updatedVampire.siring.count;
|
||||||
|
|
||||||
|
const updatedState: GameState = {
|
||||||
|
...state,
|
||||||
|
resources: { ...state.resources, blood: 0 },
|
||||||
|
vampire: updatedVampire,
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("siring", 1, { discordId, updatedSiringCount });
|
||||||
|
|
||||||
|
const response: SiringResponse = {
|
||||||
|
ichorEarned: ichorEarned,
|
||||||
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
|
||||||
|
newSiringCount: updatedSiringCount,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"siring",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
siringRouter.post("/buy-upgrade", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const body = await context.req.json<BuySiringUpgradeRequest>();
|
||||||
|
|
||||||
|
const { upgradeId } = body;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!upgradeId) {
|
||||||
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgrade = defaultVampireSiringUpgrades.find((siringUpgrade) => {
|
||||||
|
return siringUpgrade.id === upgradeId;
|
||||||
|
});
|
||||||
|
if (!upgrade) {
|
||||||
|
return context.json({ error: "Unknown siring upgrade" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
if (!record) {
|
||||||
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
|
if (!state.vampire) {
|
||||||
|
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { purchasedUpgradeIds, ichor } = state.vampire.siring;
|
||||||
|
|
||||||
|
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||||
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ichor < upgrade.ichorCost) {
|
||||||
|
return context.json({ error: "Not enough ichor" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedIchor = ichor - upgrade.ichorCost;
|
||||||
|
|
||||||
|
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||||
|
|
||||||
|
const updatedMultipliers = computeSiringIchorMultipliers(updatedPurchasedIds);
|
||||||
|
|
||||||
|
const updatedState: GameState = {
|
||||||
|
...state,
|
||||||
|
vampire: {
|
||||||
|
...state.vampire,
|
||||||
|
siring: {
|
||||||
|
...state.vampire.siring,
|
||||||
|
ichor: updatedIchor,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
|
...updatedMultipliers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("siring_upgrade_purchased", 1, { discordId, upgradeId });
|
||||||
|
|
||||||
|
const response: BuySiringUpgradeResponse = {
|
||||||
|
ichorBloodMultiplier: updatedMultipliers.ichorBloodMultiplier ?? 1,
|
||||||
|
ichorCombatMultiplier: updatedMultipliers.ichorCombatMultiplier ?? 1,
|
||||||
|
ichorRemaining: updatedIchor,
|
||||||
|
ichorThrallsMultiplier: updatedMultipliers.ichorThrallsMultiplier ?? 1,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"siring_buy_upgrade",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { siringRouter };
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* @file Vampire Awakening routes handling awakening resets and soul shard upgrade purchases.
|
||||||
|
* @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 stylistic/max-len -- Route logic requires long lines */
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { defaultVampireAwakeningUpgrades } from "../data/vampireAwakeningUpgrades.js";
|
||||||
|
import { prisma } from "../db/client.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import {
|
||||||
|
buildPostAwakeningState,
|
||||||
|
computeAwakeningMultipliers,
|
||||||
|
isEligibleForAwakening,
|
||||||
|
} from "../services/awakening.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
import type {
|
||||||
|
AwakeningResponse,
|
||||||
|
BuyAwakeningUpgradeRequest,
|
||||||
|
BuyAwakeningUpgradeResponse,
|
||||||
|
GameState,
|
||||||
|
} from "@elysium/types";
|
||||||
|
|
||||||
|
const vampireAwakeningRouter = new Hono<HonoEnvironment>();
|
||||||
|
|
||||||
|
vampireAwakeningRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
|
vampireAwakeningRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
if (!record) {
|
||||||
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
|
if (!state.vampire) {
|
||||||
|
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEligibleForAwakening(state)) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "Not eligible for awakening — defeat the Eternal Darkness first",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { soulShardsEarned, updatedVampire } = buildPostAwakeningState(state);
|
||||||
|
|
||||||
|
const updatedAwakeningCount = updatedVampire.awakening.count;
|
||||||
|
|
||||||
|
const updatedState: GameState = {
|
||||||
|
...state,
|
||||||
|
resources: { ...state.resources, blood: 0, ichor: 0 },
|
||||||
|
vampire: updatedVampire,
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: now },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("vampire_awakening", 1, { discordId, updatedAwakeningCount });
|
||||||
|
|
||||||
|
const response: AwakeningResponse = {
|
||||||
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
|
||||||
|
newAwakeningCount: updatedAwakeningCount,
|
||||||
|
soulShardsEarned: soulShardsEarned,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"vampire_awakening",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vampireAwakeningRouter.post("/buy-upgrade", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const body = await context.req.json<BuyAwakeningUpgradeRequest>();
|
||||||
|
|
||||||
|
const { upgradeId } = body;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!upgradeId) {
|
||||||
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgrade = defaultVampireAwakeningUpgrades.find((awakeningUpgrade) => {
|
||||||
|
return awakeningUpgrade.id === upgradeId;
|
||||||
|
});
|
||||||
|
if (!upgrade) {
|
||||||
|
return context.json({ error: "Unknown awakening upgrade" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
if (!record) {
|
||||||
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
|
if (!state.vampire) {
|
||||||
|
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { purchasedUpgradeIds, soulShards } = state.vampire.awakening;
|
||||||
|
|
||||||
|
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||||
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (soulShards < upgrade.cost) {
|
||||||
|
return context.json({ error: "Not enough soul shards" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSoulShards = soulShards - upgrade.cost;
|
||||||
|
|
||||||
|
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||||
|
|
||||||
|
const updatedMultipliers = computeAwakeningMultipliers(updatedPurchasedIds);
|
||||||
|
|
||||||
|
const updatedState: GameState = {
|
||||||
|
...state,
|
||||||
|
vampire: {
|
||||||
|
...state.vampire,
|
||||||
|
awakening: {
|
||||||
|
...state.vampire.awakening,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
|
soulShards: updatedSoulShards,
|
||||||
|
...updatedMultipliers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("vampire_awakening_upgrade_purchased", 1, { discordId, upgradeId });
|
||||||
|
|
||||||
|
const response: BuyAwakeningUpgradeResponse = {
|
||||||
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
|
soulShardsBloodMultiplier: updatedMultipliers.soulShardsBloodMultiplier,
|
||||||
|
soulShardsCombatMultiplier: updatedMultipliers.soulShardsCombatMultiplier,
|
||||||
|
soulShardsMetaMultiplier: updatedMultipliers.soulShardsMetaMultiplier,
|
||||||
|
soulShardsRemaining: updatedSoulShards,
|
||||||
|
soulShardsSiringIchorMultiplier: updatedMultipliers.soulShardsSiringIchorMultiplier,
|
||||||
|
soulShardsSiringThresholdMultiplier: updatedMultipliers.soulShardsSiringThresholdMultiplier,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"vampire_awakening_buy_upgrade",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { vampireAwakeningRouter };
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
/**
|
||||||
|
* @file Vampire boss challenge route handling blood combat mechanics.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- Boss handler requires many steps */
|
||||||
|
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||||
|
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||||
|
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||||
|
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import {
|
||||||
|
computeVampireSetBonuses,
|
||||||
|
type GameState,
|
||||||
|
type VampireBossChallengeResponse,
|
||||||
|
} from "@elysium/types";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { defaultVampireBosses } from "../data/vampireBosses.js";
|
||||||
|
import { defaultVampireEquipmentSets } from "../data/vampireEquipmentSets.js";
|
||||||
|
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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the HMAC-SHA256 of data using the given secret.
|
||||||
|
* @param data - The data string to sign.
|
||||||
|
* @param secret - The HMAC secret key.
|
||||||
|
* @returns The hex-encoded HMAC digest.
|
||||||
|
*/
|
||||||
|
const computeHmac = (data: string, secret: string): string => {
|
||||||
|
return createHmac("sha256", secret).update(data).
|
||||||
|
digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
|
const vampireBossRouter = new Hono<HonoEnvironment>();
|
||||||
|
|
||||||
|
vampireBossRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
|
const calculateThrallStats = (
|
||||||
|
vampire: NonNullable<GameState["vampire"]>,
|
||||||
|
): { partyDPS: number; partyMaxHp: number } => {
|
||||||
|
let globalMultiplier = 1;
|
||||||
|
for (const upgrade of vampire.upgrades) {
|
||||||
|
if (upgrade.purchased && upgrade.target === "global") {
|
||||||
|
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ichorCombatMultiplier = vampire.siring.ichorCombatMultiplier ?? 1;
|
||||||
|
const { soulShardsCombatMultiplier } = vampire.awakening;
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 7 -- @preserve */
|
||||||
|
const equipmentCombatMultiplier = vampire.equipment.
|
||||||
|
filter((item) => {
|
||||||
|
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||||
|
}).
|
||||||
|
reduce((mult, item) => {
|
||||||
|
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 7 -- @preserve */
|
||||||
|
const equippedItemIds = vampire.equipment.
|
||||||
|
filter((item) => {
|
||||||
|
return item.equipped;
|
||||||
|
}).
|
||||||
|
map((item) => {
|
||||||
|
return item.id;
|
||||||
|
});
|
||||||
|
const { combatMultiplier: setCombatMultiplier } = computeVampireSetBonuses(
|
||||||
|
equippedItemIds,
|
||||||
|
defaultVampireEquipmentSets,
|
||||||
|
);
|
||||||
|
|
||||||
|
let partyDPS = 0;
|
||||||
|
let partyMaxHp = 0;
|
||||||
|
|
||||||
|
for (const thrall of vampire.thralls) {
|
||||||
|
if (thrall.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let thrallMultiplier = 1;
|
||||||
|
for (const upgrade of vampire.upgrades) {
|
||||||
|
if (
|
||||||
|
upgrade.purchased
|
||||||
|
&& upgrade.target === "thrall"
|
||||||
|
&& upgrade.thrallId === thrall.id
|
||||||
|
) {
|
||||||
|
thrallMultiplier = thrallMultiplier * upgrade.multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const thrallContribution
|
||||||
|
= thrall.combatPower
|
||||||
|
* thrall.count
|
||||||
|
* thrallMultiplier
|
||||||
|
* globalMultiplier;
|
||||||
|
partyDPS = partyDPS + thrallContribution;
|
||||||
|
|
||||||
|
const thrallHp = thrall.level * 50 * thrall.count;
|
||||||
|
partyMaxHp = partyMaxHp + thrallHp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { craftedCombatMultiplier } = vampire.exploration;
|
||||||
|
|
||||||
|
partyDPS = partyDPS
|
||||||
|
* equipmentCombatMultiplier
|
||||||
|
* setCombatMultiplier
|
||||||
|
* ichorCombatMultiplier
|
||||||
|
* soulShardsCombatMultiplier
|
||||||
|
* craftedCombatMultiplier;
|
||||||
|
|
||||||
|
return { partyDPS, partyMaxHp };
|
||||||
|
};
|
||||||
|
|
||||||
|
vampireBossRouter.post("/challenge", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
const body = await context.req.json<{ bossId: string }>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!body.bossId) {
|
||||||
|
return context.json({ error: "Invalid request body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { vampire } = state;
|
||||||
|
|
||||||
|
const boss = vampire.bosses.find((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!boss) {
|
||||||
|
return context.json({ error: "Boss not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.status !== "available" && boss.status !== "in_progress") {
|
||||||
|
return context.json({ error: "Boss is not currently available" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boss.siringRequirement > vampire.siring.count) {
|
||||||
|
return context.json({ error: "Siring requirement not met" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { partyDPS, partyMaxHp } = calculateThrallStats(vampire);
|
||||||
|
|
||||||
|
if (
|
||||||
|
partyDPS === 0
|
||||||
|
|| partyMaxHp === 0
|
||||||
|
|| !Number.isFinite(partyDPS)
|
||||||
|
|| !Number.isFinite(partyMaxHp)
|
||||||
|
) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Your thralls have no combat power" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bossHpBefore = boss.currentHp;
|
||||||
|
const bossDPS = boss.damagePerSecond;
|
||||||
|
|
||||||
|
const timeToKillBoss = bossHpBefore / partyDPS;
|
||||||
|
const timeToKillParty = partyMaxHp / bossDPS;
|
||||||
|
|
||||||
|
const won = timeToKillBoss <= timeToKillParty;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let partyHpRemaining: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let bossHpAtBattleEnd: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||||
|
let bossUpdatedHp: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||||
|
let rewards: VampireBossChallengeResponse["rewards"];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||||
|
let casualties: VampireBossChallengeResponse["casualties"];
|
||||||
|
|
||||||
|
if (won) {
|
||||||
|
bossHpAtBattleEnd = 0;
|
||||||
|
bossUpdatedHp = 0;
|
||||||
|
const bossDamageDealt = bossDPS * timeToKillBoss;
|
||||||
|
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
|
||||||
|
|
||||||
|
boss.status = "defeated";
|
||||||
|
boss.currentHp = 0;
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 2 -- @preserve */
|
||||||
|
// eslint-disable-next-line unicorn/consistent-destructuring -- mutation requires direct property access on state.resources
|
||||||
|
state.resources.blood = (state.resources.blood ?? 0) + boss.bloodReward;
|
||||||
|
vampire.totalBloodEarned = vampire.totalBloodEarned + boss.bloodReward;
|
||||||
|
vampire.lifetimeBloodEarned = vampire.lifetimeBloodEarned + boss.bloodReward;
|
||||||
|
vampire.siring.ichor = vampire.siring.ichor + boss.ichorReward;
|
||||||
|
vampire.awakening.soulShards = vampire.awakening.soulShards + boss.soulShardsReward;
|
||||||
|
vampire.lifetimeBossesDefeated = vampire.lifetimeBossesDefeated + 1;
|
||||||
|
|
||||||
|
for (const upgradeId of boss.upgradeRewards) {
|
||||||
|
const upgrade = vampire.upgrades.find((u) => {
|
||||||
|
return u.id === upgradeId;
|
||||||
|
});
|
||||||
|
if (upgrade) {
|
||||||
|
upgrade.unlocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant equipment rewards — auto-equip if the slot is currently empty
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 14 -- @preserve */
|
||||||
|
for (const equipmentId of boss.equipmentRewards) {
|
||||||
|
const equipment = vampire.equipment.find((item) => {
|
||||||
|
return item.id === equipmentId;
|
||||||
|
});
|
||||||
|
if (equipment) {
|
||||||
|
equipment.owned = true;
|
||||||
|
const slotAlreadyEquipped = vampire.equipment.some((item) => {
|
||||||
|
return item.type === equipment.type && item.equipped;
|
||||||
|
});
|
||||||
|
if (!slotAlreadyEquipped) {
|
||||||
|
equipment.equipped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock next boss in the same zone
|
||||||
|
const zoneBosses = vampire.bosses.filter((b) => {
|
||||||
|
return b.zoneId === boss.zoneId;
|
||||||
|
});
|
||||||
|
const zoneIndex = zoneBosses.findIndex((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
|
||||||
|
if (
|
||||||
|
nextZoneBoss
|
||||||
|
&& nextZoneBoss.siringRequirement <= vampire.siring.count
|
||||||
|
) {
|
||||||
|
const nextBossInState = vampire.bosses.find((b) => {
|
||||||
|
return b.id === nextZoneBoss.id;
|
||||||
|
});
|
||||||
|
if (nextBossInState) {
|
||||||
|
nextBossInState.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock zones whose conditions are now both satisfied
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
for (const zone of vampire.zones) {
|
||||||
|
if (zone.status === "unlocked") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (zone.unlockBossId !== body.bossId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const questSatisfied
|
||||||
|
= zone.unlockQuestId === null
|
||||||
|
|| vampire.quests.some((q) => {
|
||||||
|
return q.id === zone.unlockQuestId && q.status === "completed";
|
||||||
|
});
|
||||||
|
if (!questSatisfied) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
zone.status = "unlocked";
|
||||||
|
|
||||||
|
// Unlock exploration areas for the newly unlocked zone
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 9 -- @preserve */
|
||||||
|
for (const area of vampire.exploration.areas) {
|
||||||
|
const areaDefinition = defaultVampireExplorationAreas.find((explorationArea) => {
|
||||||
|
return explorationArea.id === area.id;
|
||||||
|
});
|
||||||
|
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
|
||||||
|
area.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedZoneBosses = vampire.bosses.filter((b) => {
|
||||||
|
return b.zoneId === zone.id;
|
||||||
|
});
|
||||||
|
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
||||||
|
if (
|
||||||
|
firstUpdatedBoss
|
||||||
|
&& firstUpdatedBoss.siringRequirement <= vampire.siring.count
|
||||||
|
) {
|
||||||
|
firstUpdatedBoss.status = "available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-kill ichor bounty — only awarded once
|
||||||
|
const staticBoss = defaultVampireBosses.find((b) => {
|
||||||
|
return b.id === body.bossId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 7 -- @preserve */
|
||||||
|
const bountyIchor
|
||||||
|
= boss.bountyIchorClaimed === true
|
||||||
|
? 0
|
||||||
|
: staticBoss?.bountyIchor ?? 0;
|
||||||
|
if (bountyIchor > 0) {
|
||||||
|
boss.bountyIchorClaimed = true;
|
||||||
|
vampire.siring.ichor = vampire.siring.ichor + bountyIchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
rewards = {
|
||||||
|
blood: boss.bloodReward,
|
||||||
|
bountyIchor: bountyIchor,
|
||||||
|
equipmentIds: boss.equipmentRewards,
|
||||||
|
ichor: boss.ichorReward,
|
||||||
|
soulShards: boss.soulShardsReward,
|
||||||
|
upgradeIds: boss.upgradeRewards,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const partyDamageDealt = partyDPS * timeToKillParty;
|
||||||
|
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
|
||||||
|
bossUpdatedHp = boss.maxHp;
|
||||||
|
partyHpRemaining = 0;
|
||||||
|
|
||||||
|
boss.status = "available";
|
||||||
|
boss.currentHp = boss.maxHp;
|
||||||
|
|
||||||
|
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
||||||
|
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
||||||
|
|
||||||
|
casualties = [];
|
||||||
|
for (const thrall of vampire.thralls) {
|
||||||
|
if (thrall.count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const killed = Math.floor(thrall.count * casualtyFraction);
|
||||||
|
if (killed > 0) {
|
||||||
|
thrall.count = Math.max(1, thrall.count - killed);
|
||||||
|
|
||||||
|
casualties.push({ killed: killed, thrallId: thrall.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const updatedSignature = secret === undefined
|
||||||
|
? undefined
|
||||||
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
|
||||||
|
const { bossId } = body;
|
||||||
|
void logger.metric("vampire_boss_challenge", 1, { bossId, discordId, won });
|
||||||
|
|
||||||
|
const bossMaxHp = boss.maxHp;
|
||||||
|
const bossNewHp = bossUpdatedHp;
|
||||||
|
const response: VampireBossChallengeResponse = {
|
||||||
|
bossDPS,
|
||||||
|
bossHpAtBattleEnd,
|
||||||
|
bossHpBefore,
|
||||||
|
bossMaxHp,
|
||||||
|
bossNewHp,
|
||||||
|
partyDPS,
|
||||||
|
partyHpRemaining,
|
||||||
|
partyMaxHp,
|
||||||
|
won,
|
||||||
|
};
|
||||||
|
if (rewards !== undefined) {
|
||||||
|
response.rewards = rewards;
|
||||||
|
}
|
||||||
|
if (casualties !== undefined) {
|
||||||
|
response.casualties = casualties;
|
||||||
|
}
|
||||||
|
if (updatedSignature !== undefined) {
|
||||||
|
response.signature = updatedSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"vampire_boss_challenge",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { vampireBossRouter };
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* @file Vampire crafting route handling dark recipe crafting.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||||
|
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||||
|
/* eslint-disable complexity -- Route handler has inherent complexity */
|
||||||
|
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { defaultVampireCraftingRecipes } from "../data/vampireCrafting.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,
|
||||||
|
VampireCraftRequest,
|
||||||
|
VampireCraftResponse,
|
||||||
|
} from "@elysium/types";
|
||||||
|
|
||||||
|
const vampireCraftRouter = new Hono<HonoEnvironment>();
|
||||||
|
|
||||||
|
vampireCraftRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
|
const recomputeVampireCraftedMultipliers = (
|
||||||
|
craftedRecipeIds: Array<string>,
|
||||||
|
): {
|
||||||
|
craftedBloodMultiplier: number;
|
||||||
|
craftedCombatMultiplier: number;
|
||||||
|
craftedIchorMultiplier: number;
|
||||||
|
} => {
|
||||||
|
return {
|
||||||
|
craftedBloodMultiplier: defaultVampireCraftingRecipes.filter((recipe) => {
|
||||||
|
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
|
||||||
|
}).reduce((mult, recipe) => {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
return mult * recipe.bonus.value;
|
||||||
|
}, 1),
|
||||||
|
craftedCombatMultiplier: defaultVampireCraftingRecipes.filter((recipe) => {
|
||||||
|
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
|
||||||
|
}).reduce((mult, recipe) => {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
return mult * recipe.bonus.value;
|
||||||
|
}, 1),
|
||||||
|
craftedIchorMultiplier: defaultVampireCraftingRecipes.filter((recipe) => {
|
||||||
|
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
|
||||||
|
}).reduce((mult, recipe) => {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next -- @preserve */
|
||||||
|
return mult * recipe.bonus.value;
|
||||||
|
}, 1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
vampireCraftRouter.post("/", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const body = await context.req.json<VampireCraftRequest>();
|
||||||
|
|
||||||
|
const { recipeId } = body;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!recipeId) {
|
||||||
|
return context.json({ error: "recipeId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipe = defaultVampireCraftingRecipes.find((r) => {
|
||||||
|
return r.id === recipeId;
|
||||||
|
});
|
||||||
|
if (!recipe) {
|
||||||
|
return context.json({ error: "Unknown recipe" }, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.vampire.exploration.craftedRecipeIds.includes(recipeId)) {
|
||||||
|
return context.json({ error: "Recipe already crafted" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the player has all required dark materials
|
||||||
|
for (const requirement of recipe.requiredMaterials) {
|
||||||
|
const material = state.vampire.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === requirement.materialId;
|
||||||
|
});
|
||||||
|
const quantity = material?.quantity ?? 0;
|
||||||
|
if (quantity < requirement.quantity) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct dark materials
|
||||||
|
for (const requirement of recipe.requiredMaterials) {
|
||||||
|
const material = state.vampire.exploration.materials.find((m) => {
|
||||||
|
return m.materialId === requirement.materialId;
|
||||||
|
});
|
||||||
|
if (material) {
|
||||||
|
material.quantity = material.quantity - requirement.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add recipe and recompute all multipliers from scratch
|
||||||
|
state.vampire.exploration.craftedRecipeIds.push(recipeId);
|
||||||
|
|
||||||
|
const updatedMultipliers = recomputeVampireCraftedMultipliers(
|
||||||
|
state.vampire.exploration.craftedRecipeIds,
|
||||||
|
);
|
||||||
|
state.vampire.exploration.craftedBloodMultiplier = updatedMultipliers.craftedBloodMultiplier;
|
||||||
|
state.vampire.exploration.craftedIchorMultiplier = updatedMultipliers.craftedIchorMultiplier;
|
||||||
|
state.vampire.exploration.craftedCombatMultiplier = updatedMultipliers.craftedCombatMultiplier;
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("vampire_recipe_crafted", 1, { discordId, recipeId });
|
||||||
|
|
||||||
|
const bonusType = recipe.bonus.type;
|
||||||
|
const bonusValue = recipe.bonus.value;
|
||||||
|
|
||||||
|
const { materials } = state.vampire.exploration;
|
||||||
|
const {
|
||||||
|
craftedBloodMultiplier,
|
||||||
|
craftedIchorMultiplier,
|
||||||
|
craftedCombatMultiplier,
|
||||||
|
} = updatedMultipliers;
|
||||||
|
|
||||||
|
const response: VampireCraftResponse = {
|
||||||
|
bonusType,
|
||||||
|
bonusValue,
|
||||||
|
craftedBloodMultiplier,
|
||||||
|
craftedCombatMultiplier,
|
||||||
|
craftedIchorMultiplier,
|
||||||
|
materials,
|
||||||
|
recipeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"vampire_craft",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { vampireCraftRouter };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @file Vampire upgrade purchase route.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||||
|
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||||
|
/* eslint-disable complexity -- Route handler has inherent complexity */
|
||||||
|
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { defaultVampireUpgrades } from "../data/vampireUpgrades.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 {
|
||||||
|
BuyVampireUpgradeRequest,
|
||||||
|
BuyVampireUpgradeResponse,
|
||||||
|
GameState,
|
||||||
|
} from "@elysium/types";
|
||||||
|
|
||||||
|
const vampireUpgradeRouter = new Hono<HonoEnvironment>();
|
||||||
|
|
||||||
|
vampireUpgradeRouter.use("*", authMiddleware);
|
||||||
|
|
||||||
|
vampireUpgradeRouter.post("/buy", async(context) => {
|
||||||
|
try {
|
||||||
|
const discordId = context.get("discordId");
|
||||||
|
|
||||||
|
const body = await context.req.json<BuyVampireUpgradeRequest>();
|
||||||
|
|
||||||
|
const { upgradeId } = body;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||||
|
if (!upgradeId) {
|
||||||
|
return context.json({ error: "upgradeId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgradeTemplate = defaultVampireUpgrades.find((vampireUpgrade) => {
|
||||||
|
return vampireUpgrade.id === upgradeId;
|
||||||
|
});
|
||||||
|
if (!upgradeTemplate) {
|
||||||
|
return context.json({ error: "Unknown vampire upgrade" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||||
|
if (!record) {
|
||||||
|
return context.json({ error: "No save found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||||
|
const state = record.state as unknown as GameState;
|
||||||
|
|
||||||
|
if (!state.vampire) {
|
||||||
|
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgrade = state.vampire.upgrades.find((u) => {
|
||||||
|
return u.id === upgradeId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upgrade) {
|
||||||
|
return context.json({ error: "Upgrade not found in vampire state" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upgrade.unlocked) {
|
||||||
|
return context.json({ error: "Upgrade is not yet unlocked" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upgrade.purchased) {
|
||||||
|
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBlood = state.resources.blood ?? 0;
|
||||||
|
const currentIchor = state.vampire.siring.ichor;
|
||||||
|
const currentSoulShards = state.vampire.awakening.soulShards;
|
||||||
|
|
||||||
|
if (currentBlood < upgradeTemplate.costBlood) {
|
||||||
|
return context.json({ error: "Not enough blood" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIchor < upgradeTemplate.costIchor) {
|
||||||
|
return context.json({ error: "Not enough ichor" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSoulShards < upgradeTemplate.costSoulShards) {
|
||||||
|
return context.json({ error: "Not enough soul shards" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
upgrade.purchased = true;
|
||||||
|
|
||||||
|
const updatedBlood = currentBlood - upgradeTemplate.costBlood;
|
||||||
|
const updatedIchor = currentIchor - upgradeTemplate.costIchor;
|
||||||
|
const updatedSoulShards = currentSoulShards - upgradeTemplate.costSoulShards;
|
||||||
|
|
||||||
|
state.resources.blood = updatedBlood;
|
||||||
|
state.vampire.siring.ichor = updatedIchor;
|
||||||
|
state.vampire.awakening.soulShards = updatedSoulShards;
|
||||||
|
|
||||||
|
await prisma.gameState.update({
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
|
data: { state: state as object, updatedAt: Date.now() },
|
||||||
|
where: { discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
void logger.metric("vampire_upgrade_purchased", 1, { discordId, upgradeId });
|
||||||
|
|
||||||
|
const response: BuyVampireUpgradeResponse = {
|
||||||
|
bloodRemaining: updatedBlood,
|
||||||
|
ichorRemaining: updatedIchor,
|
||||||
|
soulShardsRemaining: updatedSoulShards,
|
||||||
|
};
|
||||||
|
return context.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
void logger.error(
|
||||||
|
"vampire_upgrade_buy",
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return context.json({ error: "Internal server error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { vampireUpgradeRouter };
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* @file Awakening service handling eligibility checks and post-awakening state building.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
|
||||||
|
import { initialVampireState } from "../data/initialState.js";
|
||||||
|
import { defaultVampireAwakeningUpgrades } from "../data/vampireAwakeningUpgrades.js";
|
||||||
|
import type { AwakeningData, GameState } from "@elysium/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the final vampire boss whose defeat triggers eligibility for awakening.
|
||||||
|
*/
|
||||||
|
const finalVampireBossId = "eternal_darkness";
|
||||||
|
|
||||||
|
const getCategoryMultiplier = (
|
||||||
|
purchasedIds: Array<string>,
|
||||||
|
category: string,
|
||||||
|
): number => {
|
||||||
|
return defaultVampireAwakeningUpgrades.filter((upgrade) => {
|
||||||
|
return upgrade.category === category && purchasedIds.includes(upgrade.id);
|
||||||
|
}).reduce((mult, upgrade) => {
|
||||||
|
return mult * upgrade.multiplier;
|
||||||
|
}, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes all five soul shard multipliers from the purchased awakening upgrade IDs.
|
||||||
|
* @param purchasedUpgradeIds - The array of purchased awakening upgrade IDs.
|
||||||
|
* @returns An object containing all five soul shard multiplier values.
|
||||||
|
*/
|
||||||
|
const computeAwakeningMultipliers = (
|
||||||
|
purchasedUpgradeIds: Array<string>,
|
||||||
|
): Omit<AwakeningData, "count" | "soulShards" | "purchasedUpgradeIds"> => {
|
||||||
|
return {
|
||||||
|
soulShardsBloodMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "blood"),
|
||||||
|
soulShardsCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
|
||||||
|
soulShardsMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "soulshards_meta"),
|
||||||
|
soulShardsSiringIchorMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "siring_ichor"),
|
||||||
|
soulShardsSiringThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "siring_threshold"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the player is eligible to awaken:
|
||||||
|
* the final vampire boss must have been defeated.
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns Whether the player is eligible for awakening.
|
||||||
|
*/
|
||||||
|
const isEligibleForAwakening = (state: GameState): boolean => {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
if (state.vampire === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return state.vampire.bosses.some((boss) => {
|
||||||
|
return boss.id === finalVampireBossId && boss.status === "defeated";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the soul shards yield from an awakening.
|
||||||
|
* Formula: MAX(1, FLOOR(SQRT(siringCount) * metaMultiplier)).
|
||||||
|
* @param siringCount - The number of sirings completed.
|
||||||
|
* @param metaMultiplier - Multiplier from soul shard meta upgrades applied to yield.
|
||||||
|
* @returns The soul shards earned.
|
||||||
|
*/
|
||||||
|
const calculateSoulShardsYield = (
|
||||||
|
siringCount: number,
|
||||||
|
metaMultiplier: number,
|
||||||
|
): number => {
|
||||||
|
return Math.max(1, Math.floor(Math.sqrt(siringCount) * metaMultiplier));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the updated vampire state after an awakening reset.
|
||||||
|
* Resets the current run including siring data (bosses, quests, thralls, upgrades, zones, siring data).
|
||||||
|
* Preserves: equipment, achievements, awakening data (updated), eternal sovereignty, lifetime stats.
|
||||||
|
* @param state - The current game state before awakening.
|
||||||
|
* @returns The soul shards earned and the updated vampire state.
|
||||||
|
*/
|
||||||
|
const buildPostAwakeningState = (
|
||||||
|
state: GameState,
|
||||||
|
): { soulShardsEarned: number; updatedVampire: NonNullable<GameState["vampire"]> } => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure vampire exists */
|
||||||
|
const vampire = state.vampire as NonNullable<GameState["vampire"]>;
|
||||||
|
|
||||||
|
const metaMultiplier = vampire.awakening.soulShardsMetaMultiplier;
|
||||||
|
const soulShardsEarned = calculateSoulShardsYield(vampire.siring.count, metaMultiplier);
|
||||||
|
|
||||||
|
const updatedCount = vampire.awakening.count + 1;
|
||||||
|
const updatedSoulShards = vampire.awakening.soulShards + soulShardsEarned;
|
||||||
|
const updatedPurchasedIds = vampire.awakening.purchasedUpgradeIds;
|
||||||
|
const updatedMultipliers = computeAwakeningMultipliers(updatedPurchasedIds);
|
||||||
|
|
||||||
|
const updatedAwakening: AwakeningData = {
|
||||||
|
count: updatedCount,
|
||||||
|
purchasedUpgradeIds: updatedPurchasedIds,
|
||||||
|
soulShards: updatedSoulShards,
|
||||||
|
...updatedMultipliers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const freshVampire = initialVampireState();
|
||||||
|
|
||||||
|
const updatedVampire: NonNullable<GameState["vampire"]> = {
|
||||||
|
...freshVampire,
|
||||||
|
achievements: vampire.achievements,
|
||||||
|
awakening: updatedAwakening,
|
||||||
|
bosses: freshVampire.bosses.map((b) => {
|
||||||
|
const existing = vampire.bosses.find((vb) => {
|
||||||
|
return vb.id === b.id;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...b,
|
||||||
|
bountyIchorClaimed: existing?.bountyIchorClaimed ?? false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
equipment: vampire.equipment,
|
||||||
|
eternalSovereignty: vampire.eternalSovereignty,
|
||||||
|
lastTickAt: Date.now(),
|
||||||
|
lifetimeBloodEarned: vampire.lifetimeBloodEarned,
|
||||||
|
lifetimeBossesDefeated: vampire.lifetimeBossesDefeated,
|
||||||
|
lifetimeQuestsCompleted: vampire.lifetimeQuestsCompleted,
|
||||||
|
totalBloodEarned: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { soulShardsEarned, updatedVampire };
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildPostAwakeningState,
|
||||||
|
calculateSoulShardsYield,
|
||||||
|
computeAwakeningMultipliers,
|
||||||
|
isEligibleForAwakening,
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* @file Siring service handling eligibility checks and post-siring state building.
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable max-lines-per-function -- Function requires many steps */
|
||||||
|
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
|
||||||
|
import { initialVampireState } from "../data/initialState.js";
|
||||||
|
import { defaultVampireSiringUpgrades } from "../data/vampireSiringUpgrades.js";
|
||||||
|
import type { GameState, SiringData } from "@elysium/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base blood threshold for the first siring.
|
||||||
|
*/
|
||||||
|
const baseSiringThreshold = 1_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divisor used in the ichor yield formula.
|
||||||
|
*/
|
||||||
|
const ichorYieldDivisor = 50_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the blood threshold required for the next siring.
|
||||||
|
* Formula: BASE * (count + 1)^2 * thresholdMultiplier.
|
||||||
|
* @param siringCount - The number of sirings completed so far.
|
||||||
|
* @param thresholdMultiplier - An optional multiplier applied to the threshold.
|
||||||
|
* @returns The blood amount required to sire.
|
||||||
|
*/
|
||||||
|
const calculateSiringThreshold = (
|
||||||
|
siringCount: number,
|
||||||
|
thresholdMultiplier = 1,
|
||||||
|
): number => {
|
||||||
|
return (
|
||||||
|
baseSiringThreshold
|
||||||
|
* Math.pow(siringCount + 1, 2)
|
||||||
|
* thresholdMultiplier
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the combined threshold multiplier from purchased utility siring upgrades.
|
||||||
|
* @param purchasedUpgradeIds - The array of purchased siring upgrade IDs.
|
||||||
|
* @returns The combined threshold multiplier.
|
||||||
|
*/
|
||||||
|
const computeSiringThresholdMultiplier = (
|
||||||
|
purchasedUpgradeIds: Array<string>,
|
||||||
|
): number => {
|
||||||
|
return defaultVampireSiringUpgrades.filter((upgrade) => {
|
||||||
|
return upgrade.id.startsWith("siring_threshold_") && purchasedUpgradeIds.includes(upgrade.id);
|
||||||
|
}).reduce((mult, upgrade) => {
|
||||||
|
return mult * upgrade.multiplier;
|
||||||
|
}, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the player is eligible to sire:
|
||||||
|
* the total blood earned in the current run must meet the threshold.
|
||||||
|
* @param state - The current game state.
|
||||||
|
* @returns Whether the player is eligible for siring.
|
||||||
|
*/
|
||||||
|
const isEligibleForSiring = (state: GameState): boolean => {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 3 -- @preserve */
|
||||||
|
if (state.vampire === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { siring, awakening, totalBloodEarned } = state.vampire;
|
||||||
|
const siringThresholdMultiplier = computeSiringThresholdMultiplier(siring.purchasedUpgradeIds);
|
||||||
|
const combinedMultiplier = siringThresholdMultiplier * awakening.soulShardsSiringThresholdMultiplier;
|
||||||
|
const threshold = calculateSiringThreshold(siring.count, combinedMultiplier);
|
||||||
|
return totalBloodEarned >= threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the ichor yield from a siring.
|
||||||
|
* Formula: MAX(1, FLOOR(SQRT(totalBloodEarned / divisor) * ichorMultiplier)).
|
||||||
|
* @param totalBloodEarned - Total blood earned in the current siring run.
|
||||||
|
* @param ichorMultiplier - Multiplier applied to the ichor yield.
|
||||||
|
* @returns The ichor earned.
|
||||||
|
*/
|
||||||
|
const calculateIchorYield = (
|
||||||
|
totalBloodEarned: number,
|
||||||
|
ichorMultiplier: number,
|
||||||
|
): number => {
|
||||||
|
return Math.max(1, Math.floor(Math.sqrt(totalBloodEarned / ichorYieldDivisor) * ichorMultiplier));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the siring production multiplier from the count.
|
||||||
|
* Each siring adds 25% to the production multiplier.
|
||||||
|
* @param count - The number of sirings completed.
|
||||||
|
* @returns The computed production multiplier as a number.
|
||||||
|
*/
|
||||||
|
const computeSiringProductionMultiplier = (count: number): number => {
|
||||||
|
const bonus = count * 0.25;
|
||||||
|
return 1 + bonus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryMultiplier = (
|
||||||
|
purchasedUpgradeIds: Array<string>,
|
||||||
|
category: string,
|
||||||
|
): number => {
|
||||||
|
return defaultVampireSiringUpgrades.filter((upgrade) => {
|
||||||
|
return (
|
||||||
|
upgrade.category === category
|
||||||
|
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||||
|
);
|
||||||
|
}).reduce((mult, upgrade) => {
|
||||||
|
return mult * upgrade.multiplier;
|
||||||
|
}, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes all three ichor-upgrade multipliers from the purchased siring upgrade IDs.
|
||||||
|
* @param purchasedUpgradeIds - The array of purchased siring upgrade IDs.
|
||||||
|
* @returns An object containing the three ichor multiplier values.
|
||||||
|
*/
|
||||||
|
const computeSiringIchorMultipliers = (
|
||||||
|
purchasedUpgradeIds: Array<string>,
|
||||||
|
): Pick<SiringData, "ichorBloodMultiplier" | "ichorCombatMultiplier" | "ichorThrallsMultiplier"> => {
|
||||||
|
return {
|
||||||
|
ichorBloodMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "blood"),
|
||||||
|
ichorCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
|
||||||
|
ichorThrallsMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "thralls"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the updated vampire state after a siring reset.
|
||||||
|
* Resets the current run (bosses, quests, thralls, upgrades, zones, exploration crafting).
|
||||||
|
* Preserves: equipment, achievements, siring data (updated), awakening, lifetime stats, dark materials.
|
||||||
|
* @param state - The current game state before siring.
|
||||||
|
* @returns The ichor earned and the updated vampire state.
|
||||||
|
*/
|
||||||
|
const buildPostSiringState = (
|
||||||
|
state: GameState,
|
||||||
|
): { ichorEarned: number; updatedVampire: NonNullable<GameState["vampire"]> } => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure vampire exists */
|
||||||
|
const vampire = state.vampire as NonNullable<GameState["vampire"]>;
|
||||||
|
|
||||||
|
const siringIchorYieldMultiplier = getCategoryMultiplier(vampire.siring.purchasedUpgradeIds, "ichor");
|
||||||
|
const awakeningIchorMultiplier = vampire.awakening.soulShardsSiringIchorMultiplier;
|
||||||
|
const combinedIchorMultiplier = siringIchorYieldMultiplier * awakeningIchorMultiplier;
|
||||||
|
|
||||||
|
const ichorEarned = calculateIchorYield(vampire.totalBloodEarned, combinedIchorMultiplier);
|
||||||
|
const updatedCount = vampire.siring.count + 1;
|
||||||
|
const updatedIchor = vampire.siring.ichor + ichorEarned;
|
||||||
|
const productionMultiplier = computeSiringProductionMultiplier(updatedCount);
|
||||||
|
|
||||||
|
const updatedSiring: SiringData = {
|
||||||
|
...vampire.siring,
|
||||||
|
count: updatedCount,
|
||||||
|
ichor: updatedIchor,
|
||||||
|
lastSiredAt: Date.now(),
|
||||||
|
productionMultiplier: productionMultiplier,
|
||||||
|
...computeSiringIchorMultipliers(vampire.siring.purchasedUpgradeIds),
|
||||||
|
};
|
||||||
|
|
||||||
|
const freshVampire = initialVampireState();
|
||||||
|
|
||||||
|
const updatedVampire: NonNullable<GameState["vampire"]> = {
|
||||||
|
...freshVampire,
|
||||||
|
achievements: vampire.achievements,
|
||||||
|
awakening: vampire.awakening,
|
||||||
|
bosses: freshVampire.bosses.map((b) => {
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 7 -- @preserve */
|
||||||
|
const existing = vampire.bosses.find((vb) => {
|
||||||
|
return vb.id === b.id;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...b,
|
||||||
|
bountyIchorClaimed: existing?.bountyIchorClaimed ?? false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
equipment: vampire.equipment,
|
||||||
|
eternalSovereignty: vampire.eternalSovereignty,
|
||||||
|
exploration: {
|
||||||
|
...freshVampire.exploration,
|
||||||
|
materials: vampire.exploration.materials,
|
||||||
|
},
|
||||||
|
lastTickAt: Date.now(),
|
||||||
|
lifetimeBloodEarned: vampire.lifetimeBloodEarned + vampire.totalBloodEarned,
|
||||||
|
lifetimeBossesDefeated: vampire.lifetimeBossesDefeated,
|
||||||
|
lifetimeQuestsCompleted: vampire.lifetimeQuestsCompleted,
|
||||||
|
siring: updatedSiring,
|
||||||
|
totalBloodEarned: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ichorEarned, updatedVampire };
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildPostSiringState,
|
||||||
|
calculateIchorYield,
|
||||||
|
calculateSiringThreshold,
|
||||||
|
computeSiringIchorMultipliers,
|
||||||
|
computeSiringProductionMultiplier,
|
||||||
|
computeSiringThresholdMultiplier,
|
||||||
|
isEligibleForSiring,
|
||||||
|
};
|
||||||
@@ -47,8 +47,12 @@ export type {
|
|||||||
ApotheosisRequest,
|
ApotheosisRequest,
|
||||||
ApotheosisResponse,
|
ApotheosisResponse,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
AwakeningRequest,
|
||||||
|
AwakeningResponse,
|
||||||
BossChallengeRequest,
|
BossChallengeRequest,
|
||||||
BossChallengeResponse,
|
BossChallengeResponse,
|
||||||
|
BuyAwakeningUpgradeRequest,
|
||||||
|
BuyAwakeningUpgradeResponse,
|
||||||
BuyConsecrationUpgradeRequest,
|
BuyConsecrationUpgradeRequest,
|
||||||
BuyConsecrationUpgradeResponse,
|
BuyConsecrationUpgradeResponse,
|
||||||
BuyEchoUpgradeRequest,
|
BuyEchoUpgradeRequest,
|
||||||
@@ -59,6 +63,10 @@ export type {
|
|||||||
BuyGoddessUpgradeResponse,
|
BuyGoddessUpgradeResponse,
|
||||||
BuyPrestigeUpgradeRequest,
|
BuyPrestigeUpgradeRequest,
|
||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
|
BuySiringUpgradeRequest,
|
||||||
|
BuySiringUpgradeResponse,
|
||||||
|
BuyVampireUpgradeRequest,
|
||||||
|
BuyVampireUpgradeResponse,
|
||||||
ConsecrationRequest,
|
ConsecrationRequest,
|
||||||
ConsecrationResponse,
|
ConsecrationResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
@@ -93,11 +101,23 @@ export type {
|
|||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SiringRequest,
|
||||||
|
SiringResponse,
|
||||||
SyncNewContentResponse,
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
UpdateProfileResponse,
|
UpdateProfileResponse,
|
||||||
|
VampireBossChallengeRequest,
|
||||||
|
VampireBossChallengeResponse,
|
||||||
|
VampireCraftRequest,
|
||||||
|
VampireCraftResponse,
|
||||||
|
VampireExploreClaimableResponse,
|
||||||
|
VampireExploreCollectEventResult,
|
||||||
|
VampireExploreCollectRequest,
|
||||||
|
VampireExploreCollectResponse,
|
||||||
|
VampireExploreStartRequest,
|
||||||
|
VampireExploreStartResponse,
|
||||||
} from "./interfaces/api.js";
|
} from "./interfaces/api.js";
|
||||||
export type { Boss, BossStatus } from "./interfaces/boss.js";
|
export type { Boss, BossStatus } from "./interfaces/boss.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -754,14 +754,159 @@ interface GoddessExploreClaimableResponse {
|
|||||||
claimable: boolean;
|
claimable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VampireBossChallengeRequest {
|
||||||
|
bossId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireBossChallengeResponse {
|
||||||
|
won: boolean;
|
||||||
|
partyDPS: number;
|
||||||
|
bossDPS: number;
|
||||||
|
bossHpBefore: number;
|
||||||
|
bossMaxHp: number;
|
||||||
|
bossHpAtBattleEnd: number;
|
||||||
|
bossNewHp: number;
|
||||||
|
partyMaxHp: number;
|
||||||
|
partyHpRemaining: number;
|
||||||
|
rewards?: {
|
||||||
|
blood: number;
|
||||||
|
ichor: number;
|
||||||
|
soulShards: number;
|
||||||
|
upgradeIds: Array<string>;
|
||||||
|
equipmentIds: Array<string>;
|
||||||
|
bountyIchor: number;
|
||||||
|
};
|
||||||
|
casualties?: Array<{
|
||||||
|
thrallId: string;
|
||||||
|
killed: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
|
||||||
|
*/
|
||||||
|
signature?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiringRequest = Record<string, never>;
|
||||||
|
|
||||||
|
interface SiringResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ichor earned from this siring.
|
||||||
|
*/
|
||||||
|
ichorEarned: number;
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
|
||||||
|
newSiringCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuySiringUpgradeRequest {
|
||||||
|
upgradeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuySiringUpgradeResponse {
|
||||||
|
ichorRemaining: number;
|
||||||
|
purchasedUpgradeIds: Array<string>;
|
||||||
|
ichorBloodMultiplier: number;
|
||||||
|
ichorThrallsMultiplier: number;
|
||||||
|
ichorCombatMultiplier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AwakeningRequest = Record<string, never>;
|
||||||
|
|
||||||
|
interface AwakeningResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soul Shards earned from this awakening.
|
||||||
|
*/
|
||||||
|
soulShardsEarned: number;
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
|
||||||
|
newAwakeningCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuyAwakeningUpgradeRequest {
|
||||||
|
upgradeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuyAwakeningUpgradeResponse {
|
||||||
|
soulShardsRemaining: number;
|
||||||
|
purchasedUpgradeIds: Array<string>;
|
||||||
|
soulShardsBloodMultiplier: number;
|
||||||
|
soulShardsCombatMultiplier: number;
|
||||||
|
soulShardsSiringThresholdMultiplier: number;
|
||||||
|
soulShardsSiringIchorMultiplier: number;
|
||||||
|
soulShardsMetaMultiplier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuyVampireUpgradeRequest {
|
||||||
|
upgradeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuyVampireUpgradeResponse {
|
||||||
|
bloodRemaining: number;
|
||||||
|
ichorRemaining: number;
|
||||||
|
soulShardsRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireCraftRequest {
|
||||||
|
recipeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireCraftResponse {
|
||||||
|
recipeId: string;
|
||||||
|
bonusType: string;
|
||||||
|
bonusValue: number;
|
||||||
|
craftedBloodMultiplier: number;
|
||||||
|
craftedIchorMultiplier: number;
|
||||||
|
craftedCombatMultiplier: number;
|
||||||
|
materials: Array<{ materialId: string; quantity: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireExploreStartRequest {
|
||||||
|
areaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireExploreStartResponse {
|
||||||
|
areaId: string;
|
||||||
|
endsAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireExploreCollectRequest {
|
||||||
|
areaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireExploreCollectEventResult {
|
||||||
|
text: string;
|
||||||
|
bloodChange: number;
|
||||||
|
ichorChange: number;
|
||||||
|
materialGained: { materialId: string; quantity: number } | null;
|
||||||
|
thrallLostCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireExploreCollectResponse {
|
||||||
|
foundNothing: boolean;
|
||||||
|
nothingMessage?: string;
|
||||||
|
materialsFound: Array<{ materialId: string; quantity: number }>;
|
||||||
|
event: VampireExploreCollectEventResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VampireExploreClaimableResponse {
|
||||||
|
claimable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AboutResponse,
|
AboutResponse,
|
||||||
ApiError,
|
ApiError,
|
||||||
ApotheosisRequest,
|
ApotheosisRequest,
|
||||||
ApotheosisResponse,
|
ApotheosisResponse,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
AwakeningRequest,
|
||||||
|
AwakeningResponse,
|
||||||
BossChallengeRequest,
|
BossChallengeRequest,
|
||||||
BossChallengeResponse,
|
BossChallengeResponse,
|
||||||
|
BuyAwakeningUpgradeRequest,
|
||||||
|
BuyAwakeningUpgradeResponse,
|
||||||
BuyConsecrationUpgradeRequest,
|
BuyConsecrationUpgradeRequest,
|
||||||
BuyConsecrationUpgradeResponse,
|
BuyConsecrationUpgradeResponse,
|
||||||
BuyEchoUpgradeRequest,
|
BuyEchoUpgradeRequest,
|
||||||
@@ -772,6 +917,10 @@ export type {
|
|||||||
BuyGoddessUpgradeResponse,
|
BuyGoddessUpgradeResponse,
|
||||||
BuyPrestigeUpgradeRequest,
|
BuyPrestigeUpgradeRequest,
|
||||||
BuyPrestigeUpgradeResponse,
|
BuyPrestigeUpgradeResponse,
|
||||||
|
BuySiringUpgradeRequest,
|
||||||
|
BuySiringUpgradeResponse,
|
||||||
|
BuyVampireUpgradeRequest,
|
||||||
|
BuyVampireUpgradeResponse,
|
||||||
ConsecrationRequest,
|
ConsecrationRequest,
|
||||||
ConsecrationResponse,
|
ConsecrationResponse,
|
||||||
CraftRecipeRequest,
|
CraftRecipeRequest,
|
||||||
@@ -806,9 +955,21 @@ export type {
|
|||||||
PublicProfileResponse,
|
PublicProfileResponse,
|
||||||
SaveRequest,
|
SaveRequest,
|
||||||
SaveResponse,
|
SaveResponse,
|
||||||
|
SiringRequest,
|
||||||
|
SiringResponse,
|
||||||
SyncNewContentResponse,
|
SyncNewContentResponse,
|
||||||
TranscendenceRequest,
|
TranscendenceRequest,
|
||||||
TranscendenceResponse,
|
TranscendenceResponse,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
UpdateProfileResponse,
|
UpdateProfileResponse,
|
||||||
|
VampireBossChallengeRequest,
|
||||||
|
VampireBossChallengeResponse,
|
||||||
|
VampireCraftRequest,
|
||||||
|
VampireCraftResponse,
|
||||||
|
VampireExploreClaimableResponse,
|
||||||
|
VampireExploreCollectEventResult,
|
||||||
|
VampireExploreCollectRequest,
|
||||||
|
VampireExploreCollectResponse,
|
||||||
|
VampireExploreStartRequest,
|
||||||
|
VampireExploreStartResponse,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user