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;
|
const prestigeCount = prestigeData.count;
|
||||||
void logger.metric("prestige", 1, { discordId, prestigeCount });
|
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,
|
const playerRecord = await prisma.player.findUnique({
|
||||||
|
select: { profileSettings: true },
|
||||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
where: { discordId },
|
||||||
/* v8 ignore next 2 -- @preserve */
|
|
||||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
|
||||||
});
|
});
|
||||||
|
/* 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({
|
return context.json({
|
||||||
milestoneRunestones: milestoneRunestones,
|
milestoneRunestones: milestoneRunestones,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user