feat: initial prototype — core game systems (#30)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s

## 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:
2026-03-08 15:53:39 -07:00
committed by Naomi Carrigan
parent c69e155de3
commit 29c817230d
172 changed files with 50706 additions and 0 deletions
+68
View File
@@ -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 };
+180
View File
@@ -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,
};
+115
View File
@@ -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 };
+92
View File
@@ -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 };
+92
View File
@@ -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 };
+246
View File
@@ -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,
};
+91
View File
@@ -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 };
+170
View File
@@ -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,
};
+89
View File
@@ -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 };