generated from nhcarrigan/template
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
87686a310f
|
|||
|
19f5f5e54f
|
@@ -147,17 +147,30 @@ prestigeRouter.post("/", async(context) => {
|
||||
|
||||
const prestigeCount = prestigeData.count;
|
||||
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
||||
void postMilestoneWebhook(discordId, "prestige", {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||
|
||||
prestige: prestigeData.count,
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||
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", {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||
|
||||
prestige: prestigeData.count,
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json({
|
||||
milestoneRunestones: milestoneRunestones,
|
||||
|
||||
@@ -47,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
||||
: "suffix";
|
||||
return {
|
||||
enableNotifications: rawObject.enableNotifications === true,
|
||||
enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false,
|
||||
enableSounds: rawObject.enableSounds === true,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
||||
@@ -222,6 +223,7 @@ profileRouter.put("/", authMiddleware, async(context) => {
|
||||
: "suffix";
|
||||
const profileSettings: ProfileSettings = {
|
||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||
enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true,
|
||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
player: { update: vi.fn() },
|
||||
player: { findUnique: vi.fn(), update: 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", () => {
|
||||
let app: Hono;
|
||||
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> };
|
||||
};
|
||||
|
||||
@@ -128,6 +128,18 @@ describe("prestige route", () => {
|
||||
const body = await res.json() as { runestones: number; newPrestigeCount: number };
|
||||
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", () => {
|
||||
|
||||
@@ -225,6 +225,10 @@ const EditProfileModal = ({
|
||||
void handleNotificationsEnable();
|
||||
}
|
||||
|
||||
function handlePrestigeAnnouncementsToggle(): void {
|
||||
toggleSetting("enablePrestigeAnnouncements");
|
||||
}
|
||||
|
||||
const isSaveDisabled = saving || characterName.trim() === "";
|
||||
|
||||
let saveLabel = "Save Profile";
|
||||
@@ -417,6 +421,23 @@ const EditProfileModal = ({
|
||||
}
|
||||
</span>
|
||||
</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 className="edit-profile-section">
|
||||
|
||||
@@ -12,25 +12,27 @@ import { useState, type JSX } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
PRESTIGE_UPGRADES,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
import {
|
||||
computeProjectedRunestones,
|
||||
} from "../../engine/tick.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { sendNotification } from "../../utils/notification.js";
|
||||
import { playSound } from "../../utils/sound.js";
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const baseThreshold = 1_000_000;
|
||||
const thresholdScale = 5;
|
||||
const runestonesPerLevel = 10;
|
||||
|
||||
/**
|
||||
* Calculates the prestige threshold for a given prestige count.
|
||||
* Mirrors the server formula: BASE * (count + 1)^2.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @returns The required gold to prestige.
|
||||
*/
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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> = [
|
||||
"income",
|
||||
"click",
|
||||
@@ -114,11 +90,7 @@ const PrestigePanel = (): JSX.Element => {
|
||||
const { autoAdventurer, prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const runestonePreview = computeProjectedRunestones(state);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
|
||||
async function handlePrestige(): Promise<void> {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
computeEssencePerSecond,
|
||||
computeGoldPerSecond,
|
||||
computePartyCombatPower,
|
||||
computeProjectedRunestones,
|
||||
} from "../../engine/tick.js";
|
||||
import type { Resource } from "@elysium/types";
|
||||
|
||||
@@ -89,10 +90,12 @@ const ResourceBar = ({
|
||||
let partyCombatPower = 0;
|
||||
let goldPerSecond = 0;
|
||||
let essencePerSecond = 0;
|
||||
let projectedRunestones = 0;
|
||||
if (state !== null) {
|
||||
partyCombatPower = computePartyCombatPower(state);
|
||||
goldPerSecond = computeGoldPerSecond(state);
|
||||
essencePerSecond = computeEssencePerSecond(state);
|
||||
projectedRunestones = computeProjectedRunestones(state);
|
||||
}
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
@@ -234,6 +237,13 @@ const ResourceBar = ({
|
||||
</span>
|
||||
<span className="resource-label">{"Runestones"}</span>
|
||||
</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">
|
||||
<span className="resource-icon">{"⚔️"}</span>
|
||||
<span className="resource-value">
|
||||
|
||||
@@ -447,6 +447,36 @@ export const computePartyCombatPower = (state: GameState): number => {
|
||||
* 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.
|
||||
* DeltaSeconds: time elapsed since last tick.
|
||||
|
||||
@@ -48,11 +48,17 @@ interface ProfileSettings {
|
||||
* Whether browser system notifications are enabled.
|
||||
*/
|
||||
enableNotifications: boolean;
|
||||
|
||||
/**
|
||||
* Whether prestige milestones are announced in the Discord server.
|
||||
*/
|
||||
enablePrestigeAnnouncements: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
|
||||
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
||||
enableNotifications: false,
|
||||
enablePrestigeAnnouncements: true,
|
||||
enableSounds: false,
|
||||
numberFormat: "suffix",
|
||||
showAchievementsUnlocked: true,
|
||||
|
||||
Reference in New Issue
Block a user