feat: vampire expansion chunk 4 — API routes and services

Adds six new routes (vampire-boss, vampire-upgrade, vampire-craft,
vampire-explore, siring, vampire-awakening) with matching siring and
awakening services, and all necessary request/response types.
This commit is contained in:
2026-04-16 09:48:50 -07:00
committed by Naomi Carrigan
parent 7f43dc725e
commit 8fa5d12f05
11 changed files with 2026 additions and 0 deletions
+12
View File
@@ -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);
+188
View File
@@ -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 };
+182
View File
@@ -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 };
+407
View File
@@ -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 };
+171
View File
@@ -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 };
+421
View File
@@ -0,0 +1,421 @@
/**
* @file Vampire exploration routes handling dark area exploration mechanics.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many statements */
/* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable max-lines -- Route file requires multiple handlers */
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
import { Hono } from "hono";
import { defaultVampireExplorationAreas } from "../data/vampireExplorations.js";
import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js";
import type {
GameState,
VampireExploreClaimableResponse,
VampireExploreCollectEventResult,
VampireExploreCollectRequest,
VampireExploreCollectResponse,
VampireExploreStartRequest,
VampireExploreStartResponse,
} from "@elysium/types";
const vampireExploreRouter = new Hono<HonoEnvironment>();
vampireExploreRouter.use("*", authMiddleware);
const nothingProbability = 0.2;
const nothingMessages = [
"Your thralls searched the shadowy depths but found nothing of value.",
"The cursed area yielded nothing remarkable this time.",
"Your thralls returned empty-handed from the darkness.",
"A wasted hunt — the darkened area proved barren.",
"Nothing to show for the bloodshed. Perhaps next time.",
];
/**
* Returns a random "nothing found" message.
* @returns A random message string.
*/
const pickNothingMessage = (): string => {
const index = Math.floor(Math.random() * nothingMessages.length);
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
return nothingMessages[index] ?? nothingMessages[0] ?? "";
};
vampireExploreRouter.get("/claimable", async(context) => {
try {
const discordId = context.get("discordId");
const areaId = context.req.query("areaId");
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = defaultVampireExplorationAreas.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.vampire) {
const response: VampireExploreClaimableResponse = { claimable: false };
return context.json(response);
}
const area = state.vampire.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area || area.status !== "in_progress") {
const response: VampireExploreClaimableResponse = { claimable: false };
return context.json(response);
}
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
const claimable = Date.now() >= expiresAt;
const response: VampireExploreClaimableResponse = { claimable };
return context.json(response);
} catch (error) {
void logger.error(
"vampire_explore_claimable",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
vampireExploreRouter.post("/start", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<VampireExploreStartRequest>();
const { areaId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = defaultVampireExplorationAreas.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.vampire) {
return context.json({ error: "Vampire realm not unlocked" }, 400);
}
const zone = state.vampire.zones.find((z) => {
return z.id === explorationArea.zoneId;
});
if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400);
}
const area = state.vampire.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json(
{ error: "Exploration area not found in state" },
404,
);
}
const anyInProgress = state.vampire.exploration.areas.some((a) => {
return a.status === "in_progress";
});
if (anyInProgress) {
return context.json(
{ error: "An exploration is already in progress" },
400,
);
}
if (area.status === "locked") {
return context.json({ error: "Exploration area is locked" }, 400);
}
const now = Date.now();
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
const endsAt = now + explorationArea.durationSeconds * 1000;
area.status = "in_progress";
area.startedAt = now;
area.endsAt = endsAt;
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
const response: VampireExploreStartResponse = {
areaId,
endsAt,
};
return context.json(response);
} catch (error) {
void logger.error(
"vampire_explore_start",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
vampireExploreRouter.post("/collect", async(context) => {
try {
const discordId = context.get("discordId");
const body = await context.req.json<VampireExploreCollectRequest>();
const { areaId } = body;
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = defaultVampireExplorationAreas.find((a) => {
return a.id === areaId;
});
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const rawState: unknown = record.state;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState;
if (!state.vampire) {
return context.json({ error: "Vampire realm not unlocked" }, 400);
}
const area = state.vampire.exploration.areas.find((a) => {
return a.id === areaId;
});
if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
if (area.status !== "in_progress") {
return context.json({ error: "Exploration is not in progress" }, 400);
}
const now = Date.now();
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
const expiresAt = startedAt + durationMs;
if (now < expiresAt) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
area.status = "available";
area.completedOnce = true;
// 20% chance of finding nothing
if (Math.random() < nothingProbability) {
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
const response: VampireExploreCollectResponse = {
event: null,
foundNothing: true,
materialsFound: [],
nothingMessage: pickNothingMessage(),
};
return context.json(response);
}
// Pick a random event
const eventIndex = Math.floor(
Math.random() * explorationArea.events.length,
);
const event = explorationArea.events[eventIndex];
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 3 -- @preserve */
if (!event) {
return context.json({ error: "No events available" }, 500);
}
// Apply event effects and build the result summary
let bloodChange = 0;
let ichorChange = 0;
let materialGained: { materialId: string; quantity: number } | null = null;
if (event.effect.type === "blood_gain") {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
const amount = event.effect.amount ?? 0;
state.resources.blood = (state.resources.blood ?? 0) + amount;
state.vampire.totalBloodEarned = state.vampire.totalBloodEarned + amount;
bloodChange = amount;
} else if (event.effect.type === "blood_loss") {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 2 -- @preserve */
const amount = Math.min(state.resources.blood ?? 0, event.effect.amount ?? 0);
state.resources.blood = (state.resources.blood ?? 0) - amount;
bloodChange = -amount;
} else if (event.effect.type === "ichor_gain") {
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const amount = event.effect.amount ?? 0;
state.vampire.siring.ichor = state.vampire.siring.ichor + amount;
ichorChange = amount;
} else if (event.effect.type === "dark_material_gain") {
const { materialId } = event.effect;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */
const quantity = event.effect.quantity ?? 1;
if (materialId !== undefined && materialId !== "") {
const existing = state.vampire.exploration.materials.find((m) => {
return m.materialId === materialId;
});
if (existing) {
existing.quantity = existing.quantity + quantity;
} else {
state.vampire.exploration.materials.push({ materialId, quantity });
}
materialGained = { materialId, quantity };
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 13 -- @preserve */
}
} else if (event.effect.type === "thrall_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 7 -- @preserve */
const fraction = event.effect.fraction ?? 0.05;
for (const thrall of state.vampire.thralls) {
const lost = Math.floor(thrall.count * fraction);
if (lost > 0) {
thrall.count = Math.max(0, thrall.count - lost);
}
}
}
let thrallLostCount = 0;
// eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next 8 -- @preserve */
if (event.effect.type === "thrall_loss") {
const fraction = event.effect.fraction ?? 0.05;
for (const thrall of state.vampire.thralls) {
const lost = Math.floor(thrall.count * fraction);
thrallLostCount = thrallLostCount + lost;
}
}
const eventResult: VampireExploreCollectEventResult = {
bloodChange: bloodChange,
ichorChange: ichorChange,
materialGained: materialGained,
text: event.text,
thrallLostCount: thrallLostCount,
};
// Roll for dark material drops from possibleMaterials (weighted random selection)
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
if (explorationArea.possibleMaterials.length > 0) {
let totalWeight = 0;
for (const materialDrop of explorationArea.possibleMaterials) {
totalWeight = totalWeight + materialDrop.weight;
}
let roll = Math.random() * totalWeight;
for (const possible of explorationArea.possibleMaterials) {
roll = roll - possible.weight;
if (roll <= 0) {
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
const range = maxMinDiff + 1;
const randomOffset = Math.floor(Math.random() * range);
const quantity = randomOffset + possible.minQuantity;
const { materialId } = possible;
const existing = state.vampire.exploration.materials.find((m) => {
return m.materialId === materialId;
});
if (existing) {
existing.quantity = existing.quantity + quantity;
} else {
state.vampire.exploration.materials.push({ materialId, quantity });
}
materialsFound.push({ materialId, quantity });
break;
}
}
}
await prisma.gameState.update({
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
data: { state: state as object, updatedAt: now },
where: { discordId },
});
const response: VampireExploreCollectResponse = {
event: eventResult,
foundNothing: false,
materialsFound: materialsFound,
};
return context.json(response);
} catch (error) {
void logger.error(
"vampire_explore_collect",
error instanceof Error
? error
: new Error(String(error)),
);
return context.json({ error: "Internal server error" }, 500);
}
});
export { vampireExploreRouter };
+125
View File
@@ -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 };
+137
View File
@@ -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,
};
+202
View File
@@ -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,
};
+20
View File
@@ -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 {
+161
View File
@@ -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,
}; };