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 { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
import { siringRouter } from "./routes/siring.js";
|
||||
import { timersRouter } from "./routes/timers.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 { logger } from "./services/logger.js";
|
||||
|
||||
@@ -60,6 +66,12 @@ app.route("/enlightenment", enlightenmentRouter);
|
||||
app.route("/goddess-upgrade", goddessUpgradeRouter);
|
||||
app.route("/goddess-craft", goddessCraftRouter);
|
||||
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("/profile", profileRouter);
|
||||
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,
|
||||
ApotheosisResponse,
|
||||
AuthResponse,
|
||||
AwakeningRequest,
|
||||
AwakeningResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyAwakeningUpgradeRequest,
|
||||
BuyAwakeningUpgradeResponse,
|
||||
BuyConsecrationUpgradeRequest,
|
||||
BuyConsecrationUpgradeResponse,
|
||||
BuyEchoUpgradeRequest,
|
||||
@@ -59,6 +63,10 @@ export type {
|
||||
BuyGoddessUpgradeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
BuySiringUpgradeRequest,
|
||||
BuySiringUpgradeResponse,
|
||||
BuyVampireUpgradeRequest,
|
||||
BuyVampireUpgradeResponse,
|
||||
ConsecrationRequest,
|
||||
ConsecrationResponse,
|
||||
CraftRecipeRequest,
|
||||
@@ -93,11 +101,23 @@ export type {
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SiringRequest,
|
||||
SiringResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
VampireBossChallengeRequest,
|
||||
VampireBossChallengeResponse,
|
||||
VampireCraftRequest,
|
||||
VampireCraftResponse,
|
||||
VampireExploreClaimableResponse,
|
||||
VampireExploreCollectEventResult,
|
||||
VampireExploreCollectRequest,
|
||||
VampireExploreCollectResponse,
|
||||
VampireExploreStartRequest,
|
||||
VampireExploreStartResponse,
|
||||
} from "./interfaces/api.js";
|
||||
export type { Boss, BossStatus } from "./interfaces/boss.js";
|
||||
export type {
|
||||
|
||||
@@ -754,14 +754,159 @@ interface GoddessExploreClaimableResponse {
|
||||
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 {
|
||||
AboutResponse,
|
||||
ApiError,
|
||||
ApotheosisRequest,
|
||||
ApotheosisResponse,
|
||||
AuthResponse,
|
||||
AwakeningRequest,
|
||||
AwakeningResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyAwakeningUpgradeRequest,
|
||||
BuyAwakeningUpgradeResponse,
|
||||
BuyConsecrationUpgradeRequest,
|
||||
BuyConsecrationUpgradeResponse,
|
||||
BuyEchoUpgradeRequest,
|
||||
@@ -772,6 +917,10 @@ export type {
|
||||
BuyGoddessUpgradeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
BuySiringUpgradeRequest,
|
||||
BuySiringUpgradeResponse,
|
||||
BuyVampireUpgradeRequest,
|
||||
BuyVampireUpgradeResponse,
|
||||
ConsecrationRequest,
|
||||
ConsecrationResponse,
|
||||
CraftRecipeRequest,
|
||||
@@ -806,9 +955,21 @@ export type {
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SiringRequest,
|
||||
SiringResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
VampireBossChallengeRequest,
|
||||
VampireBossChallengeResponse,
|
||||
VampireCraftRequest,
|
||||
VampireCraftResponse,
|
||||
VampireExploreClaimableResponse,
|
||||
VampireExploreCollectEventResult,
|
||||
VampireExploreCollectRequest,
|
||||
VampireExploreCollectResponse,
|
||||
VampireExploreStartRequest,
|
||||
VampireExploreStartResponse,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user