generated from nhcarrigan/template
feat: another balance and bug fix pass (#238)
Working through open issues — fixes, balance changes, and features. ## Closed - Closes #161 - Closes #181 - Closes #191 - Closes #199 - Closes #201 - Closes #202 - Closes #203 - Closes #204 - Closes #205 - Closes #206 - Closes #208 - Closes #211 - Closes #212 - Closes #213 - Closes #214 - Closes #216 - Closes #219 - Closes #220 - Closes #221 - Closes #222 - Closes #224 - Closes #225 - Closes #226 - Closes #228 - Closes #229 - Closes #230 - Closes #231 - Closes #232 - Closes #233 - Closes #234 - Closes #235 - Closes #236 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #238 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #238.
This commit is contained in:
@@ -334,8 +334,8 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 112, type: "questsCompleted" },
|
||||
description: "Complete all 112 quests across the known multiverse.",
|
||||
condition: { amount: 122, type: "questsCompleted" },
|
||||
description: "Complete all 122 quests across the known multiverse.",
|
||||
icon: "🌌",
|
||||
id: "quest_eternal",
|
||||
name: "Quest Eternal",
|
||||
|
||||
@@ -77,7 +77,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
combatPowerRequired: 500,
|
||||
description:
|
||||
"A rogue necromancer has raised an army of skeletons near the city. Silence him before the dead overrun us.",
|
||||
durationSeconds: 5 * 60,
|
||||
durationSeconds: 30 * 60,
|
||||
id: "necromancer_tower",
|
||||
name: "Necromancer's Tower",
|
||||
prerequisiteIds: [],
|
||||
@@ -94,7 +94,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
combatPowerRequired: 2000,
|
||||
description:
|
||||
"An ancient fortress still garrisoned by constructs who don't know the war ended. Clear it out and claim its vaults.",
|
||||
durationSeconds: 5 * 60,
|
||||
durationSeconds: 45 * 60,
|
||||
id: "crumbling_fortress",
|
||||
name: "The Crumbling Fortress",
|
||||
prerequisiteIds: [ "necromancer_tower" ],
|
||||
@@ -111,7 +111,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
combatPowerRequired: 8000,
|
||||
description:
|
||||
"A vast library sealed for centuries whose contents have warped and grown hostile. The knowledge within is priceless.",
|
||||
durationSeconds: 10 * 60,
|
||||
durationSeconds: 60 * 60,
|
||||
id: "cursed_library",
|
||||
name: "The Cursed Library",
|
||||
prerequisiteIds: [ "crumbling_fortress" ],
|
||||
@@ -127,7 +127,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
combatPowerRequired: 30_000,
|
||||
description:
|
||||
"The legendary lair of Pyraxis the Undying. Few who enter return — those who do are rich beyond imagining.",
|
||||
durationSeconds: 15 * 60,
|
||||
durationSeconds: 90 * 60,
|
||||
id: "dragon_lair",
|
||||
name: "Dragon's Lair",
|
||||
prerequisiteIds: [ "cursed_library" ],
|
||||
@@ -545,7 +545,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 3_000_000_000_000, type: "gold" },
|
||||
{ amount: 1_500_000_000, type: "essence" },
|
||||
{ amount: 12_000_000, type: "crystals" },
|
||||
{ amount: 0, type: "crystals" },
|
||||
],
|
||||
status: "locked",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -561,7 +561,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 10_000_000_000_000, type: "gold" },
|
||||
{ amount: 5_000_000_000, type: "essence" },
|
||||
{ amount: 30_000_000, type: "crystals" },
|
||||
{ amount: 0, type: "crystals" },
|
||||
],
|
||||
status: "locked",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -577,7 +577,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 30_000_000_000_000, type: "gold" },
|
||||
{ amount: 15_000_000_000, type: "essence" },
|
||||
{ amount: 60_000_000, type: "crystals" },
|
||||
{ amount: 0, type: "crystals" },
|
||||
],
|
||||
status: "locked",
|
||||
zoneId: "abyssal_trench",
|
||||
@@ -593,7 +593,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 100_000_000_000_000, type: "gold" },
|
||||
{ amount: 50_000_000_000, type: "essence" },
|
||||
{ amount: 120_000_000, type: "crystals" },
|
||||
{ amount: 0, type: "crystals" },
|
||||
{ targetId: "abyss_diver_1", type: "upgrade" },
|
||||
],
|
||||
status: "locked",
|
||||
@@ -610,7 +610,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 400_000_000_000_000, type: "gold" },
|
||||
{ amount: 200_000_000_000, type: "essence" },
|
||||
{ amount: 400_000_000, type: "crystals" },
|
||||
{ amount: 0, type: "crystals" },
|
||||
],
|
||||
status: "locked",
|
||||
zoneId: "abyssal_trench",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { gameRouter } from "./routes/game.js";
|
||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||
import { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
import { timersRouter } from "./routes/timers.js";
|
||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||
import { connectGateway } from "./services/gateway.js";
|
||||
import { logger } from "./services/logger.js";
|
||||
@@ -49,6 +50,7 @@ app.route("/transcendence", transcendenceRouter);
|
||||
app.route("/apotheosis", apotheosisRouter);
|
||||
app.route("/leaderboards", leaderboardRouter);
|
||||
app.route("/profile", profileRouter);
|
||||
app.route("/timers", timersRouter);
|
||||
|
||||
app.get("/health", (context) => {
|
||||
return context.json({ status: "ok" });
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Hono } from "hono";
|
||||
import { defaultBosses } from "../data/bosses.js";
|
||||
import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
@@ -205,9 +206,11 @@ bossRouter.post("/challenge", async(context) => {
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
const crystalMult = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||
state.resources.gold = state.resources.gold + boss.goldReward;
|
||||
state.resources.essence = state.resources.essence + boss.essenceReward;
|
||||
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
||||
const crystalAward = boss.crystalReward * crystalMult;
|
||||
state.resources.crystals = state.resources.crystals + crystalAward;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
@@ -282,6 +285,19 @@ bossRouter.post("/challenge", async(context) => {
|
||||
continue;
|
||||
}
|
||||
zone.status = "unlocked";
|
||||
|
||||
// Unlock exploration areas for the newly unlocked zone
|
||||
for (const area of state.exploration?.areas ?? []) {
|
||||
const areaDefinition = defaultExplorations.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
|
||||
area.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
const updatedZoneBosses = state.bosses.filter((b) => {
|
||||
return b.zoneId === zone.id;
|
||||
});
|
||||
@@ -323,7 +339,7 @@ bossRouter.post("/challenge", async(context) => {
|
||||
|
||||
rewards = {
|
||||
bountyRunestones: bountyRunestones,
|
||||
crystals: boss.crystalReward,
|
||||
crystals: crystalAward,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
essence: boss.essenceReward,
|
||||
gold: boss.goldReward,
|
||||
|
||||
@@ -191,17 +191,17 @@ exploreRouter.post("/start", async(context) => {
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||
const response: ExploreStartResponse = {
|
||||
areaId,
|
||||
endsAt,
|
||||
|
||||
@@ -546,6 +546,17 @@ const validateAndSanitize = (
|
||||
? previous.prestige
|
||||
: incoming.prestige;
|
||||
|
||||
/*
|
||||
* If the DB prestige count is higher than the client's, the client is sending a
|
||||
* stale pre-prestige save. Discard its upgrades (which have purchased: true) in
|
||||
* favour of the DB's post-prestige upgrades (purchased: false) so that upgrade
|
||||
* multipliers cannot persist across prestige via a race-condition auto-save.
|
||||
*/
|
||||
const upgrades
|
||||
= incoming.prestige.count < previous.prestige.count
|
||||
? previous.upgrades
|
||||
: incoming.upgrades;
|
||||
|
||||
/*
|
||||
* Echoes are only granted server-side via transcendence and can only decrease between
|
||||
* saves (spent on echo upgrades). Cap at the previous value to block inflation.
|
||||
@@ -611,11 +622,17 @@ const validateAndSanitize = (
|
||||
= Math.min(material.quantity, previousQuantity);
|
||||
return { ...material, quantity: cappedQuantity };
|
||||
});
|
||||
const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter(
|
||||
(recipeId) => {
|
||||
return previousExploration.craftedRecipeIds.includes(recipeId);
|
||||
},
|
||||
);
|
||||
|
||||
/*
|
||||
* Merge crafted recipe IDs from both states so the list can only ever grow.
|
||||
* A stale auto-save arriving after a craft must not silently un-craft items.
|
||||
*/
|
||||
const craftedRecipeIds = [
|
||||
...new Set([
|
||||
...previousExploration.craftedRecipeIds,
|
||||
...incoming.exploration.craftedRecipeIds,
|
||||
]),
|
||||
];
|
||||
explorationSpread = {
|
||||
exploration: {
|
||||
...incoming.exploration,
|
||||
@@ -671,6 +688,7 @@ const validateAndSanitize = (
|
||||
prestige,
|
||||
quests,
|
||||
resources,
|
||||
upgrades,
|
||||
...transcendenceSpread,
|
||||
...apotheosisSpread,
|
||||
...explorationSpread,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
buildPostPrestigeState,
|
||||
calculatePrestigeThreshold,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
} from "../services/prestige.js";
|
||||
@@ -40,10 +41,15 @@ prestigeRouter.post("/", async(context) => {
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!isEligibleForPrestige(state)) {
|
||||
const thresholdMultiplier
|
||||
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||
const required = calculatePrestigeThreshold(
|
||||
state.prestige.count,
|
||||
thresholdMultiplier,
|
||||
);
|
||||
return context.json(
|
||||
{
|
||||
// eslint-disable-next-line stylistic/max-len -- Error message cannot be shortened
|
||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||
error: `Not eligible for prestige — collect ${required.toLocaleString()} total gold first`,
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @file Public read-only timer API for external tooling (bots, automations, etc.).
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const timersRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
const explorationNameMap = new Map(
|
||||
defaultExplorations.map((area) => {
|
||||
return [ area.id, area.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Extracts active quest timers from a game state.
|
||||
* @param state - The player's game state.
|
||||
* @param now - The current timestamp in milliseconds.
|
||||
* @returns An array of active quest timer objects.
|
||||
*/
|
||||
const getQuestTimers = (
|
||||
state: GameState,
|
||||
now: number,
|
||||
): Array<{
|
||||
endsAt: number;
|
||||
name: string;
|
||||
questId: string;
|
||||
timeLeft: number;
|
||||
}> => {
|
||||
return state.quests.
|
||||
filter((quest) => {
|
||||
return quest.status === "active" && quest.startedAt !== undefined;
|
||||
}).
|
||||
map((quest) => {
|
||||
const durationMs = quest.durationSeconds * 1000;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const endsAt = (quest.startedAt ?? 0) + durationMs;
|
||||
return {
|
||||
endsAt: endsAt,
|
||||
name: quest.name,
|
||||
questId: quest.id,
|
||||
timeLeft: Math.max(0, endsAt - now),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts active exploration timers from a game state.
|
||||
* @param state - The player's game state.
|
||||
* @param now - The current timestamp in milliseconds.
|
||||
* @returns An array of active exploration timer objects.
|
||||
*/
|
||||
const getExplorationTimers = (
|
||||
state: GameState,
|
||||
now: number,
|
||||
): Array<{
|
||||
areaId: string;
|
||||
endsAt: number;
|
||||
name: string;
|
||||
timeLeft: number;
|
||||
}> => {
|
||||
return (state.exploration?.areas ?? []).
|
||||
filter((area) => {
|
||||
return area.status === "in_progress" && area.endsAt !== undefined;
|
||||
}).
|
||||
map((area) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const endsAt = area.endsAt ?? 0;
|
||||
return {
|
||||
areaId: area.id,
|
||||
endsAt: endsAt,
|
||||
name: explorationNameMap.get(area.id) ?? area.id,
|
||||
timeLeft: Math.max(0, endsAt - now),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns active quest and exploration timers for a given player.
|
||||
* This endpoint is public and read-only — no authentication required.
|
||||
* Rate limiting is enforced at the infrastructure level.
|
||||
*/
|
||||
timersRouter.get("/:userId", async(context) => {
|
||||
try {
|
||||
const { userId } = context.req.param();
|
||||
|
||||
if (userId.length === 0 || !/^\d+$/u.test(userId)) {
|
||||
return context.json({ error: "Invalid user ID" }, 400);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({
|
||||
where: { discordId: userId },
|
||||
});
|
||||
|
||||
if (record === null) {
|
||||
return context.json({ error: "Player not found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
const now = Date.now();
|
||||
|
||||
return context.json({
|
||||
explorations: getExplorationTimers(state, now),
|
||||
quests: getQuestTimers(state, now),
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"timers",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { timersRouter };
|
||||
@@ -28,8 +28,8 @@ const maxBaseRunestones = 200;
|
||||
|
||||
/**
|
||||
* Calculates the gold threshold required for the next prestige.
|
||||
* Formula: BASE * (count + 1)^2 — polynomial growth that peaks around prestige 8–10
|
||||
* then gets easier as the production multiplier overtakes it.
|
||||
* Formula: BASE * (count + 1)^2.5 — steeper growth to keep late prestiges
|
||||
* meaningful even as the production multiplier scales.
|
||||
* @param prestigeCount - The current number of prestiges completed.
|
||||
* @param thresholdMultiplier - An optional echo-upgrade multiplier applied to the threshold.
|
||||
* @returns The gold amount required to prestige.
|
||||
@@ -40,7 +40,7 @@ const calculatePrestigeThreshold = (
|
||||
): number => {
|
||||
return (
|
||||
basePrestigeGoldThreshold
|
||||
* Math.pow(prestigeCount + 1, 2)
|
||||
* Math.pow(prestigeCount + 1, 2.5)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
@@ -189,6 +189,7 @@ const buildPostPrestigeState = (
|
||||
} => {
|
||||
const {
|
||||
autoPrestigeEnabled,
|
||||
autoPrestigeMaxRunestonesOnly,
|
||||
count: currentPrestigeCount,
|
||||
purchasedUpgradeIds,
|
||||
runestones: currentRunestones,
|
||||
@@ -215,6 +216,9 @@ const buildPostPrestigeState = (
|
||||
...autoPrestigeEnabled === undefined
|
||||
? {}
|
||||
: { autoPrestigeEnabled },
|
||||
...autoPrestigeMaxRunestonesOnly === undefined
|
||||
? {}
|
||||
: { autoPrestigeMaxRunestonesOnly },
|
||||
};
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
|
||||
@@ -294,6 +294,52 @@ describe("boss route", () => {
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("handles zone unlock gracefully when exploration state is undefined", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||
quests: [],
|
||||
exploration: undefined,
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks exploration areas when a zone is unlocked on boss defeat", async () => {
|
||||
const state = makeState({
|
||||
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
||||
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
||||
quests: [],
|
||||
exploration: {
|
||||
areas: [{ id: "test_area", status: "locked" as const }],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
let savedState: GameState | undefined;
|
||||
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
|
||||
savedState = (args as { data: { state: GameState } }).data.state;
|
||||
return {} as never;
|
||||
});
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
// Exploration area should remain locked — no matching defaultExploration for "test_area"
|
||||
const area = savedState?.exploration?.areas.find((a) => a.id === "test_area");
|
||||
expect(area?.status).toBe("locked");
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await challenge({ bossId: "test_boss" });
|
||||
|
||||
@@ -246,6 +246,22 @@ describe("explore route", () => {
|
||||
expect(body.endsAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("persists endsAt to the DB state on exploration start", async () => {
|
||||
const state = makeState();
|
||||
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 };
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0]?.[0];
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test accesses nested mock data */
|
||||
const savedState = (updateCall?.data as { state?: { exploration?: { areas?: Array<{ id: string; endsAt?: number }> } } }).state;
|
||||
const savedArea = savedState?.exploration?.areas?.find((a) => {
|
||||
return a.id === TEST_AREA_ID;
|
||||
});
|
||||
expect(savedArea?.endsAt).toBe(body.endsAt);
|
||||
});
|
||||
|
||||
it("backfills exploration state for old saves without exploration", async () => {
|
||||
const state = makeState({ exploration: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
|
||||
@@ -477,6 +477,28 @@ describe("game route", () => {
|
||||
expect(body.savedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("restores previous upgrades when incoming prestige count is lower (stale post-prestige save)", async () => {
|
||||
const prevUpgrades = [
|
||||
{ id: "click_1", purchased: false, unlocked: true, target: "click", multiplier: 2 },
|
||||
] as GameState["upgrades"];
|
||||
const prevState = makeState({
|
||||
prestige: { count: 1, runestones: 10, productionMultiplier: 1.3, purchasedUpgradeIds: [] },
|
||||
upgrades: prevUpgrades,
|
||||
});
|
||||
const incomingState = makeState({
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
upgrades: [
|
||||
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 2 },
|
||||
] as GameState["upgrades"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("validates companion when active companion is legitimately unlocked", async () => {
|
||||
const prevState = makeState();
|
||||
const stateWithCompanion = makeState({
|
||||
|
||||
@@ -81,6 +81,16 @@ describe("prestige route", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 with echoPrestigeThresholdMultiplier applied when transcendence is present", async () => {
|
||||
const state = makeState({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 500_000, totalClicks: 0, characterName: "T" },
|
||||
transcendence: { count: 1, echoes: 0, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 2, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns runestones on successful prestige", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable max-lines-per-function -- 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";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const makeState = (overrides: Record<string, unknown> = {}) => ({
|
||||
quests: [],
|
||||
exploration: { areas: [] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("timers route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { timersRouter } = await import("../../src/routes/timers.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/timers", timersRouter);
|
||||
});
|
||||
|
||||
const get = (userId: string) =>
|
||||
app.fetch(new Request(`http://localhost/timers/${userId}`));
|
||||
|
||||
it("returns 400 for a non-numeric user ID", async () => {
|
||||
const res = await get("not-a-number");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Invalid user ID");
|
||||
});
|
||||
|
||||
it("returns 404 when player is not found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Player not found");
|
||||
});
|
||||
|
||||
it("returns empty arrays when no active quests or explorations", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({
|
||||
state: makeState(),
|
||||
});
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { quests: unknown[]; explorations: unknown[] };
|
||||
expect(body.quests).toEqual([]);
|
||||
expect(body.explorations).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns active quest timers with endsAt computed from startedAt + duration", async () => {
|
||||
const startedAt = Date.now() - 30_000;
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{
|
||||
id: "q1",
|
||||
name: "Forest Patrol",
|
||||
status: "active",
|
||||
startedAt: startedAt,
|
||||
durationSeconds: 600,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
quests: Array<{ questId: string; name: string; endsAt: number; timeLeft: number }>;
|
||||
};
|
||||
expect(body.quests).toHaveLength(1);
|
||||
expect(body.quests[0]?.questId).toBe("q1");
|
||||
expect(body.quests[0]?.name).toBe("Forest Patrol");
|
||||
expect(body.quests[0]?.endsAt).toBe(startedAt + 600_000);
|
||||
expect(body.quests[0]?.timeLeft).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("filters out quests that are not in_progress", async () => {
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{ id: "q1", name: "Done Quest", status: "completed", startedAt: 0, durationSeconds: 60 },
|
||||
{ id: "q2", name: "Idle Quest", status: "available", durationSeconds: 60 },
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as { quests: unknown[] };
|
||||
expect(body.quests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns timeLeft of 0 for already-completed quests still marked in_progress", async () => {
|
||||
const startedAt = Date.now() - 700_000;
|
||||
const state = makeState({
|
||||
quests: [
|
||||
{
|
||||
id: "q1",
|
||||
name: "Old Quest",
|
||||
status: "active",
|
||||
startedAt: startedAt,
|
||||
durationSeconds: 600,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as {
|
||||
quests: Array<{ timeLeft: number }>;
|
||||
};
|
||||
expect(body.quests[0]?.timeLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("returns active exploration timers", async () => {
|
||||
const endsAt = Date.now() + 120_000;
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: "verdant_meadows", status: "in_progress", endsAt },
|
||||
{ id: "unknown_area_xyz", status: "in_progress", endsAt },
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as {
|
||||
explorations: Array<{ areaId: string; name: string; endsAt: number; timeLeft: number }>;
|
||||
};
|
||||
expect(body.explorations).toHaveLength(2);
|
||||
expect(body.explorations[0]?.areaId).toBe("verdant_meadows");
|
||||
expect(body.explorations[0]?.endsAt).toBe(endsAt);
|
||||
expect(body.explorations[0]?.timeLeft).toBeGreaterThan(0);
|
||||
// Unknown area falls back to ID as name
|
||||
expect(body.explorations[1]?.name).toBe("unknown_area_xyz");
|
||||
});
|
||||
|
||||
it("filters out explorations not in_progress", async () => {
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: "area1", status: "available" },
|
||||
{ id: "area2", status: "completed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
const body = await res.json() as { explorations: unknown[] };
|
||||
expect(body.explorations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles missing exploration state gracefully", async () => {
|
||||
const state = { quests: [] };
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state });
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { explorations: unknown[] };
|
||||
expect(body.explorations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 500 on database error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(
|
||||
new Error("DB failure"),
|
||||
);
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 and logs non-Error throws", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error");
|
||||
const res = await get("123456789");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -55,18 +55,18 @@ const makeMinimalState = (overrides: Partial<GameState> = {}): GameState =>
|
||||
|
||||
describe("calculatePrestigeThreshold", () => {
|
||||
it("returns base threshold at count 0", () => {
|
||||
// base × (0+1)^2 = 1_000_000 × 1 = 1_000_000
|
||||
// base × (0+1)^2.5 = 1_000_000 × 1 = 1_000_000
|
||||
expect(calculatePrestigeThreshold(0)).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("returns 4× base at count 1", () => {
|
||||
// base × (1+1)^2 = 1_000_000 × 4 = 4_000_000
|
||||
expect(calculatePrestigeThreshold(1)).toBe(4_000_000);
|
||||
it("returns base × 2^2.5 at count 1", () => {
|
||||
// base × (1+1)^2.5 = 1_000_000 × 2^2.5
|
||||
expect(calculatePrestigeThreshold(1)).toBeCloseTo(1_000_000 * Math.pow(2, 2.5));
|
||||
});
|
||||
|
||||
it("returns 9× base at count 2", () => {
|
||||
// base × (2+1)^2 = 1_000_000 × 9 = 9_000_000
|
||||
expect(calculatePrestigeThreshold(2)).toBe(9_000_000);
|
||||
it("returns base × 3^2.5 at count 2", () => {
|
||||
// base × (2+1)^2.5 = 1_000_000 × 3^2.5
|
||||
expect(calculatePrestigeThreshold(2)).toBeCloseTo(1_000_000 * Math.pow(3, 2.5));
|
||||
});
|
||||
|
||||
it("applies threshold multiplier correctly", () => {
|
||||
@@ -255,6 +255,20 @@ describe("buildPostPrestigeState", () => {
|
||||
expect(prestigeData.autoPrestigeEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves autoPrestigeMaxRunestonesOnly when set", () => {
|
||||
const state = makeMinimalState({
|
||||
prestige: { autoPrestigeMaxRunestonesOnly: true, count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
||||
});
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("omits autoPrestigeMaxRunestonesOnly when not set", () => {
|
||||
const state = makeMinimalState();
|
||||
const { prestigeData } = buildPostPrestigeState(state, "Tester");
|
||||
expect(prestigeData.autoPrestigeMaxRunestonesOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves apotheosis data across prestige", () => {
|
||||
const apotheosis = { count: 2 };
|
||||
const state = makeMinimalState({ apotheosis });
|
||||
|
||||
Reference in New Issue
Block a user