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:
2026-04-13 15:48:35 -07:00
committed by Naomi Carrigan
parent 7da1f3942d
commit 0d36b255ee
19 changed files with 4250 additions and 16 deletions
+12
View File
@@ -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);
+188
View File
@@ -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 };
+180
View File
@@ -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 };
+415
View File
@@ -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 };
+173
View File
@@ -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 };
+418
View File
@@ -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 };
+125
View File
@@ -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 };
+201
View File
@@ -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,
};
+137
View File
@@ -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,
};
+295
View File
@@ -0,0 +1,295 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
metric: vi.fn().mockResolvedValue(undefined),
},
}));
const DISCORD_ID = "test_discord_id";
const makeConsecration = (overrides: Record<string, unknown> = {}) => ({
count: 0,
divinity: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [] as string[],
...overrides,
});
const makeEnlightenment = (overrides: Record<string, unknown> = {}) => ({
count: 0,
stardust: 0,
purchasedUpgradeIds: [] as string[],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
...overrides,
});
const makeGoddessExploration = (overrides: Record<string, unknown> = {}) => ({
areas: [] as Array<{ id: string; status: string }>,
materials: [] as Array<{ materialId: string; quantity: number }>,
craftedRecipeIds: [] as string[],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
...overrides,
});
/**
* Creates a minimal GoddessState that has met the first consecration threshold (50 000 prayers).
*/
const makeGoddessStateEligible = (overrides: Record<string, unknown> = {}) => ({
zones: [] as Array<{ id: string; status: string }>,
bosses: [] as Array<{ id: string; status: string }>,
quests: [] as Array<{ id: string; status: string }>,
disciples: [] as Array<{ id: string; count: number }>,
equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record<string, unknown> }>,
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number }>,
achievements: [] as Array<{ id: string; completed: boolean }>,
consecration: makeConsecration(),
enlightenment: makeEnlightenment(),
exploration: makeGoddessExploration(),
totalPrayersEarned: 50_000, // Meets base threshold for count=0
lifetimePrayersEarned: 50_000,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
...overrides,
});
/**
* Creates a minimal GoddessState that has NOT met the consecration threshold.
*/
const makeGoddessStateIneligible = (overrides: Record<string, unknown> = {}) => ({
...makeGoddessStateEligible(),
totalPrayersEarned: 0,
...overrides,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
goddess: makeGoddessStateEligible() as GameState["goddess"],
...overrides,
} as GameState);
describe("consecration route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { consecrationRouter } = await import("../../src/routes/consecration.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/consecration", consecrationRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/consecration${path}`, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
}));
describe("POST /", () => {
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("");
expect(res.status).toBe(404);
});
it("returns 400 when goddess realm is not unlocked", async () => {
const state = makeState({ goddess: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Goddess realm not unlocked");
});
it("returns 400 with threshold message when not eligible", async () => {
const state = makeState({
goddess: makeGoddessStateIneligible() as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toMatch(/Not eligible for consecration/u);
expect(body.error).toMatch(/50,000/u);
});
it("returns 200 with divinityEarned and newConsecrationCount on success", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { divinityEarned: number; newConsecrationCount: number };
expect(body.newConsecrationCount).toBe(1);
expect(body.divinityEarned).toBeGreaterThanOrEqual(1);
});
it("applies the threshold multiplier when checking eligibility", async () => {
// threshold multiplier of 2 means we need 100 000 prayers for count=0 but only have 50 000
const state = makeState({
goddess: makeGoddessStateIneligible({
totalPrayersEarned: 50_000,
enlightenment: makeEnlightenment({ stardustConsecrationThresholdMultiplier: 2 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
// threshold should be 100 000 with multiplier 2
expect(body.error).toMatch(/100,000/u);
});
it("returns 500 when database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
});
describe("POST /buy-upgrade", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy-upgrade", {});
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("upgradeId is required");
});
it("returns 404 for an unknown upgrade", async () => {
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade_id" });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toBe("Unknown consecration upgrade");
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(404);
});
it("returns 400 when goddess realm is not unlocked", async () => {
const state = makeState({ goddess: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Goddess realm not unlocked");
});
it("returns 400 when upgrade is already purchased", async () => {
const state = makeState({
goddess: makeGoddessStateEligible({
consecration: makeConsecration({
divinity: 100,
purchasedUpgradeIds: [ "divine_prayers_1" ],
}),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Upgrade already purchased");
});
it("returns 400 when not enough divinity", async () => {
// divine_prayers_1 costs 5 divinity — give the player 0
const state = makeState({
goddess: makeGoddessStateEligible({
consecration: makeConsecration({ divinity: 0 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Not enough divinity");
});
it("returns 200 with updated multipliers on successful purchase", async () => {
// divine_prayers_1 costs 5 divinity and is in the "prayers" category
const state = makeState({
goddess: makeGoddessStateEligible({
consecration: makeConsecration({ divinity: 100 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(200);
const body = await res.json() as {
divinityRemaining: number;
purchasedUpgradeIds: string[];
divinityPrayersMultiplier: number;
divinityDisciplesMultiplier: number;
divinityCombatMultiplier: number;
};
expect(body.divinityRemaining).toBe(95); // 100 - 5
expect(body.purchasedUpgradeIds).toContain("divine_prayers_1");
expect(body.divinityPrayersMultiplier).toBeGreaterThan(1);
});
it("returns 500 when database throws an Error during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(500);
});
it("returns 500 when database throws a non-Error value during buy-upgrade", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
expect(res.status).toBe(500);
});
});
});
+239
View File
@@ -0,0 +1,239 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
const DISCORD_ID = "test_discord_id";
// stardust_prayers_1 costs 2 stardust
const TEST_UPGRADE_ID = "stardust_prayers_1";
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
zones: [{ id: "goddess_celestial_garden", name: "Celestial Garden", description: "", emoji: "🌸", status: "unlocked", unlockBossId: null, unlockQuestId: null }],
bosses: [
{
id: "divine_heart_sovereign",
name: "Divine Heart Sovereign",
description: "",
status: "defeated",
maxHp: 1000,
currentHp: 0,
damagePerSecond: 10,
prayersReward: 100,
divinityReward: 1,
stardustReward: 1,
upgradeRewards: [],
equipmentRewards: [],
consecrationRequirement: 0,
zoneId: "goddess_celestial_garden",
bountyDivinity: 5,
},
],
quests: [],
disciples: [],
equipment: [],
upgrades: [],
achievements: [],
consecration: {
count: 0,
divinity: 10,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 10,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [],
materials: [],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("enlightenment route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { enlightenmentRouter } = await import("../../src/routes/enlightenment.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/enlightenment", enlightenmentRouter);
});
const post = (path: string, body?: Record<string, unknown>) =>
app.fetch(new Request(`http://localhost/enlightenment${path}`, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}));
describe("POST /", () => {
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("");
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns 400 when not eligible (final boss not defeated)", async () => {
const goddess = makeGoddessState();
// Override final boss to available (not defeated)
goddess.bosses[0]!.status = "available";
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("");
expect(res.status).toBe(400);
});
it("returns 200 with stardustEarned and newEnlightenmentCount on success", async () => {
const goddess = makeGoddessState();
goddess.consecration.count = 4; // sqrt(4)*1 = 2 stardust
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { stardustEarned: number; newEnlightenmentCount: number };
expect(body.newEnlightenmentCount).toBe(1);
expect(body.stardustEarned).toBeGreaterThanOrEqual(1);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("");
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("");
expect(res.status).toBe(500);
});
});
describe("POST /buy-upgrade", () => {
it("returns 400 when upgradeId is missing", async () => {
const res = await post("/buy-upgrade", {});
expect(res.status).toBe(400);
});
it("returns 404 for unknown upgrade", async () => {
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when upgrade is already purchased", async () => {
const goddess = makeGoddessState();
goddess.enlightenment.purchasedUpgradeIds = [TEST_UPGRADE_ID];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough stardust", async () => {
const goddess = makeGoddessState();
goddess.enlightenment.stardust = 0; // stardust_prayers_1 costs 2
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 200 with updated multipliers on success", async () => {
const goddess = makeGoddessState();
goddess.enlightenment.stardust = 10;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(200);
const body = await res.json() as { stardustRemaining: number; purchasedUpgradeIds: string[] };
expect(body.stardustRemaining).toBe(8); // 10 - 2
expect(body.purchasedUpgradeIds).toContain(TEST_UPGRADE_ID);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(500);
});
});
});
+560
View File
@@ -0,0 +1,560 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
vi.mock("../../src/services/logger.js", () => ({
logger: {
error: vi.fn().mockResolvedValue(undefined),
metric: vi.fn().mockResolvedValue(undefined),
},
}));
const DISCORD_ID = "test_discord_id";
const makeConsecration = (overrides: Record<string, unknown> = {}) => ({
count: 0,
divinity: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [] as string[],
...overrides,
});
const makeEnlightenment = (overrides: Record<string, unknown> = {}) => ({
count: 0,
stardust: 0,
purchasedUpgradeIds: [] as string[],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
...overrides,
});
const makeGoddessExploration = (overrides: Record<string, unknown> = {}) => ({
areas: [] as Array<{ id: string; status: string }>,
materials: [] as Array<{ materialId: string; quantity: number }>,
craftedRecipeIds: [] as string[],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
...overrides,
});
const makeGoddessBoss = (overrides: Record<string, unknown> = {}) => ({
id: "test_goddess_boss",
name: "Test Goddess Boss",
description: "A test boss",
status: "available",
maxHp: 100,
currentHp: 100,
damagePerSecond: 1,
prayersReward: 50,
divinityReward: 2,
stardustReward: 0,
upgradeRewards: [] as string[],
equipmentRewards: [] as string[],
consecrationRequirement: 0,
zoneId: "test_goddess_zone",
bountyDivinity: 5,
...overrides,
});
const makeDisciple = (overrides: Record<string, unknown> = {}) => ({
id: "test_disciple",
name: "Test Disciple",
class: "oracle" as const,
level: 10,
baseCost: 100,
prayersPerSecond: 1,
divinityPerSecond: 0,
combatPower: 10_000,
count: 1,
unlocked: true,
...overrides,
});
const makeGoddessState = (overrides: Record<string, unknown> = {}) => ({
zones: [] as Array<{ id: string; status: string; unlockBossId: string | null; unlockQuestId: string | null }>,
bosses: [ makeGoddessBoss() ],
quests: [] as Array<{ id: string; status: string }>,
disciples: [ makeDisciple() ],
equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record<string, unknown> }>,
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number; discipleId?: string }>,
achievements: [] as Array<{ id: string; completed: boolean }>,
consecration: makeConsecration(),
enlightenment: makeEnlightenment(),
exploration: makeGoddessExploration(),
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
...overrides,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
goddess: makeGoddessState() as GameState["goddess"],
...overrides,
} as GameState);
describe("goddessBoss route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessBossRouter } = await import("../../src/routes/goddessBoss.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-boss", goddessBossRouter);
});
const challenge = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-boss/challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when bossId is missing", async () => {
const res = await challenge({});
expect(res.status).toBe(400);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(404);
});
it("returns 400 when goddess realm is not unlocked", async () => {
const state = makeState({ goddess: undefined });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Goddess realm not unlocked");
});
it("returns 404 when boss is not found in goddess state", async () => {
const state = makeState({
goddess: makeGoddessState({ bosses: [] }) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(404);
const body = await res.json() as { error: string };
expect(body.error).toBe("Boss not found");
});
it("returns 400 when boss status is defeated", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ status: "defeated" }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Boss is not currently available");
});
it("returns 400 when boss status is locked", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ status: "locked" }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
});
it("accepts in_progress boss status", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ status: "in_progress" }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
});
it("returns 403 when consecration requirement is not met", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ consecrationRequirement: 3 }) ],
consecration: makeConsecration({ count: 0 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(403);
const body = await res.json() as { error: string };
expect(body.error).toBe("Consecration requirement not met");
});
it("returns 400 when party has no combat power (all disciples have count 0)", async () => {
const state = makeState({
goddess: makeGoddessState({
disciples: [ makeDisciple({ count: 0 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("Your disciples have no combat power");
});
it("returns 400 when party has no combat power (disciples array is empty)", async () => {
const state = makeState({
goddess: makeGoddessState({
disciples: [],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(400);
});
it("returns 200 with rewards when party wins", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1, level: 10 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { prayers: number; divinity: number } };
expect(body.won).toBe(true);
expect(body.rewards.prayers).toBe(50);
expect(body.rewards.divinity).toBe(2);
});
it("returns 200 with bountyDivinity when first kill", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 50, maxHp: 50, damagePerSecond: 1, id: "celestial_sprite" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "celestial_sprite" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } };
expect(body.won).toBe(true);
expect(body.rewards.bountyDivinity).toBeGreaterThan(0);
});
it("returns 0 bountyDivinity when already claimed", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "celestial_sprite", bountyDivinityClaimed: true }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "celestial_sprite" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } };
expect(body.won).toBe(true);
expect(body.rewards.bountyDivinity).toBe(0);
});
it("unlocks upgrade rewards on win", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ upgradeRewards: [ "test_upgrade" ] }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
upgrades: [ { id: "test_upgrade", purchased: false, unlocked: false, target: "global", multiplier: 1 } ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; rewards: { upgradeIds: string[] } };
expect(body.won).toBe(true);
expect(body.rewards.upgradeIds).toContain("test_upgrade");
});
it("unlocks next zone boss when boss is defeated", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [
makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }),
makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 0 }),
],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("does not unlock next boss if consecration requirement not met", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [
makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }),
makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 5 }),
],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
consecration: makeConsecration({ count: 0 }),
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("unlocks goddess zone when boss and quest conditions are both met", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" },
],
quests: [ { id: "test_quest", status: "completed" } ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("does not unlock zone when quest condition is not satisfied", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" },
],
quests: [ { id: "test_quest", status: "active" } ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("unlocks zone when unlockQuestId is null", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: null },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("skips zone that is already unlocked", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "test_goddess_zone", status: "unlocked", unlockBossId: "test_goddess_boss", unlockQuestId: null },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("skips zone whose unlockBossId does not match the defeated boss", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
zones: [
{ id: "other_zone", status: "locked", unlockBossId: "different_boss", unlockQuestId: null },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("applies global upgrade multiplier to party DPS", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ],
disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ],
upgrades: [ { id: "global_u", purchased: true, target: "global", multiplier: 100_000 } ],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("applies disciple-specific upgrade multiplier", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ],
disciples: [ makeDisciple({ id: "test_disciple", combatPower: 1, count: 1, level: 10 }) ],
upgrades: [
{ id: "disciple_u", purchased: true, target: "disciple", multiplier: 100_000, discipleId: "test_disciple" },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("skips unpurchased upgrades", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [ makeGoddessBoss({ currentHp: 100_000, damagePerSecond: 1 }) ],
disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ],
upgrades: [
{ id: "not_bought", purchased: false, target: "global", multiplier: 100_000 },
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(false);
});
it("returns 200 with casualties when party loses", async () => {
const state = makeState({
goddess: makeGoddessState({
bosses: [
makeGoddessBoss({ currentHp: 1_000_000, maxHp: 1_000_000, damagePerSecond: 1_000_000 }),
],
disciples: [
makeDisciple({ combatPower: 1, count: 10, level: 1 }),
makeDisciple({ id: "zero_disciple", combatPower: 0, count: 0, level: 1 }),
],
}) as GameState["goddess"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean; casualties: Array<{ discipleId: string }> };
expect(body.won).toBe(false);
expect(Array.isArray(body.casualties)).toBe(true);
});
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeDefined();
delete process.env.ANTI_CHEAT_SECRET;
});
it("omits signature when ANTI_CHEAT_SECRET is not set", async () => {
delete process.env.ANTI_CHEAT_SECRET;
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { signature: string | undefined };
expect(body.signature).toBeUndefined();
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await challenge({ bossId: "test_goddess_boss" });
expect(res.status).toBe(500);
});
});
+193
View File
@@ -0,0 +1,193 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
const DISCORD_ID = "test_discord_id";
// prayer_amplifier requires: divine_petal×3, prayer_crystal×2; bonus: gold_income 1.1
const TEST_RECIPE_ID = "prayer_amplifier";
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
zones: [],
bosses: [],
quests: [],
disciples: [],
equipment: [],
upgrades: [],
achievements: [],
consecration: {
count: 0,
divinity: 0,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 0,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [],
materials: [
{ materialId: "divine_petal", quantity: 5 },
{ materialId: "prayer_crystal", quantity: 5 },
],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("goddessCraft route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessCraftRouter } = await import("../../src/routes/goddessCraft.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-craft", goddessCraftRouter);
});
const post = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-craft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when recipeId is missing", async () => {
const res = await post({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown recipe", async () => {
const res = await post({ recipeId: "nonexistent_recipe" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when recipe is already crafted", async () => {
const goddess = makeGoddessState();
goddess.exploration.craftedRecipeIds = [TEST_RECIPE_ID];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough material (first requirement)", async () => {
const goddess = makeGoddessState();
goddess.exploration.materials = [
{ materialId: "divine_petal", quantity: 1 }, // needs 3
{ materialId: "prayer_crystal", quantity: 5 },
];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when material is completely absent", async () => {
const goddess = makeGoddessState();
goddess.exploration.materials = []; // neither material present
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(400);
});
it("returns 200 with updated multipliers and materials on success", async () => {
const goddess = makeGoddessState();
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(200);
const body = await res.json() as {
recipeId: string;
bonusType: string;
bonusValue: number;
craftedPrayersMultiplier: number;
craftedDivinityMultiplier: number;
craftedCombatMultiplier: number;
materials: Array<{ materialId: string; quantity: number }>;
};
expect(body.recipeId).toBe(TEST_RECIPE_ID);
expect(body.bonusType).toBe("gold_income");
expect(body.bonusValue).toBe(1.1);
expect(body.craftedPrayersMultiplier).toBeGreaterThan(1);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post({ recipeId: TEST_RECIPE_ID });
expect(res.status).toBe(500);
});
});
+619
View File
@@ -0,0 +1,619 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState, GoddessExplorationArea } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
// Custom test areas exercising event types not present in the real data
const PRAYERS_LOSS_AREA: GoddessExplorationArea = {
description: "Test area for prayers_loss events",
durationSeconds: 1,
events: [
{ effect: { amount: 100, type: "prayers_loss" }, id: "test_prayers_loss", text: "You lost some prayers." },
],
id: "test_prayers_loss_area",
name: "Test Prayers Loss Area",
possibleMaterials: [],
zoneId: "goddess_celestial_garden",
};
const DIVINITY_GAIN_AREA: GoddessExplorationArea = {
description: "Test area for divinity_gain events",
durationSeconds: 1,
events: [
{ effect: { amount: 10, type: "divinity_gain" }, id: "test_divinity_gain", text: "You gained divinity." },
],
id: "test_divinity_gain_area",
name: "Test Divinity Gain Area",
possibleMaterials: [],
zoneId: "goddess_celestial_garden",
};
vi.mock("../../src/data/goddessExplorations.js", async (importOriginal) => {
const original = await importOriginal<typeof import("../../src/data/goddessExplorations.js")>();
return {
defaultGoddessExplorationAreas: [
...original.defaultGoddessExplorationAreas,
PRAYERS_LOSS_AREA,
DIVINITY_GAIN_AREA,
],
};
});
const DISCORD_ID = "test_discord_id";
// garden_glade: zoneId=goddess_celestial_garden, durationSeconds=30
// events[0]: prayers_gain 50; events[1]: disciple_loss 0.05
// possibleMaterials: divine_petal(weight 5), prayer_crystal(weight 3) — total 8
const TEST_AREA_ID = "garden_glade";
const TEST_ZONE_ID = "goddess_celestial_garden";
// celestial_meadow: durationSeconds=60
// events[0]: prayers_gain 200; events[1]: sacred_material_gain celestial_dust qty 2
const MATERIAL_AREA_ID = "celestial_meadow";
const makeGoddessState = (areaId: string, zoneStatus: "unlocked" | "locked" = "unlocked"): NonNullable<GameState["goddess"]> => ({
zones: [
{
id: TEST_ZONE_ID,
name: "Celestial Garden",
description: "",
emoji: "🌸",
status: zoneStatus,
unlockBossId: null,
unlockQuestId: null,
},
],
bosses: [],
quests: [],
disciples: [
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
],
equipment: [],
upgrades: [],
achievements: [],
consecration: {
count: 0,
divinity: 50,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 0,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [
{ id: areaId, status: "available" },
],
materials: [],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
/** Builds a state with the area in_progress and startedAt in the past so it's complete. */
const makeCompletedAreaState = (
areaId: string,
extraMaterials: Array<{ materialId: string; quantity: number }> = [],
extraPrayers = 0,
): GameState => {
const goddess = makeGoddessState(areaId);
goddess.exploration.areas = [{ id: areaId, status: "in_progress", startedAt: 0 }];
goddess.exploration.materials = extraMaterials;
return makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: extraPrayers } });
};
describe("goddessExplore route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessExploreRouter } = await import("../../src/routes/goddessExplore.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-explore", goddessExploreRouter);
});
afterEach(() => {
vi.restoreAllMocks();
});
const getClaimable = (areaId?: string) => {
const url = areaId === undefined
? "http://localhost/goddess-explore/claimable"
: `http://localhost/goddess-explore/claimable?areaId=${areaId}`;
return app.fetch(new Request(url));
};
const postStart = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-explore/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
const postCollect = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-explore/collect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
describe("GET /claimable", () => {
it("returns 400 when areaId is missing", async () => {
const res = await getClaimable();
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await getClaimable("nonexistent_area");
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(404);
});
it("returns claimable=false when goddess is undefined", async () => {
const state = makeState(); // no goddess
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when area is not in_progress", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// area status is "available" (not in_progress)
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when area not found in state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = []; // area missing entirely
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=false when exploration is not yet complete", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = now → not complete yet (duration is 30s)
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(false);
});
it("returns claimable=true when exploration is complete", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = 0 → expired long ago
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(200);
const body = await res.json() as { claimable: boolean };
expect(body.claimable).toBe(true);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await getClaimable(TEST_AREA_ID);
expect(res.status).toBe(500);
});
});
describe("POST /start", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postStart({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await postStart({ areaId: "nonexistent_area" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when zone is not unlocked", async () => {
const goddess = makeGoddessState(TEST_AREA_ID, "locked");
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when zone is not found in goddess state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.zones = []; // zone missing
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 404 when area is not found in state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = []; // area missing
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when another exploration is already in progress", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [
{ id: TEST_AREA_ID, status: "available" },
{ id: "other_area", status: "in_progress" },
];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when area is locked", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "locked" }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 200 with areaId and endsAt on success", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { areaId: string; endsAt: number };
expect(body.areaId).toBe(TEST_AREA_ID);
expect(body.endsAt).toBeGreaterThan(Date.now());
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postStart({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
describe("POST /collect", () => {
it("returns 400 when areaId is missing", async () => {
const res = await postCollect({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown area", async () => {
const res = await postCollect({ areaId: "nonexistent_area" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 404 when area is not found in state", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(404);
});
it("returns 400 when area is not in_progress", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// area is "available", not "in_progress"
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns 400 when exploration is not yet complete", async () => {
const goddess = makeGoddessState(TEST_AREA_ID);
// startedAt = now → still in progress for 30s
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(400);
});
it("returns foundNothing=true when Math.random is below 0.2 (nothing path)", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.1); // < 0.2 → nothing
const state = makeCompletedAreaState(TEST_AREA_ID);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { foundNothing: boolean; nothingMessage: string; materialsFound: unknown[] };
expect(body.foundNothing).toBe(true);
expect(typeof body.nothingMessage).toBe("string");
});
it("applies prayers_gain event and returns prayersChange > 0", async () => {
const mockRandom = vi.spyOn(Math, "random");
// garden_glade has 2 events: [prayers_gain(0), disciple_loss(1)]
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 2) = 0 → prayers_gain
// Call 3: possibleMaterials roll (total weight 8): 0 * 8 = 0, 0 - 5 = -5 ≤ 0 → divine_petal
// Call 4: quantity Math.floor(0 * (3-1+1)) + 1 = 0 + 1 = 1
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0)
.mockReturnValueOnce(0);
const state = makeCompletedAreaState(TEST_AREA_ID);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { foundNothing: boolean; event: { prayersChange: number }; materialsFound: Array<{ materialId: string }> };
expect(body.foundNothing).toBe(false);
expect(body.event.prayersChange).toBeGreaterThan(0);
});
it("applies sacred_material_gain event and pushes new material", async () => {
const mockRandom = vi.spyOn(Math, "random");
// celestial_meadow: events[1] = sacred_material_gain celestial_dust qty 2
// index 1: Math.floor(0.6 * 2) = 1
// possibleMaterials: [divine_petal(4), celestial_dust(3)] total 7
// Call 3: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal (new material)
// Call 4: Math.floor(0 * (4-2+1)) + 2 = 0 + 2 = 2
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event index: Math.floor(0.6 * 2) = 1 → sacred_material_gain
.mockReturnValueOnce(0) // possibleMaterials roll: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal
.mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 2 = 2
const state = makeCompletedAreaState(MATERIAL_AREA_ID); // no materials in state
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } }; materialsFound: Array<{ materialId: string }> };
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
});
it("increments existing material quantity on sacred_material_gain event", async () => {
const mockRandom = vi.spyOn(Math, "random");
// Same as above — celestial_meadow events[1] = sacred_material_gain celestial_dust
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event: sacred_material_gain
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
.mockReturnValueOnce(0); // quantity → 2
const state = makeCompletedAreaState(MATERIAL_AREA_ID, [
{ materialId: "celestial_dust", quantity: 5 }, // pre-existing
]);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
expect(body.event.materialGained?.quantity).toBeGreaterThan(0);
});
it("returns materialsFound with new material from possibleMaterials when none pre-existing", async () => {
const mockRandom = vi.spyOn(Math, "random");
// prayers_gain event, then roll for divine_petal (new)
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal (first, weight 5)
.mockReturnValueOnce(0); // quantity → min (1)
const state = makeCompletedAreaState(TEST_AREA_ID); // no materials
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { materialsFound: Array<{ materialId: string; quantity: number }> };
expect(body.materialsFound.length).toBeGreaterThan(0);
expect(body.materialsFound[0]?.materialId).toBe("divine_petal");
});
it("increments existing possibleMaterial quantity when material is already in state", async () => {
const mockRandom = vi.spyOn(Math, "random");
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
.mockReturnValueOnce(0); // quantity → 1
const state = makeCompletedAreaState(TEST_AREA_ID, [
{ materialId: "divine_petal", quantity: 10 }, // pre-existing
]);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
const body = await res.json() as { materialsFound: Array<{ materialId: string }> };
expect(body.materialsFound.some((m) => {
return m.materialId === "divine_petal";
})).toBe(true);
});
it("applies disciple_loss event and reduces disciple counts", async () => {
const mockRandom = vi.spyOn(Math, "random");
// garden_glade events[1] = disciple_loss fraction 0.05
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.6 * 2) = 1 → disciple_loss
// possibleMaterials: total weight 8; call 3: 0.9 * 8 = 7.2; 7.2 - 5 = 2.2 > 0; 2.2 - 3 = -0.8 ≤ 0 → prayer_crystal
// Call 4: quantity for prayer_crystal: Math.floor(0 * (2-1+1)) + 1 = 1
mockRandom
.mockReturnValueOnce(0.5) // nothing check: proceed
.mockReturnValueOnce(0.6) // event: Math.floor(0.6 * 2) = 1 → disciple_loss
.mockReturnValueOnce(0.9) // possibleMaterials roll → prayer_crystal
.mockReturnValueOnce(0); // quantity → 1
const goddess = makeGoddessState(TEST_AREA_ID);
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
// Need disciples with non-zero count so lost > 0 triggers
goddess.disciples = [
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
];
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(200);
});
it("applies prayers_loss event and returns negative prayersChange", async () => {
const mockRandom = vi.spyOn(Math, "random");
// test_prayers_loss_area has 1 event: prayers_loss 100
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 1) = 0 → prayers_loss
// No possibleMaterials, so no further calls needed
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1);
const goddess = makeGoddessState(PRAYERS_LOSS_AREA.id);
goddess.exploration.areas = [{ id: PRAYERS_LOSS_AREA.id, status: "in_progress", startedAt: 0 }];
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: PRAYERS_LOSS_AREA.id });
expect(res.status).toBe(200);
const body = await res.json() as { event: { prayersChange: number }; foundNothing: boolean };
expect(body.foundNothing).toBe(false);
expect(body.event.prayersChange).toBeLessThanOrEqual(0);
});
it("applies divinity_gain event and increases divinity", async () => {
const mockRandom = vi.spyOn(Math, "random");
// test_divinity_gain_area has 1 event: divinity_gain 10
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
// Call 2: event index Math.floor(0.1 * 1) = 0 → divinity_gain
// No possibleMaterials
mockRandom
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.1);
const goddess = makeGoddessState(DIVINITY_GAIN_AREA.id);
goddess.exploration.areas = [{ id: DIVINITY_GAIN_AREA.id, status: "in_progress", startedAt: 0 }];
const initialDivinity = goddess.consecration.divinity;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await postCollect({ areaId: DIVINITY_GAIN_AREA.id });
expect(res.status).toBe(200);
// Verify state was saved with updated divinity
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
data: { state: { goddess: { consecration: { divinity: number } } } };
};
expect(updateArg.data.state.goddess.consecration.divinity).toBe(initialDivinity + 10);
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await postCollect({ areaId: TEST_AREA_ID });
expect(res.status).toBe(500);
});
});
});
+255
View File
@@ -0,0 +1,255 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
/* eslint-disable max-lines -- Test suites naturally have many cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({
prisma: {
gameState: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("../../src/middleware/auth.js", () => ({
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
c.set("discordId", "test_discord_id");
await next();
}),
}));
const DISCORD_ID = "test_discord_id";
// prayer_offering_1 costs 50 prayers, 0 divinity, 0 stardust; unlocked: true
const TEST_UPGRADE_ID = "prayer_offering_1";
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
zones: [],
bosses: [],
quests: [],
disciples: [],
equipment: [],
upgrades: [
{
id: TEST_UPGRADE_ID,
name: "Morning Offering I",
description: "",
target: "prayers",
multiplier: 1.25,
costPrayers: 50,
costDivinity: 0,
costStardust: 0,
purchased: false,
unlocked: true,
},
],
achievements: [],
consecration: {
count: 0,
divinity: 100,
productionMultiplier: 1,
purchasedUpgradeIds: [],
},
enlightenment: {
count: 0,
stardust: 100,
purchasedUpgradeIds: [],
stardustPrayersMultiplier: 1,
stardustCombatMultiplier: 1,
stardustConsecrationThresholdMultiplier: 1,
stardustConsecrationDivinityMultiplier: 1,
stardustMetaMultiplier: 1,
},
exploration: {
areas: [],
materials: [],
craftedRecipeIds: [],
craftedPrayersMultiplier: 1,
craftedDivinityMultiplier: 1,
craftedCombatMultiplier: 1,
},
totalPrayersEarned: 0,
lifetimePrayersEarned: 0,
lifetimeBossesDefeated: 0,
lifetimeQuestsCompleted: 0,
baseClickPower: 1,
lastTickAt: 0,
});
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 },
adventurers: [],
upgrades: [],
quests: [],
bosses: [],
equipment: [],
achievements: [],
zones: [],
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
companions: { unlockedCompanionIds: [], activeCompanionId: null },
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
baseClickPower: 1,
lastTickAt: 0,
schemaVersion: 1,
...overrides,
} as GameState);
describe("goddessUpgrade route", () => {
let app: Hono;
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
beforeEach(async () => {
vi.clearAllMocks();
const { goddessUpgradeRouter } = await import("../../src/routes/goddessUpgrade.js");
const { prisma: p } = await import("../../src/db/client.js");
prisma = p as typeof prisma;
app = new Hono();
app.route("/goddess-upgrade", goddessUpgradeRouter);
});
const post = (body: Record<string, unknown>) =>
app.fetch(new Request("http://localhost/goddess-upgrade/buy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
it("returns 400 when upgradeId is missing", async () => {
const res = await post({});
expect(res.status).toBe(400);
});
it("returns 404 for unknown upgrade", async () => {
const res = await post({ upgradeId: "nonexistent_upgrade" });
expect(res.status).toBe(404);
});
it("returns 404 when no save is found", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when goddess is undefined", async () => {
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 404 when upgrade is not found in goddess state", async () => {
const goddess = makeGoddessState();
goddess.upgrades = []; // no upgrades in state
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(404);
});
it("returns 400 when upgrade is not yet unlocked", async () => {
const goddess = makeGoddessState();
goddess.upgrades[0]!.unlocked = false;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when upgrade is already purchased", async () => {
const goddess = makeGoddessState();
goddess.upgrades[0]!.purchased = true;
const state = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when prayers is undefined (treats as 0)", async () => {
const goddess = makeGoddessState();
// Omitting prayers entirely exercises the `?? 0` fallback on line 75
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough prayers", async () => {
const goddess = makeGoddessState();
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 10 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(400);
});
it("returns 400 when not enough divinity", async () => {
// prayer_offering_3 costs 1 divinity
const upgradeId = "prayer_offering_3";
const goddess = makeGoddessState();
goddess.upgrades.push({
id: upgradeId,
name: "Morning Offering III",
description: "",
target: "prayers",
multiplier: 2,
costPrayers: 1000,
costDivinity: 1,
costStardust: 0,
purchased: false,
unlocked: true,
});
goddess.consecration.divinity = 0; // need 1 but have 0
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 5000 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId });
expect(res.status).toBe(400);
});
it("returns 400 when not enough stardust", async () => {
// divine_spark_2 is in defaultGoddessUpgrades with costStardust: 1, costDivinity: 100, costPrayers: 500_000
const upgradeId = "divine_spark_2";
const goddess = makeGoddessState();
goddess.upgrades.push({
id: upgradeId,
name: "Divine Spark II",
description: "",
target: "prayers",
multiplier: 25,
costPrayers: 500_000,
costDivinity: 100,
costStardust: 1,
purchased: false,
unlocked: true,
});
goddess.consecration.divinity = 100; // enough divinity
goddess.enlightenment.stardust = 0; // NOT enough stardust (need 1)
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 500_000 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await post({ upgradeId });
expect(res.status).toBe(400);
});
it("returns 200 with remaining resources on success", async () => {
const goddess = makeGoddessState();
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(200);
const body = await res.json() as { prayersRemaining: number; divinityRemaining: number; stardustRemaining: number };
expect(body.prayersRemaining).toBe(150); // 200 - 50
expect(body.divinityRemaining).toBe(100); // no divinity cost
expect(body.stardustRemaining).toBe(100); // no stardust cost
});
it("returns 500 when the database throws an Error", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(500);
});
it("returns 500 when the database throws a non-Error value", async () => {
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
const res = await post({ upgradeId: TEST_UPGRADE_ID });
expect(res.status).toBe(500);
});
});
+1 -5
View File
@@ -10,11 +10,7 @@ export default defineConfig({
"src/db/client.ts",
"src/index.ts",
"src/data/materials.ts",
// Goddess expansion data filesexcluded until goddess routes import them in a later chunk
"src/data/goddessConsecrationUpgrades.ts",
"src/data/goddessCrafting.ts",
"src/data/goddessEnlightenmentUpgrades.ts",
"src/data/goddessEquipmentSets.ts",
// Goddess materials data file — not directly imported by any route (referenced by ID strings only)
"src/data/goddessMaterials.ts",
],
thresholds: {
+15 -11
View File
@@ -33,18 +33,22 @@ Branch: `feat/goddess`
- NOTE: All data files excluded from coverage until Chunk 4 routes import them
- Lint ✅ · Build ✅ · Tests ✅ (100% coverage)
## Chunk 3 — Sync / Sanitize
- [ ] Update `validateAndSanitize` to inject goddess state defaults for existing saves
- [ ] Update force-sync (`syncNewContent`) to inject missing goddess fields
- [ ] Add apotheosis unlock flag handling
## Chunk 3 — Sync / Sanitize ✅ COMPLETE
- [x] Update `validateAndSanitize` to inject goddess state defaults for existing saves
- [x] Update force-sync (`syncNewContent`) to inject missing goddess fields
- [x] Add apotheosis unlock flag handling
- Lint ✅ · Build ✅ · Tests ✅ (100% coverage)
## Chunk 4 — API Routes
- [ ] Goddess boss fight route
- [ ] Consecration (goddess prestige) route
- [ ] Enlightenment (goddess transcendence) route
- [ ] Goddess upgrade purchase route
- [ ] Goddess crafting route
- [ ] Goddess exploration route
## Chunk 4 — API Routes ✅ COMPLETE
- [x] Goddess boss fight route (`goddessBoss.ts`)
- [x] Consecration (goddess prestige) route (`consecration.ts`)
- [x] Enlightenment (goddess transcendence) route (`enlightenment.ts`)
- [x] Goddess upgrade purchase route (`goddessUpgrade.ts`)
- [x] Goddess crafting route (`goddessCraft.ts`)
- [x] Goddess exploration route (`goddessExplore.ts`)
- [x] Services: `consecration.ts`, `enlightenment.ts`
- [x] Tests for all 6 routes (100% coverage)
- Lint ✅ · Build ✅ · Tests ✅ (100% coverage)
## Chunk 5 — UI: Resource Bar + Mode/Tab Nav
- [ ] Add goddess currencies to resource bar dropdown (greyed pre-apotheosis)
+20
View File
@@ -49,12 +49,22 @@ export type {
AuthResponse,
BossChallengeRequest,
BossChallengeResponse,
BuyConsecrationUpgradeRequest,
BuyConsecrationUpgradeResponse,
BuyEchoUpgradeRequest,
BuyEchoUpgradeResponse,
BuyEnlightenmentUpgradeRequest,
BuyEnlightenmentUpgradeResponse,
BuyGoddessUpgradeRequest,
BuyGoddessUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
ConsecrationRequest,
ConsecrationResponse,
CraftRecipeRequest,
CraftRecipeResponse,
EnlightenmentRequest,
EnlightenmentResponse,
ExploreClaimableResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
@@ -63,6 +73,16 @@ export type {
ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease,
GoddessBossChallengeRequest,
GoddessBossChallengeResponse,
GoddessCraftRequest,
GoddessCraftResponse,
GoddessExploreClaimableResponse,
GoddessExploreCollectEventResult,
GoddessExploreCollectRequest,
GoddessExploreCollectResponse,
GoddessExploreStartRequest,
GoddessExploreStartResponse,
LeaderboardCategory,
LeaderboardEntry,
LeaderboardResponse,
+204
View File
@@ -564,12 +564,196 @@ interface SyncNewContentResponse {
*/
zonesPatched: number;
/**
* Number of goddess achievements added to the save.
*/
goddessAchievementsAdded: number;
/**
* Number of goddess bosses added to the save.
*/
goddessBossesAdded: number;
/**
* Number of goddess disciples added to the save.
*/
goddessDiscipesAdded: number;
/**
* Number of goddess equipment items added to the save.
*/
goddessEquipmentAdded: number;
/**
* Number of goddess exploration areas added to the save.
*/
goddessExplorationAreasAdded: number;
/**
* Number of goddess quests added to the save.
*/
goddessQuestsAdded: number;
/**
* Number of goddess upgrades added to the save.
*/
goddessUpgradesAdded: number;
/**
* Number of goddess zones added to the save.
*/
goddessZonesAdded: number;
/**
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
*/
signature?: string;
}
interface GoddessBossChallengeRequest {
bossId: string;
}
interface GoddessBossChallengeResponse {
won: boolean;
partyDPS: number;
bossDPS: number;
bossHpBefore: number;
bossMaxHp: number;
bossHpAtBattleEnd: number;
bossNewHp: number;
partyMaxHp: number;
partyHpRemaining: number;
rewards?: {
prayers: number;
divinity: number;
stardust: number;
upgradeIds: Array<string>;
equipmentIds: Array<string>;
/**
* One-time divinity bounty awarded for defeating this boss for the very first time.
*/
bountyDivinity: number;
};
casualties?: Array<{
discipleId: string;
killed: number;
}>;
/**
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
*/
signature?: string;
}
type ConsecrationRequest = Record<string, never>;
interface ConsecrationResponse {
/**
* Divinity earned from this consecration.
*/
divinityEarned: number;
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newConsecrationCount: number;
}
interface BuyConsecrationUpgradeRequest {
upgradeId: string;
}
interface BuyConsecrationUpgradeResponse {
divinityRemaining: number;
purchasedUpgradeIds: Array<string>;
divinityPrayersMultiplier: number;
divinityDisciplesMultiplier: number;
divinityCombatMultiplier: number;
}
type EnlightenmentRequest = Record<string, never>;
interface EnlightenmentResponse {
/**
* Stardust earned from this enlightenment.
*/
stardustEarned: number;
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
newEnlightenmentCount: number;
}
interface BuyEnlightenmentUpgradeRequest {
upgradeId: string;
}
interface BuyEnlightenmentUpgradeResponse {
stardustRemaining: number;
purchasedUpgradeIds: Array<string>;
stardustPrayersMultiplier: number;
stardustCombatMultiplier: number;
stardustConsecrationThresholdMultiplier: number;
stardustConsecrationDivinityMultiplier: number;
stardustMetaMultiplier: number;
}
interface BuyGoddessUpgradeRequest {
upgradeId: string;
}
interface BuyGoddessUpgradeResponse {
prayersRemaining: number;
divinityRemaining: number;
stardustRemaining: number;
}
interface GoddessCraftRequest {
recipeId: string;
}
interface GoddessCraftResponse {
recipeId: string;
bonusType: string;
bonusValue: number;
craftedPrayersMultiplier: number;
craftedDivinityMultiplier: number;
craftedCombatMultiplier: number;
materials: Array<{ materialId: string; quantity: number }>;
}
interface GoddessExploreStartRequest {
areaId: string;
}
interface GoddessExploreStartResponse {
areaId: string;
endsAt: number;
}
interface GoddessExploreCollectRequest {
areaId: string;
}
interface GoddessExploreCollectEventResult {
text: string;
prayersChange: number;
discipleLostCount: number;
materialGained: { materialId: string; quantity: number } | null;
}
interface GoddessExploreCollectResponse {
foundNothing: boolean;
nothingMessage?: string;
materialsFound: Array<{ materialId: string; quantity: number }>;
event: GoddessExploreCollectEventResult | null;
}
interface GoddessExploreClaimableResponse {
claimable: boolean;
}
export type {
AboutResponse,
ApiError,
@@ -578,12 +762,22 @@ export type {
AuthResponse,
BossChallengeRequest,
BossChallengeResponse,
BuyConsecrationUpgradeRequest,
BuyConsecrationUpgradeResponse,
BuyEchoUpgradeRequest,
BuyEchoUpgradeResponse,
BuyEnlightenmentUpgradeRequest,
BuyEnlightenmentUpgradeResponse,
BuyGoddessUpgradeRequest,
BuyGoddessUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
ConsecrationRequest,
ConsecrationResponse,
CraftRecipeRequest,
CraftRecipeResponse,
EnlightenmentRequest,
EnlightenmentResponse,
ExploreClaimableResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
@@ -592,6 +786,16 @@ export type {
ExploreStartResponse,
ForceUnlocksResponse,
GiteaRelease,
GoddessBossChallengeRequest,
GoddessBossChallengeResponse,
GoddessCraftRequest,
GoddessCraftResponse,
GoddessExploreClaimableResponse,
GoddessExploreCollectEventResult,
GoddessExploreCollectRequest,
GoddessExploreCollectResponse,
GoddessExploreStartRequest,
GoddessExploreStartResponse,
LeaderboardCategory,
LeaderboardEntry,
LeaderboardResponse,