feat: another balance and bug fix pass (#238)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m10s
CI / Lint, Build & Test (push) Successful in 1m13s

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:
2026-04-06 18:17:00 -07:00
committed by Naomi Carrigan
parent b0227c1709
commit 1195b657a0
34 changed files with 980 additions and 203 deletions
+2 -2
View File
@@ -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",
+9 -9
View File
@@ -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",
+2
View File
@@ -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 -2
View File
@@ -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,
+3 -3
View File
@@ -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,
+23 -5
View File
@@ -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,
+8 -2
View File
@@ -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,
);
+127
View File
@@ -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 };
+7 -3
View File
@@ -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 810
* then gets easier as the production multiplier overtakes it.
* Formula: BASE * (count + 1)^2.5steeper 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);