generated from nhcarrigan/template
feat: goddess API routes, services, and tests (chunk 4)
Add six new goddess-mode API routes (boss fight, consecration, enlightenment, upgrade purchase, crafting, exploration) alongside matching service modules and full test suites at 100% coverage.
This commit is contained in:
@@ -12,11 +12,17 @@ import { aboutRouter } from "./routes/about.js";
|
||||
import { apotheosisRouter } from "./routes/apotheosis.js";
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { bossRouter } from "./routes/boss.js";
|
||||
import { consecrationRouter } from "./routes/consecration.js";
|
||||
import { craftRouter } from "./routes/craft.js";
|
||||
import { debugRouter } from "./routes/debug.js";
|
||||
import { enlightenmentRouter } from "./routes/enlightenment.js";
|
||||
import { exploreRouter } from "./routes/explore.js";
|
||||
import { frontendRouter } from "./routes/frontend.js";
|
||||
import { gameRouter } from "./routes/game.js";
|
||||
import { goddessBossRouter } from "./routes/goddessBoss.js";
|
||||
import { goddessCraftRouter } from "./routes/goddessCraft.js";
|
||||
import { goddessExploreRouter } from "./routes/goddessExplore.js";
|
||||
import { goddessUpgradeRouter } from "./routes/goddessUpgrade.js";
|
||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||
import { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
@@ -48,6 +54,12 @@ app.route("/craft", craftRouter);
|
||||
app.route("/prestige", prestigeRouter);
|
||||
app.route("/transcendence", transcendenceRouter);
|
||||
app.route("/apotheosis", apotheosisRouter);
|
||||
app.route("/goddess-boss", goddessBossRouter);
|
||||
app.route("/consecration", consecrationRouter);
|
||||
app.route("/enlightenment", enlightenmentRouter);
|
||||
app.route("/goddess-upgrade", goddessUpgradeRouter);
|
||||
app.route("/goddess-craft", goddessCraftRouter);
|
||||
app.route("/goddess-explore", goddessExploreRouter);
|
||||
app.route("/leaderboards", leaderboardRouter);
|
||||
app.route("/profile", profileRouter);
|
||||
app.route("/timers", timersRouter);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file Consecration routes handling consecration resets and divinity 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 { defaultConsecrationUpgrades } from "../data/goddessConsecrationUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import {
|
||||
buildPostConsecrationState,
|
||||
calculateConsecrationThreshold,
|
||||
computeConsecrationDivinityMultipliers,
|
||||
isEligibleForConsecration,
|
||||
} from "../services/consecration.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
BuyConsecrationUpgradeRequest,
|
||||
ConsecrationResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const consecrationRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
consecrationRouter.use("*", authMiddleware);
|
||||
|
||||
consecrationRouter.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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (!isEligibleForConsecration(state)) {
|
||||
const thresholdMultiplier
|
||||
= state.goddess.enlightenment.stardustConsecrationThresholdMultiplier;
|
||||
const required = calculateConsecrationThreshold(
|
||||
state.goddess.consecration.count,
|
||||
thresholdMultiplier,
|
||||
);
|
||||
return context.json(
|
||||
{
|
||||
error: `Not eligible for consecration — earn ${required.toLocaleString()} total prayers first`,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const { divinityEarned, updatedGoddess } = buildPostConsecrationState(state);
|
||||
|
||||
const updatedConsecrationCount = updatedGoddess.consecration.count;
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
goddess: updatedGoddess,
|
||||
resources: {
|
||||
...state.resources,
|
||||
prayers: 0,
|
||||
},
|
||||
};
|
||||
|
||||
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("consecration", 1, { discordId, updatedConsecrationCount });
|
||||
|
||||
const response: ConsecrationResponse = {
|
||||
divinityEarned: divinityEarned,
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||
newConsecrationCount: updatedConsecrationCount,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"consecration",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
consecrationRouter.post("/buy-upgrade", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuyConsecrationUpgradeRequest>();
|
||||
|
||||
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 = defaultConsecrationUpgrades.find((consecrationUpgrade) => {
|
||||
return consecrationUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown consecration 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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, divinity } = state.goddess.consecration;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (divinity < upgrade.divinityCost) {
|
||||
return context.json({ error: "Not enough divinity" }, 400);
|
||||
}
|
||||
|
||||
const updatedDivinity = divinity - upgrade.divinityCost;
|
||||
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedMultipliers = computeConsecrationDivinityMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
goddess: {
|
||||
...state.goddess,
|
||||
consecration: {
|
||||
...state.goddess.consecration,
|
||||
divinity: updatedDivinity,
|
||||
|
||||
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("consecration_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
return context.json({
|
||||
divinityRemaining: updatedDivinity,
|
||||
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...updatedMultipliers,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"consecration_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { consecrationRouter };
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file Enlightenment routes handling enlightenment resets and stardust 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 { defaultEnlightenmentUpgrades } from "../data/goddessEnlightenmentUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import {
|
||||
buildPostEnlightenmentState,
|
||||
computeEnlightenmentMultipliers,
|
||||
isEligibleForEnlightenment,
|
||||
} from "../services/enlightenment.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
BuyEnlightenmentUpgradeRequest,
|
||||
EnlightenmentResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const enlightenmentRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
enlightenmentRouter.use("*", authMiddleware);
|
||||
|
||||
enlightenmentRouter.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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (!isEligibleForEnlightenment(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Not eligible for enlightenment — defeat the Divine Heart Sovereign first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const { stardustEarned, updatedGoddess } = buildPostEnlightenmentState(state);
|
||||
|
||||
const updatedEnlightenmentCount = updatedGoddess.enlightenment.count;
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
goddess: updatedGoddess,
|
||||
resources: {
|
||||
...state.resources,
|
||||
prayers: 0,
|
||||
},
|
||||
};
|
||||
|
||||
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("enlightenment", 1, { discordId, updatedEnlightenmentCount });
|
||||
|
||||
const response: EnlightenmentResponse = {
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||
newEnlightenmentCount: updatedEnlightenmentCount,
|
||||
stardustEarned: stardustEarned,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"enlightenment",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
enlightenmentRouter.post("/buy-upgrade", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuyEnlightenmentUpgradeRequest>();
|
||||
|
||||
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 = defaultEnlightenmentUpgrades.find((enlightenmentUpgrade) => {
|
||||
return enlightenmentUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown enlightenment 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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, stardust } = state.goddess.enlightenment;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (stardust < upgrade.cost) {
|
||||
return context.json({ error: "Not enough stardust" }, 400);
|
||||
}
|
||||
|
||||
const updatedStardust = stardust - upgrade.cost;
|
||||
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedMultipliers = computeEnlightenmentMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
goddess: {
|
||||
...state.goddess,
|
||||
enlightenment: {
|
||||
...state.goddess.enlightenment,
|
||||
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
stardust: updatedStardust,
|
||||
...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("enlightenment_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
return context.json({
|
||||
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
stardustRemaining: updatedStardust,
|
||||
...updatedMultipliers,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"enlightenment_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { enlightenmentRouter };
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* @file Goddess boss challenge route handling divine 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 {
|
||||
computeGoddessSetBonuses,
|
||||
type GameState,
|
||||
type GoddessBossChallengeResponse,
|
||||
} from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { defaultGoddessBosses } from "../data/goddessBosses.js";
|
||||
import { defaultGoddessEquipmentSets } from "../data/goddessEquipmentSets.js";
|
||||
import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.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 goddessBossRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
goddessBossRouter.use("*", authMiddleware);
|
||||
|
||||
const calculateDiscipleStats = (
|
||||
goddess: NonNullable<GameState["goddess"]>,
|
||||
): { partyDPS: number; partyMaxHp: number } => {
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of goddess.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply consecration production multiplier as a combat boost
|
||||
const consecrationCombatMultiplier
|
||||
= goddess.consecration.divinityCombatMultiplier ?? 1;
|
||||
const { stardustCombatMultiplier } = goddess.enlightenment;
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equipmentCombatMultiplier = goddess.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 = goddess.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const { combatMultiplier: setCombatMultiplier } = computeGoddessSetBonuses(
|
||||
equippedItemIds,
|
||||
defaultGoddessEquipmentSets,
|
||||
);
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyMaxHp = 0;
|
||||
|
||||
for (const disciple of goddess.disciples) {
|
||||
if (disciple.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let discipleMultiplier = 1;
|
||||
for (const upgrade of goddess.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "disciple"
|
||||
&& upgrade.discipleId === disciple.id
|
||||
) {
|
||||
discipleMultiplier = discipleMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const discipleContribution
|
||||
= disciple.combatPower
|
||||
* disciple.count
|
||||
* discipleMultiplier
|
||||
* globalMultiplier;
|
||||
partyDPS = partyDPS + discipleContribution;
|
||||
|
||||
const discipleHp = disciple.level * 50 * disciple.count;
|
||||
partyMaxHp = partyMaxHp + discipleHp;
|
||||
}
|
||||
|
||||
const { craftedCombatMultiplier } = goddess.exploration;
|
||||
|
||||
partyDPS = partyDPS
|
||||
* equipmentCombatMultiplier
|
||||
* setCombatMultiplier
|
||||
* consecrationCombatMultiplier
|
||||
* stardustCombatMultiplier
|
||||
* craftedCombatMultiplier;
|
||||
|
||||
return { partyDPS, partyMaxHp };
|
||||
};
|
||||
|
||||
goddessBossRouter.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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
const boss = goddess.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.consecrationRequirement > goddess.consecration.count) {
|
||||
return context.json({ error: "Consecration requirement not met" }, 403);
|
||||
}
|
||||
|
||||
const { partyDPS, partyMaxHp } = calculateDiscipleStats(goddess);
|
||||
|
||||
if (
|
||||
partyDPS === 0
|
||||
|| partyMaxHp === 0
|
||||
|| !Number.isFinite(partyDPS)
|
||||
|| !Number.isFinite(partyMaxHp)
|
||||
) {
|
||||
return context.json(
|
||||
{ error: "Your disciples 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: GoddessBossChallengeResponse["rewards"];
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||
let casualties: GoddessBossChallengeResponse["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.prayers = (state.resources.prayers ?? 0) + boss.prayersReward;
|
||||
goddess.totalPrayersEarned
|
||||
= goddess.totalPrayersEarned + boss.prayersReward;
|
||||
goddess.lifetimePrayersEarned
|
||||
= goddess.lifetimePrayersEarned + boss.prayersReward;
|
||||
goddess.consecration.divinity
|
||||
= goddess.consecration.divinity + boss.divinityReward;
|
||||
goddess.enlightenment.stardust
|
||||
= goddess.enlightenment.stardust + boss.stardustReward;
|
||||
goddess.lifetimeBossesDefeated
|
||||
= goddess.lifetimeBossesDefeated + 1;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
const upgrade = goddess.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 = goddess.equipment.find((item) => {
|
||||
return item.id === equipmentId;
|
||||
});
|
||||
if (equipment) {
|
||||
equipment.owned = true;
|
||||
const slotAlreadyEquipped = goddess.equipment.some((item) => {
|
||||
return item.type === equipment.type && item.equipped;
|
||||
});
|
||||
if (!slotAlreadyEquipped) {
|
||||
equipment.equipped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock next boss in the same zone
|
||||
const zoneBosses = goddess.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.consecrationRequirement <= goddess.consecration.count
|
||||
) {
|
||||
const nextBossInState = goddess.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 goddess.zones) {
|
||||
if (zone.status === "unlocked") {
|
||||
continue;
|
||||
}
|
||||
if (zone.unlockBossId !== body.bossId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const questSatisfied
|
||||
= zone.unlockQuestId === null
|
||||
|| goddess.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 goddess.exploration.areas) {
|
||||
const areaDefinition = defaultGoddessExplorationAreas.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
|
||||
area.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
const updatedZoneBosses = goddess.bosses.filter((b) => {
|
||||
return b.zoneId === zone.id;
|
||||
});
|
||||
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
||||
if (
|
||||
firstUpdatedBoss
|
||||
&& firstUpdatedBoss.consecrationRequirement <= goddess.consecration.count
|
||||
) {
|
||||
firstUpdatedBoss.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
// First-kill divinity bounty — only awarded once
|
||||
const staticBoss = defaultGoddessBosses.find((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const bountyDivinity
|
||||
= boss.bountyDivinityClaimed === true
|
||||
? 0
|
||||
: staticBoss?.bountyDivinity ?? 0;
|
||||
if (bountyDivinity > 0) {
|
||||
boss.bountyDivinityClaimed = true;
|
||||
}
|
||||
goddess.consecration.divinity
|
||||
= goddess.consecration.divinity + bountyDivinity;
|
||||
|
||||
rewards = {
|
||||
bountyDivinity: bountyDivinity,
|
||||
divinity: boss.divinityReward,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
prayers: boss.prayersReward,
|
||||
stardust: boss.stardustReward,
|
||||
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 disciple of goddess.disciples) {
|
||||
if (disciple.count === 0) {
|
||||
continue;
|
||||
}
|
||||
const killed = Math.floor(disciple.count * casualtyFraction);
|
||||
if (killed > 0) {
|
||||
disciple.count = Math.max(1, disciple.count - killed);
|
||||
|
||||
casualties.push({ discipleId: disciple.id, killed: killed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("goddess_boss_challenge", 1, { bossId, discordId, won });
|
||||
|
||||
const bossMaxHp = boss.maxHp;
|
||||
const bossNewHp = bossUpdatedHp;
|
||||
const response: GoddessBossChallengeResponse = {
|
||||
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(
|
||||
"goddess_boss_challenge",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { goddessBossRouter };
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @file Goddess crafting route handling divine 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 { defaultGoddessCraftingRecipes } from "../data/goddessCrafting.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 {
|
||||
GoddessCraftRequest,
|
||||
GoddessCraftResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const goddessCraftRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
goddessCraftRouter.use("*", authMiddleware);
|
||||
|
||||
const recomputeGoddessCraftedMultipliers = (
|
||||
craftedRecipeIds: Array<string>,
|
||||
): {
|
||||
craftedPrayersMultiplier: number;
|
||||
craftedDivinityMultiplier: number;
|
||||
craftedCombatMultiplier: number;
|
||||
} => {
|
||||
return {
|
||||
craftedCombatMultiplier: defaultGoddessCraftingRecipes.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),
|
||||
craftedDivinityMultiplier: defaultGoddessCraftingRecipes.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),
|
||||
craftedPrayersMultiplier: defaultGoddessCraftingRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
|
||||
}).reduce((mult, recipe) => {
|
||||
return mult * recipe.bonus.value;
|
||||
}, 1),
|
||||
};
|
||||
};
|
||||
|
||||
goddessCraftRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<GoddessCraftRequest>();
|
||||
|
||||
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 = defaultGoddessCraftingRecipes.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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (state.goddess.exploration.craftedRecipeIds.includes(recipeId)) {
|
||||
return context.json({ error: "Recipe already crafted" }, 400);
|
||||
}
|
||||
|
||||
// Verify the player has all required sacred materials
|
||||
for (const requirement of recipe.requiredMaterials) {
|
||||
const material = state.goddess.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 sacred materials
|
||||
for (const requirement of recipe.requiredMaterials) {
|
||||
const material = state.goddess.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.goddess.exploration.craftedRecipeIds.push(recipeId);
|
||||
|
||||
const updatedMultipliers = recomputeGoddessCraftedMultipliers(
|
||||
state.goddess.exploration.craftedRecipeIds,
|
||||
);
|
||||
state.goddess.exploration.craftedPrayersMultiplier
|
||||
= updatedMultipliers.craftedPrayersMultiplier;
|
||||
state.goddess.exploration.craftedDivinityMultiplier
|
||||
= updatedMultipliers.craftedDivinityMultiplier;
|
||||
state.goddess.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("goddess_recipe_crafted", 1, { discordId, recipeId });
|
||||
|
||||
const bonusType = recipe.bonus.type;
|
||||
const bonusValue = recipe.bonus.value;
|
||||
|
||||
const { materials } = state.goddess.exploration;
|
||||
const {
|
||||
craftedPrayersMultiplier,
|
||||
craftedDivinityMultiplier,
|
||||
craftedCombatMultiplier,
|
||||
} = updatedMultipliers;
|
||||
|
||||
const response: GoddessCraftResponse = {
|
||||
bonusType,
|
||||
bonusValue,
|
||||
craftedCombatMultiplier,
|
||||
craftedDivinityMultiplier,
|
||||
craftedPrayersMultiplier,
|
||||
materials,
|
||||
recipeId,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_craft",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { goddessCraftRouter };
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* @file Goddess exploration routes handling divine 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 { defaultGoddessExplorationAreas } from "../data/goddessExplorations.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 {
|
||||
GoddessExploreClaimableResponse,
|
||||
GoddessExploreCollectEventResult,
|
||||
GoddessExploreCollectRequest,
|
||||
GoddessExploreCollectResponse,
|
||||
GoddessExploreStartRequest,
|
||||
GoddessExploreStartResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const goddessExploreRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
goddessExploreRouter.use("*", authMiddleware);
|
||||
|
||||
const nothingProbability = 0.2;
|
||||
|
||||
const nothingMessages = [
|
||||
"Your disciples searched every corner of the divine realm but found nothing of value.",
|
||||
"The sacred area yielded nothing remarkable this time.",
|
||||
"Your disciples returned empty-handed from the divine realm.",
|
||||
"A wasted journey — the sacred area proved barren.",
|
||||
"Nothing to show for the devotion. 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] ?? "";
|
||||
};
|
||||
|
||||
goddessExploreRouter.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 = defaultGoddessExplorationAreas.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.goddess) {
|
||||
const response: GoddessExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
const area = state.goddess.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
|
||||
if (!area || area.status !== "in_progress") {
|
||||
const response: GoddessExploreClaimableResponse = { 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: GoddessExploreClaimableResponse = { claimable };
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_explore_claimable",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
goddessExploreRouter.post("/start", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<GoddessExploreStartRequest>();
|
||||
|
||||
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 = defaultGoddessExplorationAreas.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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const zone = state.goddess.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.goddess.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!area) {
|
||||
return context.json(
|
||||
{ error: "Exploration area not found in state" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const anyInProgress = state.goddess.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: GoddessExploreStartResponse = {
|
||||
areaId,
|
||||
endsAt,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_explore_start",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
goddessExploreRouter.post("/collect", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<GoddessExploreCollectRequest>();
|
||||
|
||||
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 = defaultGoddessExplorationAreas.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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const area = state.goddess.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: GoddessExploreCollectResponse = {
|
||||
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 prayersChange = 0;
|
||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||
|
||||
if (event.effect.type === "prayers_gain") {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.prayers = (state.resources.prayers ?? 0) + amount;
|
||||
state.goddess.totalPrayersEarned = state.goddess.totalPrayersEarned + amount;
|
||||
prayersChange = amount;
|
||||
} else if (event.effect.type === "prayers_loss") {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const amount = Math.min(state.resources.prayers ?? 0, event.effect.amount ?? 0);
|
||||
state.resources.prayers = (state.resources.prayers ?? 0) - amount;
|
||||
prayersChange = -amount;
|
||||
} else if (event.effect.type === "divinity_gain") {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.goddess.consecration.divinity = state.goddess.consecration.divinity + amount;
|
||||
} else if (event.effect.type === "sacred_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.goddess.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.goddess.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 === "disciple_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 disciple of state.goddess.disciples) {
|
||||
const lost = Math.floor(disciple.count * fraction);
|
||||
if (lost > 0) {
|
||||
disciple.count = Math.max(0, disciple.count - lost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let discipleLostCount = 0;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
if (event.effect.type === "disciple_loss") {
|
||||
const fraction = event.effect.fraction ?? 0.05;
|
||||
for (const disciple of state.goddess.disciples) {
|
||||
const lost = Math.floor(disciple.count * fraction);
|
||||
discipleLostCount = discipleLostCount + lost;
|
||||
}
|
||||
}
|
||||
|
||||
const eventResult: GoddessExploreCollectEventResult = {
|
||||
discipleLostCount: discipleLostCount,
|
||||
materialGained: materialGained,
|
||||
prayersChange: prayersChange,
|
||||
text: event.text,
|
||||
};
|
||||
|
||||
// Roll for sacred 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.goddess.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.goddess.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: GoddessExploreCollectResponse = {
|
||||
event: eventResult,
|
||||
foundNothing: false,
|
||||
materialsFound: materialsFound,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_explore_collect",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { goddessExploreRouter };
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file Goddess 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 { defaultGoddessUpgrades } from "../data/goddessUpgrades.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 {
|
||||
BuyGoddessUpgradeRequest,
|
||||
BuyGoddessUpgradeResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const goddessUpgradeRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
goddessUpgradeRouter.use("*", authMiddleware);
|
||||
|
||||
goddessUpgradeRouter.post("/buy", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuyGoddessUpgradeRequest>();
|
||||
|
||||
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 = defaultGoddessUpgrades.find((goddessUpgrade) => {
|
||||
return goddessUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgradeTemplate) {
|
||||
return context.json({ error: "Unknown goddess 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.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = state.goddess.upgrades.find((u) => {
|
||||
return u.id === upgradeId;
|
||||
});
|
||||
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Upgrade not found in goddess 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 currentPrayers = state.resources.prayers ?? 0;
|
||||
const currentDivinity = state.goddess.consecration.divinity;
|
||||
const currentStardust = state.goddess.enlightenment.stardust;
|
||||
|
||||
if (currentPrayers < upgradeTemplate.costPrayers) {
|
||||
return context.json({ error: "Not enough prayers" }, 400);
|
||||
}
|
||||
|
||||
if (currentDivinity < upgradeTemplate.costDivinity) {
|
||||
return context.json({ error: "Not enough divinity" }, 400);
|
||||
}
|
||||
|
||||
if (currentStardust < upgradeTemplate.costStardust) {
|
||||
return context.json({ error: "Not enough stardust" }, 400);
|
||||
}
|
||||
|
||||
upgrade.purchased = true;
|
||||
|
||||
const updatedPrayers = currentPrayers - upgradeTemplate.costPrayers;
|
||||
const updatedDivinity = currentDivinity - upgradeTemplate.costDivinity;
|
||||
const updatedStardust = currentStardust - upgradeTemplate.costStardust;
|
||||
|
||||
state.resources.prayers = updatedPrayers;
|
||||
state.goddess.consecration.divinity = updatedDivinity;
|
||||
state.goddess.enlightenment.stardust = updatedStardust;
|
||||
|
||||
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("goddess_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
|
||||
const response: BuyGoddessUpgradeResponse = {
|
||||
divinityRemaining: updatedDivinity,
|
||||
prayersRemaining: updatedPrayers,
|
||||
stardustRemaining: updatedStardust,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_upgrade_buy",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { goddessUpgradeRouter };
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @file Consecration service handling eligibility checks and post-consecration 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 { defaultConsecrationUpgrades } from "../data/goddessConsecrationUpgrades.js";
|
||||
import { initialGoddessState } from "../data/initialState.js";
|
||||
import type { ConsecrationData, GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Base prayers threshold for the first consecration.
|
||||
*/
|
||||
const baseConsecrationThreshold = 50_000;
|
||||
|
||||
/**
|
||||
* Divisor used in the divinity yield formula.
|
||||
*/
|
||||
const divinityYieldDivisor = 1000;
|
||||
|
||||
/**
|
||||
* Calculates the prayers threshold required for the next consecration.
|
||||
* Formula: BASE * (count + 1)^2 * thresholdMultiplier.
|
||||
* @param consecrationCount - The number of consecrations completed so far.
|
||||
* @param thresholdMultiplier - An optional stardust-upgrade multiplier applied to the threshold.
|
||||
* @returns The prayers amount required to consecrate.
|
||||
*/
|
||||
const calculateConsecrationThreshold = (
|
||||
consecrationCount: number,
|
||||
thresholdMultiplier = 1,
|
||||
): number => {
|
||||
return (
|
||||
baseConsecrationThreshold
|
||||
* Math.pow(consecrationCount + 1, 2)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the player is eligible to consecrate:
|
||||
* the total prayers earned in the current run must meet the threshold.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for consecration.
|
||||
*/
|
||||
const isEligibleForConsecration = (state: GameState): boolean => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.goddess === undefined) {
|
||||
return false;
|
||||
}
|
||||
const thresholdMultiplier
|
||||
= state.goddess.enlightenment.stardustConsecrationThresholdMultiplier;
|
||||
const threshold = calculateConsecrationThreshold(
|
||||
state.goddess.consecration.count,
|
||||
thresholdMultiplier,
|
||||
);
|
||||
return state.goddess.totalPrayersEarned >= threshold;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the divinity yield from a consecration.
|
||||
* Formula: MAX(1, FLOOR(SQRT(totalPrayersEarned / divisor) * divinityMultiplier)).
|
||||
* @param totalPrayersEarned - Total prayers earned in the current consecration run.
|
||||
* @param divinityMultiplier - Multiplier from stardust upgrades applied to divinity yield.
|
||||
* @returns The divinity earned.
|
||||
*/
|
||||
const calculateDivinityYield = (
|
||||
totalPrayersEarned: number,
|
||||
divinityMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
Math.sqrt(totalPrayersEarned / divinityYieldDivisor) * divinityMultiplier,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the consecration production multiplier from the count.
|
||||
* Each consecration adds 25% to the production multiplier.
|
||||
* @param count - The number of consecrations completed.
|
||||
* @returns The computed production multiplier as a number.
|
||||
*/
|
||||
const computeConsecrationProductionMultiplier = (count: number): number => {
|
||||
const bonus = count * 0.25;
|
||||
return 1 + bonus;
|
||||
};
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
category: string,
|
||||
): number => {
|
||||
return defaultConsecrationUpgrades.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === category
|
||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all three divinity-upgrade multipliers from the purchased upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased consecration upgrade IDs.
|
||||
* @returns An object containing the three divinity multiplier values.
|
||||
*/
|
||||
const computeConsecrationDivinityMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): Pick<
|
||||
ConsecrationData,
|
||||
| "divinityCombatMultiplier"
|
||||
| "divinityDisciplesMultiplier"
|
||||
| "divinityPrayersMultiplier"
|
||||
> => {
|
||||
return {
|
||||
divinityCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
|
||||
divinityDisciplesMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "disciples"),
|
||||
divinityPrayersMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prayers"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the updated goddess state after a consecration reset.
|
||||
* Resets the current run (bosses, quests, disciples, upgrades, zones, exploration crafting).
|
||||
* Preserves: equipment, achievements, consecration data (updated), enlightenment, lifetime stats, sacred materials.
|
||||
* @param state - The current game state before consecration.
|
||||
* @returns The divinity earned and the updated goddess state.
|
||||
*/
|
||||
const buildPostConsecrationState = (
|
||||
state: GameState,
|
||||
): { divinityEarned: number; updatedGoddess: NonNullable<GameState["goddess"]> } => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure goddess exists
|
||||
const goddess = state.goddess as NonNullable<GameState["goddess"]>;
|
||||
|
||||
const divinityMultiplier
|
||||
= goddess.enlightenment.stardustConsecrationDivinityMultiplier;
|
||||
const divinityEarned = calculateDivinityYield(
|
||||
goddess.totalPrayersEarned,
|
||||
divinityMultiplier,
|
||||
);
|
||||
|
||||
const updatedCount = goddess.consecration.count + 1;
|
||||
const updatedDivinity = goddess.consecration.divinity + divinityEarned;
|
||||
const productionMultiplier = computeConsecrationProductionMultiplier(updatedCount);
|
||||
|
||||
const updatedConsecration: ConsecrationData = {
|
||||
...goddess.consecration,
|
||||
count: updatedCount,
|
||||
divinity: updatedDivinity,
|
||||
lastConsecratedAt: Date.now(),
|
||||
productionMultiplier: productionMultiplier,
|
||||
...computeConsecrationDivinityMultipliers(
|
||||
goddess.consecration.purchasedUpgradeIds,
|
||||
),
|
||||
};
|
||||
|
||||
const freshGoddess = initialGoddessState();
|
||||
|
||||
const updatedGoddess: NonNullable<GameState["goddess"]> = {
|
||||
...freshGoddess,
|
||||
achievements: goddess.achievements,
|
||||
bosses: freshGoddess.bosses.map((b) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const existing = goddess.bosses.find((gb) => {
|
||||
return gb.id === b.id;
|
||||
});
|
||||
return {
|
||||
...b,
|
||||
bountyDivinityClaimed: existing?.bountyDivinityClaimed ?? false,
|
||||
};
|
||||
}),
|
||||
consecration: updatedConsecration,
|
||||
enlightenment: goddess.enlightenment,
|
||||
equipment: goddess.equipment,
|
||||
exploration: {
|
||||
...freshGoddess.exploration,
|
||||
materials: goddess.exploration.materials,
|
||||
},
|
||||
lastTickAt: Date.now(),
|
||||
lifetimeBossesDefeated: goddess.lifetimeBossesDefeated,
|
||||
lifetimePrayersEarned: goddess.lifetimePrayersEarned + goddess.totalPrayersEarned,
|
||||
lifetimeQuestsCompleted: goddess.lifetimeQuestsCompleted,
|
||||
totalPrayersEarned: 0,
|
||||
};
|
||||
|
||||
return { divinityEarned, updatedGoddess };
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostConsecrationState,
|
||||
calculateConsecrationThreshold,
|
||||
calculateDivinityYield,
|
||||
computeConsecrationDivinityMultipliers,
|
||||
computeConsecrationProductionMultiplier,
|
||||
isEligibleForConsecration,
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file Enlightenment service handling eligibility checks and post-enlightenment state building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
|
||||
import { defaultEnlightenmentUpgrades } from "../data/goddessEnlightenmentUpgrades.js";
|
||||
import { initialGoddessState } from "../data/initialState.js";
|
||||
import type { EnlightenmentData, GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* ID of the final goddess boss — must be defeated to unlock Enlightenment.
|
||||
*/
|
||||
const finalGoddessBossId = "divine_heart_sovereign";
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedIds: Array<string>,
|
||||
category: string,
|
||||
): number => {
|
||||
return defaultEnlightenmentUpgrades.filter((upgrade) => {
|
||||
return upgrade.category === category && purchasedIds.includes(upgrade.id);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all five stardust multipliers from the purchased enlightenment upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased enlightenment upgrade IDs.
|
||||
* @returns An object containing all five stardust multiplier values.
|
||||
*/
|
||||
const computeEnlightenmentMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): Omit<EnlightenmentData, "count" | "stardust" | "purchasedUpgradeIds"> => {
|
||||
return {
|
||||
stardustCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
|
||||
stardustConsecrationDivinityMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "consecration_divinity"),
|
||||
stardustConsecrationThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "consecration_threshold"),
|
||||
stardustMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "stardust_meta"),
|
||||
stardustPrayersMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prayers"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when the player is eligible for Enlightenment:
|
||||
* they must have defeated the final goddess boss at least once.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for Enlightenment.
|
||||
*/
|
||||
const isEligibleForEnlightenment = (state: GameState): boolean => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.goddess === undefined) {
|
||||
return false;
|
||||
}
|
||||
return state.goddess.bosses.some((boss) => {
|
||||
return boss.id === finalGoddessBossId && boss.status === "defeated";
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the stardust yield from an Enlightenment.
|
||||
* Formula: MAX(1, FLOOR(SQRT(consecrationCount) * metaMultiplier)).
|
||||
* @param consecrationCount - The number of consecrations completed before this Enlightenment.
|
||||
* @param metaMultiplier - Multiplier from prior enlightenment upgrades applied to stardust yield.
|
||||
* @returns The stardust earned.
|
||||
*/
|
||||
const calculateStardustYield = (
|
||||
consecrationCount: number,
|
||||
metaMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(1, Math.floor(Math.sqrt(consecrationCount) * metaMultiplier));
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the updated goddess state after an Enlightenment — a full goddess reset.
|
||||
* Wipes everything including consecration, preserving only equipment, achievements, and enlightenment data.
|
||||
* @param state - The current game state before enlightenment.
|
||||
* @returns The stardust earned and the updated goddess state.
|
||||
*/
|
||||
const buildPostEnlightenmentState = (
|
||||
state: GameState,
|
||||
): { stardustEarned: number; updatedGoddess: NonNullable<GameState["goddess"]> } => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure goddess exists
|
||||
const goddess = state.goddess as NonNullable<GameState["goddess"]>;
|
||||
|
||||
const metaMultiplier = goddess.enlightenment.stardustMetaMultiplier;
|
||||
const stardustEarned = calculateStardustYield(
|
||||
goddess.consecration.count,
|
||||
metaMultiplier,
|
||||
);
|
||||
|
||||
const updatedCount = goddess.enlightenment.count + 1;
|
||||
const updatedStardust = goddess.enlightenment.stardust + stardustEarned;
|
||||
const updatedPurchasedIds = goddess.enlightenment.purchasedUpgradeIds;
|
||||
const updatedMultipliers = computeEnlightenmentMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedEnlightenment: EnlightenmentData = {
|
||||
count: updatedCount,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
stardust: updatedStardust,
|
||||
...updatedMultipliers,
|
||||
};
|
||||
|
||||
const freshGoddess = initialGoddessState();
|
||||
|
||||
const updatedGoddess: NonNullable<GameState["goddess"]> = {
|
||||
...freshGoddess,
|
||||
achievements: goddess.achievements,
|
||||
bosses: freshGoddess.bosses.map((b) => {
|
||||
const existing = goddess.bosses.find((gb) => {
|
||||
return gb.id === b.id;
|
||||
});
|
||||
return {
|
||||
...b,
|
||||
bountyDivinityClaimed: existing?.bountyDivinityClaimed ?? false,
|
||||
};
|
||||
}),
|
||||
enlightenment: updatedEnlightenment,
|
||||
equipment: goddess.equipment,
|
||||
lastTickAt: Date.now(),
|
||||
lifetimeBossesDefeated: goddess.lifetimeBossesDefeated,
|
||||
lifetimePrayersEarned: goddess.lifetimePrayersEarned,
|
||||
lifetimeQuestsCompleted: goddess.lifetimeQuestsCompleted,
|
||||
totalPrayersEarned: 0,
|
||||
};
|
||||
|
||||
return { stardustEarned, updatedGoddess };
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostEnlightenmentState,
|
||||
calculateStardustYield,
|
||||
computeEnlightenmentMultipliers,
|
||||
isEligibleForEnlightenment,
|
||||
};
|
||||
Reference in New Issue
Block a user