2 Commits

Author SHA1 Message Date
hikari 87686a310f feat: add opt-out toggle for prestige bot announcements
CI / Lint, Build & Test (pull_request) Failing after 1m2s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m9s
Adds enablePrestigeAnnouncements to ProfileSettings (defaults to true).
The prestige route now checks this setting before posting the Discord
webhook, and the edit profile modal exposes a toggle in the Sounds &
Notifications section so players can opt out.

Closes #169
2026-03-31 13:20:01 -07:00
hikari 19f5f5e54f feat: show projected runestone gain persistently in resource bar
Adds computeProjectedRunestones() to the shared tick engine using the
correct server-side formula (cbrt, (count+1)^2 threshold). The resource
bar now shows a persistent '+N On Prestige' row so players can always
see what they would earn. The prestige panel's own preview was also
fixed to use the shared helper, replacing a broken local calculation
that used sqrt and the wrong threshold formula.

Closes #168
2026-03-31 13:19:54 -07:00
8 changed files with 112 additions and 46 deletions
+13
View File
@@ -147,6 +147,18 @@ prestigeRouter.post("/", async(context) => {
const prestigeCount = prestigeData.count; const prestigeCount = prestigeData.count;
void logger.metric("prestige", 1, { discordId, prestigeCount }); void logger.metric("prestige", 1, { discordId, prestigeCount });
const playerRecord = await prisma.player.findUnique({
select: { profileSettings: true },
where: { discordId },
});
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check for JSON field */
const playerSettings = playerRecord?.profileSettings as
Record<string, unknown> | null | undefined;
const announcementsEnabled
= playerSettings?.enablePrestigeAnnouncements !== false;
if (announcementsEnabled) {
void postMilestoneWebhook(discordId, "prestige", { void postMilestoneWebhook(discordId, "prestige", {
// eslint-disable-next-line capitalized-comments -- v8 ignore // eslint-disable-next-line capitalized-comments -- v8 ignore
/* v8 ignore next -- @preserve */ /* v8 ignore next -- @preserve */
@@ -158,6 +170,7 @@ prestigeRouter.post("/", async(context) => {
/* v8 ignore next 2 -- @preserve */ /* v8 ignore next 2 -- @preserve */
transcendence: prestigeState.transcendence?.count ?? 0, transcendence: prestigeState.transcendence?.count ?? 0,
}); });
}
return context.json({ return context.json({
milestoneRunestones: milestoneRunestones, milestoneRunestones: milestoneRunestones,
+2
View File
@@ -47,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
: "suffix"; : "suffix";
return { return {
enableNotifications: rawObject.enableNotifications === true, enableNotifications: rawObject.enableNotifications === true,
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
enableSounds: rawObject.enableSounds === true, enableSounds: rawObject.enableSounds === true,
numberFormat: numberFormat, numberFormat: numberFormat,
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false, showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
@@ -222,6 +223,7 @@ profileRouter.put("/", authMiddleware, async(context) => {
: "suffix"; : "suffix";
const profileSettings: ProfileSettings = { const profileSettings: ProfileSettings = {
enableNotifications: body.profileSettings.enableNotifications ?? false, enableNotifications: body.profileSettings.enableNotifications ?? false,
enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true,
enableSounds: body.profileSettings.enableSounds ?? false, enableSounds: body.profileSettings.enableSounds ?? false,
numberFormat: numberFormat, numberFormat: numberFormat,
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
+14 -2
View File
@@ -7,7 +7,7 @@ import type { GameState } from "@elysium/types";
vi.mock("../../src/db/client.js", () => ({ vi.mock("../../src/db/client.js", () => ({
prisma: { prisma: {
player: { update: vi.fn() }, player: { findUnique: vi.fn(), update: vi.fn() },
gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
}, },
})); }));
@@ -47,7 +47,7 @@ const makeState = (overrides: Partial<GameState> = {}): GameState => ({
describe("prestige route", () => { describe("prestige route", () => {
let app: Hono; let app: Hono;
let prisma: { let prisma: {
player: { update: ReturnType<typeof vi.fn> }; player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> }; gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> };
}; };
@@ -128,6 +128,18 @@ describe("prestige route", () => {
const body = await res.json() as { runestones: number; newPrestigeCount: number }; const body = await res.json() as { runestones: number; newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1); expect(body.newPrestigeCount).toBe(1);
}); });
it("skips webhook when enablePrestigeAnnouncements is false", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
const res = await post("");
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
});
}); });
describe("POST /buy-upgrade", () => { describe("POST /buy-upgrade", () => {
@@ -225,6 +225,10 @@ const EditProfileModal = ({
void handleNotificationsEnable(); void handleNotificationsEnable();
} }
function handlePrestigeAnnouncementsToggle(): void {
toggleSetting("enablePrestigeAnnouncements");
}
const isSaveDisabled = saving || characterName.trim() === ""; const isSaveDisabled = saving || characterName.trim() === "";
let saveLabel = "Save Profile"; let saveLabel = "Save Profile";
@@ -417,6 +421,23 @@ const EditProfileModal = ({
} }
</span> </span>
</button> </button>
<button
className={`stat-toggle-btn ${
profileSettings.enablePrestigeAnnouncements
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handlePrestigeAnnouncementsToggle}
type="button"
>
<span>{"⭐ Prestige Bot Announcements"}</span>
<span className="stat-toggle-indicator">
{profileSettings.enablePrestigeAnnouncements
? "✓ On"
: "Off"
}
</span>
</button>
</div> </div>
<div className="edit-profile-section"> <div className="edit-profile-section">
+7 -35
View File
@@ -12,25 +12,27 @@ import { useState, type JSX } from "react";
import { prestige } from "../../api/client.js"; import { prestige } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { import {
PRESTIGE_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS, PRESTIGE_UPGRADE_CATEGORY_LABELS,
PRESTIGE_UPGRADES,
} from "../../data/prestigeUpgrades.js"; } from "../../data/prestigeUpgrades.js";
import {
computeProjectedRunestones,
} from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { sendNotification } from "../../utils/notification.js"; import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js"; import { playSound } from "../../utils/sound.js";
import type { PrestigeUpgradeCategory } from "@elysium/types"; import type { PrestigeUpgradeCategory } from "@elysium/types";
const baseThreshold = 1_000_000; const baseThreshold = 1_000_000;
const thresholdScale = 5;
const runestonesPerLevel = 10;
/** /**
* Calculates the prestige threshold for a given prestige count. * Calculates the prestige threshold for a given prestige count.
* Mirrors the server formula: BASE * (count + 1)^2.
* @param prestigeCount - The current prestige count. * @param prestigeCount - The current prestige count.
* @returns The required gold to prestige. * @returns The required gold to prestige.
*/ */
const calculateThreshold = (prestigeCount: number): number => { const calculateThreshold = (prestigeCount: number): number => {
return baseThreshold * Math.pow(thresholdScale, prestigeCount); return baseThreshold * Math.pow(prestigeCount + 1, 2);
}; };
/** /**
@@ -42,32 +44,6 @@ const calculateProductionMultiplier = (prestigeCount: number): number => {
return Math.pow(1.15, prestigeCount); return Math.pow(1.15, prestigeCount);
}; };
/**
* Calculates the runestone preview for a prestige.
* @param totalGoldEarned - Total gold earned this run.
* @param prestigeCount - The current prestige count.
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
* @returns The predicted runestone reward.
*/
const calculateRunestonePreview = (
totalGoldEarned: number,
prestigeCount: number,
purchasedUpgradeIds: Array<string>,
): number => {
const threshold = calculateThreshold(prestigeCount);
const base
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
return (
upgrade.category === "runestones"
&& purchasedUpgradeIds.includes(upgrade.id)
);
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
return Math.floor(base * runestoneMult);
};
const categoryOrder: Array<PrestigeUpgradeCategory> = [ const categoryOrder: Array<PrestigeUpgradeCategory> = [
"income", "income",
"click", "click",
@@ -114,11 +90,7 @@ const PrestigePanel = (): JSX.Element => {
const { autoAdventurer, prestige: prestigeData, player } = state; const { autoAdventurer, prestige: prestigeData, player } = state;
const threshold = calculateThreshold(prestigeData.count); const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold; const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = calculateRunestonePreview( const runestonePreview = computeProjectedRunestones(state);
player.totalGoldEarned,
prestigeData.count,
prestigeData.purchasedUpgradeIds,
);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1); const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
async function handlePrestige(): Promise<void> { async function handlePrestige(): Promise<void> {
@@ -15,6 +15,7 @@ import {
computeEssencePerSecond, computeEssencePerSecond,
computeGoldPerSecond, computeGoldPerSecond,
computePartyCombatPower, computePartyCombatPower,
computeProjectedRunestones,
} from "../../engine/tick.js"; } from "../../engine/tick.js";
import type { Resource } from "@elysium/types"; import type { Resource } from "@elysium/types";
@@ -89,10 +90,12 @@ const ResourceBar = ({
let partyCombatPower = 0; let partyCombatPower = 0;
let goldPerSecond = 0; let goldPerSecond = 0;
let essencePerSecond = 0; let essencePerSecond = 0;
let projectedRunestones = 0;
if (state !== null) { if (state !== null) {
partyCombatPower = computePartyCombatPower(state); partyCombatPower = computePartyCombatPower(state);
goldPerSecond = computeGoldPerSecond(state); goldPerSecond = computeGoldPerSecond(state);
essencePerSecond = computeEssencePerSecond(state); essencePerSecond = computeEssencePerSecond(state);
projectedRunestones = computeProjectedRunestones(state);
} }
let avatarUrl: string | null = null; let avatarUrl: string | null = null;
@@ -234,6 +237,13 @@ const ResourceBar = ({
</span> </span>
<span className="resource-label">{"Runestones"}</span> <span className="resource-label">{"Runestones"}</span>
</div> </div>
<div className="resource">
<span className="resource-icon">{"⭐"}</span>
<span className="resource-value">
{`+${formatNumber(projectedRunestones)}`}
</span>
<span className="resource-label">{"On Prestige"}</span>
</div>
<div className="resource"> <div className="resource">
<span className="resource-icon">{"⚔️"}</span> <span className="resource-icon">{"⚔️"}</span>
<span className="resource-value"> <span className="resource-value">
+30
View File
@@ -447,6 +447,36 @@ export const computePartyCombatPower = (state: GameState): number => {
* companionCombatMult; * companionCombatMult;
}; };
const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 15;
const maxBaseRunestones = 200;
/**
* Computes the projected runestone reward if the player were to prestige right now.
* Mirrors the server-side calculateRunestones formula exactly.
* @param state - The current game state.
* @returns The number of runestones the player would earn from a prestige now.
*/
export const computeProjectedRunestones = (state: GameState): number => {
const { count, purchasedUpgradeIds } = state.prestige;
const threshold = basePrestigeThreshold * Math.pow(count + 1, 2);
const base = Math.min(
Math.floor(Math.cbrt(state.player.totalGoldEarned / threshold))
* runestonesPerPrestigeLevelClient,
maxBaseRunestones,
);
const gain1Mult = purchasedUpgradeIds.includes("runestone_gain_1")
? 1.25
: 1;
const gain2Mult = purchasedUpgradeIds.includes("runestone_gain_2")
? 1.5
: 1;
const runestoneMult = gain1Mult * gain2Mult;
/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- optional chained game state field */
const echoMult: number = state.transcendence?.echoRunestoneMultiplier ?? 1;
return Math.floor(base * runestoneMult * echoMult);
};
/** /**
* Pure function — applies one game tick to the state. * Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick. * DeltaSeconds: time elapsed since last tick.
@@ -48,11 +48,17 @@ interface ProfileSettings {
* Whether browser system notifications are enabled. * Whether browser system notifications are enabled.
*/ */
enableNotifications: boolean; enableNotifications: boolean;
/**
* Whether prestige milestones are announced in the Discord server.
*/
enablePrestigeAnnouncements: boolean;
} }
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name // eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
enableNotifications: false, enableNotifications: false,
enablePrestigeAnnouncements: true,
enableSounds: false, enableSounds: false,
numberFormat: "suffix", numberFormat: "suffix",
showAchievementsUnlocked: true, showAchievementsUnlocked: true,