generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file Apotheosis service handling eligibility checks and state building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import {
|
||||
defaultTranscendenceUpgrades,
|
||||
} from "../data/transcendenceUpgrades.js";
|
||||
import type { ApotheosisData, GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Total number of echo upgrades — all must be purchased to unlock Apotheosis.
|
||||
*/
|
||||
const totalEchoUpgrades = defaultTranscendenceUpgrades.length;
|
||||
|
||||
/**
|
||||
* Returns true when the player is eligible to achieve Apotheosis:
|
||||
* all Transcendence echo upgrades must be purchased.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for Apotheosis.
|
||||
*/
|
||||
const isEligibleForApotheosis = (state: GameState): boolean => {
|
||||
const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? [];
|
||||
return (
|
||||
purchasedIds.length >= totalEchoUpgrades
|
||||
&& defaultTranscendenceUpgrades.every((u) => {
|
||||
return purchasedIds.includes(u.id);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the updated game state after Apotheosis — the ultimate nuclear reset.
|
||||
* Wipes absolutely everything including prestige and transcendence.
|
||||
* Only codex lore entries and the apotheosis count itself are preserved.
|
||||
* @param currentState - The current game state before apotheosis.
|
||||
* @param characterName - The character name to carry over.
|
||||
* @returns The updated game state and apotheosis data.
|
||||
*/
|
||||
const buildPostApotheosisState = (
|
||||
currentState: GameState,
|
||||
characterName: string,
|
||||
): { updatedApotheosisData: ApotheosisData; updatedState: GameState } => {
|
||||
const apotheosisCount = (currentState.apotheosis?.count ?? 0) + 1;
|
||||
const updatedApotheosisData: ApotheosisData = { count: apotheosisCount };
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
const updatedState: GameState = {
|
||||
...freshState,
|
||||
lastTickAt: Date.now(),
|
||||
// Codex lore persists through all resets — players keep their discovered entries
|
||||
...currentState.codex
|
||||
? { codex: currentState.codex }
|
||||
: {},
|
||||
// Apotheosis data is eternal — never wiped by any reset
|
||||
apotheosis: updatedApotheosisData,
|
||||
// Story chapter progress is permanent — survives all resets
|
||||
...currentState.story
|
||||
? { story: currentState.story }
|
||||
: {},
|
||||
};
|
||||
|
||||
return { updatedApotheosisData, updatedState };
|
||||
};
|
||||
|
||||
export { buildPostApotheosisState, isEligibleForApotheosis };
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file Daily challenge generation and progress tracking utilities.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { dailyChallengeTemplates } from "../data/dailyChallenges.js";
|
||||
import type {
|
||||
DailyChallenge,
|
||||
DailyChallengeState,
|
||||
DailyChallengeType,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Returns today's date string in PST/PDT so challenges roll over at midnight Pacific.
|
||||
* @returns A date string in YYYY-MM-DD format.
|
||||
*/
|
||||
const getTodayString = (): string => {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/Los_Angeles",
|
||||
}).format(new Date());
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple deterministic pseudo-random number based on a numeric seed.
|
||||
* @param seed - The numeric seed value.
|
||||
* @returns A pseudo-random float in [0, 1).
|
||||
*/
|
||||
const seededRandom = (seed: number): number => {
|
||||
const x = Math.sin(seed + 1) * 10_000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a date string into a stable numeric seed.
|
||||
* @param dateString - A date string such as "2025-01-01".
|
||||
* @returns A numeric seed derived from the date characters.
|
||||
*/
|
||||
const dateSeed = (dateString: string): number => {
|
||||
let accumulator = 0;
|
||||
let index = 0;
|
||||
for (const char of dateString) {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const charValue = char.codePointAt(0) ?? 0;
|
||||
const contribution = charValue * (index + 1);
|
||||
accumulator = accumulator + contribution;
|
||||
index = index + 1;
|
||||
}
|
||||
return accumulator;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deterministically shuffles an array using a numeric seed (Fisher-Yates).
|
||||
* @param array - The array to shuffle.
|
||||
* @param seed - The seed controlling shuffle order.
|
||||
* @returns A new shuffled array.
|
||||
*/
|
||||
const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
|
||||
const result = [ ...array ];
|
||||
for (let index = result.length - 1; index > 0; index = index - 1) {
|
||||
const swapIndex = Math.floor(seededRandom(seed + index) * (index + 1));
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index and swapIndex are always in bounds */
|
||||
const fromSwap = result[swapIndex]!;
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index and swapIndex are always in bounds */
|
||||
const fromIndex = result[index]!;
|
||||
result[index] = fromSwap;
|
||||
result[swapIndex] = fromIndex;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const challengeTypes: Array<DailyChallengeType> = [
|
||||
"clicks",
|
||||
"bossesDefeated",
|
||||
"questsCompleted",
|
||||
"prestige",
|
||||
];
|
||||
|
||||
/**
|
||||
* Generates 3 daily challenges for the given date string, deterministically.
|
||||
* Picks one challenge from 3 different randomly-selected types.
|
||||
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
||||
* @returns An array of 3 DailyChallenge objects.
|
||||
*/
|
||||
const generateDailyChallenges = (
|
||||
dateString: string,
|
||||
): Array<DailyChallenge> => {
|
||||
const seed = dateSeed(dateString);
|
||||
const selectedTypes = shuffleWithSeed([ ...challengeTypes ], seed).
|
||||
slice(0, 3);
|
||||
|
||||
return selectedTypes.map((type, index) => {
|
||||
const templates = dailyChallengeTemplates.filter((template) => {
|
||||
return template.type === type;
|
||||
});
|
||||
const indexOffset = index * 100;
|
||||
const templateIndex = Math.floor(
|
||||
seededRandom(seed + indexOffset) * templates.length,
|
||||
);
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- templateIndex is always valid: seededRandom returns [0,1) so floor * length is always in bounds */
|
||||
const template = templates[templateIndex]!;
|
||||
|
||||
return {
|
||||
completed: false,
|
||||
id: `${dateString}_${type}`,
|
||||
label: template.label,
|
||||
progress: 0,
|
||||
rewardCrystals: template.rewardCrystals,
|
||||
target: template.target,
|
||||
type: template.type,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current daily challenge state, generating fresh challenges when
|
||||
* the stored date does not match today.
|
||||
* @param state - The current game state.
|
||||
* @returns The current or freshly-generated DailyChallengeState.
|
||||
*/
|
||||
const getOrResetDailyChallenges = (
|
||||
state: GameState,
|
||||
): DailyChallengeState => {
|
||||
const today = getTodayString();
|
||||
if (state.dailyChallenges?.date === today) {
|
||||
return state.dailyChallenges;
|
||||
}
|
||||
return { challenges: generateDailyChallenges(today), date: today };
|
||||
};
|
||||
|
||||
/**
|
||||
* Increments progress for challenges matching the given type.
|
||||
* Returns the updated challenge state and total crystals awarded for newly completed challenges.
|
||||
* @param challengeState - The current daily challenge state.
|
||||
* @param type - The challenge type to increment progress for.
|
||||
* @param amount - The amount to increment progress by.
|
||||
* @returns The updated challenge state and total crystals awarded.
|
||||
*/
|
||||
const updateChallengeProgress = (
|
||||
challengeState: DailyChallengeState,
|
||||
type: DailyChallengeType,
|
||||
amount: number,
|
||||
): { updatedChallenges: DailyChallengeState; crystalsAwarded: number } => {
|
||||
let crystalsAwarded = 0;
|
||||
|
||||
const updatedChallenges: DailyChallengeState = {
|
||||
...challengeState,
|
||||
challenges: challengeState.challenges.map((challenge) => {
|
||||
if (challenge.type !== type || challenge.completed) {
|
||||
return challenge;
|
||||
}
|
||||
|
||||
const updatedProgress = Math.min(
|
||||
challenge.progress + amount,
|
||||
challenge.target,
|
||||
);
|
||||
const nowCompleted = updatedProgress >= challenge.target;
|
||||
|
||||
if (nowCompleted) {
|
||||
crystalsAwarded = crystalsAwarded + challenge.rewardCrystals;
|
||||
}
|
||||
|
||||
return {
|
||||
...challenge,
|
||||
completed: nowCompleted,
|
||||
progress: updatedProgress,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return { crystalsAwarded, updatedChallenges };
|
||||
};
|
||||
|
||||
export {
|
||||
generateDailyChallenges,
|
||||
getOrResetDailyChallenges,
|
||||
updateChallengeProgress,
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @file Discord OAuth helpers for token exchange, user fetching, and URL building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||
|
||||
interface DiscordTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges a Discord OAuth authorisation code for an access token.
|
||||
* @param code - The authorisation code received from Discord's OAuth callback.
|
||||
* @returns The Discord token response containing the access token.
|
||||
* @throws {Error} If OAuth environment variables are missing or the exchange fails.
|
||||
*/
|
||||
const exchangeCode = async(
|
||||
code: string,
|
||||
): Promise<DiscordTokenResponse> => {
|
||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
||||
|
||||
if (
|
||||
clientId === undefined || clientId === ""
|
||||
|| clientSecret === undefined || clientSecret === ""
|
||||
|| redirectUri === undefined || redirectUri === ""
|
||||
) {
|
||||
throw new Error("Discord OAuth environment variables are required");
|
||||
}
|
||||
|
||||
const parameters = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||
body: parameters.toString(),
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord token exchange failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */
|
||||
return await (response.json() as Promise<DiscordTokenResponse>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the Discord user profile for the given access token.
|
||||
* @param accessToken - A valid Discord OAuth access token.
|
||||
* @returns The Discord user object.
|
||||
* @throws {Error} If the user fetch fails.
|
||||
*/
|
||||
const fetchDiscordUser = async(
|
||||
accessToken: string,
|
||||
): Promise<DiscordUser> => {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord user fetch failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */
|
||||
return await (response.json() as Promise<DiscordUser>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the Discord OAuth authorisation URL.
|
||||
* @returns The full OAuth URL to redirect the user to.
|
||||
* @throws {Error} If OAuth environment variables are missing.
|
||||
*/
|
||||
const buildOAuthUrl = (): string => {
|
||||
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
||||
|
||||
if (
|
||||
clientId === undefined || clientId === ""
|
||||
|| redirectUri === undefined || redirectUri === ""
|
||||
) {
|
||||
throw new Error("Discord OAuth environment variables are required");
|
||||
}
|
||||
|
||||
const parameters = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "identify",
|
||||
});
|
||||
|
||||
return `https://discord.com/api/oauth2/authorize?${parameters.toString()}`;
|
||||
};
|
||||
|
||||
export type { DiscordTokenResponse, DiscordUser };
|
||||
export { buildOAuthUrl, exchangeCode, fetchDiscordUser };
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @file JWT token signing and verification utilities.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
interface JwtPayload {
|
||||
discordId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const base64UrlEncode = (data: string): string => {
|
||||
return Buffer.from(data).toString("base64url");
|
||||
};
|
||||
|
||||
const base64UrlDecode = (data: string): string => {
|
||||
return Buffer.from(data, "base64url").toString("utf8");
|
||||
};
|
||||
|
||||
/**
|
||||
* Signs a JWT token for the given Discord ID.
|
||||
* @param discordId - The Discord user ID to encode in the token.
|
||||
* @returns A signed JWT string valid for 30 days.
|
||||
* @throws {Error} If the JWT_SECRET environment variable is not set.
|
||||
*/
|
||||
const signToken = (discordId: string): string => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (secret === undefined || secret === "") {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
// 30 days expiry
|
||||
const thirtyDaysInSeconds = 60 * 60 * 24 * 30;
|
||||
const payload = base64UrlEncode(
|
||||
JSON.stringify({
|
||||
discordId: discordId,
|
||||
exp: Math.floor(Date.now() / 1000) + thirtyDaysInSeconds,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
);
|
||||
|
||||
const signature = createHmac("sha256", secret).
|
||||
update(`${header}.${payload}`).
|
||||
digest("base64url");
|
||||
|
||||
return `${header}.${payload}.${signature}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies a JWT token and returns the decoded payload.
|
||||
* @param token - The JWT string to verify.
|
||||
* @returns The decoded JWT payload containing discordId, iat, and exp.
|
||||
* @throws {Error} If the JWT_SECRET is missing, the token is malformed, the
|
||||
* signature is invalid, or the token has expired.
|
||||
*/
|
||||
const verifyToken = (token: string): JwtPayload => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (secret === undefined || secret === "") {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Array destructure of known-length tuple */
|
||||
const [ header, payload, signature ] = parts as [string, string, string];
|
||||
|
||||
const expectedSignature = createHmac("sha256", secret).
|
||||
update(`${header}.${payload}`).
|
||||
digest("base64url");
|
||||
|
||||
if (signature !== expectedSignature) {
|
||||
throw new Error("Invalid token signature");
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Parsed JSON from trusted base64url payload */
|
||||
const decoded = JSON.parse(base64UrlDecode(payload)) as JwtPayload;
|
||||
|
||||
if (decoded.exp < Math.floor(Date.now() / 1000)) {
|
||||
throw new Error("Token has expired");
|
||||
}
|
||||
|
||||
return decoded;
|
||||
};
|
||||
|
||||
export { signToken, verifyToken };
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @file Offline earnings calculator for gold and essence accrued while logged out.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Offline earnings calculation requires iterating all adventurers with multi-step math */
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Maximum offline accrual cap: 8 hours.
|
||||
*/
|
||||
const maxOfflineSeconds = 8 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Calculates the gold and essence earned whilst the player was offline.
|
||||
* Capped at 8 hours to prevent exploit via system clock manipulation.
|
||||
* Applies the same multipliers as the client-side tick engine.
|
||||
* @param state - The current game state to calculate offline earnings from.
|
||||
* @param nowMs - The current timestamp in milliseconds.
|
||||
* @returns The gold, essence, and elapsed seconds earned offline.
|
||||
*/
|
||||
const calculateOfflineEarnings = (
|
||||
state: GameState,
|
||||
nowMs: number,
|
||||
): { offlineGold: number; offlineEssence: number; offlineSeconds: number } => {
|
||||
const elapsedSeconds = Math.min(
|
||||
(nowMs - state.lastTickAt) / 1000,
|
||||
maxOfflineSeconds,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Defensive check for runtime nullable fields
|
||||
const equipmentGoldMultiplier = (state.equipment ?? []).
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
|
||||
let goldPerSecond = 0;
|
||||
let essencePerSecond = 0;
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const upgradeMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
const isGlobal = upgrade.target === "global";
|
||||
const isForAdventurer
|
||||
= upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id;
|
||||
const affectsAdventurer = isGlobal || isForAdventurer;
|
||||
return upgrade.purchased && affectsAdventurer;
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const prestige = state.prestige.productionMultiplier;
|
||||
|
||||
const goldContribution
|
||||
= adventurer.goldPerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* prestige
|
||||
* runestonesIncome
|
||||
* equipmentGoldMultiplier;
|
||||
goldPerSecond = goldPerSecond + goldContribution;
|
||||
|
||||
const essenceContribution
|
||||
= adventurer.essencePerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* prestige
|
||||
* runestonesEssence;
|
||||
essencePerSecond = essencePerSecond + essenceContribution;
|
||||
}
|
||||
|
||||
return {
|
||||
offlineEssence: essencePerSecond * elapsedSeconds,
|
||||
offlineGold: goldPerSecond * elapsedSeconds,
|
||||
offlineSeconds: elapsedSeconds,
|
||||
};
|
||||
};
|
||||
|
||||
export { calculateOfflineEarnings };
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @file Prestige eligibility checks, runestone calculations, and post-prestige state builder.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- buildPostPrestigeState requires constructing a large composite state object */
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||
import type {
|
||||
GameState,
|
||||
PrestigeData,
|
||||
PrestigeUpgradeCategory,
|
||||
} from "@elysium/types";
|
||||
|
||||
const basePrestigeGoldThreshold = 1_000_000;
|
||||
const thresholdScaleFactor = 5;
|
||||
const runestonesPerPrestigeLevel = 10;
|
||||
const milestoneInterval = 5;
|
||||
const milestoneRunestonesPerInterval = 25;
|
||||
|
||||
/**
|
||||
* Calculates the gold threshold required for the next prestige.
|
||||
* Formula: BASE * SCALE_FACTOR^prestigeCount — each prestige makes the next threshold harder.
|
||||
* @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.
|
||||
*/
|
||||
const calculatePrestigeThreshold = (
|
||||
prestigeCount: number,
|
||||
thresholdMultiplier = 1,
|
||||
): number => {
|
||||
return (
|
||||
basePrestigeGoldThreshold
|
||||
* Math.pow(thresholdScaleFactor, prestigeCount)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the player has earned enough gold to prestige.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for a prestige reset.
|
||||
*/
|
||||
const isEligibleForPrestige = (state: GameState): boolean => {
|
||||
const thresholdMultiplier
|
||||
= state.transcendence?.echoPrestigeThresholdMultiplier ?? 1;
|
||||
return (
|
||||
state.player.totalGoldEarned
|
||||
>= calculatePrestigeThreshold(state.prestige.count, thresholdMultiplier)
|
||||
);
|
||||
};
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
category: PrestigeUpgradeCategory,
|
||||
): number => {
|
||||
return defaultPrestigeUpgrades.filter((upgrade) => {
|
||||
const matchesCategory = upgrade.category === category;
|
||||
const isPurchased = purchasedUpgradeIds.includes(upgrade.id);
|
||||
return matchesCategory && isPurchased;
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all four runestone multipliers from the purchased upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased prestige upgrade IDs.
|
||||
* @returns An object containing all four runestone multiplier values.
|
||||
*/
|
||||
const computeRunestoneMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): {
|
||||
runestonesIncomeMultiplier: number;
|
||||
runestonesClickMultiplier: number;
|
||||
runestonesEssenceMultiplier: number;
|
||||
runestonesCrystalMultiplier: number;
|
||||
} => {
|
||||
return {
|
||||
runestonesClickMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"click",
|
||||
),
|
||||
runestonesCrystalMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"crystals",
|
||||
),
|
||||
runestonesEssenceMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"essence",
|
||||
),
|
||||
runestonesIncomeMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"income",
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
interface RunestoneParameters {
|
||||
totalGoldEarned: number;
|
||||
prestigeCount: number;
|
||||
purchasedUpgradeIds: Array<string>;
|
||||
echoRunestoneMultiplier?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates how many runestones the player earns from a prestige.
|
||||
* Formula: floor(sqrt(totalGoldEarned / threshold)) * RUNESTONES_PER_PRESTIGE_LEVEL * runestoneMultiplier.
|
||||
* @param parameters - The parameters for the runestone calculation.
|
||||
* @param parameters.totalGoldEarned - The total gold earned in the current run.
|
||||
* @param parameters.prestigeCount - The current prestige count.
|
||||
* @param parameters.purchasedUpgradeIds - The purchased prestige upgrade IDs.
|
||||
* @param parameters.echoRunestoneMultiplier - An optional echo-upgrade multiplier.
|
||||
* @returns The number of runestones earned.
|
||||
*/
|
||||
const calculateRunestones = (parameters: RunestoneParameters): number => {
|
||||
const {
|
||||
totalGoldEarned,
|
||||
prestigeCount,
|
||||
purchasedUpgradeIds,
|
||||
echoRunestoneMultiplier = 1,
|
||||
} = parameters;
|
||||
const threshold = calculatePrestigeThreshold(prestigeCount);
|
||||
const base
|
||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold))
|
||||
* runestonesPerPrestigeLevel;
|
||||
const runestoneMult = getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"runestones",
|
||||
);
|
||||
return Math.floor(base * runestoneMult * echoRunestoneMultiplier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the new prestige production multiplier.
|
||||
* Formula: 1.15^prestigeCount — exponential scaling per prestige.
|
||||
* @param prestigeCount - The new prestige count.
|
||||
* @returns The production multiplier for the new prestige level.
|
||||
*/
|
||||
const calculateProductionMultiplier = (
|
||||
prestigeCount: number,
|
||||
): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the milestone runestone bonus for the given prestige count.
|
||||
* Every MILESTONE_INTERVAL prestiges awards milestone_number * MILESTONE_RUNESTONES_PER_INTERVAL stones.
|
||||
* @param prestigeCount - The prestige count after the current prestige.
|
||||
* @returns The milestone runestone bonus, or 0 if not a milestone prestige.
|
||||
*/
|
||||
const calculateMilestoneBonus = (prestigeCount: number): number => {
|
||||
if (prestigeCount % milestoneInterval !== 0) {
|
||||
return 0;
|
||||
}
|
||||
const milestoneNumber = prestigeCount / milestoneInterval;
|
||||
return milestoneNumber * milestoneRunestonesPerInterval;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the reset game state after a prestige.
|
||||
* Carries over prestige data and runestones; resets everything else.
|
||||
* @param currentState - The game state at the time of the prestige.
|
||||
* @param characterName - The player's character name to carry forward.
|
||||
* @returns The new game state, prestige data, and runestone counts.
|
||||
*/
|
||||
const buildPostPrestigeState = (
|
||||
currentState: GameState,
|
||||
characterName: string,
|
||||
): {
|
||||
prestigeState: GameState;
|
||||
prestigeData: PrestigeData;
|
||||
runestonesEarned: number;
|
||||
milestoneRunestones: number;
|
||||
} => {
|
||||
const {
|
||||
autoPrestigeEnabled,
|
||||
count: currentPrestigeCount,
|
||||
purchasedUpgradeIds,
|
||||
runestones: currentRunestones,
|
||||
} = currentState.prestige;
|
||||
const echoRunestoneMultiplier
|
||||
= currentState.transcendence?.echoPrestigeRunestoneMultiplier ?? 1;
|
||||
const runestonesEarned = calculateRunestones({
|
||||
echoRunestoneMultiplier: echoRunestoneMultiplier,
|
||||
prestigeCount: currentPrestigeCount,
|
||||
purchasedUpgradeIds: purchasedUpgradeIds,
|
||||
totalGoldEarned: currentState.player.totalGoldEarned,
|
||||
});
|
||||
const updatedPrestigeCount = currentPrestigeCount + 1;
|
||||
const milestoneRunestones = calculateMilestoneBonus(updatedPrestigeCount);
|
||||
|
||||
const prestigeData: PrestigeData = {
|
||||
count: updatedPrestigeCount,
|
||||
lastPrestigedAt: Date.now(),
|
||||
productionMultiplier: calculateProductionMultiplier(updatedPrestigeCount),
|
||||
purchasedUpgradeIds: purchasedUpgradeIds,
|
||||
runestones:
|
||||
currentRunestones + runestonesEarned + milestoneRunestones,
|
||||
...computeRunestoneMultipliers(purchasedUpgradeIds),
|
||||
...autoPrestigeEnabled === undefined
|
||||
? {}
|
||||
: { autoPrestigeEnabled },
|
||||
};
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
const prestigeState: GameState = {
|
||||
...freshState,
|
||||
lastTickAt: Date.now(),
|
||||
prestige: prestigeData,
|
||||
// Codex lore persists across prestiges — players keep their discovered entries
|
||||
...currentState.codex === undefined
|
||||
? {}
|
||||
: { codex: currentState.codex },
|
||||
// Transcendence data is permanent — never wiped by prestige
|
||||
...currentState.transcendence === undefined
|
||||
? {}
|
||||
: { transcendence: currentState.transcendence },
|
||||
// Apotheosis data is eternal — never wiped by prestige
|
||||
...currentState.apotheosis === undefined
|
||||
? {}
|
||||
: { apotheosis: currentState.apotheosis },
|
||||
// Story chapter progress is permanent — survives all resets
|
||||
...currentState.story === undefined
|
||||
? {}
|
||||
: { story: currentState.story },
|
||||
};
|
||||
|
||||
return {
|
||||
milestoneRunestones,
|
||||
prestigeData,
|
||||
prestigeState,
|
||||
runestonesEarned,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostPrestigeState,
|
||||
calculateMilestoneBonus,
|
||||
calculatePrestigeThreshold,
|
||||
calculateProductionMultiplier,
|
||||
calculateRunestones,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file Title unlock logic for checking and awarding in-game titles.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { gameTitles } from "../data/titles.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
interface TitleCheckParameters {
|
||||
currentUnlocked: Array<string>;
|
||||
state: GameState;
|
||||
guildName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks which titles the player has newly earned and returns their IDs.
|
||||
* @param parameters - The parameters for the title check.
|
||||
* @param parameters.currentUnlocked - The array of already-unlocked title IDs.
|
||||
* @param parameters.state - The current game state.
|
||||
* @param parameters.guildName - The player's current guild name.
|
||||
* @param parameters.createdAt - The timestamp (ms) when the player account was created.
|
||||
* @returns An array of newly unlocked title IDs.
|
||||
*/
|
||||
const checkAndUnlockTitles = (
|
||||
parameters: TitleCheckParameters,
|
||||
): Array<string> => {
|
||||
const { currentUnlocked, state, guildName, createdAt } = parameters;
|
||||
const metrics: Record<string, number | boolean> = {
|
||||
achievementsUnlocked: state.achievements.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length,
|
||||
adventurerTotal: state.adventurers.reduce((sum, adventurer) => {
|
||||
return sum + adventurer.count;
|
||||
}, 0),
|
||||
apotheosisCount: state.apotheosis?.count ?? 0,
|
||||
bossesDefeated: state.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length,
|
||||
guildFounded: guildName.trim().length > 0,
|
||||
playedDays: Math.floor((Date.now() - createdAt) / 86_400_000),
|
||||
prestigeCount: state.prestige.count,
|
||||
questsCompleted: state.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length,
|
||||
totalClicks: state.player.totalClicks,
|
||||
totalGoldEarned: state.player.totalGoldEarned,
|
||||
transcendenceCount: state.transcendence?.count ?? 0,
|
||||
};
|
||||
|
||||
const newlyUnlocked: Array<string> = [];
|
||||
|
||||
for (const title of gameTitles) {
|
||||
if (currentUnlocked.includes(title.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { type, amount } = title.condition;
|
||||
let earned = false;
|
||||
|
||||
if (type === "guildFounded") {
|
||||
earned = metrics.guildFounded === true;
|
||||
} else if (amount !== undefined) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- metrics[type] is number when type is not guildFounded */
|
||||
earned = (metrics[type] as number) >= amount;
|
||||
}
|
||||
|
||||
if (earned) {
|
||||
newlyUnlocked.push(title.id);
|
||||
}
|
||||
}
|
||||
|
||||
return newlyUnlocked;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the raw unlocked titles value from the database into a string array.
|
||||
* @param raw - The raw value from the database (may be any type).
|
||||
* @returns An array of title ID strings.
|
||||
*/
|
||||
const parseUnlockedTitles = (raw: unknown): Array<string> => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter((item): item is string => {
|
||||
return typeof item === "string";
|
||||
});
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export { checkAndUnlockTitles, parseUnlockedTitles };
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @file Transcendence eligibility checks, echo calculations, and post-transcendence state builder.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||
import type {
|
||||
GameState,
|
||||
TranscendenceData,
|
||||
TranscendenceUpgradeCategory,
|
||||
} from "@elysium/types";
|
||||
|
||||
/**
|
||||
* ID of the boss that must be defeated to unlock transcendence.
|
||||
*/
|
||||
const finalBossId = "the_absolute_one";
|
||||
|
||||
/**
|
||||
* Base constant used in the echo yield formula.
|
||||
*/
|
||||
const echoFormulaConstant = 853;
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedIds: Array<string>,
|
||||
category: TranscendenceUpgradeCategory,
|
||||
): number => {
|
||||
return defaultTranscendenceUpgrades.filter((upgrade) => {
|
||||
return upgrade.category === category && purchasedIds.includes(upgrade.id);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all transcendence multipliers from the purchased upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased transcendence upgrade IDs.
|
||||
* @returns An object containing all transcendence multiplier values.
|
||||
*/
|
||||
const computeTranscendenceMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): Omit<TranscendenceData, "count" | "echoes" | "purchasedUpgradeIds"> => {
|
||||
return {
|
||||
echoCombatMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"combat",
|
||||
),
|
||||
echoIncomeMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"income",
|
||||
),
|
||||
echoMetaMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"echo_meta",
|
||||
),
|
||||
echoPrestigeRunestoneMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"prestige_runestones",
|
||||
),
|
||||
echoPrestigeThresholdMultiplier: getCategoryMultiplier(
|
||||
purchasedUpgradeIds,
|
||||
"prestige_threshold",
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when the player is eligible to transcend:
|
||||
* they must have defeated the final boss at least once.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for transcendence.
|
||||
*/
|
||||
const isEligibleForTranscendence = (state: GameState): boolean => {
|
||||
return state.bosses.some((boss) => {
|
||||
return boss.id === finalBossId && boss.status === "defeated";
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates echo yield for a transcendence.
|
||||
* Formula: floor(CONSTANT / sqrt(prestigeCount)) × echoMetaMultiplier.
|
||||
* Fewer prestiges = more echoes (rewards efficient play).
|
||||
* Minimum prestige count of 1 is enforced to avoid division by zero.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param echoMetaMultiplier - The echo meta multiplier from transcendence upgrades.
|
||||
* @returns The number of echoes earned.
|
||||
*/
|
||||
const calculateEchoes = (
|
||||
prestigeCount: number,
|
||||
echoMetaMultiplier: number,
|
||||
): number => {
|
||||
const safeCount = Math.max(prestigeCount, 1);
|
||||
const baseEchoes = echoFormulaConstant / Math.sqrt(safeCount);
|
||||
return Math.floor(baseEchoes * echoMetaMultiplier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the permanent-data spread objects that survive a transcendence reset.
|
||||
* @param currentState - The game state at the time of transcendence.
|
||||
* @param transcendenceData - The newly-computed transcendence data to carry forward.
|
||||
* @returns A partial GameState object containing all data that persists through transcendence.
|
||||
*/
|
||||
const buildPermanentSpreads = (
|
||||
currentState: GameState,
|
||||
transcendenceData: TranscendenceData,
|
||||
): Partial<GameState> => {
|
||||
return {
|
||||
transcendence: transcendenceData,
|
||||
...currentState.codex === undefined
|
||||
? {}
|
||||
: { codex: currentState.codex },
|
||||
...currentState.apotheosis === undefined
|
||||
? {}
|
||||
: { apotheosis: currentState.apotheosis },
|
||||
...currentState.story === undefined
|
||||
? {}
|
||||
: { story: currentState.story },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the new game state after a transcendence (nuclear reset).
|
||||
* Wipes everything except codex, dailyChallenges, and transcendence data.
|
||||
* @param currentState - The game state at the time of transcendence.
|
||||
* @param characterName - The player's character name to carry forward.
|
||||
* @returns The new game state, transcendence data, and echoes earned.
|
||||
*/
|
||||
const buildPostTranscendenceState = (
|
||||
currentState: GameState,
|
||||
characterName: string,
|
||||
): {
|
||||
transcendenceState: GameState;
|
||||
transcendenceData: TranscendenceData;
|
||||
echoesEarned: number;
|
||||
} => {
|
||||
const previousTranscendence = currentState.transcendence;
|
||||
const echoMetaMultiplier = previousTranscendence?.echoMetaMultiplier ?? 1;
|
||||
|
||||
const echoesEarned = calculateEchoes(
|
||||
currentState.prestige.count,
|
||||
echoMetaMultiplier,
|
||||
);
|
||||
const previousEchoes = previousTranscendence?.echoes ?? 0;
|
||||
const updatedCount = (previousTranscendence?.count ?? 0) + 1;
|
||||
const updatedPurchasedIds = previousTranscendence?.purchasedUpgradeIds ?? [];
|
||||
|
||||
const transcendenceData: TranscendenceData = {
|
||||
count: updatedCount,
|
||||
echoes: previousEchoes + echoesEarned,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...computeTranscendenceMultipliers(updatedPurchasedIds),
|
||||
};
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
const transcendenceState: GameState = {
|
||||
...freshState,
|
||||
lastTickAt: Date.now(),
|
||||
...buildPermanentSpreads(currentState, transcendenceData),
|
||||
};
|
||||
|
||||
return { echoesEarned, transcendenceData, transcendenceState };
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostTranscendenceState,
|
||||
calculateEchoes,
|
||||
computeTranscendenceMultipliers,
|
||||
isEligibleForTranscendence,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file Discord webhook and role-grant utilities for milestone events.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
|
||||
const discordApi = "https://discord.com/api/v10";
|
||||
|
||||
/**
|
||||
* Grants the apotheosis Discord role to the given player if configured.
|
||||
* Fails silently so role grant errors do not affect the game action.
|
||||
* @param discordId - The Discord user ID to grant the role to.
|
||||
* @returns A promise that resolves when the role grant attempt completes.
|
||||
*/
|
||||
const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
|
||||
|
||||
if (
|
||||
botToken === undefined || botToken === ""
|
||||
|| guildId === undefined || guildId === ""
|
||||
|| roleId === undefined || roleId === ""
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
|
||||
{
|
||||
headers: { Authorization: `Bot ${botToken}` },
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Graceful degradation — role grant failure must not affect the apotheosis
|
||||
}
|
||||
};
|
||||
|
||||
type MilestoneType = "prestige" | "transcendence" | "apotheosis";
|
||||
|
||||
interface MilestoneCounts {
|
||||
prestige: number;
|
||||
transcendence: number;
|
||||
apotheosis: number;
|
||||
}
|
||||
|
||||
const milestoneVerbs: Record<MilestoneType, string> = {
|
||||
apotheosis: "reached apotheosis",
|
||||
prestige: "prestiged",
|
||||
transcendence: "transcended",
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts a milestone announcement to the configured Discord webhook.
|
||||
* Fails silently so webhook errors do not affect the game action.
|
||||
* @param discordId - The Discord user ID of the player.
|
||||
* @param milestone - The type of milestone reached.
|
||||
* @param counts - The current prestige, transcendence, and apotheosis counts.
|
||||
* @returns A promise that resolves when the webhook post attempt completes.
|
||||
*/
|
||||
const postMilestoneWebhook = async(
|
||||
discordId: string,
|
||||
milestone: MilestoneType,
|
||||
counts: MilestoneCounts,
|
||||
): Promise<void> => {
|
||||
const webhookUrl = process.env.DISCORD_MILESTONE_WEBHOOK;
|
||||
if (webhookUrl === undefined || webhookUrl === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const verb = milestoneVerbs[milestone];
|
||||
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */
|
||||
const content = `<@${discordId}> has ${verb}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`;
|
||||
|
||||
try {
|
||||
await fetch(webhookUrl, {
|
||||
body: JSON.stringify({ content }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// Graceful degradation — webhook failure must not affect the game action
|
||||
}
|
||||
};
|
||||
|
||||
export { grantApotheosisRole, postMilestoneWebhook };
|
||||
Reference in New Issue
Block a user