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,302 @@
|
||||
/**
|
||||
* @file API client for communicating with the Elysium backend.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type {
|
||||
AboutResponse,
|
||||
ApotheosisRequest,
|
||||
ApotheosisResponse,
|
||||
AuthResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyEchoUpgradeRequest,
|
||||
BuyEchoUpgradeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
ExploreStartRequest,
|
||||
ExploreStartResponse,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
const baseUrl = "/api";
|
||||
|
||||
const getToken = (): string | null => {
|
||||
return globalThis.localStorage.getItem("elysium_token");
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- HTTP header names require specific casing */
|
||||
const buildHeaders = (): Record<string, string> => {
|
||||
const token = getToken();
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...token !== null && token.length > 0
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {},
|
||||
};
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- HTTP header names require specific casing */
|
||||
|
||||
const fetchJson = async <T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: { ...buildHeaders(), ...options?.headers },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON error response requires type assertion */
|
||||
const errorBody = (await response.json().catch(() => {
|
||||
return { error: "Unknown error" };
|
||||
})) as Record<string, unknown>;
|
||||
const message
|
||||
= typeof errorBody.error === "string"
|
||||
? errorBody.error
|
||||
: "Unknown error";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON response requires type assertion */
|
||||
return await (response.json() as Promise<T>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the about information from the API.
|
||||
* @returns The about response data.
|
||||
*/
|
||||
const getAbout = async(): Promise<AboutResponse> => {
|
||||
return await fetchJson<AboutResponse>("/about");
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the Discord OAuth URL from the API.
|
||||
* @returns The authentication URL string.
|
||||
*/
|
||||
const getAuthUrl = async(): Promise<string> => {
|
||||
const data = await fetchJson<{ url: string }>("/auth/url");
|
||||
return data.url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the Discord OAuth callback and stores the auth token.
|
||||
* @param code - The OAuth authorization code from Discord.
|
||||
* @returns The authentication response data.
|
||||
*/
|
||||
const handleAuthCallback = async(code: string): Promise<AuthResponse> => {
|
||||
const data = await fetchJson<AuthResponse>(`/auth/callback?code=${code}`);
|
||||
globalThis.localStorage.setItem("elysium_token", data.token);
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the current game state from the server.
|
||||
* @returns The load response containing the game state.
|
||||
*/
|
||||
const loadGame = async(): Promise<LoadResponse> => {
|
||||
return await fetchJson<LoadResponse>("/game/load");
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets all game progress on the server.
|
||||
* @returns The load response after reset.
|
||||
*/
|
||||
const resetProgress = async(): Promise<LoadResponse> => {
|
||||
return await fetchJson<LoadResponse>("/game/reset", { method: "POST" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves the current game state to the server.
|
||||
* @param body - The save request payload containing the game state.
|
||||
* @returns The save response data.
|
||||
*/
|
||||
const saveGame = async(body: SaveRequest): Promise<SaveResponse> => {
|
||||
return await fetchJson<SaveResponse>("/game/save", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Challenges a boss with the current game state.
|
||||
* @param body - The boss challenge request payload.
|
||||
* @returns The boss challenge response data.
|
||||
*/
|
||||
const challengeBoss = async(
|
||||
body: BossChallengeRequest,
|
||||
): Promise<BossChallengeResponse> => {
|
||||
return await fetchJson<BossChallengeResponse>("/boss/challenge", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers a prestige reset on the server.
|
||||
* @param body - The prestige request payload.
|
||||
* @returns The prestige response data.
|
||||
*/
|
||||
const prestige = async(body: PrestigeRequest): Promise<PrestigeResponse> => {
|
||||
return await fetchJson<PrestigeResponse>("/prestige", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases a prestige upgrade on the server.
|
||||
* @param body - The buy prestige upgrade request payload.
|
||||
* @returns The buy prestige upgrade response data.
|
||||
*/
|
||||
const buyPrestigeUpgrade = async(
|
||||
body: BuyPrestigeUpgradeRequest,
|
||||
): Promise<BuyPrestigeUpgradeResponse> => {
|
||||
return await fetchJson<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers a transcendence reset on the server.
|
||||
* @param body - The transcendence request payload.
|
||||
* @returns The transcendence response data.
|
||||
*/
|
||||
const transcend = async(
|
||||
body: TranscendenceRequest,
|
||||
): Promise<TranscendenceResponse> => {
|
||||
return await fetchJson<TranscendenceResponse>("/transcendence", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases an echo upgrade on the server.
|
||||
* @param body - The buy echo upgrade request payload.
|
||||
* @returns The buy echo upgrade response data.
|
||||
*/
|
||||
const buyEchoUpgrade = async(
|
||||
body: BuyEchoUpgradeRequest,
|
||||
): Promise<BuyEchoUpgradeResponse> => {
|
||||
return await fetchJson<BuyEchoUpgradeResponse>("/transcendence/buy-upgrade", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers an apotheosis reset on the server.
|
||||
* @param body - The apotheosis request payload.
|
||||
* @returns The apotheosis response data.
|
||||
*/
|
||||
const achieveApotheosis = async(
|
||||
body: ApotheosisRequest,
|
||||
): Promise<ApotheosisResponse> => {
|
||||
return await fetchJson<ApotheosisResponse>("/apotheosis", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts an exploration in a given area.
|
||||
* @param body - The exploration start request payload.
|
||||
* @returns The exploration start response data.
|
||||
*/
|
||||
const startExploration = async(
|
||||
body: ExploreStartRequest,
|
||||
): Promise<ExploreStartResponse> => {
|
||||
return await fetchJson<ExploreStartResponse>("/explore/start", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects the rewards from a completed exploration.
|
||||
* @param body - The exploration collect request payload.
|
||||
* @returns The exploration collect response data.
|
||||
*/
|
||||
const collectExploration = async(
|
||||
body: ExploreCollectRequest,
|
||||
): Promise<ExploreCollectResponse> => {
|
||||
return await fetchJson<ExploreCollectResponse>("/explore/collect", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Crafts a recipe on the server.
|
||||
* @param body - The craft recipe request payload.
|
||||
* @returns The craft recipe response data.
|
||||
*/
|
||||
const craftRecipe = async(
|
||||
body: CraftRecipeRequest,
|
||||
): Promise<CraftRecipeResponse> => {
|
||||
return await fetchJson<CraftRecipeResponse>("/craft", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a public player profile by Discord ID.
|
||||
* @param discordId - The Discord ID of the player to look up.
|
||||
* @returns The public profile response data.
|
||||
*/
|
||||
const getPublicProfile = async(
|
||||
discordId: string,
|
||||
): Promise<PublicProfileResponse> => {
|
||||
return await fetchJson<PublicProfileResponse>(`/profile/${discordId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the current player's profile.
|
||||
* @param body - The update profile request payload.
|
||||
* @returns The update profile response data.
|
||||
*/
|
||||
const updateProfile = async(
|
||||
body: UpdateProfileRequest,
|
||||
): Promise<UpdateProfileResponse> => {
|
||||
return await fetchJson<UpdateProfileResponse>("/profile", {
|
||||
body: JSON.stringify(body),
|
||||
method: "PUT",
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
achieveApotheosis,
|
||||
buyEchoUpgrade,
|
||||
buyPrestigeUpgrade,
|
||||
challengeBoss,
|
||||
collectExploration,
|
||||
craftRecipe,
|
||||
getAbout,
|
||||
getAuthUrl,
|
||||
getPublicProfile,
|
||||
handleAuthCallback,
|
||||
loadGame,
|
||||
prestige,
|
||||
resetProgress,
|
||||
saveGame,
|
||||
startExploration,
|
||||
transcend,
|
||||
updateProfile,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @file Root application component that handles routing and authentication.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type JSX, useState } from "react";
|
||||
import { CharacterPage } from "./components/game/characterPage.js";
|
||||
import { GameLayout } from "./components/game/gameLayout.js";
|
||||
import { LeaderboardPage } from "./components/game/leaderboardPage.js";
|
||||
import { LoginPage } from "./components/game/loginPage.js";
|
||||
import { ProfilePage } from "./components/game/profilePage.js";
|
||||
import { GameProvider } from "./context/gameContext.js";
|
||||
|
||||
const getProfileDiscordId = (): string | null => {
|
||||
const match = /^\/profile\/(?<id>\d+)$/.exec(window.location.pathname);
|
||||
return match?.groups?.id ?? null;
|
||||
};
|
||||
|
||||
const getCharacterDiscordId = (): string | null => {
|
||||
const match = /^\/character\/(?<id>\d+)$/.exec(window.location.pathname);
|
||||
return match?.groups?.id ?? null;
|
||||
};
|
||||
|
||||
const handleAuthCallback = (): boolean => {
|
||||
if (window.location.pathname !== "/auth/callback") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parameters = new URLSearchParams(window.location.search);
|
||||
const token = parameters.get("token");
|
||||
|
||||
if (token !== null && token.length > 0) {
|
||||
localStorage.setItem("elysium_token", token);
|
||||
}
|
||||
|
||||
window.history.replaceState(null, "", "/");
|
||||
return token !== null && token.length > 0;
|
||||
};
|
||||
|
||||
const isAuthenticated = (): boolean => {
|
||||
const fromCallback = handleAuthCallback();
|
||||
if (fromCallback) {
|
||||
return true;
|
||||
}
|
||||
const storedToken = localStorage.getItem("elysium_token");
|
||||
return storedToken !== null && storedToken.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the root application component, handling routing and authentication.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const app = (): JSX.Element => {
|
||||
const [ loggedIn, setLoggedIn ] = useState(isAuthenticated);
|
||||
|
||||
const profileDiscordId = getProfileDiscordId();
|
||||
if (profileDiscordId !== null) {
|
||||
return <ProfilePage discordId={profileDiscordId} />;
|
||||
}
|
||||
|
||||
const characterDiscordId = getCharacterDiscordId();
|
||||
if (characterDiscordId !== null) {
|
||||
return <CharacterPage discordId={characterDiscordId} />;
|
||||
}
|
||||
|
||||
if (window.location.pathname === "/leaderboards") {
|
||||
return <LeaderboardPage />;
|
||||
}
|
||||
|
||||
function handleLogin(): void {
|
||||
setLoggedIn(true);
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
return <LoginPage onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GameProvider>
|
||||
<GameLayout />
|
||||
</GameProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export { app as App };
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* @file About panel component displaying changelog and how-to-play guide.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- HOW_TO_PLAY data and render logic */
|
||||
/* eslint-disable max-lines -- HOW_TO_PLAY data makes this file long */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { getAbout } from "../../api/client.js";
|
||||
import type { AboutResponse } from "@elysium/types";
|
||||
|
||||
const howToPlay = [
|
||||
{
|
||||
body:
|
||||
"Hire adventurers to earn gold and essence automatically. Each tier is"
|
||||
+ " more powerful than the last. Adventurers also contribute combat"
|
||||
+ " power for boss fights — the more you recruit, the stronger your"
|
||||
+ " party becomes.",
|
||||
title: "⚔️ Adventurers",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Click the guild hall to earn gold manually. Upgrades and equipment can"
|
||||
+ " dramatically increase your gold per click. Clicking is especially"
|
||||
+ " powerful in the early game and when saving up for big purchases.",
|
||||
title: "👆 Clicking",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Purchase upgrades to multiply the gold and essence output of specific"
|
||||
+ " adventurer tiers, or boost your whole guild. Upgrades are permanent"
|
||||
+ " for the current run and compound with each other.",
|
||||
title: "🔧 Upgrades",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Send your guild on quests that complete over time and reward gold,"
|
||||
+ " essence, crystals, equipment, and upgrades. Multiple quests can run"
|
||||
+ " simultaneously. Completing quests also unlocks new zones.",
|
||||
title: "📜 Quests",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Challenge zone bosses to earn large one-time rewards and unlock new"
|
||||
+ " zones. Your party's combat power is based on the number and tier of"
|
||||
+ " adventurers you've recruited. Defeated bosses cannot be re-fought,"
|
||||
+ " but undefeated bosses regenerate HP over time.",
|
||||
title: "👹 Boss Fights",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"New zones unlock when you defeat the final boss AND complete the final"
|
||||
+ " quest of the previous zone. Each zone contains new bosses and"
|
||||
+ " quests with progressively greater rewards.",
|
||||
title: "🗺️ Zones",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Earn equipment from boss drops and quest rewards. Each piece provides"
|
||||
+ " bonuses to gold income, click power, or combat. Rarer equipment"
|
||||
+ " provides stronger bonuses. Equip matching set pieces (2 or 3 of a"
|
||||
+ " named set) to unlock escalating set bonuses shown at the top of the"
|
||||
+ " Equipment panel.",
|
||||
title: "🗡️ Equipment & Sets",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"When you've progressed far enough, you can prestige to earn runestones"
|
||||
+ " — a permanent currency that persists across all runs. Prestige"
|
||||
+ " resets your current run but grants a production multiplier that"
|
||||
+ " stacks with every prestige.",
|
||||
title: "⭐ Prestige",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Spend runestones in the Prestige Shop on permanent upgrades that carry"
|
||||
+ " over across all future runs. These upgrades multiply income, click"
|
||||
+ " power, essence, and crystal gain — making each new run more powerful"
|
||||
+ " than the last.",
|
||||
title: "🔮 Runestones & Prestige Upgrades",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Purchase the Autonomous Ascension upgrade in the Prestige Shop"
|
||||
+ " (100 runestones) to unlock the Auto-Prestige toggle. When enabled,"
|
||||
+ " you will automatically ascend the moment you reach the prestige"
|
||||
+ " threshold, using your current character name. Toggle it on and off"
|
||||
+ " freely from the Prestige Shop.",
|
||||
title: "⚙️ Auto-Prestige",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Earn achievements by hitting milestones — total gold earned, bosses"
|
||||
+ " defeated, quests completed, and more. Achievements are purely"
|
||||
+ " cosmetic and track your long-term progress across all prestige runs.",
|
||||
title: "🏆 Achievements",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Complete daily challenges for bonus rewards including gold, essence,"
|
||||
+ " crystals, and runestones. Challenges reset each day and vary in"
|
||||
+ " difficulty. Completing all daily challenges gives an extra bonus"
|
||||
+ " reward.",
|
||||
title: "📅 Daily Challenges",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Send scouts to explore areas within each zone. Explorations run in"
|
||||
+ " real-time and reward gold, essence, and crafting materials when"
|
||||
+ " collected. Each area has a set duration — short explorations are"
|
||||
+ " faster but longer ones offer rarer finds. A 📖 icon marks areas"
|
||||
+ " you've collected from at least once, unlocking a Codex entry.",
|
||||
title: "🗺️ Exploration",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Use materials gathered from exploration to craft permanent bonuses."
|
||||
+ " Each recipe provides a multiplier to gold income, essence income,"
|
||||
+ " click power, or combat power — all of which stack and persist across"
|
||||
+ " prestige runs. Check the Crafting tab to see your material inventory"
|
||||
+ " and available recipes per zone.",
|
||||
title: "⚗️ Crafting",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Defeating bosses, completing quests, acquiring equipment, hiring"
|
||||
+ " adventurers, purchasing upgrades, unlocking prestige upgrades,"
|
||||
+ " discovering new zones, collecting from exploration areas, and"
|
||||
+ " crafting recipes all permanently unlock lore entries in the Codex."
|
||||
+ " A badge appears on the Codex tab and a toast notification pops up"
|
||||
+ " each time new lore is discovered. Collect all 472 entries to build"
|
||||
+ " a complete picture of the world of Elysium.",
|
||||
title: "📖 Codex",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Visit the Character tab to write about your character and guild. Fill"
|
||||
+ " in your character's name, pronouns, race, class, and backstory,"
|
||||
+ " then create a guild with its own name and lore. Your character sheet"
|
||||
+ " is visible on your public profile page.",
|
||||
title: "📋 Character Sheet",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Earn Titles by reaching milestones — defeating bosses, completing"
|
||||
+ " quests, prestiging, and more. Once unlocked, titles are yours"
|
||||
+ " forever and are never lost on prestige or transcendence resets. Set"
|
||||
+ " your active title from the Character tab to display it on your"
|
||||
+ " character sheet and public profile.",
|
||||
title: "🏅 Titles",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Defeat bosses to earn equipment drops: weapons, armour, and trinkets."
|
||||
+ " Each item provides bonuses to gold income, combat power, or click"
|
||||
+ " power. Only one item per slot can be equipped at a time — visit the"
|
||||
+ " Equipment panel to manage your loadout. Your currently equipped"
|
||||
+ " items are displayed on your character sheet and public profile.",
|
||||
title: "🗡️ Equipment",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Compete with other adventurers on the public Leaderboards page!"
|
||||
+ " Categories include Lifetime Gold, Bosses Defeated, Quests"
|
||||
+ " Completed, Achievements, Prestige Count, Transcendence Count, and"
|
||||
+ " Apotheosis Count. Click any player's row to view their character"
|
||||
+ " sheet. You can opt out of appearing on leaderboards via the Privacy"
|
||||
+ " section in your profile settings.",
|
||||
title: "🏆 Leaderboards",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Log in every day to earn escalating rewards! Each consecutive day"
|
||||
+ " awards more gold, and the 7th day of your streak grants bonus"
|
||||
+ " crystals. Your streak resets if you miss a day. A week multiplier"
|
||||
+ " increases all rewards the longer your overall streak runs. Your"
|
||||
+ " current streak is displayed on your character sheet.",
|
||||
title: "🔥 Daily Login Bonus",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Toggle automation in the Quests and Boss Encounters panels! Auto-Quest"
|
||||
+ " automatically sends your party on the highest-zone available quest"
|
||||
+ " as soon as one completes, skipping quests whose combat power"
|
||||
+ " requirement isn't met. Auto-Boss automatically challenges the"
|
||||
+ " highest available boss as soon as one is ready. Both can be toggled"
|
||||
+ " on or off at any time using the 🤖 Auto button in each panel"
|
||||
+ " header.",
|
||||
title: "🤖 Auto-Quest & Auto-Boss",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Unlock companions by reaching certain milestones across all your runs."
|
||||
+ " Each companion provides a powerful permanent bonus: increased"
|
||||
+ " passive gold, click gold, boss damage, essence income, or reduced"
|
||||
+ " quest time. You can only have one companion active at a time —"
|
||||
+ " choose wisely based on your current strategy! Companions are"
|
||||
+ " unlocked permanently once their condition is met and will never be"
|
||||
+ " lost.",
|
||||
title: "👥 Companions",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Your progress is automatically saved to the cloud every 30 seconds"
|
||||
+ " whilst you play. You can also force a manual save at any time using"
|
||||
+ " the sync button in the resource bar. Your save is protected by HMAC"
|
||||
+ " validation to ensure data integrity.",
|
||||
title: "☁️ Cloud Saves",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Transcendence is the ultimate prestige layer, unlocked by defeating"
|
||||
+ " The Absolute One (requires Prestige 90). Transcending performs a"
|
||||
+ " nuclear reset — wiping resources, prestige, runestones, upgrades,"
|
||||
+ " and equipment — but grants Echoes based on your prestige count"
|
||||
+ " (fewer prestiges = more Echoes). Echoes are permanent and survive"
|
||||
+ " all future resets. Spend them in the Echo Shop on lasting"
|
||||
+ " multipliers: passive income, combat power, prestige"
|
||||
+ " quality-of-life, and Echo meta upgrades that amplify future Echo"
|
||||
+ " yields.",
|
||||
title: "🌌 Transcendence",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Apotheosis is the final act — a complete dissolution of everything you"
|
||||
+ " have built, including your prestige and transcendence progress. It"
|
||||
+ " is unlocked once you have purchased every Transcendence upgrade. In"
|
||||
+ " exchange for this total reset, you receive the Apotheosis badge:"
|
||||
+ " pure bragging rights, a mark of reaching the absolute pinnacle of"
|
||||
+ " the game. Apotheosis can be achieved multiple times; each cycle"
|
||||
+ " requires purchasing all Transcendence upgrades again. Your Codex"
|
||||
+ " entries and lifetime profile statistics are always preserved.",
|
||||
title: "✨ Apotheosis",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"The Story tab contains 22 chapters that unlock as you progress. The"
|
||||
+ " first 18 unlock when you defeat the final boss of each zone."
|
||||
+ " Chapters 19 and 20 unlock after your first and fifth prestige"
|
||||
+ " respectively. Chapter 21 unlocks on your first transcendence, and"
|
||||
+ " Chapter 22 on your first apotheosis. Each chapter presents a"
|
||||
+ " narrative moment and three choices — the choice you make is recorded"
|
||||
+ " on your Character Sheet and shapes your guild's story. Story"
|
||||
+ " progress is permanent and survives all resets.",
|
||||
title: "📖 Story",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Enable sound effects and browser notifications in your profile settings"
|
||||
+ " (click your character name in the top bar). Sound effects play when"
|
||||
+ " you defeat a boss, complete or fail a quest, unlock an achievement,"
|
||||
+ " prestige, transcend, or achieve apotheosis. Browser notifications"
|
||||
+ " alert you to the same events even when the game tab is in the"
|
||||
+ " background. You will be prompted to grant notification permission"
|
||||
+ " when you first enable them.",
|
||||
title: "🔔 Sounds & Notifications",
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the about panel with changelog and how-to-play sections.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const aboutPanel = (): JSX.Element => {
|
||||
const [ about, setAbout ] = useState<AboutResponse | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ expandedRelease, setExpandedRelease ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAbout().
|
||||
then(setAbout).
|
||||
catch((caughtError: unknown) => {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to load about data.",
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="panel about-panel">
|
||||
<h2>{"ℹ️ About"}</h2>
|
||||
|
||||
<h3 className="stats-section-header">{"📋 Changelog"}</h3>
|
||||
{error !== null && <p className="about-error">{error}</p>}
|
||||
{about === null && error === null
|
||||
&& <p className="about-loading">{"Loading changelog..."}</p>
|
||||
}
|
||||
{about !== null && about.releases.length === 0
|
||||
&& <p className="about-empty">{"No releases yet."}</p>
|
||||
}
|
||||
{about !== null && about.releases.length > 0
|
||||
&& <ul className="about-releases">
|
||||
{about.releases.map((release) => {
|
||||
function handleToggle(): void {
|
||||
setExpandedRelease(
|
||||
expandedRelease === release.tag_name
|
||||
? null
|
||||
: release.tag_name,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li className="about-release" key={release.tag_name}>
|
||||
<button
|
||||
className="about-release-header"
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="about-release-tag">
|
||||
{release.name.length > 0
|
||||
? release.name
|
||||
: release.tag_name}
|
||||
</span>
|
||||
<span className="about-release-date">
|
||||
{formatDate(release.published_at)}
|
||||
</span>
|
||||
<span className="about-release-chevron">
|
||||
{expandedRelease === release.tag_name
|
||||
? "▲"
|
||||
: "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expandedRelease === release.tag_name
|
||||
&& <pre className="about-release-body">{release.body}</pre>
|
||||
}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<h3 className="stats-section-header">{"📖 How to Play"}</h3>
|
||||
<ul className="about-how-to-play">
|
||||
{howToPlay.map((section) => {
|
||||
return (
|
||||
<li className="about-htp-section" key={section.title}>
|
||||
<h4 className="about-htp-title">{section.title}</h4>
|
||||
<p className="about-htp-body">{section.body}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { aboutPanel as AboutPanel };
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @file Achievement panel component displaying all game achievements.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Returns the plural form of a word based on a count.
|
||||
* @param count - The count to check.
|
||||
* @param word - The base word to pluralise.
|
||||
* @returns The pluralised word string.
|
||||
*/
|
||||
const pluralise = (count: number, word: string): string => {
|
||||
return count > 1
|
||||
? `${word}s`
|
||||
: word;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a human-readable condition description for an achievement.
|
||||
* @param achievement - The achievement to describe.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns A string describing the achievement condition.
|
||||
*/
|
||||
const conditionDescription = (
|
||||
achievement: Achievement,
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
return `Earn ${formatNumber(condition.amount)} total gold`;
|
||||
case "totalClicks":
|
||||
return `Click ${formatNumber(condition.amount)} times`;
|
||||
case "bossesDefeated":
|
||||
return `Defeat ${String(condition.amount)} ${pluralise(condition.amount, "boss")}`;
|
||||
case "questsCompleted":
|
||||
return `Complete ${String(condition.amount)} ${pluralise(condition.amount, "quest")}`;
|
||||
case "adventurerTotal":
|
||||
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
|
||||
case "prestigeCount":
|
||||
return `Prestige ${String(condition.amount)} ${pluralise(condition.amount, "time")}`;
|
||||
case "equipmentOwned":
|
||||
return `Own ${String(condition.amount)} equipment ${pluralise(condition.amount, "item")}`;
|
||||
default:
|
||||
return "Unknown condition";
|
||||
}
|
||||
};
|
||||
|
||||
interface AchievementCardProperties {
|
||||
readonly achievement: Achievement;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single achievement card.
|
||||
* @param props - The achievement card properties.
|
||||
* @param props.achievement - The achievement to display.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AchievementCard = ({
|
||||
achievement,
|
||||
formatNumber,
|
||||
}: AchievementCardProperties): JSX.Element => {
|
||||
const isUnlocked = achievement.unlockedAt !== null;
|
||||
const crystals = achievement.reward?.crystals;
|
||||
|
||||
return (
|
||||
<div className={`achievement-card ${isUnlocked
|
||||
? "unlocked"
|
||||
: "locked"}`}>
|
||||
<div className="achievement-icon">{achievement.icon}</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">
|
||||
{conditionDescription(achievement, formatNumber)}
|
||||
</p>
|
||||
{crystals !== undefined
|
||||
&& <p className="achievement-reward">
|
||||
{"💎 +"}
|
||||
{crystals}
|
||||
{" Crystals"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked
|
||||
? <span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the achievement panel with all achievements.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function -- Achievement panel renders many achievement states
|
||||
const AchievementPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const achievementList = state.achievements;
|
||||
const unlocked = achievementList.filter((a) => {
|
||||
return a.unlockedAt !== null;
|
||||
});
|
||||
const locked = achievementList.filter((a) => {
|
||||
return a.unlockedAt === null;
|
||||
});
|
||||
const visible = showLocked
|
||||
? achievementList
|
||||
: unlocked;
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel achievement-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Achievements"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="achievement-progress">
|
||||
{unlocked.length}
|
||||
{" / "}
|
||||
{achievementList.length}
|
||||
{" unlocked"}
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{visible.map((achievement) => {
|
||||
return (
|
||||
<AchievementCard
|
||||
achievement={achievement}
|
||||
formatNumber={formatNumber}
|
||||
key={achievement.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AchievementPanel };
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Achievement toast notification component.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
interface ToastItemProperties {
|
||||
readonly achievement: Achievement;
|
||||
readonly onDismiss: (id: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single achievement toast item.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.achievement - The achievement to display.
|
||||
* @param props.onDismiss - Callback to dismiss the toast.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ToastItem = ({
|
||||
achievement,
|
||||
onDismiss,
|
||||
}: ToastItemProperties): JSX.Element => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(achievement.id);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ achievement.id, onDismiss ]);
|
||||
|
||||
function handleClick(): void {
|
||||
onDismiss(achievement.id);
|
||||
}
|
||||
|
||||
const crystals = achievement.reward?.crystals;
|
||||
|
||||
return (
|
||||
<div className="achievement-toast" onClick={handleClick}>
|
||||
<span className="toast-icon">{achievement.icon}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">{"Achievement Unlocked!"}</span>
|
||||
<span className="toast-name">{achievement.name}</span>
|
||||
{crystals !== undefined
|
||||
&& <span className="toast-reward">
|
||||
{"💎 +"}
|
||||
{crystals}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the achievement toast container with pending achievement notifications.
|
||||
* @returns The JSX element or null if there are no pending achievements.
|
||||
*/
|
||||
const AchievementToast = (): JSX.Element | null => {
|
||||
const { unlockedAchievements: pendingAchievements, dismissAchievement }
|
||||
= useGame();
|
||||
|
||||
if (pendingAchievements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingAchievements.map((achievement) => {
|
||||
return (
|
||||
<ToastItem
|
||||
achievement={achievement}
|
||||
key={achievement.id}
|
||||
onDismiss={dismissAchievement}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { AchievementToast };
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @file Adventurer panel component for hiring and managing adventurers.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
|
||||
const iconByClass: Record<string, string> = {
|
||||
cleric: "✝️",
|
||||
mage: "🔮",
|
||||
paladin: "🛡️",
|
||||
ranger: "🏹",
|
||||
rogue: "🗝️",
|
||||
warrior: "🗡️",
|
||||
};
|
||||
|
||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
||||
|
||||
/**
|
||||
* Computes the total cost to buy a batch of adventurers.
|
||||
* @param adventurer - The adventurer to buy.
|
||||
* @param quantity - The number to buy.
|
||||
* @returns The total gold cost.
|
||||
*/
|
||||
const computeBatchCost = (adventurer: Adventurer, quantity: number): number => {
|
||||
let total = 0;
|
||||
for (let index = 0; index < quantity; index = index + 1) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
|
||||
total = total + cost;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the maximum number of adventurers affordable with given gold.
|
||||
* @param adventurer - The adventurer type.
|
||||
* @param gold - The available gold.
|
||||
* @returns The maximum affordable quantity.
|
||||
*/
|
||||
const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
||||
let total = 0;
|
||||
let quantity = 0;
|
||||
for (let index = 0; index < 100_000; index = index + 1) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
|
||||
if (total + cost > gold) {
|
||||
break;
|
||||
}
|
||||
total = total + cost;
|
||||
quantity = quantity + 1;
|
||||
}
|
||||
return quantity;
|
||||
};
|
||||
|
||||
interface AdventurerCardProperties {
|
||||
readonly adventurer: Adventurer;
|
||||
readonly currentGold: number;
|
||||
readonly batchSize: BatchSize;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single adventurer card with buy controls.
|
||||
* @param props - The adventurer card properties.
|
||||
* @param props.adventurer - The adventurer data.
|
||||
* @param props.currentGold - The current gold available.
|
||||
* @param props.batchSize - The selected batch size.
|
||||
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerCard = ({
|
||||
adventurer,
|
||||
currentGold,
|
||||
batchSize,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: AdventurerCardProperties): JSX.Element => {
|
||||
const { buyAdventurer } = useGame();
|
||||
|
||||
const resolvedQuantity
|
||||
= batchSize === "max"
|
||||
? computeMaxAffordable(adventurer, currentGold)
|
||||
: batchSize;
|
||||
const cost = computeBatchCost(adventurer, resolvedQuantity);
|
||||
const canAfford = resolvedQuantity > 0 && currentGold >= cost;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyAdventurer(adventurer.id, resolvedQuantity);
|
||||
}
|
||||
|
||||
const maxSuffix
|
||||
= batchSize === "max" && resolvedQuantity > 0
|
||||
? ` (×${String(resolvedQuantity)})`
|
||||
: "";
|
||||
const buttonLabel = adventurer.unlocked
|
||||
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||
: "🔒 Locked";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
||||
|
||||
return (
|
||||
<div className={`adventurer-card ${adventurer.unlocked
|
||||
? ""
|
||||
: "locked"}`}>
|
||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>
|
||||
{formatNumber(adventurer.goldPerSecond)}
|
||||
{" gold/s each"}
|
||||
</p>
|
||||
{adventurer.essencePerSecond > 0
|
||||
&& <p>
|
||||
{formatNumber(adventurer.essencePerSecond)}
|
||||
{" essence/s each"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className="adventurer-count">
|
||||
{"×"}
|
||||
{adventurer.count}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford || !adventurer.unlocked}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
{!adventurer.unlocked && unlockHint !== undefined
|
||||
? <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the adventurer panel with all available adventurers.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(1);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const locked = state.adventurers.filter((adventurer) => {
|
||||
return !adventurer.unlocked;
|
||||
});
|
||||
const visible = showLocked
|
||||
? state.adventurers
|
||||
: state.adventurers.filter((adventurer) => {
|
||||
return adventurer.unlocked;
|
||||
});
|
||||
|
||||
const adventurerUnlockHints = new Map<string, string>();
|
||||
for (const quest of state.quests) {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "adventurer" && reward.targetId !== undefined) {
|
||||
adventurerUnlockHints.set(reward.targetId, quest.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel adventurer-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Adventurers"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((option) => {
|
||||
function handleBatchSelect(): void {
|
||||
setBatchSize(option);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`batch-button ${batchSize === option
|
||||
? "active"
|
||||
: ""}`}
|
||||
key={option}
|
||||
onClick={handleBatchSelect}
|
||||
type="button"
|
||||
>
|
||||
{option === "max"
|
||||
? "xMax"
|
||||
: `x${String(option)}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="adventurer-list">
|
||||
{visible.map((adventurer) => {
|
||||
return (
|
||||
<AdventurerCard
|
||||
adventurer={adventurer}
|
||||
batchSize={batchSize}
|
||||
currentGold={state.resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={adventurer.id}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AdventurerPanel };
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file Apotheosis panel component for the final prestige layer.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { TRANSCENDENCE_UPGRADES } from "../../data/transcendenceUpgrades.js";
|
||||
|
||||
const totalEchoUpgrades = TRANSCENDENCE_UPGRADES.length;
|
||||
|
||||
/**
|
||||
* Renders the apotheosis panel for achieving the final game milestone.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ApotheosisPanel = (): JSX.Element => {
|
||||
const { state, apotheosis } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<number | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const purchasedIds = state.transcendence?.purchasedUpgradeIds ?? [];
|
||||
const purchasedCount = TRANSCENDENCE_UPGRADES.filter((upgrade) => {
|
||||
return purchasedIds.includes(upgrade.id);
|
||||
}).length;
|
||||
const isEligible = purchasedCount >= totalEchoUpgrades;
|
||||
const apotheosisCount = state.apotheosis?.count ?? 0;
|
||||
|
||||
async function handleApotheosis(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apotheosis();
|
||||
setResult(data.newApotheosisCount);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Apotheosis failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApotheosisClick(): void {
|
||||
void handleApotheosis();
|
||||
}
|
||||
|
||||
const plural = apotheosisCount === 1
|
||||
? ""
|
||||
: "s";
|
||||
|
||||
return (
|
||||
<section className="panel apotheosis-panel">
|
||||
<h2>{"✨ Apotheosis"}</h2>
|
||||
|
||||
<p className="apotheosis-intro">
|
||||
{"Apotheosis is the final act — a complete dissolution of everything"
|
||||
+ " you have built. Prestige, Transcendence, Echoes, upgrades,"
|
||||
+ " equipment, resources: all of it returns to nothing."
|
||||
+ " In exchange, you receive only one thing:"}
|
||||
</p>
|
||||
<p className="apotheosis-reward">
|
||||
{"The "}
|
||||
<strong>{"✨ Apotheosis"}</strong>
|
||||
{" badge. Proof that you have done it all."}
|
||||
</p>
|
||||
<p className="apotheosis-intro">
|
||||
{"Apotheosis can be achieved multiple times. Each cycle requires"
|
||||
+ " you to purchase every Transcendence upgrade again before the"
|
||||
+ " next Apotheosis becomes available. There is no mechanical"
|
||||
+ " benefit — only the knowledge that you have reached the"
|
||||
+ " pinnacle, dissolved it, and climbed back up."}
|
||||
</p>
|
||||
|
||||
{apotheosisCount > 0
|
||||
&& <div className="apotheosis-count">
|
||||
<span>
|
||||
{"You have achieved Apotheosis "}
|
||||
<strong>{apotheosisCount}</strong>
|
||||
{" time"}
|
||||
{plural}
|
||||
{"."}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="apotheosis-status">
|
||||
<p>
|
||||
{"Transcendence upgrades purchased: "}
|
||||
<strong>
|
||||
{purchasedCount}
|
||||
{" / "}
|
||||
{totalEchoUpgrades}
|
||||
</strong>
|
||||
</p>
|
||||
{isEligible
|
||||
? null
|
||||
: <p className="apotheosis-missing">
|
||||
{"🔒 Purchase all "}
|
||||
{totalEchoUpgrades}
|
||||
{" Transcendence upgrades to unlock Apotheosis. ("}
|
||||
{totalEchoUpgrades - purchasedCount}
|
||||
{" remaining)"}
|
||||
</p>
|
||||
}
|
||||
{isEligible
|
||||
? <p className="apotheosis-ready">
|
||||
{"✅ All Transcendence upgrades purchased. You are ready."}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"This action is "}
|
||||
<strong>{"permanent and irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="apotheosis-button"
|
||||
disabled={isPending}
|
||||
onClick={handleApotheosisClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Ascending..."
|
||||
: "✨ Achieve Apotheosis"}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
: <p className="error">{error}</p>}
|
||||
{result !== null
|
||||
&& <p className="success">
|
||||
{"Apotheosis achieved. This is cycle "}
|
||||
<strong>{result}</strong>
|
||||
{". The infinite loop continues."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ApotheosisPanel };
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @file Battle modal component displaying animated battle results.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex battle animation and result display */
|
||||
/* eslint-disable complexity -- Battle result display requires many conditional paths */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { type BattleResult, useGame } from "../../context/gameContext.js";
|
||||
|
||||
/**
|
||||
* Converts HP values to a percentage for display.
|
||||
* @param current - The current HP value.
|
||||
* @param maximum - The maximum HP value.
|
||||
* @returns The percentage as a number between 0 and 100.
|
||||
*/
|
||||
const toHpPercent = (current: number, maximum: number): number => {
|
||||
if (maximum === 0) {
|
||||
return 0;
|
||||
}
|
||||
const scaled = current * 100;
|
||||
return scaled / maximum;
|
||||
};
|
||||
|
||||
interface BattleModalProperties {
|
||||
readonly battle: BattleResult;
|
||||
readonly onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the battle modal with HP bars and animated battle results.
|
||||
* @param props - The battle modal properties.
|
||||
* @param props.battle - The battle result data to display.
|
||||
* @param props.onDismiss - Callback to dismiss the modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BattleModal = ({
|
||||
battle,
|
||||
onDismiss,
|
||||
}: BattleModalProperties): JSX.Element => {
|
||||
const { result, bossName } = battle;
|
||||
const { formatNumber } = useGame();
|
||||
|
||||
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
|
||||
|
||||
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
|
||||
const partyStartPercent = 100;
|
||||
|
||||
const bossEndPercent = toHpPercent(
|
||||
result.bossHpAtBattleEnd,
|
||||
result.bossMaxHp,
|
||||
);
|
||||
const partyEndPercent = toHpPercent(
|
||||
result.partyHpRemaining,
|
||||
result.partyMaxHp,
|
||||
);
|
||||
|
||||
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
|
||||
const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent);
|
||||
|
||||
useEffect(() => {
|
||||
const startAnimation = setTimeout(() => {
|
||||
setBossHpPercent(bossEndPercent);
|
||||
setPartyHpPercent(partyEndPercent);
|
||||
}, 200);
|
||||
|
||||
const revealResult = setTimeout(() => {
|
||||
setPhase("result");
|
||||
}, 5200);
|
||||
|
||||
return (): void => {
|
||||
clearTimeout(startAnimation);
|
||||
clearTimeout(revealResult);
|
||||
};
|
||||
}, [ bossEndPercent, partyEndPercent ]);
|
||||
|
||||
let bossHpBarColour = "#c0392b";
|
||||
if (bossHpPercent > 50) {
|
||||
bossHpBarColour = "#e74c3c";
|
||||
} else if (bossHpPercent > 25) {
|
||||
bossHpBarColour = "#e67e22";
|
||||
}
|
||||
|
||||
let partyHpBarColour = "#e74c3c";
|
||||
if (partyHpPercent > 50) {
|
||||
partyHpBarColour = "#27ae60";
|
||||
} else if (partyHpPercent > 25) {
|
||||
partyHpBarColour = "#f39c12";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal battle-modal">
|
||||
<h2>
|
||||
{"⚔️ Battle: "}
|
||||
{bossName}
|
||||
</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"Your Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat-divider">{"vs"}</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"Boss DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="battle-bars">
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">
|
||||
{"👹 "}
|
||||
{bossName}
|
||||
</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill"
|
||||
style={{
|
||||
backgroundColor: bossHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
width: `${bossHpPercent.toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.bossHpAtBattleEnd)}
|
||||
{" / "}
|
||||
{formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="vs-divider">{"⚔️ VS ⚔️"}</div>
|
||||
|
||||
<div className="battle-bar-row">
|
||||
<span className="bar-label">{"🛡️ Your Party"}</span>
|
||||
<div className="hp-bar-container">
|
||||
<div
|
||||
className="hp-bar-fill party-hp"
|
||||
style={{
|
||||
backgroundColor: partyHpBarColour,
|
||||
transition: "width 5s ease-in-out",
|
||||
width: `${partyHpPercent.toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-hp">
|
||||
{formatNumber(result.partyHpRemaining)}
|
||||
{" / "}
|
||||
{formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phase === "animating"
|
||||
&& <p className="battle-in-progress">{"Battling…"}</p>
|
||||
}
|
||||
|
||||
{phase === "result"
|
||||
&& <div
|
||||
className={`battle-outcome ${result.won
|
||||
? "victory"
|
||||
: "defeat"}`}
|
||||
>
|
||||
{result.won
|
||||
? <>
|
||||
<h3>{"🏆 Victory!"}</h3>
|
||||
{result.rewards === undefined
|
||||
? null
|
||||
: <div className="battle-rewards">
|
||||
<p>{"Rewards:"}</p>
|
||||
<span>
|
||||
{"🪙 "}
|
||||
{formatNumber(result.rewards.gold)}
|
||||
{" gold"}
|
||||
</span>
|
||||
{result.rewards.essence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(result.rewards.essence)}
|
||||
{" essence"}
|
||||
</span>
|
||||
}
|
||||
{result.rewards.crystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(result.rewards.crystals)}
|
||||
{" crystals"}
|
||||
</span>
|
||||
}
|
||||
{result.rewards.bountyRunestones > 0
|
||||
&& <span className="battle-bounty">
|
||||
{"🔮 "}
|
||||
{formatNumber(result.rewards.bountyRunestones)}
|
||||
{" runestones (first kill!)"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
: <>
|
||||
<h3>{"💀 Defeat"}</h3>
|
||||
<p>{"Your party was defeated. The boss has reset."}</p>
|
||||
{result.casualties !== undefined
|
||||
&& result.casualties.length > 0
|
||||
? <div className="battle-casualties">
|
||||
<p>{"Casualties:"}</p>
|
||||
{result.casualties.map((casualty) => {
|
||||
return (
|
||||
<span key={casualty.adventurerId}>
|
||||
{"☠️ "}
|
||||
{casualty.killed} {casualty.adventurerId}
|
||||
{" lost"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Continue"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { BattleModal };
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @file Boss panel component for viewing and challenging zone bosses.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Boss card requires many conditional render paths */
|
||||
/* eslint-disable max-statements -- Boss panel requires many variable declarations */
|
||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Boss, GameState } from "@elysium/types";
|
||||
|
||||
interface BossCardProperties {
|
||||
readonly boss: Boss;
|
||||
readonly prestigeCount: number;
|
||||
readonly onChallenge: (bossId: string)=> void;
|
||||
readonly isChallenging: boolean;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single boss card.
|
||||
* @param props - The boss card properties.
|
||||
* @param props.boss - The boss data.
|
||||
* @param props.prestigeCount - The current prestige count for lock checking.
|
||||
* @param props.onChallenge - Callback to challenge this boss.
|
||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BossCard = ({
|
||||
boss,
|
||||
prestigeCount,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: BossCardProperties): JSX.Element => {
|
||||
const scaled = boss.currentHp * 100;
|
||||
const hpPercent = scaled / boss.maxHp;
|
||||
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
|
||||
const canChallenge
|
||||
= (boss.status === "available" || boss.status === "in_progress")
|
||||
&& !isChallenging;
|
||||
|
||||
function handleChallenge(): void {
|
||||
onChallenge(boss.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{isPrestigeLocked && boss.status === "locked"
|
||||
? <p className="prestige-lock">
|
||||
{"🔒 Requires Prestige "}
|
||||
{boss.prestigeRequirement}
|
||||
</p>
|
||||
: null}
|
||||
{!isPrestigeLocked
|
||||
&& boss.status === "locked"
|
||||
&& unlockHint !== undefined
|
||||
? <p className="unlock-hint">{unlockHint}</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated"
|
||||
&& <div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)}
|
||||
{" / "}
|
||||
{formatNumber(boss.maxHp)}
|
||||
{" HP"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">
|
||||
{"💢 Boss DPS: "}
|
||||
{formatNumber(boss.damagePerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
<span>
|
||||
{"🪙 "}
|
||||
{formatNumber(boss.goldReward)}
|
||||
</span>
|
||||
{boss.essenceReward > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(boss.essenceReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.crystalReward > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(boss.crystalReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
&& <span>
|
||||
{"🗡️ "}
|
||||
{boss.equipmentRewards.length}
|
||||
{" Equipment"}
|
||||
</span>
|
||||
}
|
||||
{boss.status !== "defeated" && boss.bountyRunestones > 0
|
||||
&& <span className="boss-bounty">
|
||||
{"🔮 "}
|
||||
{boss.bountyRunestones}
|
||||
{" (first kill)"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{(boss.status === "available" || boss.status === "in_progress")
|
||||
&& <button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={handleChallenge}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging
|
||||
? "⚔️ Battling…"
|
||||
: "⚔️ Challenge"}
|
||||
</button>
|
||||
}
|
||||
|
||||
{boss.status === "defeated"
|
||||
&& <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes party DPS and HP from the current game state.
|
||||
* @param state - The full game state.
|
||||
* @returns The computed party DPS and HP values.
|
||||
*/
|
||||
const computePartyStats = (
|
||||
state: GameState,
|
||||
): {
|
||||
partyDps: number;
|
||||
partyHp: number;
|
||||
} => {
|
||||
const { upgrades, adventurers, equipment, prestige } = state;
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of upgrades) {
|
||||
const { purchased, target, multiplier } = upgrade;
|
||||
if (purchased && target === "global") {
|
||||
globalMultiplier = globalMultiplier * multiplier;
|
||||
}
|
||||
}
|
||||
const prestigeBonus = prestige.count * 0.1;
|
||||
const prestigeMultiplier = 1 + prestigeBonus;
|
||||
const equipmentCombatMultiplier = equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((multiplier, item) => {
|
||||
return multiplier * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
let partyDps = 0;
|
||||
let partyHp = 0;
|
||||
for (const adventurer of adventurers) {
|
||||
const { count, id: adventurerId, combatPower, level } = adventurer;
|
||||
if (count === 0) {
|
||||
continue;
|
||||
}
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of upgrades) {
|
||||
const {
|
||||
purchased,
|
||||
target,
|
||||
multiplier,
|
||||
adventurerId: upgradeAdventurerId,
|
||||
} = upgrade;
|
||||
if (
|
||||
purchased
|
||||
&& target === "adventurer"
|
||||
&& upgradeAdventurerId === adventurerId
|
||||
) {
|
||||
adventurerMultiplier = adventurerMultiplier * multiplier;
|
||||
}
|
||||
}
|
||||
const dps
|
||||
= combatPower
|
||||
* count
|
||||
* adventurerMultiplier
|
||||
* globalMultiplier
|
||||
* prestigeMultiplier;
|
||||
partyDps = partyDps + dps;
|
||||
const hp = level * 50 * count;
|
||||
partyHp = partyHp + hp;
|
||||
}
|
||||
partyDps = partyDps * equipmentCombatMultiplier;
|
||||
return { partyDps, partyHp };
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the boss panel with zone selection and boss list.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const BossPanel = (): JSX.Element => {
|
||||
const { state, challengeBoss, formatNumber, toggleAutoBoss } = useGame();
|
||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChallenge(bossId: string): Promise<void> {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChallengeClick(bossId: string): void {
|
||||
void handleChallenge(bossId);
|
||||
}
|
||||
|
||||
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
|
||||
const zoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === activeZoneId;
|
||||
});
|
||||
const lockedCount = zoneBosses.filter((boss) => {
|
||||
return boss.status === "locked";
|
||||
}).length;
|
||||
const visibleBosses = showLocked
|
||||
? zoneBosses
|
||||
: zoneBosses.filter((boss) => {
|
||||
return boss.status !== "locked";
|
||||
});
|
||||
|
||||
const bossUnlockHints = new Map<string, string>();
|
||||
for (const zone of zones) {
|
||||
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
|
||||
const allZoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === zoneId;
|
||||
});
|
||||
for (let index = 0; index < allZoneBosses.length; index = index + 1) {
|
||||
const boss = allZoneBosses[index];
|
||||
if (boss === undefined || boss.status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
if (index === 0) {
|
||||
const parts: Array<string> = [];
|
||||
if (unlockBossId !== null) {
|
||||
const gateBoss = bosses.find((candidate) => {
|
||||
return candidate.id === unlockBossId;
|
||||
});
|
||||
if (gateBoss !== undefined) {
|
||||
parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
}
|
||||
if (unlockQuestId !== null) {
|
||||
const gateQuest = quests.find((candidate) => {
|
||||
return candidate.id === unlockQuestId;
|
||||
});
|
||||
if (gateQuest !== undefined) {
|
||||
parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const previousBoss = allZoneBosses[index - 1];
|
||||
if (previousBoss !== undefined) {
|
||||
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
const autoBossOn = autoBoss === true;
|
||||
const { partyDps, partyHp } = computePartyStats(state);
|
||||
const { count: prestigeCount } = playerPrestige;
|
||||
|
||||
return (
|
||||
<section className="panel boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Boss Encounters"}</h2>
|
||||
<div className="panel-header-controls">
|
||||
<button
|
||||
className={`auto-toggle-btn ${
|
||||
autoBossOn
|
||||
? "auto-toggle-on"
|
||||
: "auto-toggle-off"
|
||||
}`}
|
||||
onClick={toggleAutoBoss}
|
||||
title="Automatically challenge the highest available boss"
|
||||
type="button"
|
||||
>
|
||||
{"🤖 Auto: "}
|
||||
{autoBossOn
|
||||
? "ON"
|
||||
: "OFF"}
|
||||
</button>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="party-combat-stats">
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(partyDps)}</span>
|
||||
</div>
|
||||
<div className="combat-stat">
|
||||
<span className="stat-label">{"❤️ Party HP"}</span>
|
||||
<span className="stat-value">{formatNumber(partyHp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boss-list">
|
||||
{visibleBosses.map((boss) => {
|
||||
const { id: bossId } = boss;
|
||||
return (
|
||||
<BossCard
|
||||
boss={boss}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === bossId}
|
||||
key={bossId}
|
||||
onChallenge={handleChallengeClick}
|
||||
prestigeCount={prestigeCount}
|
||||
unlockHint={bossUnlockHints.get(bossId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{visibleBosses.length === 0
|
||||
&& <p className="empty-zone">{"No bosses to show in this zone."}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { BossPanel };
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @file Public character page for viewing a player's character sheet.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import type {
|
||||
EquipmentBonus,
|
||||
EquipmentType,
|
||||
PublicProfileResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
interface CharacterPageProperties {
|
||||
readonly discordId: string;
|
||||
}
|
||||
|
||||
const slotIcons: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment bonus as a human-readable string.
|
||||
* @param bonus - The equipment bonus to format.
|
||||
* @returns The formatted bonus string.
|
||||
*/
|
||||
const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold Income`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat Power`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click Power`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the public character page for a given Discord user.
|
||||
* @param props - The character page properties.
|
||||
* @param props.discordId - The Discord ID of the player to display.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
|
||||
const [ profile, setProfile ] = useState<PublicProfileResponse | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/profile/${discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Player not found");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response requires cast
|
||||
return await (response.json() as Promise<PublicProfileResponse>);
|
||||
}).
|
||||
then(setProfile).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load character sheet",
|
||||
);
|
||||
});
|
||||
}, [ discordId ]);
|
||||
|
||||
function handleCopy(): void {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-error">
|
||||
<p>
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</p>
|
||||
<a className="character-page-link" href="/">
|
||||
{"← Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile === null) {
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-loading">
|
||||
{"Loading character sheet…"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const discordIndex = Number.parseInt(discordId, 10) % 5;
|
||||
const avatarUrl
|
||||
= profile.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(discordIndex)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`;
|
||||
|
||||
const subtitleParts = [
|
||||
profile.characterRace,
|
||||
profile.characterClass,
|
||||
].filter((part) => {
|
||||
return part !== "";
|
||||
});
|
||||
const subtitle = subtitleParts.join(" · ");
|
||||
|
||||
const activeTitleEntry
|
||||
= profile.activeTitle === ""
|
||||
? undefined
|
||||
: profile.unlockedTitles.find((title) => {
|
||||
return title.id === profile.activeTitle;
|
||||
});
|
||||
const activeTitleName
|
||||
= activeTitleEntry === undefined
|
||||
? null
|
||||
: activeTitleEntry.name;
|
||||
|
||||
const hasBadge
|
||||
= profile.apotheosisCount > 0
|
||||
|| profile.transcendenceCount > 0
|
||||
|| profile.prestigeCount > 0;
|
||||
|
||||
const displayName
|
||||
= profile.characterName === ""
|
||||
? profile.username
|
||||
: profile.characterName;
|
||||
|
||||
return (
|
||||
<div className="character-page">
|
||||
<div className="character-page-card">
|
||||
<div className="character-page-header">
|
||||
<img
|
||||
alt={`${displayName}'s avatar`}
|
||||
className="character-page-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="character-page-identity">
|
||||
<h1 className="character-page-name">{displayName}</h1>
|
||||
{activeTitleName === null
|
||||
? null
|
||||
: <p className="character-page-title">{activeTitleName}</p>
|
||||
}
|
||||
{profile.pronouns === ""
|
||||
? null
|
||||
: <p className="character-page-pronouns">{profile.pronouns}</p>
|
||||
}
|
||||
{subtitle === ""
|
||||
? null
|
||||
: <p className="character-page-subtitle">{subtitle}</p>
|
||||
}
|
||||
{hasBadge
|
||||
? <div className="character-page-badges">
|
||||
{profile.apotheosisCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge character-page-badge--apotheosis"
|
||||
}
|
||||
>
|
||||
{"✨ Apotheosis "}
|
||||
{profile.apotheosisCount}
|
||||
</span>
|
||||
}
|
||||
{profile.transcendenceCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge"
|
||||
+ " character-page-badge--transcendence"
|
||||
}
|
||||
>
|
||||
{"🌌 Transcendence "}
|
||||
{profile.transcendenceCount}
|
||||
</span>
|
||||
}
|
||||
{profile.prestigeCount > 0
|
||||
&& <span
|
||||
className={
|
||||
"character-page-badge character-page-badge--prestige"
|
||||
}
|
||||
>
|
||||
{"⭐ Prestige "}
|
||||
{profile.prestigeCount}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio === ""
|
||||
? null
|
||||
: <div className="character-page-section">
|
||||
<h2 className="character-page-section-title">{"⚔️ About"}</h2>
|
||||
<p className="character-page-bio">{profile.bio}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{profile.guildName === ""
|
||||
? null
|
||||
: <div className="character-page-section">
|
||||
<h2 className="character-page-section-title">{"🏰 Guild"}</h2>
|
||||
<p className="character-page-guild-name">{profile.guildName}</p>
|
||||
{profile.guildDescription === ""
|
||||
? null
|
||||
: <p className="character-page-guild-desc">
|
||||
{profile.guildDescription}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{profile.equippedItems.length > 0
|
||||
&& <div className="character-page-section">
|
||||
<h2 className="character-page-section-title">{"🗡️ Equipment"}</h2>
|
||||
<div className="character-page-equipment-list">
|
||||
{profile.equippedItems.map((item) => {
|
||||
return (
|
||||
<div
|
||||
className="character-page-equipment-item"
|
||||
key={item.type}
|
||||
>
|
||||
<div className="character-page-equipment-header">
|
||||
<span className="character-page-equipment-slot">
|
||||
{slotIcons[item.type]}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-page-equipment-name"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-page-equipment-rarity"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-page-equipment-bonus">
|
||||
{formatBonus(item.bonus)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="character-page-divider" />
|
||||
|
||||
<p className="character-page-player-line">
|
||||
{"Played by "}
|
||||
<span className="character-page-username">
|
||||
{"@"}
|
||||
{profile.username}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="character-page-actions">
|
||||
<button
|
||||
className="character-page-share-btn"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Share Character"}
|
||||
</button>
|
||||
<a
|
||||
className="character-page-profile-link"
|
||||
href={`/profile/${discordId}`}
|
||||
>
|
||||
{"📊 View Stats"}
|
||||
</a>
|
||||
<a className="character-page-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CharacterPage };
|
||||
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* @file Character sheet panel for viewing and editing the player's character.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many fields */
|
||||
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
||||
/* eslint-disable max-statements -- Component requires many state declarations */
|
||||
/* eslint-disable max-lines -- Large component with editing and view modes */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
STORY_CHAPTERS,
|
||||
type EquipmentBonus,
|
||||
type EquipmentRarity,
|
||||
type EquipmentType,
|
||||
type ProfileSettings,
|
||||
} from "@elysium/types";
|
||||
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface EquippedItem {
|
||||
name: string;
|
||||
type: EquipmentType;
|
||||
rarity: EquipmentRarity;
|
||||
bonus: EquipmentBonus;
|
||||
}
|
||||
|
||||
interface CharacterSheetData {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: Array<EquippedItem>;
|
||||
}
|
||||
|
||||
const emptySheet: CharacterSheetData = {
|
||||
activeTitle: "",
|
||||
bio: "",
|
||||
characterClass: "",
|
||||
characterName: "",
|
||||
characterRace: "",
|
||||
equippedItems: [],
|
||||
guildDescription: "",
|
||||
guildName: "",
|
||||
pronouns: "",
|
||||
unlockedTitles: [],
|
||||
};
|
||||
|
||||
const slotIcons: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment bonus as a human-readable string.
|
||||
* @param bonus - The equipment bonus to format.
|
||||
* @returns The formatted bonus string.
|
||||
*/
|
||||
const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold Income`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat Power`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click Power`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the character sheet panel for viewing and editing player profile.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CharacterSheetPanel = (): JSX.Element => {
|
||||
const { state, loginStreak } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
|
||||
const [ draft, setDraft ] = useState<CharacterSheetData>(emptySheet);
|
||||
const [ editing, setEditing ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ saved, setSaved ] = useState(false);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
const savedSettingsReference = useRef<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (player?.discordId === undefined || player.discordId === "") {
|
||||
return;
|
||||
}
|
||||
fetch(`/api/profile/${player.discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
profileSettings: ProfileSettings;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: Array<EquippedItem>;
|
||||
};
|
||||
const loaded: CharacterSheetData = {
|
||||
activeTitle: data.activeTitle,
|
||||
bio: data.bio,
|
||||
characterClass: data.characterClass,
|
||||
characterName: data.characterName,
|
||||
characterRace: data.characterRace,
|
||||
equippedItems: data.equippedItems,
|
||||
guildDescription: data.guildDescription,
|
||||
guildName: data.guildName,
|
||||
pronouns: data.pronouns,
|
||||
unlockedTitles: data.unlockedTitles,
|
||||
};
|
||||
setSheet(loaded);
|
||||
setDraft(loaded);
|
||||
savedSettingsReference.current = {
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
...data.profileSettings,
|
||||
};
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
/* Fall back to empty */
|
||||
}).
|
||||
finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ player?.discordId ]);
|
||||
|
||||
function handleEdit(): void {
|
||||
setDraft({ ...sheet });
|
||||
setEditing(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const characterName
|
||||
= draft.characterName === ""
|
||||
? player?.characterName ?? ""
|
||||
: draft.characterName;
|
||||
await updateProfile({
|
||||
activeTitle: draft.activeTitle,
|
||||
bio: draft.bio,
|
||||
characterClass: draft.characterClass,
|
||||
characterName: characterName,
|
||||
characterRace: draft.characterRace,
|
||||
guildDescription: draft.guildDescription,
|
||||
guildName: draft.guildName,
|
||||
profileSettings: savedSettingsReference.current,
|
||||
pronouns: draft.pronouns,
|
||||
});
|
||||
setSheet({ ...draft });
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setEditing(false);
|
||||
setSaved(false);
|
||||
}, 900);
|
||||
} catch (error_) {
|
||||
setError(error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick(): void {
|
||||
void handleSave();
|
||||
}
|
||||
|
||||
function handleShareClick(): void {
|
||||
const discordId = player?.discordId ?? "";
|
||||
const url = `${window.location.origin}/character/${discordId}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterName: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handlePronounsChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, pronouns: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleRaceChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterRace: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleClassChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterClass: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, bio: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleTitleChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, activeTitle: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleGuildNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, guildName: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleGuildDescChange(
|
||||
event: ChangeEvent<HTMLTextAreaElement>,
|
||||
): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, guildDescription: value };
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading character sheet…"}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
const isSaveDisabled = saving || draft.characterName.trim() === "";
|
||||
let saveLabel = "Save";
|
||||
if (saving) {
|
||||
saveLabel = "Saving…";
|
||||
}
|
||||
if (saved) {
|
||||
saveLabel = "✓ Saved!";
|
||||
}
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"📋 Character Sheet"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-form">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-name">
|
||||
{"Character Name"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-name"
|
||||
maxLength={32}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={draft.characterName}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterName.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-pronouns">
|
||||
{"Pronouns"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-pronouns"
|
||||
maxLength={20}
|
||||
onChange={handlePronounsChange}
|
||||
placeholder="e.g. she/her, he/him, they/them"
|
||||
type="text"
|
||||
value={draft.pronouns}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.pronouns.length}
|
||||
{" / 20"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-race">
|
||||
{"Race"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-race"
|
||||
maxLength={32}
|
||||
onChange={handleRaceChange}
|
||||
placeholder="e.g. Elf, Dwarf, Human, Tiefling…"
|
||||
type="text"
|
||||
value={draft.characterRace}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterRace.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-class">
|
||||
{"Class"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-class"
|
||||
maxLength={32}
|
||||
onChange={handleClassChange}
|
||||
placeholder="e.g. Paladin, Archmage, Shadow Rogue…"
|
||||
type="text"
|
||||
value={draft.characterClass}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterClass.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-bio">
|
||||
{"About Your Character"}
|
||||
</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-bio"
|
||||
maxLength={200}
|
||||
onChange={handleBioChange}
|
||||
placeholder={
|
||||
"Describe your character's story, personality, or appearance…"
|
||||
}
|
||||
rows={4}
|
||||
value={draft.bio}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.bio.length}
|
||||
{" / 200"}
|
||||
</span>
|
||||
|
||||
{draft.unlockedTitles.length > 0
|
||||
&& <>
|
||||
<label className="character-sheet-label" htmlFor="cs-title">
|
||||
{"Active Title"}
|
||||
</label>
|
||||
<select
|
||||
className="character-sheet-input"
|
||||
id="cs-title"
|
||||
onChange={handleTitleChange}
|
||||
value={draft.activeTitle}
|
||||
>
|
||||
<option value="">{"— None —"}</option>
|
||||
{draft.unlockedTitles.map((title) => {
|
||||
return (
|
||||
<option key={title.id} value={title.id}>
|
||||
{title.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-name">
|
||||
{"Guild Name"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-guild-name"
|
||||
maxLength={64}
|
||||
onChange={handleGuildNameChange}
|
||||
placeholder="Name your guild"
|
||||
type="text"
|
||||
value={draft.guildName}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.guildName.length}
|
||||
{" / 64"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-desc">
|
||||
{"Guild Description"}
|
||||
</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-guild-desc"
|
||||
maxLength={500}
|
||||
onChange={handleGuildDescChange}
|
||||
placeholder="Describe your guild's history, goals, or lore…"
|
||||
rows={6}
|
||||
value={draft.guildDescription}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.guildDescription.length}
|
||||
{" / 500"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <p className="character-sheet-error">{error}</p>
|
||||
}
|
||||
|
||||
<div className="character-sheet-actions">
|
||||
<button
|
||||
className="character-sheet-cancel"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
className="character-sheet-save"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSaveClick}
|
||||
type="button"
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const subtitleParts = [ sheet.characterRace, sheet.characterClass ].filter(
|
||||
(part) => {
|
||||
return part !== "";
|
||||
},
|
||||
);
|
||||
const subtitle = subtitleParts.join(" · ");
|
||||
|
||||
const completedChapters = state?.story?.completedChapters ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"📋 Character Sheet"}</h2>
|
||||
<div className="character-sheet-header-actions">
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={handleShareClick}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Share"}
|
||||
</button>
|
||||
<a className="character-sheet-edit-btn" href="/leaderboards">
|
||||
{"🏆 Boards"}
|
||||
</a>
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
{"✏️ Edit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-view">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Name"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.characterName === ""
|
||||
? <em className="character-sheet-empty">{"Not set"}</em>
|
||||
: sheet.characterName
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Streak"}</span>
|
||||
<span className="character-sheet-streak">
|
||||
{"🔥 "}
|
||||
{loginStreak}
|
||||
{"-day login streak"}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.activeTitle === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Title"}</span>
|
||||
<span
|
||||
className={"character-sheet-field-value character-sheet-title"}
|
||||
>
|
||||
{sheet.unlockedTitles.find((title) => {
|
||||
return title.id === sheet.activeTitle;
|
||||
})?.name ?? sheet.activeTitle}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{sheet.pronouns === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Pronouns"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.pronouns}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{subtitle === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Identity"}</span>
|
||||
<span className="character-sheet-field-value">{subtitle}</span>
|
||||
</div>
|
||||
}
|
||||
{sheet.bio === ""
|
||||
? null
|
||||
: <div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">{"About"}</span>
|
||||
<p className="character-sheet-bio-text">{sheet.bio}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
|
||||
{sheet.equippedItems.length > 0
|
||||
? <div className="character-sheet-equipment-list">
|
||||
{sheet.equippedItems.map((item) => {
|
||||
return (
|
||||
<div
|
||||
className="character-sheet-equipment-item"
|
||||
key={item.type}
|
||||
>
|
||||
<div className="character-sheet-equipment-header">
|
||||
<span className="character-sheet-equipment-slot">
|
||||
{slotIcons[item.type]}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-sheet-equipment-name"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-sheet-equipment-rarity"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-sheet-equipment-bonus">
|
||||
{formatBonus(item.bonus)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: <p className="character-sheet-empty">
|
||||
{"No equipment found. Defeat bosses to earn gear!"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
|
||||
{sheet.guildName === ""
|
||||
? <p className="character-sheet-empty">
|
||||
{"No guild registered yet. Click ✏️ Edit to add one!"}
|
||||
</p>
|
||||
: <>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Name"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.guildName}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.guildDescription === ""
|
||||
? null
|
||||
: <div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">{"Lore"}</span>
|
||||
<p className="character-sheet-bio-text">
|
||||
{sheet.guildDescription}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
{completedChapters.length === 0
|
||||
? null
|
||||
: <div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">
|
||||
{"📖 Story Choices"}
|
||||
</h3>
|
||||
{completedChapters.map((completion) => {
|
||||
const chapter = STORY_CHAPTERS.find((candidate) => {
|
||||
return candidate.id === completion.chapterId;
|
||||
});
|
||||
if (chapter === undefined) {
|
||||
return null;
|
||||
}
|
||||
const choice = chapter.choices.find((candidate) => {
|
||||
return candidate.id === completion.choiceId;
|
||||
});
|
||||
if (choice === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="character-sheet-story-entry"
|
||||
key={completion.chapterId}
|
||||
>
|
||||
<span className="character-sheet-story-chapter">
|
||||
{chapter.title}
|
||||
</span>
|
||||
<span className="character-sheet-story-choice">
|
||||
{choice.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CharacterSheetPanel };
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file Click area component - the main guild hall click target.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex useCallback with float management */
|
||||
import {
|
||||
type JSX,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { calculateClickPower } from "../../engine/tick.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle -- Vite define constant
|
||||
declare const __WEB_VERSION__: string;
|
||||
|
||||
interface FloatText {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the guild hall click area with floating gold text on click.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ClickArea = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
handleClick,
|
||||
formatNumber,
|
||||
saveSchemaVersion,
|
||||
currentSchemaVersion,
|
||||
} = useGame();
|
||||
const [ floats, setFloats ] = useState<Array<FloatText>>([]);
|
||||
const nextIdReference = useRef(0);
|
||||
|
||||
const handleClickWithFloat = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (state === null) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const id = nextIdReference.current;
|
||||
nextIdReference.current = nextIdReference.current + 1;
|
||||
const clickPower = calculateClickPower(state);
|
||||
const text = `+${formatNumber(clickPower)}`;
|
||||
|
||||
setFloats((previous) => {
|
||||
return [ ...previous, { id, text, x, y } ];
|
||||
});
|
||||
handleClick();
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line max-nested-callbacks -- Float cleanup requires nesting within setTimeout
|
||||
setFloats((previous) => {
|
||||
// eslint-disable-next-line max-nested-callbacks -- Float cleanup requires nesting within setTimeout
|
||||
return previous.filter((floatItem) => {
|
||||
return floatItem.id !== id;
|
||||
});
|
||||
});
|
||||
}, 900);
|
||||
},
|
||||
[ state, handleClick, formatNumber ],
|
||||
);
|
||||
|
||||
if (state === null) {
|
||||
return <div className="click-area-placeholder" />;
|
||||
}
|
||||
|
||||
const clickPower = calculateClickPower(state);
|
||||
|
||||
return (
|
||||
<section className="click-area">
|
||||
<h1 className="game-title">{"Elysium"}</h1>
|
||||
<p className="game-version">
|
||||
{"v"}
|
||||
{__WEB_VERSION__}
|
||||
</p>
|
||||
{currentSchemaVersion > 0
|
||||
&& <p className="game-schema-version">
|
||||
{"Save: v"}
|
||||
{saveSchemaVersion}
|
||||
{" / Latest: v"}
|
||||
{currentSchemaVersion}
|
||||
</p>
|
||||
}
|
||||
<h2>{"Guild Hall"}</h2>
|
||||
<div className="click-button-wrapper">
|
||||
<button
|
||||
aria-label={`Click to earn ${formatNumber(clickPower)} gold`}
|
||||
className="click-button"
|
||||
onClick={handleClickWithFloat}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt="Guild Hall"
|
||||
className="click-button-image"
|
||||
src="https://cdn.nhcarrigan.com/avatars/elysium.png"
|
||||
/>
|
||||
</button>
|
||||
{floats.map((floatItem) => {
|
||||
return (
|
||||
<span
|
||||
className="click-float"
|
||||
key={floatItem.id}
|
||||
style={{ left: floatItem.x, top: floatItem.y }}
|
||||
>
|
||||
{floatItem.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="click-power">
|
||||
{"+"}
|
||||
{formatNumber(clickPower)}
|
||||
{" gold/click"}
|
||||
</p>
|
||||
<p className="early-access-notice">
|
||||
{"⚠️ Early Access — this build is subject to change. "}
|
||||
<strong>
|
||||
{"All game progress WILL be reset upon v1.0.0 release."}
|
||||
</strong>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ClickArea };
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file Codex panel component displaying discovered lore entries.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with zone and entry rendering */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||
import type { CodexEntry } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Converts a fraction to a percentage value.
|
||||
* @param numerator - The numerator value.
|
||||
* @param denominator - The denominator value.
|
||||
* @returns The percentage as a number between 0 and 100.
|
||||
*/
|
||||
const toPercent = (numerator: number, denominator: number): number => {
|
||||
if (denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
const scaled = numerator * 100;
|
||||
return scaled / denominator;
|
||||
};
|
||||
|
||||
const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
||||
adventurer: "👥",
|
||||
boss: "⚔️",
|
||||
equipment: "🛡️",
|
||||
exploration: "🧭",
|
||||
prestige: "🔮",
|
||||
quest: "📜",
|
||||
recipe: "⚗️",
|
||||
upgrade: "🔧",
|
||||
zone: "🗺️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex panel with lore entries grouped by zone.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CodexPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ expandedId, setExpandedId ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = new Set(state.codex?.unlockedEntryIds ?? []);
|
||||
const totalEntries = CODEX_ENTRIES.length;
|
||||
const unlockedCount = CODEX_ENTRIES.filter((entry) => {
|
||||
return unlockedIds.has(entry.id);
|
||||
}).length;
|
||||
const progressPercent = toPercent(unlockedCount, totalEntries);
|
||||
|
||||
const entriesByZone = Object.entries(ZONE_LABELS).
|
||||
map(([ zoneId, zoneName ]) => {
|
||||
const entries = CODEX_ENTRIES.filter((entry) => {
|
||||
return entry.zoneId === zoneId;
|
||||
});
|
||||
const unlockedEntries = entries.filter((entry) => {
|
||||
return unlockedIds.has(entry.id);
|
||||
});
|
||||
return {
|
||||
entries,
|
||||
unlockedEntries,
|
||||
zoneId,
|
||||
zoneName,
|
||||
};
|
||||
}).
|
||||
filter(({ entries }) => {
|
||||
return entries.length > 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel codex-panel">
|
||||
<h2>{"📖 Codex"}</h2>
|
||||
|
||||
<div className="codex-progress">
|
||||
<p className="codex-progress-text">
|
||||
{"Lore discovered: "}
|
||||
<strong>
|
||||
{unlockedCount}
|
||||
{" / "}
|
||||
{totalEntries}
|
||||
</strong>
|
||||
</p>
|
||||
<div className="codex-progress-bar">
|
||||
<div
|
||||
className="codex-progress-fill"
|
||||
style={{ width: `${String(Math.round(progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entriesByZone.map(({ zoneId, zoneName, entries, unlockedEntries }) => {
|
||||
return (
|
||||
<div className="codex-zone" key={zoneId}>
|
||||
<h3 className="codex-zone-header">
|
||||
{zoneName}
|
||||
<span className="codex-zone-count">
|
||||
{unlockedEntries.length}
|
||||
{"/"}
|
||||
{entries.length}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="codex-entries">
|
||||
{entries.map((entry) => {
|
||||
const isUnlocked = unlockedIds.has(entry.id);
|
||||
const isExpanded = expandedId === entry.id;
|
||||
|
||||
if (!isUnlocked) {
|
||||
return (
|
||||
<div className="codex-entry locked" key={entry.id}>
|
||||
<div className="codex-entry-header">
|
||||
<span className="codex-lock">{"🔒"}</span>
|
||||
<span className="codex-entry-title">{"???"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleExpand(): void {
|
||||
setExpandedId(isExpanded
|
||||
? null
|
||||
: entry.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`codex-entry unlocked ${
|
||||
isExpanded
|
||||
? "expanded"
|
||||
: ""
|
||||
}`}
|
||||
key={entry.id}
|
||||
onClick={handleExpand}
|
||||
>
|
||||
<div className="codex-entry-header">
|
||||
<span className="codex-source-badge">
|
||||
{sourceBadge[entry.sourceType]}
|
||||
</span>
|
||||
<span className="codex-entry-title">{entry.title}</span>
|
||||
<span className="codex-chevron">
|
||||
{isExpanded
|
||||
? "▲"
|
||||
: "▼"}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded
|
||||
? <p className="codex-entry-content">{entry.content}</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CodexPanel };
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file Codex toast notification component for new lore discoveries.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { CODEX_ENTRIES } from "../../data/codex.js";
|
||||
|
||||
interface CodexToastItemProperties {
|
||||
readonly entryId: string;
|
||||
readonly onDismiss: (id: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single codex lore toast notification.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.entryId - The codex entry ID to display.
|
||||
* @param props.onDismiss - Callback to dismiss the toast.
|
||||
* @returns The JSX element or null if entry is not found.
|
||||
*/
|
||||
const CodexToastItem = ({
|
||||
entryId,
|
||||
onDismiss,
|
||||
}: CodexToastItemProperties): JSX.Element | null => {
|
||||
const entry = CODEX_ENTRIES.find((codexEntry) => {
|
||||
return codexEntry.id === entryId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(entryId);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ entryId, onDismiss ]);
|
||||
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(): void {
|
||||
onDismiss(entryId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="codex-toast" onClick={handleClick}>
|
||||
<span className="toast-icon">{"📖"}</span>
|
||||
<div className="toast-content">
|
||||
<span className="toast-label">{"✨ Lore Unlocked!"}</span>
|
||||
<span className="toast-name">{entry.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex toast container with pending lore notifications.
|
||||
* @returns The JSX element or null if there are no pending entries.
|
||||
*/
|
||||
const CodexToast = (): JSX.Element | null => {
|
||||
const { unlockedCodexEntryIds: pendingEntryIds, dismissCodexEntry }
|
||||
= useGame();
|
||||
|
||||
if (pendingEntryIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingEntryIds.map((id) => {
|
||||
return (
|
||||
<CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CodexToast };
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file Companion panel component for managing active companions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
const bonusLabels: Record<string, string> = {
|
||||
bossDamage: "Boss Damage",
|
||||
clickGold: "Click Gold",
|
||||
essenceIncome: "Essence Income",
|
||||
passiveGold: "Passive Gold",
|
||||
questTime: "Quest Time",
|
||||
};
|
||||
|
||||
const unlockLabels: Record<string, string> = {
|
||||
apotheosis: "apotheosis",
|
||||
lifetimeBosses: "lifetime bosses defeated",
|
||||
lifetimeGold: "lifetime gold earned",
|
||||
lifetimeQuests: "lifetime quests completed",
|
||||
prestige: "prestige(s)",
|
||||
transcendence: "transcendence(s)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a companion unlock threshold for display.
|
||||
* @param type - The unlock condition type.
|
||||
* @param threshold - The threshold value.
|
||||
* @returns The formatted threshold string.
|
||||
*/
|
||||
const formatThreshold = (type: string, threshold: number): string => {
|
||||
if (type === "lifetimeGold") {
|
||||
if (threshold >= 1e18) {
|
||||
return `${(threshold / 1e18).toFixed(0)}Qt`;
|
||||
}
|
||||
if (threshold >= 1e15) {
|
||||
return `${(threshold / 1e15).toFixed(0)}Q`;
|
||||
}
|
||||
if (threshold >= 1e12) {
|
||||
return `${(threshold / 1e12).toFixed(0)}T`;
|
||||
}
|
||||
if (threshold >= 1e9) {
|
||||
return `${(threshold / 1e9).toFixed(0)}B`;
|
||||
}
|
||||
if (threshold >= 1e6) {
|
||||
return `${(threshold / 1e6).toFixed(0)}M`;
|
||||
}
|
||||
if (threshold >= 1e3) {
|
||||
return `${(threshold / 1e3).toFixed(0)}K`;
|
||||
}
|
||||
}
|
||||
return threshold.toString();
|
||||
};
|
||||
|
||||
interface CompanionCardProperties {
|
||||
readonly companion: Companion;
|
||||
readonly isUnlocked: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly onSelect: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single companion card.
|
||||
* @param props - The companion card properties.
|
||||
* @param props.companion - The companion data.
|
||||
* @param props.isUnlocked - Whether this companion is unlocked.
|
||||
* @param props.isActive - Whether this companion is currently active.
|
||||
* @param props.onSelect - Callback when the companion is selected/deselected.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionCard = ({
|
||||
companion,
|
||||
isUnlocked,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: CompanionCardProperties): JSX.Element => {
|
||||
const bonusSign = companion.bonus.type === "questTime"
|
||||
? "-"
|
||||
: "+";
|
||||
const bonusPercent = Math.round(companion.bonus.value * 100);
|
||||
const bonusLabel = bonusLabels[companion.bonus.type] ?? companion.bonus.type;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`companion-card ${
|
||||
isUnlocked
|
||||
? "companion-unlocked"
|
||||
: "companion-locked"
|
||||
} ${isActive
|
||||
? "companion-active"
|
||||
: ""}`}
|
||||
>
|
||||
<div className="companion-header">
|
||||
<div className="companion-name-block">
|
||||
<span className="companion-name">{companion.name}</span>
|
||||
<span className="companion-title">{companion.title}</span>
|
||||
</div>
|
||||
{isActive
|
||||
? <span className="companion-active-badge">{"Active"}</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<p className="companion-description">{companion.description}</p>
|
||||
|
||||
<div className="companion-bonus">
|
||||
<span className="companion-bonus-label">{bonusLabel}</span>
|
||||
<span className="companion-bonus-value">
|
||||
{bonusSign}
|
||||
{bonusPercent}
|
||||
{"%"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isUnlocked
|
||||
? <button
|
||||
className={`companion-select-btn ${
|
||||
isActive
|
||||
? "companion-select-active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{isActive
|
||||
? "Deactivate"
|
||||
: "Activate"}
|
||||
</button>
|
||||
: <div className="companion-unlock-requirement">
|
||||
{"🔒 Unlock: "}
|
||||
{formatThreshold(
|
||||
companion.unlock.type,
|
||||
companion.unlock.threshold,
|
||||
)}{" "}
|
||||
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the companion panel with all companions.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CompanionPanel = (): JSX.Element => {
|
||||
const { state, setActiveCompanion } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
||||
const activeId = state.companions?.activeCompanionId ?? null;
|
||||
|
||||
function handleSelect(companionId: string): void {
|
||||
setActiveCompanion(activeId === companionId
|
||||
? null
|
||||
: companionId);
|
||||
}
|
||||
|
||||
const activeCompanion
|
||||
= activeId === null
|
||||
? undefined
|
||||
: COMPANIONS.find((companion) => {
|
||||
return companion.id === activeId;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="companion-panel">
|
||||
<h2>{"👥 Companions"}</h2>
|
||||
<p className="companion-intro">
|
||||
{"Companions provide powerful bonuses while active."
|
||||
+ " You can only have one companion active at a time."}
|
||||
{activeId === null
|
||||
? null
|
||||
: <>
|
||||
{" Currently active: "}
|
||||
<strong>{activeCompanion?.name ?? activeId}</strong>
|
||||
{"."}
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="companion-grid">
|
||||
{COMPANIONS.map((companion) => {
|
||||
function handleCompanionSelect(): void {
|
||||
handleSelect(companion.id);
|
||||
}
|
||||
return (
|
||||
<CompanionCard
|
||||
companion={companion}
|
||||
isActive={activeId === companion.id}
|
||||
isUnlocked={unlockedIds.includes(companion.id)}
|
||||
key={companion.id}
|
||||
onSelect={handleCompanionSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CompanionPanel };
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @file Crafting panel component for crafting items from materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable max-nested-callbacks -- Nested recipe/material maps require nesting */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { MATERIALS } from "../../data/materials.js";
|
||||
import { RECIPES } from "../../data/recipes.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
|
||||
const bonusLabel: Record<string, string> = {
|
||||
click_power: "👆 Click Power",
|
||||
combat_power: "⚔️ Combat Power",
|
||||
essence_income: "✨ Essence Income",
|
||||
gold_income: "🪙 Gold Income",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the crafting panel for crafting recipes from gathered materials.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CraftingPanel = (): JSX.Element => {
|
||||
const { state, craftRecipe, formatNumber } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, exploration: explorationState } = state;
|
||||
const playerMaterials = explorationState?.materials ?? [];
|
||||
const craftedIds = explorationState?.craftedRecipeIds ?? [];
|
||||
|
||||
const zoneRecipes = RECIPES.filter((recipe) => {
|
||||
return recipe.zoneId === activeZoneId;
|
||||
});
|
||||
const zoneMaterials = MATERIALS.filter((material) => {
|
||||
return material.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
function getQuantity(materialId: string): number {
|
||||
return (
|
||||
playerMaterials.find((playerMaterial) => {
|
||||
return playerMaterial.materialId === materialId;
|
||||
})?.quantity ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function canAffordRecipe(recipeId: string): boolean {
|
||||
const recipe = RECIPES.find((candidateRecipe) => {
|
||||
return candidateRecipe.id === recipeId;
|
||||
});
|
||||
if (recipe === undefined) {
|
||||
return false;
|
||||
}
|
||||
return recipe.requiredMaterials.every((request) => {
|
||||
return getQuantity(request.materialId) >= request.quantity;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCraft(recipeId: string): Promise<void> {
|
||||
setPendingRecipeId(recipeId);
|
||||
try {
|
||||
await craftRecipe(recipeId);
|
||||
} finally {
|
||||
setPendingRecipeId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel crafting-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚗️ Crafting"}</h2>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="crafting-content">
|
||||
<div className="materials-section">
|
||||
<h3>{"📦 Materials"}</h3>
|
||||
{zoneMaterials.length === 0
|
||||
? <p className="empty-zone">{"No materials in this zone."}</p>
|
||||
: <div className="materials-list">
|
||||
{zoneMaterials.map((material) => {
|
||||
const qty = getQuantity(material.id);
|
||||
return (
|
||||
<div
|
||||
className={`material-card rarity-${material.rarity} ${
|
||||
qty === 0
|
||||
? "material-empty"
|
||||
: ""
|
||||
}`}
|
||||
key={material.id}
|
||||
>
|
||||
<div className="material-info">
|
||||
<span className="material-name">{material.name}</span>
|
||||
<span className="material-rarity">{material.rarity}</span>
|
||||
</div>
|
||||
<span className="material-quantity">
|
||||
{formatNumber(qty)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="recipes-section">
|
||||
<h3>{"📜 Recipes"}</h3>
|
||||
{zoneRecipes.length === 0
|
||||
? <p className="empty-zone">{"No recipes in this zone."}</p>
|
||||
: <div className="recipes-list">
|
||||
{zoneRecipes.map((recipe) => {
|
||||
const crafted = craftedIds.includes(recipe.id);
|
||||
const affordable = canAffordRecipe(recipe.id);
|
||||
const isPending = pendingRecipeId === recipe.id;
|
||||
|
||||
function handleCraftClick(): void {
|
||||
void handleCraft(recipe.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`recipe-card ${
|
||||
crafted
|
||||
? "recipe-crafted"
|
||||
: ""
|
||||
} ${!affordable && !crafted
|
||||
? "recipe-unaffordable"
|
||||
: ""}`}
|
||||
key={recipe.id}
|
||||
>
|
||||
<div className="recipe-info">
|
||||
<h4>{recipe.name}</h4>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
<div className="recipe-bonus">
|
||||
<span className="bonus-label">
|
||||
{bonusLabel[recipe.bonus.type] ?? recipe.bonus.type}
|
||||
</span>
|
||||
<span className="bonus-value">
|
||||
{"×"}
|
||||
{recipe.bonus.value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="recipe-requirements">
|
||||
{recipe.requiredMaterials.map((request) => {
|
||||
const have = getQuantity(request.materialId);
|
||||
const enough = have >= request.quantity;
|
||||
const matName
|
||||
= MATERIALS.find((mat) => {
|
||||
return mat.id === request.materialId;
|
||||
})?.name ?? request.materialId;
|
||||
return (
|
||||
<span
|
||||
className={`req-tag ${
|
||||
enough
|
||||
? "req-met"
|
||||
: "req-missing"
|
||||
}`}
|
||||
key={request.materialId}
|
||||
>
|
||||
{matName}
|
||||
{": "}
|
||||
{formatNumber(have)}
|
||||
{"/"}
|
||||
{formatNumber(request.quantity)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="recipe-action">
|
||||
{crafted
|
||||
? <span className="quest-badge active">
|
||||
{"✅ Crafted"}
|
||||
</span>
|
||||
: <button
|
||||
className="craft-button"
|
||||
disabled={
|
||||
!affordable || isPending || pendingRecipeId !== null
|
||||
}
|
||||
onClick={handleCraftClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Crafting..."
|
||||
: "⚗️ Craft"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CraftingPanel };
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file Daily challenge panel component showing today's challenges.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* Formats the time remaining until the daily reset.
|
||||
* @returns The formatted time string.
|
||||
*/
|
||||
const formatTimeUntilReset = (): string => {
|
||||
const now = new Date();
|
||||
const nowAsPst = new Date(
|
||||
now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }),
|
||||
);
|
||||
const tomorrowMidnightPst = new Date(nowAsPst);
|
||||
tomorrowMidnightPst.setDate(tomorrowMidnightPst.getDate() + 1);
|
||||
tomorrowMidnightPst.setHours(0, 0, 0, 0);
|
||||
const pstOffset = nowAsPst.getTime() - now.getTime();
|
||||
const resetAt = new Date(tomorrowMidnightPst.getTime() - pstOffset);
|
||||
const msRemaining = resetAt.getTime() - now.getTime();
|
||||
const msPerHour = 1000 * 60 * 60;
|
||||
const msPerMinute = 1000 * 60;
|
||||
const hoursRemaining = Math.floor(msRemaining / msPerHour);
|
||||
const msAfterHours = msRemaining % msPerHour;
|
||||
const minutesRemaining = Math.floor(msAfterHours / msPerMinute);
|
||||
return `${String(hoursRemaining)}h ${String(minutesRemaining)}m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the daily challenge panel with progress tracking.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const DailyChallengePanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { dailyChallenges } = state;
|
||||
|
||||
if (dailyChallenges === undefined) {
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>{"📅 Daily Challenges"}</h2>
|
||||
<p className="daily-challenge-subtitle">
|
||||
{"Load the game to generate today's challenges!"}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = dailyChallenges.challenges.filter((challenge) => {
|
||||
return challenge.completed;
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<section className="panel daily-challenge-panel">
|
||||
<h2>{"📅 Daily Challenges"}</h2>
|
||||
<div className="daily-challenge-header">
|
||||
<p className="daily-challenge-subtitle">
|
||||
{"Complete challenges for bonus 💎 crystals! Resets in "}
|
||||
<strong>{formatTimeUntilReset()}</strong>
|
||||
{" (PST midnight)."}
|
||||
</p>
|
||||
<p className="daily-challenge-progress">
|
||||
{completedCount}
|
||||
{" / "}
|
||||
{dailyChallenges.challenges.length}
|
||||
{" completed"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-list">
|
||||
{dailyChallenges.challenges.map((challenge) => {
|
||||
const progressScaled = challenge.progress * 100;
|
||||
const progressPercent = Math.min(
|
||||
100,
|
||||
Math.floor(progressScaled / challenge.target),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`daily-challenge-card ${
|
||||
challenge.completed
|
||||
? "completed"
|
||||
: ""
|
||||
}`}
|
||||
key={challenge.id}
|
||||
>
|
||||
<div className="daily-challenge-info">
|
||||
<h3 className="daily-challenge-label">{challenge.label}</h3>
|
||||
<p className="daily-challenge-reward">
|
||||
{"Reward: "}
|
||||
<strong>
|
||||
{"💎 "}
|
||||
{formatNumber(challenge.rewardCrystals)}
|
||||
{" crystals"}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="daily-challenge-right">
|
||||
{challenge.completed
|
||||
? <span className="daily-challenge-done">
|
||||
{"✅ Complete!"}
|
||||
</span>
|
||||
|
||||
: <>
|
||||
<p className="daily-challenge-count">
|
||||
{formatNumber(challenge.progress)}
|
||||
{" / "}
|
||||
{formatNumber(challenge.target)}
|
||||
</p>
|
||||
<div className="daily-challenge-bar-track">
|
||||
<div
|
||||
className="daily-challenge-bar-fill"
|
||||
style={{ width: `${String(progressPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { DailyChallengePanel };
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* @file Edit profile modal component for updating player profile settings.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex form with many fields */
|
||||
/* eslint-disable complexity -- Many conditional render paths for toggles */
|
||||
/* eslint-disable max-lines -- Large modal with profile and settings forms */
|
||||
/* eslint-disable max-statements -- Many state initialisations and handlers */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
type NumberFormat,
|
||||
type ProfileSettings,
|
||||
} from "@elysium/types";
|
||||
import { type ChangeEvent, type JSX, useEffect, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
requestNotificationPermission,
|
||||
} from "../../utils/notification.js";
|
||||
|
||||
interface EditProfileModalProperties {
|
||||
readonly onClose: ()=> void;
|
||||
}
|
||||
|
||||
interface StatToggle {
|
||||
key: keyof ProfileSettings;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const currentRunToggles: Array<StatToggle> = [
|
||||
{ icon: "🪙", key: "showCurrentGold", label: "Gold Earned This Run" },
|
||||
{ icon: "👆", key: "showCurrentClicks", label: "Clicks This Run" },
|
||||
{ icon: "✨", key: "showApotheosis", label: "Apotheosis Badge" },
|
||||
{ icon: "🌌", key: "showTranscendence", label: "Transcendence Badge" },
|
||||
{ icon: "⭐", key: "showPrestige", label: "Prestige Level" },
|
||||
{ icon: "💀", key: "showBossesDefeated", label: "Bosses Defeated" },
|
||||
{ icon: "📜", key: "showQuestsCompleted", label: "Quests Completed" },
|
||||
{
|
||||
icon: "⚔️",
|
||||
key: "showAdventurersRecruited",
|
||||
label: "Adventurers Recruited",
|
||||
},
|
||||
{
|
||||
icon: "🏆",
|
||||
key: "showAchievementsUnlocked",
|
||||
label: "Achievements Unlocked",
|
||||
},
|
||||
];
|
||||
|
||||
const allTimeToggles: Array<StatToggle> = [
|
||||
{ icon: "🪙", key: "showTotalGold", label: "Total Gold Earned" },
|
||||
{ icon: "👆", key: "showTotalClicks", label: "Total Clicks" },
|
||||
{
|
||||
icon: "💀",
|
||||
key: "showLifetimeBossesDefeated",
|
||||
label: "Bosses Defeated",
|
||||
},
|
||||
{
|
||||
icon: "📜",
|
||||
key: "showLifetimeQuestsCompleted",
|
||||
label: "Quests Completed",
|
||||
},
|
||||
{
|
||||
icon: "⚔️",
|
||||
key: "showLifetimeAdventurersRecruited",
|
||||
label: "Adventurers Recruited",
|
||||
},
|
||||
{
|
||||
icon: "🏆",
|
||||
key: "showLifetimeAchievementsUnlocked",
|
||||
label: "Achievements Unlocked",
|
||||
},
|
||||
{ icon: "📅", key: "showGuildFounded", label: "Guild Founded Date" },
|
||||
];
|
||||
|
||||
const numberFormatOptions: Array<{
|
||||
value: NumberFormat;
|
||||
label: string;
|
||||
example: string;
|
||||
}> = [
|
||||
{ example: "1.23Qa", label: "Suffix", value: "suffix" },
|
||||
{ example: "1.23e15", label: "Scientific", value: "scientific" },
|
||||
{ example: "1.23E15", label: "Engineering", value: "engineering" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the edit profile modal for updating player display settings.
|
||||
* @param props - The modal properties.
|
||||
* @param props.onClose - Callback to close the modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EditProfileModal = ({
|
||||
onClose,
|
||||
}: EditProfileModalProperties): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
numberFormat: currentNumberFormat,
|
||||
setNumberFormat,
|
||||
setEnableSounds,
|
||||
setEnableNotifications,
|
||||
} = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [ characterName, setCharacterName ] = useState(
|
||||
player?.characterName ?? "",
|
||||
);
|
||||
const [ bio, setBio ] = useState("");
|
||||
const [ profileSettings, setProfileSettings ] = useState<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
numberFormat: currentNumberFormat,
|
||||
});
|
||||
const [ loadingProfile, setLoadingProfile ] = useState(true);
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ saved, setSaved ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (player?.discordId === undefined || player.discordId === "") {
|
||||
return;
|
||||
}
|
||||
fetch(`/api/profile/${player.discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
bio: string;
|
||||
profileSettings: ProfileSettings;
|
||||
characterName: string;
|
||||
};
|
||||
setBio(data.bio);
|
||||
setProfileSettings({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
...data.profileSettings,
|
||||
});
|
||||
setCharacterName(
|
||||
data.characterName === ""
|
||||
? player.characterName
|
||||
: data.characterName,
|
||||
);
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
/* Fall back to local state if fetch fails — not a blocking error */
|
||||
}).
|
||||
finally(() => {
|
||||
setLoadingProfile(false);
|
||||
});
|
||||
}, [ player?.discordId, player?.characterName ]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateProfile({
|
||||
bio,
|
||||
characterName,
|
||||
profileSettings,
|
||||
});
|
||||
setNumberFormat(profileSettings.numberFormat);
|
||||
setEnableSounds(profileSettings.enableSounds);
|
||||
setEnableNotifications(profileSettings.enableNotifications);
|
||||
setSaved(true);
|
||||
setTimeout(onClose, 900);
|
||||
} catch (error_: unknown) {
|
||||
setError(error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick(): void {
|
||||
void handleSave();
|
||||
}
|
||||
|
||||
function toggleSetting(key: keyof ProfileSettings): void {
|
||||
setProfileSettings((previous) => {
|
||||
const current = previous[key];
|
||||
const toggled = typeof current === "boolean"
|
||||
? !current
|
||||
: current;
|
||||
return { ...previous, [key]: toggled };
|
||||
});
|
||||
}
|
||||
|
||||
function handleLeaderboardToggle(): void {
|
||||
toggleSetting("showOnLeaderboards");
|
||||
}
|
||||
|
||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
setCharacterName(event.target.value);
|
||||
}
|
||||
|
||||
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
setBio(event.target.value);
|
||||
}
|
||||
|
||||
function handleSoundsToggle(): void {
|
||||
toggleSetting("enableSounds");
|
||||
}
|
||||
|
||||
async function handleNotificationsEnable(): Promise<void> {
|
||||
if (profileSettings.enableNotifications) {
|
||||
toggleSetting("enableNotifications");
|
||||
return;
|
||||
}
|
||||
const granted = await requestNotificationPermission();
|
||||
if (granted) {
|
||||
toggleSetting("enableNotifications");
|
||||
} else {
|
||||
setError(
|
||||
"Browser notification permission was denied."
|
||||
+ " Please enable it in your browser settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotificationsToggle(): void {
|
||||
void handleNotificationsEnable();
|
||||
}
|
||||
|
||||
const isSaveDisabled = saving || characterName.trim() === "";
|
||||
|
||||
let saveLabel = "Save Profile";
|
||||
if (saving) {
|
||||
saveLabel = "Saving…";
|
||||
}
|
||||
if (saved) {
|
||||
saveLabel = "✓ Saved!";
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-modal="true" className="modal-overlay" role="dialog">
|
||||
<div className="modal edit-profile-modal">
|
||||
<div className="modal-header">
|
||||
<h2>{"Edit Profile"}</h2>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingProfile
|
||||
? <p className="edit-profile-loading">{"Loading your profile…"}</p>
|
||||
: <div className="edit-profile-form">
|
||||
<label className="edit-profile-label" htmlFor="edit-char-name">
|
||||
{"Display Name"}
|
||||
</label>
|
||||
<input
|
||||
className="edit-profile-input"
|
||||
id="edit-char-name"
|
||||
maxLength={32}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={characterName}
|
||||
/>
|
||||
<span className="edit-profile-hint">
|
||||
{characterName.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="edit-profile-label" htmlFor="edit-bio">
|
||||
{"Bio"}
|
||||
</label>
|
||||
<textarea
|
||||
className="edit-profile-textarea"
|
||||
id="edit-bio"
|
||||
maxLength={200}
|
||||
onChange={handleBioChange}
|
||||
placeholder="Tell the world about your guild… (optional)"
|
||||
rows={3}
|
||||
value={bio}
|
||||
/>
|
||||
<span className="edit-profile-hint">
|
||||
{bio.length}
|
||||
{" / 200"}
|
||||
</span>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Visible Stats"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"Choose which stats appear on your public profile."}
|
||||
</p>
|
||||
|
||||
<p className="edit-profile-stat-group-heading">{"Current Run"}</p>
|
||||
<div className="stat-toggles">
|
||||
{currentRunToggles.map(({ key, label, icon }) => {
|
||||
const isOn = profileSettings[key] === true;
|
||||
const toggleClass = isOn
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off";
|
||||
const toggleIndicator = isOn
|
||||
? "✓ Shown"
|
||||
: "Hidden";
|
||||
function handleToggle(): void {
|
||||
toggleSetting(key);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`stat-toggle-btn ${toggleClass}`}
|
||||
key={key}
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{icon} {label}
|
||||
</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{toggleIndicator}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="edit-profile-stat-group-heading">{"All Time"}</p>
|
||||
<div className="stat-toggles">
|
||||
{allTimeToggles.map(({ key, label, icon }) => {
|
||||
const isOn = profileSettings[key] === true;
|
||||
const toggleClass = isOn
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off";
|
||||
const toggleIndicator = isOn
|
||||
? "✓ Shown"
|
||||
: "Hidden";
|
||||
function handleToggle(): void {
|
||||
toggleSetting(key);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`stat-toggle-btn ${toggleClass}`}
|
||||
key={key}
|
||||
onClick={handleToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{icon} {label}
|
||||
</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{toggleIndicator}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Privacy"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"Control your visibility on public leaderboards."}
|
||||
</p>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.showOnLeaderboards
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handleLeaderboardToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"🏆 Appear on Leaderboards"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.showOnLeaderboards
|
||||
? "✓ Shown"
|
||||
: "Hidden"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Sounds & Notifications"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"Control in-game sound effects and browser notifications."}
|
||||
</p>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.enableSounds
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handleSoundsToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"🔊 Sound Effects"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.enableSounds
|
||||
? "✓ On"
|
||||
: "Off"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={`stat-toggle-btn ${
|
||||
profileSettings.enableNotifications
|
||||
? "stat-toggle-on"
|
||||
: "stat-toggle-off"
|
||||
}`}
|
||||
onClick={handleNotificationsToggle}
|
||||
type="button"
|
||||
>
|
||||
<span>{"🔔 Browser Notifications"}</span>
|
||||
<span className="stat-toggle-indicator">
|
||||
{profileSettings.enableNotifications
|
||||
? "✓ On"
|
||||
: "Off"
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-profile-section">
|
||||
<p className="edit-profile-label">{"Number Format"}</p>
|
||||
<p className="edit-profile-sublabel">
|
||||
{"How large numbers appear across the game."}
|
||||
</p>
|
||||
<div className="number-format-picker">
|
||||
{numberFormatOptions.map(({ value, label, example }) => {
|
||||
function handleFormatSelect(): void {
|
||||
setProfileSettings((previous) => {
|
||||
return { ...previous, numberFormat: value };
|
||||
});
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`number-format-btn ${
|
||||
profileSettings.numberFormat === value
|
||||
? "number-format-active"
|
||||
: ""
|
||||
}`}
|
||||
key={value}
|
||||
onClick={handleFormatSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="number-format-label">{label}</span>
|
||||
<span className="number-format-example">{example}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <p className="edit-profile-error">{error}</p>
|
||||
}
|
||||
|
||||
<div className="edit-profile-actions">
|
||||
<button
|
||||
className="edit-profile-cancel"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
className="edit-profile-save"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSaveClick}
|
||||
type="button"
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { EditProfileModal };
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @file Equipment panel component for managing owned and available equipment.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
|
||||
const rarityLabel: Record<string, string> = {
|
||||
common: "Common",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
rare: "Rare",
|
||||
};
|
||||
|
||||
const typeIcon: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a human-readable bonus description for a piece of equipment.
|
||||
* @param item - The equipment item.
|
||||
* @returns The formatted bonus description.
|
||||
*/
|
||||
const bonusDescription = (item: Equipment): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (item.bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat`);
|
||||
}
|
||||
if (item.bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold/s`);
|
||||
}
|
||||
if (item.bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment cost as a readable string.
|
||||
* @param cost - The cost object with gold, essence, and crystals.
|
||||
* @param cost.gold - The gold component of the cost.
|
||||
* @param cost.essence - The essence component of the cost.
|
||||
* @param cost.crystals - The crystals component of the cost.
|
||||
* @returns The formatted cost string.
|
||||
*/
|
||||
const costLabel = (cost: {
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
}): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (cost.gold > 0) {
|
||||
parts.push(`🪙 ${cost.gold.toLocaleString()}`);
|
||||
}
|
||||
if (cost.essence > 0) {
|
||||
parts.push(`✨ ${cost.essence.toLocaleString()}`);
|
||||
}
|
||||
if (cost.crystals > 0) {
|
||||
parts.push(`💎 ${cost.crystals.toLocaleString()}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
interface EquipmentCardProperties {
|
||||
readonly item: Equipment;
|
||||
readonly gold: number;
|
||||
readonly essence: number;
|
||||
readonly crystals: number;
|
||||
readonly dropBossName: string | undefined;
|
||||
readonly setName: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single equipment card.
|
||||
* @param props - The equipment card properties.
|
||||
* @param props.item - The equipment item data.
|
||||
* @param props.gold - The current gold amount.
|
||||
* @param props.essence - The current essence amount.
|
||||
* @param props.crystals - The current crystals amount.
|
||||
* @param props.dropBossName - The name of the boss that drops this item.
|
||||
* @param props.setName - The name of the set this item belongs to.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EquipmentCard = ({
|
||||
item,
|
||||
gold,
|
||||
essence,
|
||||
crystals,
|
||||
dropBossName,
|
||||
setName,
|
||||
}: EquipmentCardProperties): JSX.Element => {
|
||||
const { equipItem, buyEquipment } = useGame();
|
||||
|
||||
const canAfford
|
||||
= item.cost !== undefined
|
||||
&& gold >= item.cost.gold
|
||||
&& essence >= item.cost.essence
|
||||
&& crystals >= item.cost.crystals;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyEquipment(item.id);
|
||||
}
|
||||
function handleEquip(): void {
|
||||
equipItem(item.id);
|
||||
}
|
||||
|
||||
const ownedClass = item.owned
|
||||
? ""
|
||||
: "not-owned";
|
||||
const equippedClass = item.equipped
|
||||
? "equipped"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
||||
>
|
||||
<div className="equipment-icon">{typeIcon[item.type]}</div>
|
||||
<div className="equipment-info">
|
||||
<div className="equipment-name-row">
|
||||
<h3>{item.name}</h3>
|
||||
<span className={`rarity-badge rarity-${item.rarity}`}>
|
||||
{rarityLabel[item.rarity]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{setName === undefined
|
||||
? null
|
||||
: <span className="equipment-set-badge">
|
||||
{"🔗 "}
|
||||
{setName}
|
||||
</span>
|
||||
}
|
||||
{item.owned || item.cost === undefined
|
||||
? null
|
||||
: <p className="equipment-cost">{costLabel(item.cost)}</p>
|
||||
}
|
||||
</div>
|
||||
<div className="equipment-action">
|
||||
{!item.owned && item.cost === undefined
|
||||
&& <span className="equipment-locked">
|
||||
{dropBossName === undefined
|
||||
? "🔒 Boss drop"
|
||||
: `⚔️ Drop: ${dropBossName}`}
|
||||
</span>
|
||||
}
|
||||
{item.owned || item.cost === undefined
|
||||
? null
|
||||
: <button
|
||||
className="equip-button"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{canAfford
|
||||
? "Purchase"
|
||||
: "Can't afford"}
|
||||
</button>
|
||||
}
|
||||
{item.owned && item.equipped
|
||||
? <span className="equipment-equipped-badge">{"✓ Equipped"}</span>
|
||||
: null}
|
||||
{item.owned && !item.equipped
|
||||
? <button
|
||||
className="equip-button"
|
||||
onClick={handleEquip}
|
||||
type="button"
|
||||
>
|
||||
{"Equip"}
|
||||
</button>
|
||||
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
||||
const slotLabel: Record<EquipmentType, string> = {
|
||||
armour: "🛡️ Armour",
|
||||
trinket: "💍 Trinkets",
|
||||
weapon: "⚔️ Weapons",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the equipment panel with all owned and available equipment.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EquipmentPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, equipment, resources } = state;
|
||||
const unownedCount = equipment.filter((item) => {
|
||||
return !item.owned;
|
||||
}).length;
|
||||
|
||||
const equipmentDropSources = new Map<string, string>();
|
||||
for (const { equipmentRewards, name: bossName } of bosses) {
|
||||
for (const equipmentId of equipmentRewards) {
|
||||
equipmentDropSources.set(equipmentId, bossName);
|
||||
}
|
||||
}
|
||||
|
||||
const setNameById = new Map<string, string>(
|
||||
EQUIPMENT_SETS.map((equipSet) => {
|
||||
return [ equipSet.id, equipSet.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
const equippedItemIds = new Set(
|
||||
equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
);
|
||||
const activeSets = EQUIPMENT_SETS.map((set) => {
|
||||
const count = set.pieces.filter((id) => {
|
||||
return equippedItemIds.has(id);
|
||||
}).length;
|
||||
return { count, set };
|
||||
}).filter(({ count }) => {
|
||||
return count >= 2;
|
||||
});
|
||||
|
||||
function setBonusDescription(
|
||||
equipSet: (typeof EQUIPMENT_SETS)[number],
|
||||
count: number,
|
||||
): string {
|
||||
const parts: Array<string> = [];
|
||||
for (const threshold of [ 2, 3 ] as const) {
|
||||
if (count >= threshold) {
|
||||
const bonus = equipSet.bonuses[threshold];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold/s (${String(threshold)}pc)`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click (${String(threshold)}pc)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel equipment-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Equipment"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={unownedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="equipment-intro">
|
||||
{"Equipment drops from bosses and grants passive bonuses."
|
||||
+ " Only one item per slot can be equipped at a time."
|
||||
+ " Equip matching set pieces for bonus effects!"}
|
||||
</p>
|
||||
|
||||
{activeSets.length > 0
|
||||
&& <div className="active-sets">
|
||||
<h3 className="active-sets-heading">{"✨ Active Set Bonuses"}</h3>
|
||||
{activeSets.map(({ set, count }) => {
|
||||
return (
|
||||
<div className="active-set-row" key={set.id}>
|
||||
<span className="active-set-name">
|
||||
{set.name}
|
||||
{" ("}
|
||||
{count}
|
||||
{"/"}
|
||||
{set.pieces.length}
|
||||
{")"}
|
||||
</span>
|
||||
<span className="active-set-bonus">
|
||||
{setBonusDescription(set, count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
{slotOrder.map((slotType) => {
|
||||
const items = equipment.filter((item) => {
|
||||
return item.type === slotType && (showLocked || item.owned);
|
||||
});
|
||||
return (
|
||||
<div className="equipment-slot-section" key={slotType}>
|
||||
<h3 className="slot-heading">{slotLabel[slotType]}</h3>
|
||||
<div className="equipment-list">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<EquipmentCard
|
||||
crystals={resources.crystals}
|
||||
dropBossName={equipmentDropSources.get(item.id)}
|
||||
essence={resources.essence}
|
||||
gold={resources.gold}
|
||||
item={item}
|
||||
key={item.id}
|
||||
setName={
|
||||
item.setId === undefined
|
||||
? undefined
|
||||
: setNameById.get(item.setId)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{items.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No items to show in this slot."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { EquipmentPanel };
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @file Exploration panel component for exploring areas and collecting materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { ExploreCollectResponse } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerDay = 86_400;
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerDay) {
|
||||
const days = Math.floor(seconds / secondsPerDay);
|
||||
const remainingAfterDays = seconds % secondsPerDay;
|
||||
const hours = Math.floor(remainingAfterDays / secondsPerHour);
|
||||
return hours > 0
|
||||
? `${String(days)}d ${String(hours)}h`
|
||||
: `${String(days)}d`;
|
||||
}
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainingAfterHours = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainingAfterHours / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the time remaining for an exploration in progress.
|
||||
* @param startedAt - The timestamp when exploration started.
|
||||
* @param durationSeconds - The total duration in seconds.
|
||||
* @returns The remaining seconds.
|
||||
*/
|
||||
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
|
||||
const elapsed = (Date.now() - startedAt) / 1000;
|
||||
return Math.max(0, durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface CollectResult {
|
||||
areaId: string;
|
||||
response: ExploreCollectResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the exploration panel for managing area explorations.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ExplorationPanel = (): JSX.Element => {
|
||||
const { state, startExploration, collectExploration, formatNumber }
|
||||
= useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, exploration: explorationState } = state;
|
||||
|
||||
const zoneAreas = EXPLORATION_AREAS.filter((area) => {
|
||||
return area.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const hasActiveExploration
|
||||
= explorationState?.areas.some((area) => {
|
||||
return area.status === "in_progress";
|
||||
}) ?? false;
|
||||
|
||||
async function handleStart(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
await startExploration(areaId);
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCollect(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
const result = await collectExploration(areaId);
|
||||
setLastResult({ areaId: areaId, response: result });
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissResult(): void {
|
||||
setLastResult(null);
|
||||
}
|
||||
|
||||
function handleZoneSelect(id: string): void {
|
||||
setActiveZoneId(id);
|
||||
setLastResult(null);
|
||||
}
|
||||
|
||||
const goldChange = lastResult?.response.event?.goldChange ?? 0;
|
||||
const essenceChange = lastResult?.response.event?.essenceChange ?? 0;
|
||||
|
||||
return (
|
||||
<section className="panel exploration-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🗺️ Exploration"}</h2>
|
||||
</div>
|
||||
|
||||
{lastResult === null
|
||||
? null
|
||||
: <div className="exploration-result">
|
||||
<button
|
||||
className="exploration-result-close"
|
||||
onClick={handleDismissResult}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
{lastResult.response.foundNothing
|
||||
? <p className="exploration-nothing">
|
||||
{lastResult.response.nothingMessage}
|
||||
</p>
|
||||
: <>
|
||||
{lastResult.response.event === null
|
||||
? null
|
||||
: <p className="exploration-event-text">
|
||||
{lastResult.response.event.text}
|
||||
</p>
|
||||
}
|
||||
<div className="exploration-rewards">
|
||||
{goldChange !== 0
|
||||
&& <span
|
||||
className={`reward-tag ${goldChange > 0
|
||||
? ""
|
||||
: "negative"}`}
|
||||
>
|
||||
{"🪙 "}
|
||||
{goldChange > 0
|
||||
? "+"
|
||||
: ""}
|
||||
{formatNumber(goldChange)}
|
||||
{" gold"}
|
||||
</span>
|
||||
}
|
||||
{essenceChange > 0
|
||||
&& <span className="reward-tag">
|
||||
{"✨ +"}
|
||||
{formatNumber(essenceChange)}
|
||||
{" essence"}
|
||||
</span>
|
||||
}
|
||||
{lastResult.response.event?.materialGained !== null
|
||||
&& lastResult.response.event?.materialGained !== undefined
|
||||
? <span className="reward-tag material-tag">
|
||||
{"📦 +"}
|
||||
{lastResult.response.event.materialGained.quantity}{" "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */}
|
||||
{lastResult.response.event.materialGained.materialId.replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
)}
|
||||
{" (event)"}
|
||||
</span>
|
||||
: null}
|
||||
{lastResult.response.materialsFound.map((foundMaterial) => {
|
||||
return (
|
||||
<span
|
||||
className="reward-tag material-tag"
|
||||
key={foundMaterial.materialId}
|
||||
>
|
||||
{"📦 +"}
|
||||
{foundMaterial.quantity}{" "}
|
||||
{foundMaterial.materialId.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={handleZoneSelect}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="exploration-list">
|
||||
{zoneAreas.map((area) => {
|
||||
const areaState = explorationState?.areas.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
const status = areaState?.status ?? "locked";
|
||||
const startedAt = areaState?.startedAt ?? 0;
|
||||
const isReady
|
||||
= status === "in_progress"
|
||||
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
|
||||
const isPending = pendingAreaId === area.id;
|
||||
|
||||
function handleStartClick(): void {
|
||||
void handleStart(area.id);
|
||||
}
|
||||
function handleCollectClick(): void {
|
||||
void handleCollect(area.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`exploration-card exploration-${status}`}
|
||||
key={area.id}
|
||||
>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
{areaState?.completedOnce === true
|
||||
? <span className="exploration-discovered">{" 📖"}</span>
|
||||
: null}
|
||||
</h3>
|
||||
<p>{area.description}</p>
|
||||
<span className="exploration-duration">
|
||||
{"⏱️ "}
|
||||
{formatDuration(area.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="exploration-action">
|
||||
{status === "locked"
|
||||
&& <span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
}
|
||||
{status === "available"
|
||||
&& <button
|
||||
className="start-quest-button"
|
||||
disabled={isPending || hasActiveExploration}
|
||||
onClick={handleStartClick}
|
||||
title={
|
||||
hasActiveExploration
|
||||
? "An exploration is already in progress"
|
||||
: undefined
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Departing..."
|
||||
: `Explore (${formatDuration(area.durationSeconds)})`}
|
||||
</button>
|
||||
}
|
||||
{status === "in_progress" && !isReady
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{formatDuration(
|
||||
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
|
||||
)}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{status === "in_progress" && isReady
|
||||
? <button
|
||||
className="collect-button"
|
||||
disabled={isPending}
|
||||
onClick={handleCollectClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Collecting..."
|
||||
: "📦 Collect Results"}
|
||||
</button>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zoneAreas.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No exploration areas in this zone."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ExplorationPanel };
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @file Game layout component rendering the main game UI.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex layout with many conditional renders */
|
||||
/* eslint-disable complexity -- Many tab render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { ResourceBar } from "../ui/resourceBar.js";
|
||||
import { AboutPanel } from "./aboutPanel.js";
|
||||
import { AchievementPanel } from "./achievementPanel.js";
|
||||
import { AchievementToast } from "./achievementToast.js";
|
||||
import { AdventurerPanel } from "./adventurerPanel.js";
|
||||
import { ApotheosisPanel } from "./apotheosisPanel.js";
|
||||
import { BattleModal } from "./battleModal.js";
|
||||
import { BossPanel } from "./bossPanel.js";
|
||||
import { CharacterSheetPanel } from "./characterSheetPanel.js";
|
||||
import { ClickArea } from "./clickArea.js";
|
||||
import { CodexPanel } from "./codexPanel.js";
|
||||
import { CodexToast } from "./codexToast.js";
|
||||
import { CompanionPanel } from "./companionPanel.js";
|
||||
import { CraftingPanel } from "./craftingPanel.js";
|
||||
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
||||
import { EditProfileModal } from "./editProfileModal.js";
|
||||
import { EquipmentPanel } from "./equipmentPanel.js";
|
||||
import { ExplorationPanel } from "./explorationPanel.js";
|
||||
import { LoginBonusModal } from "./loginBonusModal.js";
|
||||
import { OfflineModal } from "./offlineModal.js";
|
||||
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
|
||||
import { PrestigePanel } from "./prestigePanel.js";
|
||||
import { QuestPanel } from "./questPanel.js";
|
||||
import { StatisticsPanel } from "./statisticsPanel.js";
|
||||
import { StoryPanel } from "./storyPanel.js";
|
||||
import { StoryToast } from "./storyToast.js";
|
||||
import { TranscendencePanel } from "./transcendencePanel.js";
|
||||
import { UpgradePanel } from "./upgradePanel.js";
|
||||
|
||||
type Tab =
|
||||
| "adventurers"
|
||||
| "upgrades"
|
||||
| "quests"
|
||||
| "bosses"
|
||||
| "equipment"
|
||||
| "achievements"
|
||||
| "prestige"
|
||||
| "transcendence"
|
||||
| "apotheosis"
|
||||
| "statistics"
|
||||
| "daily"
|
||||
| "codex"
|
||||
| "about"
|
||||
| "exploration"
|
||||
| "crafting"
|
||||
| "character"
|
||||
| "companions"
|
||||
| "story";
|
||||
|
||||
const baseTabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||
{ id: "quests", label: "📜 Quests" },
|
||||
{ id: "bosses", label: "👹 Bosses" },
|
||||
{ id: "equipment", label: "🗡️ Equipment" },
|
||||
{ id: "exploration", label: "🗺️ Exploration" },
|
||||
{ id: "crafting", label: "⚗️ Crafting" },
|
||||
{ id: "daily", label: "📅 Daily" },
|
||||
{ id: "prestige", label: "⭐ Prestige" },
|
||||
{ id: "transcendence", label: "🌌 Transcendence" },
|
||||
{ id: "apotheosis", label: "✨ Apotheosis" },
|
||||
{ id: "statistics", label: "📊 Statistics" },
|
||||
{ id: "companions", label: "👥 Companions" },
|
||||
{ id: "character", label: "📋 Character" },
|
||||
{ id: "achievements", label: "🏆 Achievements" },
|
||||
{ id: "story", label: "📖 Story" },
|
||||
{ id: "codex", label: "🗺️ Codex" },
|
||||
{ id: "about", label: "ℹ️ About" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the main game layout with tabs and panels.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GameLayout = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
isLoading,
|
||||
error,
|
||||
battleResult,
|
||||
dismissBattle,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
forceSync,
|
||||
unlockedCodexEntryIds: pendingCodexEntryIds,
|
||||
unlockedStoryChapterIds: pendingStoryChapterIds,
|
||||
loginBonus,
|
||||
dismissLoginBonus,
|
||||
schemaOutdated,
|
||||
} = useGame();
|
||||
const [ activeTab, setActiveTab ] = useState<Tab>("adventurers");
|
||||
const [ editingProfile, setEditingProfile ] = useState(false);
|
||||
const [ dismissedOutdatedWarning, setDismissedOutdatedWarning ]
|
||||
= useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<p>{"Loading your adventure..."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null && error !== "") {
|
||||
return (
|
||||
<div className="error-screen">
|
||||
<p>
|
||||
{"Error: "}
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<p>{"Loading..."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const profileUrl = `/profile/${state.player.discordId}`;
|
||||
const codexBadgeCount = pendingCodexEntryIds.length;
|
||||
const storyBadgeCount = pendingStoryChapterIds.length;
|
||||
|
||||
function handleOpenEditProfile(): void {
|
||||
setEditingProfile(true);
|
||||
}
|
||||
|
||||
function handleCloseEditProfile(): void {
|
||||
setEditingProfile(false);
|
||||
}
|
||||
|
||||
function handleDismissOutdated(): void {
|
||||
setDismissedOutdatedWarning(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-layout">
|
||||
<ResourceBar
|
||||
apotheosisCount={state.apotheosis?.count ?? 0}
|
||||
isSyncing={isSyncing}
|
||||
lastSavedAt={lastSavedAt}
|
||||
onEditProfile={handleOpenEditProfile}
|
||||
onForceSync={forceSync}
|
||||
prestigeCount={state.prestige.count}
|
||||
profileUrl={profileUrl}
|
||||
resources={state.resources}
|
||||
runestones={state.prestige.runestones}
|
||||
transcendenceCount={state.transcendence?.count ?? 0}
|
||||
/>
|
||||
<OfflineModal />
|
||||
{schemaOutdated && !dismissedOutdatedWarning
|
||||
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
|
||||
: null}
|
||||
<AchievementToast />
|
||||
<CodexToast />
|
||||
<StoryToast />
|
||||
{loginBonus === null
|
||||
? null
|
||||
: <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
|
||||
}
|
||||
{battleResult === null
|
||||
? null
|
||||
: <BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
||||
}
|
||||
{editingProfile
|
||||
? <EditProfileModal onClose={handleCloseEditProfile} />
|
||||
: null}
|
||||
|
||||
<div className="game-main">
|
||||
<aside className="game-sidebar">
|
||||
<ClickArea />
|
||||
<p className="game-copyright">{"© NHCarrigan"}</p>
|
||||
</aside>
|
||||
|
||||
<main className="game-content">
|
||||
<nav className="tab-bar">
|
||||
{baseTabs.map((tab) => {
|
||||
const { id: tabId, label } = tab;
|
||||
function handleTabClick(): void {
|
||||
setActiveTab(tabId);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-button ${
|
||||
activeTab === tabId
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
key={tabId}
|
||||
onClick={handleTabClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
{tabId === "codex" && codexBadgeCount > 0
|
||||
&& <span className="tab-badge">{codexBadgeCount}</span>
|
||||
}
|
||||
{tabId === "story" && storyBadgeCount > 0
|
||||
&& <span className="tab-badge">{storyBadgeCount}</span>
|
||||
}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === "adventurers" && <AdventurerPanel />}
|
||||
{activeTab === "upgrades" && <UpgradePanel />}
|
||||
{activeTab === "quests" && <QuestPanel />}
|
||||
{activeTab === "bosses" && <BossPanel />}
|
||||
{activeTab === "equipment" && <EquipmentPanel />}
|
||||
{activeTab === "achievements" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
{activeTab === "transcendence" && <TranscendencePanel />}
|
||||
{activeTab === "apotheosis" && <ApotheosisPanel />}
|
||||
{activeTab === "exploration" && <ExplorationPanel />}
|
||||
{activeTab === "crafting" && <CraftingPanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "companions" && <CompanionPanel />}
|
||||
{activeTab === "character" && <CharacterSheetPanel />}
|
||||
{activeTab === "story" && <StoryPanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { GameLayout };
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file Leaderboard page component showing top players across categories.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths for categories and entries */
|
||||
import { useEffect, useState, type JSX } from "react";
|
||||
import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types";
|
||||
|
||||
interface CategoryConfig {
|
||||
id: LeaderboardCategory;
|
||||
label: string;
|
||||
icon: string;
|
||||
formatValue: (value: number)=> string;
|
||||
}
|
||||
|
||||
const goldSuffixes = [
|
||||
"",
|
||||
"K",
|
||||
"M",
|
||||
"B",
|
||||
"T",
|
||||
"Qa",
|
||||
"Qt",
|
||||
"S",
|
||||
"Sp",
|
||||
"O",
|
||||
"N",
|
||||
"D",
|
||||
];
|
||||
|
||||
/**
|
||||
* Formats a gold value with a short suffix.
|
||||
* @param value - The gold amount to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatGold = (value: number): string => {
|
||||
if (value === 0) {
|
||||
return "0";
|
||||
}
|
||||
const tier = Math.floor(Math.log10(Math.abs(value)) / 3);
|
||||
const clamped = Math.min(tier, goldSuffixes.length - 1);
|
||||
const scaled = value / Math.pow(1000, clamped);
|
||||
return `${String(Number.parseFloat(scaled.toFixed(2)))}${goldSuffixes[clamped] ?? ""}`;
|
||||
};
|
||||
|
||||
const categories: Array<CategoryConfig> = [
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return formatGold(v);
|
||||
},
|
||||
icon: "🪙",
|
||||
id: "totalGold",
|
||||
label: "Lifetime Gold",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "💀",
|
||||
id: "bossesDefeated",
|
||||
label: "Bosses Defeated",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "📜",
|
||||
id: "questsCompleted",
|
||||
label: "Quests Completed",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "🏆",
|
||||
id: "achievementsUnlocked",
|
||||
label: "Achievements",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "⭐",
|
||||
id: "prestigeCount",
|
||||
label: "Prestige",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "🌌",
|
||||
id: "transcendenceCount",
|
||||
label: "Transcendence",
|
||||
},
|
||||
{
|
||||
formatValue: (v): string => {
|
||||
return v.toLocaleString();
|
||||
},
|
||||
icon: "✨",
|
||||
id: "apotheosisCount",
|
||||
label: "Apotheosis",
|
||||
},
|
||||
];
|
||||
|
||||
const rankBadges: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
|
||||
|
||||
/**
|
||||
* Renders the leaderboard page with category tabs and player rankings.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LeaderboardPage = (): JSX.Element => {
|
||||
const [ category, setCategory ] = useState<LeaderboardCategory>("totalGold");
|
||||
const [ entries, setEntries ] = useState<Array<LeaderboardEntry>>([]);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/leaderboards?category=${category}&limit=100`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load leaderboard");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
entries: Array<LeaderboardEntry>;
|
||||
};
|
||||
setEntries(data.entries);
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load leaderboard",
|
||||
);
|
||||
}).
|
||||
finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ category ]);
|
||||
|
||||
const currentConfig
|
||||
= categories.find((cat) => {
|
||||
return cat.id === category;
|
||||
}) ?? categories[0];
|
||||
|
||||
return (
|
||||
<div className="leaderboard-page">
|
||||
<div className="leaderboard-card">
|
||||
<div className="leaderboard-header">
|
||||
<h1 className="leaderboard-title">{"🏆 Leaderboards"}</h1>
|
||||
<p className="leaderboard-subtitle">
|
||||
{"The mightiest adventurers in Elysium"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="leaderboard-tabs">
|
||||
{categories.map((cat) => {
|
||||
function handleCategoryClick(): void {
|
||||
setCategory(cat.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`leaderboard-tab ${
|
||||
category === cat.id
|
||||
? "leaderboard-tab--active"
|
||||
: ""
|
||||
}`}
|
||||
key={cat.id}
|
||||
onClick={handleCategoryClick}
|
||||
type="button"
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <div className="leaderboard-loading">{"Loading…"}</div>
|
||||
: null}
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <div className="leaderboard-error">
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loading && error === null && entries.length === 0
|
||||
&& <div className="leaderboard-empty">
|
||||
{"No entries yet — be the first on the board!"}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loading && error === null && entries.length > 0
|
||||
&& <div className="leaderboard-table">
|
||||
<div className="leaderboard-table-header">
|
||||
<span className="leaderboard-col-rank">{"Rank"}</span>
|
||||
<span className="leaderboard-col-player">{"Player"}</span>
|
||||
<span className="leaderboard-col-value">
|
||||
{currentConfig?.icon} {currentConfig?.label}
|
||||
</span>
|
||||
</div>
|
||||
{entries.map((entry) => {
|
||||
const avatarUrl
|
||||
= entry.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(entry.discordId, 10) % 5)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32`;
|
||||
const displayName
|
||||
= entry.characterName === ""
|
||||
? entry.username
|
||||
: entry.characterName;
|
||||
|
||||
return (
|
||||
<a
|
||||
className={`leaderboard-row ${
|
||||
entry.rank <= 3
|
||||
? `leaderboard-row--top${String(entry.rank)}`
|
||||
: ""
|
||||
}`}
|
||||
href={`/character/${entry.discordId}`}
|
||||
key={entry.discordId}
|
||||
>
|
||||
<span className="leaderboard-col-rank">
|
||||
{rankBadges[entry.rank] ?? `#${String(entry.rank)}`}
|
||||
</span>
|
||||
<span className="leaderboard-col-player">
|
||||
<img
|
||||
alt={displayName}
|
||||
className="leaderboard-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<span className="leaderboard-player-info">
|
||||
<span className="leaderboard-player-name">
|
||||
{displayName}
|
||||
</span>
|
||||
{entry.activeTitle === ""
|
||||
? null
|
||||
: <span className="leaderboard-player-title">
|
||||
{entry.activeTitle}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<span className="leaderboard-col-value">
|
||||
{currentConfig?.formatValue(entry.value)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="leaderboard-footer">
|
||||
<a className="leaderboard-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
<p className="leaderboard-privacy-note">
|
||||
{"Players can opt out via their profile settings."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LeaderboardPage };
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file Login bonus modal component displaying daily login rewards.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex modal with many render paths */
|
||||
import type { LoginBonusResult } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface LoginBonusModalProperties {
|
||||
readonly bonus: LoginBonusResult;
|
||||
readonly onClose: ()=> void;
|
||||
}
|
||||
|
||||
const dayIcons = [ "🌱", "🌿", "⚔️", "🛡️", "💎", "👑", "🔥" ];
|
||||
|
||||
/**
|
||||
* Formats a gold value with a short suffix.
|
||||
* @param value - The gold amount to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatGold = (value: number): string => {
|
||||
const suffixes = [ "", "K", "M", "B", "T" ];
|
||||
if (value < 1000) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
const tier = Math.min(Math.floor(Math.log10(value) / 3), suffixes.length - 1);
|
||||
const scaled = value / Math.pow(1000, tier);
|
||||
return `${String(Number.parseFloat(scaled.toFixed(1)))}${suffixes[tier] ?? ""}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the login bonus modal showing daily reward details.
|
||||
* @param props - The modal properties.
|
||||
* @param props.bonus - The login bonus result data.
|
||||
* @param props.onClose - Callback when the modal is closed.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LoginBonusModal = ({
|
||||
bonus,
|
||||
onClose,
|
||||
}: LoginBonusModalProperties): JSX.Element => {
|
||||
const isWeeklyBonus = bonus.day === 7;
|
||||
const dayIcon = dayIcons[bonus.day - 1] ?? "⭐";
|
||||
|
||||
return (
|
||||
<div aria-modal="true" className="modal-overlay" role="dialog">
|
||||
<div className="modal login-bonus-modal">
|
||||
<div className="login-bonus-streak">
|
||||
<span className="login-bonus-fire">{"🔥"}</span>
|
||||
<span className="login-bonus-streak-count">{bonus.streak}</span>
|
||||
<span className="login-bonus-streak-label">{"Day Streak"}</span>
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-day-badge">
|
||||
<span className="login-bonus-day-icon">{dayIcon}</span>
|
||||
<span className="login-bonus-day-label">
|
||||
{"Day "}
|
||||
{bonus.day}
|
||||
{" Reward"}
|
||||
</span>
|
||||
{bonus.weekMultiplier > 1
|
||||
&& <span className="login-bonus-week-tag">
|
||||
{"×"}
|
||||
{bonus.weekMultiplier}
|
||||
{" Week Bonus!"}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-rewards">
|
||||
<div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">{"🪙"}</span>
|
||||
<span className="login-bonus-reward-value">
|
||||
{"+"}
|
||||
{formatGold(bonus.goldEarned)}
|
||||
{" Gold"}
|
||||
</span>
|
||||
</div>
|
||||
{bonus.crystalsEarned > 0
|
||||
&& <div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">{"💎"}</span>
|
||||
<span className="login-bonus-reward-value">
|
||||
{"+"}
|
||||
{bonus.crystalsEarned}
|
||||
{" Crystals"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{isWeeklyBonus
|
||||
? <p className="login-bonus-weekly-message">
|
||||
{"🎉 Weekly bonus — keep the streak going!"}
|
||||
</p>
|
||||
: null}
|
||||
|
||||
<div className="login-bonus-calendar">
|
||||
{dayIcons.map((icon, index) => {
|
||||
const dayNumber = index + 1;
|
||||
const isLastDayCompleted = bonus.day === 7 && dayNumber === 7;
|
||||
const isCompleted = dayNumber < bonus.day || isLastDayCompleted;
|
||||
const isToday = dayNumber === bonus.day;
|
||||
return (
|
||||
<div
|
||||
className={`login-bonus-cal-day ${
|
||||
isToday
|
||||
? "login-bonus-cal-day--today"
|
||||
: ""
|
||||
} ${isCompleted
|
||||
? "login-bonus-cal-day--done"
|
||||
: ""}`}
|
||||
key={dayNumber}
|
||||
>
|
||||
<span className="login-bonus-cal-icon">{icon}</span>
|
||||
<span className="login-bonus-cal-num">{dayNumber}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="login-bonus-claim-btn"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"Claim Reward"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoginBonusModal };
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file Login page component with Discord OAuth authentication.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Authentication flow requires many render paths */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { getAuthUrl, handleAuthCallback } from "../../api/client.js";
|
||||
|
||||
interface LoginPageProperties {
|
||||
readonly onLogin: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the login page with Discord OAuth authentication.
|
||||
* @param props - The login page properties.
|
||||
* @param props.onLogin - Callback when authentication completes successfully.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LoginPage = ({ onLogin }: LoginPageProperties): JSX.Element => {
|
||||
const [ authUrl, setAuthUrl ] = useState<string | null>(null);
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const parameters = new URLSearchParams(window.location.search);
|
||||
const code = parameters.get("code");
|
||||
|
||||
if (code !== null) {
|
||||
setIsLoading(true);
|
||||
handleAuthCallback(code).
|
||||
then(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
onLogin();
|
||||
}).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Authentication failed",
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
getAuthUrl().
|
||||
then((url) => {
|
||||
setAuthUrl(url);
|
||||
setIsLoading(false);
|
||||
}).
|
||||
catch(() => {
|
||||
setError("Failed to load authentication URL");
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [ onLogin ]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p>{"Loading..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
function handleReload(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<p className="error">{error}</p>
|
||||
<button onClick={handleReload} type="button">
|
||||
{"Try Again"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1>{"⚔️ Elysium"}</h1>
|
||||
<p>
|
||||
{"An idle fantasy RPG. Hire adventurers, defeat bosses,"
|
||||
+ " and ascend to glory."}
|
||||
</p>
|
||||
<a className="discord-login-button" href={authUrl ?? "#"}>
|
||||
{"Login with Discord"}
|
||||
</a>
|
||||
<p className="login-note">
|
||||
{"Your progress is saved to your Discord account and shareable"
|
||||
+ " with others!"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoginPage };
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @file Offline modal component showing gold earned while away.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* Renders the offline earnings modal if the player earned resources offline.
|
||||
* @returns The JSX element or null if no offline earnings.
|
||||
*/
|
||||
const OfflineModal = (): JSX.Element | null => {
|
||||
const { offlineGold, offlineEssence, dismissOfflineGold, formatNumber }
|
||||
= useGame();
|
||||
|
||||
if (offlineGold <= 0 && offlineEssence <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h2>{"Welcome back!"}</h2>
|
||||
<p>
|
||||
{"Your adventurers kept working whilst you were away and earned:"}
|
||||
</p>
|
||||
{offlineGold > 0
|
||||
&& <p>
|
||||
<strong>
|
||||
{"🪙 "}
|
||||
{formatNumber(offlineGold)}
|
||||
{" gold"}
|
||||
</strong>
|
||||
</p>
|
||||
}
|
||||
{offlineEssence > 0
|
||||
&& <p>
|
||||
<strong>
|
||||
{"✨ "}
|
||||
{formatNumber(offlineEssence)}
|
||||
{" essence"}
|
||||
</strong>
|
||||
</p>
|
||||
}
|
||||
<p className="modal-note">
|
||||
{"Offline progress is calculated up to 8 hours."}
|
||||
</p>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={dismissOfflineGold}
|
||||
type="button"
|
||||
>
|
||||
{"Collect!"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { OfflineModal };
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file Outdated schema modal component warning about incompatible save data.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface OutdatedSchemaModalProperties {
|
||||
readonly onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the outdated schema modal prompting the user to reset or continue.
|
||||
* @param props - The modal properties.
|
||||
* @param props.onDismiss - Callback to dismiss the modal without resetting.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const OutdatedSchemaModal = ({
|
||||
onDismiss,
|
||||
}: OutdatedSchemaModalProperties): JSX.Element => {
|
||||
const { resetProgress } = useGame();
|
||||
const [ isResetting, setIsResetting ] = useState(false);
|
||||
|
||||
async function handleReset(): Promise<void> {
|
||||
setIsResetting(true);
|
||||
await resetProgress();
|
||||
setIsResetting(false);
|
||||
}
|
||||
|
||||
function handleResetClick(): void {
|
||||
void handleReset();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal offline-modal">
|
||||
<h2>{"⚠️ Outdated Save Data"}</h2>
|
||||
<p>
|
||||
{"Your save data is from an older version of Elysium and may cause"
|
||||
+ " bugs or unexpected behaviour. Cloud saves are "}
|
||||
<strong>{"disabled"}</strong>
|
||||
{" until you reset your progress."}
|
||||
</p>
|
||||
<p>{"Resetting will start you fresh — all progress will be lost."}</p>
|
||||
<div className="outdated-modal-actions">
|
||||
<button
|
||||
className="outdated-modal-reset-button"
|
||||
disabled={isResetting}
|
||||
onClick={handleResetClick}
|
||||
type="button"
|
||||
>
|
||||
{isResetting
|
||||
? "Resetting…"
|
||||
: "Reset Progress"}
|
||||
</button>
|
||||
<button
|
||||
className="modal-close-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Proceed with Bugs"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { OutdatedSchemaModal };
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* @file Prestige panel component for ascending and purchasing runestone upgrades.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-lines -- Large panel with prestige and shop tabs */
|
||||
/* eslint-disable max-statements -- Prestige panel manages many local state variables */
|
||||
import { useState, type JSX } from "react";
|
||||
import { prestige } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
import { sendNotification } from "../../utils/notification.js";
|
||||
import { playSound } from "../../utils/sound.js";
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const baseThreshold = 1_000_000;
|
||||
const thresholdScale = 5;
|
||||
const runestonesPerLevel = 10;
|
||||
|
||||
/**
|
||||
* Calculates the prestige threshold for a given prestige count.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @returns The required gold to prestige.
|
||||
*/
|
||||
const calculateThreshold = (prestigeCount: number): number => {
|
||||
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the production multiplier for a given prestige count.
|
||||
* @param prestigeCount - The number of times the player has prestiged.
|
||||
* @returns The compounding multiplier applied to all income sources.
|
||||
*/
|
||||
const calculateProductionMultiplier = (prestigeCount: number): number => {
|
||||
return Math.pow(1.15, prestigeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the runestone preview for a prestige.
|
||||
* @param totalGoldEarned - Total gold earned this run.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
|
||||
* @returns The predicted runestone reward.
|
||||
*/
|
||||
const calculateRunestonePreview = (
|
||||
totalGoldEarned: number,
|
||||
prestigeCount: number,
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): number => {
|
||||
const threshold = calculateThreshold(prestigeCount);
|
||||
const base
|
||||
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
|
||||
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === "runestones"
|
||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
return Math.floor(base * runestoneMult);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
||||
"income",
|
||||
"click",
|
||||
"essence",
|
||||
"crystals",
|
||||
"runestones",
|
||||
"utility",
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the prestige panel with ascension and runestone shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const PrestigePanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reload,
|
||||
formatNumber,
|
||||
buyPrestigeUpgrade,
|
||||
enableNotifications,
|
||||
enableSounds,
|
||||
toggleAutoPrestige,
|
||||
} = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
runestones: number;
|
||||
count: number;
|
||||
milestoneRunestones: number;
|
||||
} | null>(null);
|
||||
const [ prestigeError, setPrestigeError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<"prestige" | "shop">("prestige");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { prestige: prestigeData, player } = state;
|
||||
const threshold = calculateThreshold(prestigeData.count);
|
||||
const isEligible = player.totalGoldEarned >= threshold;
|
||||
const runestonePreview = calculateRunestonePreview(
|
||||
player.totalGoldEarned,
|
||||
prestigeData.count,
|
||||
prestigeData.purchasedUpgradeIds,
|
||||
);
|
||||
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
|
||||
|
||||
async function handlePrestige(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setPrestigeError(null);
|
||||
try {
|
||||
const data = await prestige({});
|
||||
setResult({
|
||||
count: data.newPrestigeCount,
|
||||
milestoneRunestones: data.milestoneRunestones,
|
||||
runestones: data.runestones,
|
||||
});
|
||||
if (enableSounds) {
|
||||
playSound("prestige");
|
||||
}
|
||||
if (enableNotifications) {
|
||||
sendNotification(
|
||||
"⭐ Prestige!",
|
||||
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
||||
);
|
||||
}
|
||||
await reload();
|
||||
} catch (error_: unknown) {
|
||||
setPrestigeError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Prestige failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyPrestigeUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((categoryId) => {
|
||||
const label = PRESTIGE_UPGRADE_CATEGORY_LABELS[categoryId] ?? categoryId;
|
||||
const upgrades = PRESTIGE_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === categoryId;
|
||||
});
|
||||
return { categoryId, label, upgrades };
|
||||
});
|
||||
|
||||
function handlePrestigeClick(): void {
|
||||
void handlePrestige();
|
||||
}
|
||||
|
||||
function handleAutoPrestigeToggle(): void {
|
||||
toggleAutoPrestige();
|
||||
}
|
||||
|
||||
function handlePrestigeTabClick(): void {
|
||||
setActiveTab("prestige");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
const progressRatio = player.totalGoldEarned / threshold;
|
||||
const progressPct = (progressRatio * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<section className="panel prestige-panel">
|
||||
<h2>{"⭐ Prestige"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "prestige"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handlePrestigeTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Ascend"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"🔮 Runestone Shop ("}
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{" stones)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "prestige"
|
||||
&& <>
|
||||
<p>
|
||||
{"Prestige resets your progress but grants "}
|
||||
<strong>{"Runestones"}</strong>
|
||||
{"— permanent currency used for powerful upgrades."}
|
||||
{" Each prestige multiplies your global production by ×1.15"}
|
||||
{" (compounding each run)."}
|
||||
</p>
|
||||
|
||||
<div className="prestige-status">
|
||||
<p>
|
||||
{"Total gold this run: "}
|
||||
<strong>{formatNumber(player.totalGoldEarned)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Required to prestige: "}
|
||||
<strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Prestige count: "}
|
||||
<strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current production multiplier: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{prestigeData.productionMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"After next prestige: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{nextMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Runestones: "}
|
||||
<strong>{formatNumber(prestigeData.runestones)}</strong>
|
||||
</p>
|
||||
{isEligible
|
||||
? <p className="runestone-preview">
|
||||
{"Runestones on prestige: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(runestonePreview)}
|
||||
</strong>
|
||||
</p>
|
||||
: null}
|
||||
{isEligible
|
||||
? null
|
||||
: <p className="prestige-progress">
|
||||
{"Progress: "}
|
||||
{formatNumber(player.totalGoldEarned)}
|
||||
{" / "}
|
||||
{formatNumber(threshold)}
|
||||
{" ("}
|
||||
{progressPct}
|
||||
{"%"}
|
||||
{")"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>{"You are ready to prestige!"}</p>
|
||||
<button
|
||||
className="prestige-button"
|
||||
disabled={isPending}
|
||||
onClick={handlePrestigeClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Ascending..."
|
||||
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
|
||||
</button>
|
||||
{prestigeError === null
|
||||
? null
|
||||
: <p className="error">{prestigeError}</p>
|
||||
}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Ascended to Prestige "}
|
||||
{result.count}
|
||||
{"! Earned "}
|
||||
{formatNumber(result.runestones)}
|
||||
{" Runestones."}
|
||||
{result.milestoneRunestones > 0
|
||||
&& <>
|
||||
{" 🎉 Milestone bonus: +"}
|
||||
{formatNumber(result.milestoneRunestones)}
|
||||
{" Runestones!"}
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: <p className="prestige-locked">
|
||||
{"Earn "}
|
||||
{formatNumber(threshold - player.totalGoldEarned)}
|
||||
{" more gold to unlock prestige."}
|
||||
</p>
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="runestone-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(prestigeData.runestones)}
|
||||
{" Runestones"}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ categoryId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={categoryId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = prestigeData.purchasedUpgradeIds.includes(
|
||||
upgrade.id,
|
||||
);
|
||||
const canAfford
|
||||
= prestigeData.runestones >= upgrade.runestonesCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
const isAutoPrestigeToggle
|
||||
= upgrade.id === "auto_prestige" && purchased;
|
||||
const autoPrestigeEnabled
|
||||
= prestigeData.autoPrestigeEnabled ?? false;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoPrestigeToggle
|
||||
? <button
|
||||
className={`auto-prestige-toggle ${
|
||||
autoPrestigeEnabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={handleAutoPrestigeToggle}
|
||||
type="button"
|
||||
>
|
||||
{autoPrestigeEnabled
|
||||
? "⚡ Auto ON"
|
||||
: "⏸ Auto OFF"}
|
||||
</button>
|
||||
: null}
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="buy-upgrade-button"
|
||||
disabled={
|
||||
!canAfford || isLoading || buyingId !== null
|
||||
}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { PrestigePanel };
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @file Profile page component displaying a player's public profile.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional stat visibility checks */
|
||||
import { useEffect, useState, type JSX } from "react";
|
||||
import { formatNumber } from "../../utils/format.js";
|
||||
import type { PublicProfileResponse } from "@elysium/types";
|
||||
|
||||
interface ProfilePageProperties {
|
||||
readonly discordId: string;
|
||||
}
|
||||
|
||||
interface StatEntry {
|
||||
icon: string;
|
||||
value: string;
|
||||
label: string;
|
||||
date: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the public profile page for a given player.
|
||||
* @param props - The profile page properties.
|
||||
* @param props.discordId - The Discord ID of the player to display.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
|
||||
const [ profile, setProfile ] = useState<PublicProfileResponse | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/profile/${discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Player not found");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
return await (response.json() as Promise<PublicProfileResponse>);
|
||||
}).
|
||||
then(setProfile).
|
||||
catch((error_: unknown) => {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to load profile",
|
||||
);
|
||||
});
|
||||
}, [ discordId ]);
|
||||
|
||||
function handleCopy(): void {
|
||||
void navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-error">
|
||||
<p>
|
||||
{"⚠️ "}
|
||||
{error}
|
||||
</p>
|
||||
<a className="profile-play-link" href="/">
|
||||
{"← Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile === null) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-loading">{"Loading profile…"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = profile.profileSettings;
|
||||
function fmt(value: number): string {
|
||||
return formatNumber(value, settings.numberFormat);
|
||||
}
|
||||
|
||||
const avatarUrl
|
||||
= profile.avatar === null
|
||||
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(discordId, 10) % 5)}.png`
|
||||
: `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`;
|
||||
|
||||
const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const currentRunStatsRaw: Array<StatEntry | false> = [
|
||||
settings.showCurrentGold && {
|
||||
date: false,
|
||||
icon: "🪙",
|
||||
label: "Gold Earned",
|
||||
value: fmt(profile.currentRunGold),
|
||||
},
|
||||
settings.showCurrentClicks && {
|
||||
date: false,
|
||||
icon: "👆",
|
||||
label: "Clicks",
|
||||
value: fmt(profile.currentRunClicks),
|
||||
},
|
||||
settings.showBossesDefeated && {
|
||||
date: false,
|
||||
icon: "💀",
|
||||
label: "Bosses Defeated",
|
||||
value: String(profile.bossesDefeated),
|
||||
},
|
||||
settings.showQuestsCompleted && {
|
||||
date: false,
|
||||
icon: "📜",
|
||||
label: "Quests Completed",
|
||||
value: String(profile.questsCompleted),
|
||||
},
|
||||
settings.showAdventurersRecruited && {
|
||||
date: false,
|
||||
icon: "⚔️",
|
||||
label: "Adventurers Recruited",
|
||||
value: fmt(profile.adventurersRecruited),
|
||||
},
|
||||
settings.showAchievementsUnlocked && {
|
||||
date: false,
|
||||
icon: "🏆",
|
||||
label: "Achievements Unlocked",
|
||||
value: String(profile.achievementsUnlocked),
|
||||
},
|
||||
];
|
||||
const currentRunStats = currentRunStatsRaw.filter(
|
||||
(entry): entry is StatEntry => {
|
||||
return entry !== false;
|
||||
},
|
||||
);
|
||||
|
||||
const allTimeStatsRaw: Array<StatEntry | false> = [
|
||||
settings.showTotalGold && {
|
||||
date: false,
|
||||
icon: "🪙",
|
||||
label: "Total Gold Earned",
|
||||
value: fmt(profile.totalGoldEarned),
|
||||
},
|
||||
settings.showTotalClicks && {
|
||||
date: false,
|
||||
icon: "👆",
|
||||
label: "Total Clicks",
|
||||
value: fmt(profile.totalClicks),
|
||||
},
|
||||
settings.showLifetimeBossesDefeated && {
|
||||
date: false,
|
||||
icon: "💀",
|
||||
label: "Bosses Defeated",
|
||||
value: String(profile.lifetimeBossesDefeated),
|
||||
},
|
||||
settings.showLifetimeQuestsCompleted && {
|
||||
date: false,
|
||||
icon: "📜",
|
||||
label: "Quests Completed",
|
||||
value: String(profile.lifetimeQuestsCompleted),
|
||||
},
|
||||
settings.showLifetimeAdventurersRecruited && {
|
||||
date: false,
|
||||
icon: "⚔️",
|
||||
label: "Adventurers Recruited",
|
||||
value: fmt(profile.lifetimeAdventurersRecruited),
|
||||
},
|
||||
settings.showLifetimeAchievementsUnlocked && {
|
||||
date: false,
|
||||
icon: "🏆",
|
||||
label: "Achievements Unlocked",
|
||||
value: String(profile.lifetimeAchievementsUnlocked),
|
||||
},
|
||||
settings.showGuildFounded && {
|
||||
date: true,
|
||||
icon: "📅",
|
||||
label: "Guild Founded",
|
||||
value: memberSince,
|
||||
},
|
||||
];
|
||||
const allTimeStats = allTimeStatsRaw.filter((entry): entry is StatEntry => {
|
||||
return entry !== false;
|
||||
});
|
||||
|
||||
function renderStats(stats: Array<StatEntry>): JSX.Element {
|
||||
return (
|
||||
<div className="profile-stats">
|
||||
{stats.map((stat) => {
|
||||
return (
|
||||
<div className="profile-stat" key={stat.label}>
|
||||
<span className="profile-stat-icon">{stat.icon}</span>
|
||||
<span
|
||||
className={`profile-stat-value ${
|
||||
stat.date
|
||||
? "profile-stat-date"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="profile-stat-label">{stat.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="profile-card">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
alt={`${profile.username}'s avatar`}
|
||||
className="profile-avatar"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
<div className="profile-identity">
|
||||
<h1 className="profile-character-name">{profile.characterName}</h1>
|
||||
<p className="profile-username">
|
||||
{"@"}
|
||||
{profile.username}
|
||||
</p>
|
||||
{settings.showApotheosis && profile.apotheosisCount > 0
|
||||
? <span className="profile-apotheosis-badge">
|
||||
{"✨ Apotheosis "}
|
||||
{profile.apotheosisCount}
|
||||
</span>
|
||||
: null}
|
||||
{settings.showTranscendence && profile.transcendenceCount > 0
|
||||
? <span className="profile-transcendence-badge">
|
||||
{"🌌 Transcendence "}
|
||||
{profile.transcendenceCount}
|
||||
</span>
|
||||
: null}
|
||||
{settings.showPrestige && profile.prestigeCount > 0
|
||||
? <span className="profile-prestige-badge">
|
||||
{"⭐ Prestige "}
|
||||
{profile.prestigeCount}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio === ""
|
||||
? null
|
||||
: <p className="profile-bio">{profile.bio}</p>
|
||||
}
|
||||
|
||||
{currentRunStats.length > 0
|
||||
&& <div className="profile-stats-section">
|
||||
<h3 className="profile-stats-heading">{"Current Run"}</h3>
|
||||
{renderStats(currentRunStats)}
|
||||
</div>
|
||||
}
|
||||
|
||||
{allTimeStats.length > 0
|
||||
&& <div className="profile-stats-section">
|
||||
<h3 className="profile-stats-heading">{"All Time"}</h3>
|
||||
{renderStats(allTimeStats)}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="profile-actions">
|
||||
<button
|
||||
className="profile-share-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Copy Profile Link"}
|
||||
</button>
|
||||
<a className="profile-play-link" href="/">
|
||||
{"⚔️ Play Elysium"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfilePage };
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @file Quest panel component for managing and completing quests.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Quest } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainderSeconds = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainderSeconds / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the time remaining for an active quest.
|
||||
* @param quest - The quest to check.
|
||||
* @returns The remaining seconds.
|
||||
*/
|
||||
const questTimeRemaining = (quest: Quest): number => {
|
||||
if (quest.status !== "active" || quest.startedAt === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = (Date.now() - quest.startedAt) / 1000;
|
||||
return Math.max(0, quest.durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface QuestCardProperties {
|
||||
readonly quest: Quest;
|
||||
readonly partyCombatPower: number;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly zoneHint: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single quest card.
|
||||
* @param props - The quest card properties.
|
||||
* @param props.quest - The quest to display.
|
||||
* @param props.partyCombatPower - The current party's combat power.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this quest.
|
||||
* @param props.zoneHint - Optional hint for which zone to unlock.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const QuestCard = ({
|
||||
quest,
|
||||
partyCombatPower,
|
||||
unlockHint,
|
||||
zoneHint,
|
||||
}: QuestCardProperties): JSX.Element => {
|
||||
const { startQuest, formatNumber } = useGame();
|
||||
const cpRequired = quest.combatPowerRequired ?? 0;
|
||||
const meetsCP = partyCombatPower >= cpRequired;
|
||||
|
||||
function handleStartQuest(): void {
|
||||
startQuest(quest.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
{cpRequired > 0
|
||||
&& <p
|
||||
className={`quest-cp-requirement ${
|
||||
meetsCP
|
||||
? "cp-met"
|
||||
: "cp-unmet"
|
||||
}`}
|
||||
>
|
||||
{"⚔️ Requires "}
|
||||
{formatNumber(cpRequired)}
|
||||
{" Combat Power"}
|
||||
{quest.status === "available"
|
||||
&& (meetsCP
|
||||
? " ✓"
|
||||
: ` (you have ${formatNumber(partyCombatPower)})`)}
|
||||
</p>
|
||||
}
|
||||
<div className="quest-rewards">
|
||||
{quest.rewards.map((reward) => {
|
||||
return (
|
||||
<span className="reward-tag" key={`${reward.type}-${String(reward.amount ?? "")}`}>
|
||||
{reward.type === "gold"
|
||||
&& `🪙 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "essence"
|
||||
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "crystals"
|
||||
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quest-action">
|
||||
{quest.status === "locked"
|
||||
&& <>
|
||||
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
{zoneHint === undefined
|
||||
? null
|
||||
: <p className="unlock-hint">
|
||||
{"🗺️ Unlock zone: "}
|
||||
{zoneHint}
|
||||
</p>
|
||||
}
|
||||
{zoneHint === undefined && unlockHint !== undefined
|
||||
? <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
{quest.status === "available" && quest.lastFailedAt !== undefined
|
||||
&& <p className="quest-failed-hint">{"⚠️ Last attempt failed"}</p>
|
||||
}
|
||||
{quest.status === "available"
|
||||
&& <button
|
||||
className="start-quest-button"
|
||||
disabled={!meetsCP}
|
||||
onClick={handleStartQuest}
|
||||
title={
|
||||
meetsCP
|
||||
? undefined
|
||||
: `Need ${formatNumber(cpRequired)} combat power`
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{"Send Party ("}
|
||||
{formatDuration(quest.durationSeconds)}
|
||||
{")"}
|
||||
</button>
|
||||
}
|
||||
{quest.status === "active"
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{formatDuration(Math.ceil(questTimeRemaining(quest)))}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{quest.status === "completed"
|
||||
&& <span className="quest-badge completed">{"✅ Complete"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the quest panel with zone selection and quest list.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const QuestPanel = (): JSX.Element => {
|
||||
const { state, toggleAutoQuest } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState("verdant_vale");
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { adventurers, autoQuest, quests, zones } = state;
|
||||
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
|
||||
const partyCombatPower = adventurers.reduce((total, adventurer) => {
|
||||
const power = total + adventurer.combatPower;
|
||||
return power * adventurer.count;
|
||||
}, 0);
|
||||
const zoneQuests = quests.filter(({ zoneId }) => {
|
||||
return zoneId === activeZoneId;
|
||||
});
|
||||
const lockedCount = zoneQuests.filter(({ status }) => {
|
||||
return status === "locked";
|
||||
}).length;
|
||||
const visibleQuests = showLocked
|
||||
? zoneQuests
|
||||
: zoneQuests.filter(({ status }) => {
|
||||
return status !== "locked";
|
||||
});
|
||||
|
||||
const questNameById = new Map(
|
||||
quests.map(({ id, name }) => {
|
||||
return [ id, name ];
|
||||
}),
|
||||
);
|
||||
const zoneById = new Map(
|
||||
zones.map((zone) => {
|
||||
return [ zone.id, zone ];
|
||||
}),
|
||||
);
|
||||
const questUnlockHints = new Map<string, string>();
|
||||
const questZoneHints = new Map<string, string>();
|
||||
for (const { id: questId, status, zoneId, prerequisiteIds } of quests) {
|
||||
if (status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
const zone = zoneById.get(zoneId);
|
||||
if (zone?.status === "locked") {
|
||||
questZoneHints.set(questId, zone.name);
|
||||
} else if (prerequisiteIds.length > 0) {
|
||||
const [ prereqId ] = prerequisiteIds;
|
||||
if (prereqId !== undefined) {
|
||||
const prereqName = questNameById.get(prereqId);
|
||||
if (prereqName !== undefined) {
|
||||
questUnlockHints.set(questId, prereqName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAutoQuest(): void {
|
||||
toggleAutoQuest();
|
||||
}
|
||||
|
||||
const autoQuestOn = autoQuest === true;
|
||||
|
||||
return (
|
||||
<section className="panel quest-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Quests"}</h2>
|
||||
<div className="panel-header-controls">
|
||||
<button
|
||||
className={`auto-toggle-btn ${
|
||||
autoQuestOn
|
||||
? "auto-toggle-on"
|
||||
: "auto-toggle-off"
|
||||
}`}
|
||||
onClick={handleAutoQuest}
|
||||
title="Automatically send the party on the highest available quest"
|
||||
type="button"
|
||||
>
|
||||
{"🤖 Auto: "}
|
||||
{autoQuestOn
|
||||
? "ON"
|
||||
: "OFF"}
|
||||
</button>
|
||||
<LockToggle
|
||||
lockedCount={lockedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZoneSelector
|
||||
activeZoneId={activeZoneId}
|
||||
onSelectZone={setActiveZoneId}
|
||||
zones={zones}
|
||||
/>
|
||||
|
||||
<div className="quest-list">
|
||||
{visibleQuests.map((quest) => {
|
||||
return (
|
||||
<QuestCard
|
||||
key={quest.id}
|
||||
partyCombatPower={partyCombatPower}
|
||||
quest={quest}
|
||||
unlockHint={questUnlockHints.get(quest.id)}
|
||||
zoneHint={questZoneHints.get(quest.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{visibleQuests.length === 0
|
||||
&& <p className="empty-zone">{"No quests to show in this zone."}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { QuestPanel };
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file Statistics panel component showing player progress and all-time stats.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable react/require-default-props -- TypeScript optional props with default parameters are sufficient */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { PRESTIGE_UPGRADES } from "../../data/prestigeUpgrades.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
interface StatCardProperties {
|
||||
readonly icon: string;
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly sub?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single statistic card.
|
||||
* @param props - The stat card properties.
|
||||
* @param props.icon - The icon to display.
|
||||
* @param props.label - The label for the stat.
|
||||
* @param props.value - The value to display.
|
||||
* @param props.sub - Optional sub-label.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatCard = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub = undefined,
|
||||
}: StatCardProperties): JSX.Element => {
|
||||
return (
|
||||
<div className="profile-stat">
|
||||
<span className="profile-stat-icon">{icon}</span>
|
||||
<span className="profile-stat-value">{value}</span>
|
||||
<span className="profile-stat-label">{label}</span>
|
||||
{sub === undefined
|
||||
? null
|
||||
: <span className="profile-stat-date">{sub}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the statistics panel with player progress and all-time stats.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StatisticsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
player,
|
||||
resources,
|
||||
prestige,
|
||||
bosses,
|
||||
quests,
|
||||
zones,
|
||||
adventurers,
|
||||
upgrades,
|
||||
equipment,
|
||||
achievements,
|
||||
} = state;
|
||||
|
||||
const bossesDefeated = bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length;
|
||||
const questsCompleted = quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
const zonesUnlocked = zones.filter((zone) => {
|
||||
return zone.status === "unlocked";
|
||||
}).length;
|
||||
const adventurersRecruited = adventurers.reduce((sum, adventurer) => {
|
||||
return sum + adventurer.count;
|
||||
}, 0);
|
||||
const equipmentOwned = equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length;
|
||||
const upgradesPurchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
}).length;
|
||||
const achievementsUnlocked = achievements.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length;
|
||||
const prestigeUpgradesPurchased = prestige.purchasedUpgradeIds.length;
|
||||
|
||||
return (
|
||||
<section className="panel statistics-panel">
|
||||
<h2>{"📊 Statistics"}</h2>
|
||||
|
||||
<h3 className="stats-section-header">{"All-Time"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="🪙"
|
||||
label="Total Gold Earned"
|
||||
sub="across all runs"
|
||||
value={formatNumber(player.totalGoldEarned)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="👆"
|
||||
label="Total Clicks"
|
||||
value={formatNumber(player.totalClicks)}
|
||||
/>
|
||||
<StatCard icon="⭐" label="Prestiges" value={String(prestige.count)} />
|
||||
<StatCard
|
||||
icon="📅"
|
||||
label="Guild Founded"
|
||||
value={formatDate(player.createdAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="☁️"
|
||||
label="Last Cloud Save"
|
||||
value={formatDate(player.lastSavedAt)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="✖️"
|
||||
label="Production Multiplier"
|
||||
sub="from prestige"
|
||||
value={`×${prestige.productionMultiplier.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">{"Current Run"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard icon="🪙" label="Gold" value={formatNumber(resources.gold)} />
|
||||
<StatCard
|
||||
icon="✨"
|
||||
label="Essence"
|
||||
value={formatNumber(resources.essence)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💎"
|
||||
label="Crystals"
|
||||
value={formatNumber(resources.crystals)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Runestones"
|
||||
sub="permanent currency"
|
||||
value={formatNumber(prestige.runestones)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="stats-section-header">{"Progress"}</h3>
|
||||
<div className="profile-stats">
|
||||
<StatCard
|
||||
icon="👹"
|
||||
label="Bosses Defeated"
|
||||
value={`${String(bossesDefeated)} / ${String(bosses.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📜"
|
||||
label="Quests Completed"
|
||||
value={`${String(questsCompleted)} / ${String(quests.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗺️"
|
||||
label="Zones Unlocked"
|
||||
value={`${String(zonesUnlocked)} / ${String(zones.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⚔️"
|
||||
label="Adventurers Recruited"
|
||||
value={formatNumber(adventurersRecruited)}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🗡️"
|
||||
label="Equipment Owned"
|
||||
value={`${String(equipmentOwned)} / ${String(equipment.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔧"
|
||||
label="Upgrades Purchased"
|
||||
value={`${String(upgradesPurchased)} / ${String(upgrades.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🏆"
|
||||
label="Achievements"
|
||||
value={`${String(achievementsUnlocked)} / ${String(achievements.length)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔮"
|
||||
label="Prestige Upgrades"
|
||||
value={`${String(prestigeUpgradesPurchased)} / ${String(PRESTIGE_UPGRADES.length)}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { StatisticsPanel };
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @file Story panel component displaying the main questline narrative.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
/**
|
||||
* Substitutes the character name placeholder in story text.
|
||||
* @param text - The story text with placeholders.
|
||||
* @param characterName - The player's character name.
|
||||
* @returns The text with placeholders replaced.
|
||||
*/
|
||||
const substituteCharacterName = (
|
||||
text: string,
|
||||
characterName: string,
|
||||
): string => {
|
||||
const fallback = characterName === ""
|
||||
? "the guild leader"
|
||||
: characterName;
|
||||
return text.replaceAll("{characterName}", fallback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the story panel with chapter navigation and content.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const StoryPanel = (): JSX.Element => {
|
||||
const { state, completeChapter } = useGame();
|
||||
const [ activeChapterIndex, setActiveChapterIndex ] = useState(0);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<div className="story-panel">
|
||||
<p>{"Loading…"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unlockedIds = state.story?.unlockedChapterIds ?? [];
|
||||
const completedChapters = state.story?.completedChapters ?? [];
|
||||
const { characterName } = state.player;
|
||||
|
||||
const activeChapter = STORY_CHAPTERS[activeChapterIndex];
|
||||
const isUnlocked = unlockedIds.includes(activeChapter?.id ?? "");
|
||||
const completion
|
||||
= activeChapter === undefined
|
||||
? null
|
||||
: completedChapters.find((completedChapter) => {
|
||||
return completedChapter.chapterId === activeChapter.id;
|
||||
}) ?? null;
|
||||
const isUnread = isUnlocked && completion === null;
|
||||
|
||||
return (
|
||||
<div className="story-panel">
|
||||
<div className="story-chapter-tabs">
|
||||
{STORY_CHAPTERS.map((chapter, index) => {
|
||||
const unlocked = unlockedIds.includes(chapter.id);
|
||||
const completed = completedChapters.some((completedChapter) => {
|
||||
return completedChapter.chapterId === chapter.id;
|
||||
});
|
||||
const unread = unlocked && !completed;
|
||||
function handleChapterSelect(): void {
|
||||
setActiveChapterIndex(index);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-label={
|
||||
unlocked
|
||||
? chapter.title
|
||||
: `Chapter ${String(index + 1)} (locked)`
|
||||
}
|
||||
className={[
|
||||
"story-tab-btn",
|
||||
activeChapterIndex === index
|
||||
? "active"
|
||||
: "",
|
||||
unlocked
|
||||
? ""
|
||||
: "locked",
|
||||
].join(" ")}
|
||||
key={chapter.id}
|
||||
onClick={handleChapterSelect}
|
||||
type="button"
|
||||
>
|
||||
{index + 1}
|
||||
{unread
|
||||
? <span className="story-unread-dot" />
|
||||
: null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeChapter === undefined
|
||||
? null
|
||||
: <div className="story-chapter-view">
|
||||
{isUnlocked
|
||||
? <>
|
||||
<h2 className="story-chapter-title">
|
||||
{"Chapter "}
|
||||
{activeChapterIndex + 1}
|
||||
{": "}
|
||||
{activeChapter.title}
|
||||
</h2>
|
||||
<div className="story-chapter-content">
|
||||
{substituteCharacterName(activeChapter.content, characterName).
|
||||
split("\n\n").
|
||||
map((paragraph, paraIndex) => {
|
||||
// eslint-disable-next-line react/no-array-index-key -- Static content paragraphs have no stable id
|
||||
return <p key={paraIndex}>{paragraph}</p>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{completion === null && isUnread
|
||||
? <div className="story-choices">
|
||||
<p className="story-choices-prompt">{"What do you do?"}</p>
|
||||
{activeChapter.choices.map((storyChoice) => {
|
||||
const chapterForClosure = activeChapter;
|
||||
function handleChoice(): void {
|
||||
completeChapter(chapterForClosure.id, storyChoice.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="story-choice-btn"
|
||||
key={storyChoice.id}
|
||||
onClick={handleChoice}
|
||||
type="button"
|
||||
>
|
||||
{storyChoice.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
{completion === null
|
||||
? null
|
||||
: <div className="story-choice-result">
|
||||
<p className="story-choice-label">
|
||||
<strong>{"Your choice:"}</strong>{" "}
|
||||
{
|
||||
activeChapter.choices.find((storyChoice) => {
|
||||
return storyChoice.id === completion.choiceId;
|
||||
})?.label
|
||||
}
|
||||
</p>
|
||||
<p className="story-choice-outcome">
|
||||
{substituteCharacterName(
|
||||
activeChapter.choices.find((storyChoice) => {
|
||||
return storyChoice.id === completion.choiceId;
|
||||
})?.outcome ?? "",
|
||||
characterName,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
: <div className="story-locked">
|
||||
<p className="story-locked-title">
|
||||
{"Chapter "}
|
||||
{activeChapterIndex + 1}
|
||||
</p>
|
||||
<p className="story-locked-hint">
|
||||
{"🔒 This chapter has not yet been unlocked."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StoryPanel };
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @file Story toast notification component for new chapter unlocks.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the toast container */
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface StoryToastItemProperties {
|
||||
readonly chapterId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single story chapter toast notification.
|
||||
* @param props - The toast item properties.
|
||||
* @param props.chapterId - The chapter ID to display.
|
||||
* @returns The JSX element or null if chapter is not found.
|
||||
*/
|
||||
const StoryToastItem = ({
|
||||
chapterId,
|
||||
}: StoryToastItemProperties): JSX.Element | null => {
|
||||
const { dismissStoryChapter } = useGame();
|
||||
const chapter = STORY_CHAPTERS.find((storyChapter) => {
|
||||
return storyChapter.id === chapterId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
dismissStoryChapter(chapterId);
|
||||
}, 4000);
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ chapterId, dismissStoryChapter ]);
|
||||
|
||||
if (chapter === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(): void {
|
||||
dismissStoryChapter(chapterId);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="achievement-toast" onClick={handleClick} type="button">
|
||||
<span className="achievement-toast-icon">{"📖"}</span>
|
||||
<div className="achievement-toast-content">
|
||||
<span className="achievement-toast-label">{"✨ New Chapter!"}</span>
|
||||
<span className="achievement-toast-name">{chapter.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the story toast container with pending chapter notifications.
|
||||
* @returns The JSX element or null if there are no pending chapters.
|
||||
*/
|
||||
const StoryToast = (): JSX.Element | null => {
|
||||
const { unlockedStoryChapterIds: pendingChapterIds } = useGame();
|
||||
if (pendingChapterIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="achievement-toast-container">
|
||||
{pendingChapterIds.map((id) => {
|
||||
return <StoryToastItem chapterId={id} key={id} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StoryToast };
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @file Transcendence panel component for the second prestige layer.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
TRANSCENDENCE_UPGRADES,
|
||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/transcendenceUpgrades.js";
|
||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const echoFormulaConstant = 853;
|
||||
const finalBossId = "the_absolute_one";
|
||||
|
||||
/**
|
||||
* Calculates the echo preview for a transcendence.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param echoMetaMultiplier - The echo meta multiplier from upgrades.
|
||||
* @returns The predicted echo reward.
|
||||
*/
|
||||
const calculateEchoPreview = (
|
||||
prestigeCount: number,
|
||||
echoMetaMultiplier: number,
|
||||
): number => {
|
||||
const safeCount = Math.max(prestigeCount, 1);
|
||||
return Math.floor(
|
||||
// eslint-disable-next-line stylistic/no-extra-parens -- Required by no-mixed-operators rule
|
||||
(echoFormulaConstant / Math.sqrt(safeCount)) * echoMetaMultiplier,
|
||||
);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<TranscendenceUpgradeCategory> = [
|
||||
"income",
|
||||
"combat",
|
||||
"prestige_threshold",
|
||||
"prestige_runestones",
|
||||
"echo_meta",
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the transcendence panel with transcendence and echo shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const TranscendencePanel = (): JSX.Element => {
|
||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
echoes: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
type TranscendTab = "transcend" | "shop";
|
||||
const [ activeTab, setActiveTab ] = useState<TranscendTab>("transcend");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, prestige: prestigeData, transcendence } = state;
|
||||
const hasDefeatedFinalBoss = bosses.some((boss) => {
|
||||
return boss.id === finalBossId && boss.status === "defeated";
|
||||
});
|
||||
const echoMetaMultiplier = transcendence?.echoMetaMultiplier ?? 1;
|
||||
const echoPreview = calculateEchoPreview(
|
||||
prestigeData.count,
|
||||
echoMetaMultiplier,
|
||||
);
|
||||
const currentEchoes = transcendence?.echoes ?? 0;
|
||||
const transcendenceCount = transcendence?.count ?? 0;
|
||||
|
||||
async function handleTranscend(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await transcend();
|
||||
setResult({ count: data.newTranscendenceCount, echoes: data.echoes });
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Transcendence failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyEchoUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((catId) => {
|
||||
const categoryLabels = TRANSCENDENCE_UPGRADE_CATEGORY_LABELS;
|
||||
const label = categoryLabels[catId] ?? catId;
|
||||
const upgrades = TRANSCENDENCE_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === catId;
|
||||
});
|
||||
return { catId, label, upgrades };
|
||||
});
|
||||
|
||||
function handleTranscendClick(): void {
|
||||
void handleTranscend();
|
||||
}
|
||||
|
||||
function handleTranscendTabClick(): void {
|
||||
setActiveTab("transcend");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel transcendence-panel">
|
||||
<h2>{"🌌 Transcendence"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${
|
||||
activeTab === "transcend"
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={handleTranscendTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Transcend"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"✨ Echo Shop ("}
|
||||
{formatNumber(currentEchoes)}
|
||||
{" echoes)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "transcend"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Transcendence is the ultimate reset. It wipes "}
|
||||
<strong>{"everything"}</strong>
|
||||
{" — resources, prestige, runestones, upgrades, and equipment"
|
||||
+ " — but grants "}
|
||||
<strong>{"Echoes"}</strong>
|
||||
{", a permanent currency that survives all future resets."}
|
||||
{" Echoes power upgrades that permanently amplify every run."}
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
{"Fewer prestiges = more Echoes."}
|
||||
{" Optimise your run for maximum yield!"}
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{transcendenceCount > 0
|
||||
&& <p>
|
||||
{"Transcendence count: "}
|
||||
<strong>{transcendenceCount}</strong>
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
{"Current Echoes: "}
|
||||
<strong>{formatNumber(currentEchoes)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current prestige count: "}
|
||||
<strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
{hasDefeatedFinalBoss
|
||||
? <p className="echo-preview">
|
||||
{"Echoes on transcendence: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(echoPreview)}
|
||||
</strong>
|
||||
{echoMetaMultiplier > 1
|
||||
&& <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{echoMetaMultiplier.toFixed(2)}
|
||||
{" meta bonus applied)"}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Defeat The Absolute One"}</strong>
|
||||
{" to unlock transcendence."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"The Absolute One is the final boss of The Absolute zone,"
|
||||
+ " requiring Prestige 90 to challenge."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to transcend. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleTranscendClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Transcending..."
|
||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
: <p className="error">{error}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Transcended! Earned "}
|
||||
<strong>
|
||||
{formatNumber(result.echoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
{". This is Transcendence "}
|
||||
{result.count}
|
||||
{". A new cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(currentEchoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Echo upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive all future prestiges and transcendences."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ catId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={catId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = (
|
||||
transcendence?.purchasedUpgradeIds ?? []
|
||||
).includes(upgrade.id);
|
||||
const canAfford = currentEchoes >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `✨ ${formatNumber(upgrade.cost)} Echoes`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="buy-upgrade-button echo-buy-button"
|
||||
disabled={
|
||||
!canAfford || isLoading || buyingId !== null
|
||||
}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { TranscendencePanel };
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file Upgrade panel component for purchasing game upgrades.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Upgrade } from "@elysium/types";
|
||||
|
||||
interface UpgradeCardProperties {
|
||||
readonly upgrade: Upgrade;
|
||||
readonly currentGold: number;
|
||||
readonly currentEssence: number;
|
||||
readonly currentCrystals: number;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single upgrade card.
|
||||
* @param props - The upgrade card properties.
|
||||
* @param props.upgrade - The upgrade data.
|
||||
* @param props.currentGold - The current gold amount.
|
||||
* @param props.currentEssence - The current essence amount.
|
||||
* @param props.currentCrystals - The current crystals amount.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this upgrade.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const UpgradeCard = ({
|
||||
upgrade,
|
||||
currentGold,
|
||||
currentEssence,
|
||||
currentCrystals,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: UpgradeCardProperties): JSX.Element => {
|
||||
const { buyUpgrade } = useGame();
|
||||
const canAfford
|
||||
= currentGold >= upgrade.costGold
|
||||
&& currentEssence >= upgrade.costEssence
|
||||
&& currentCrystals >= upgrade.costCrystals;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked && upgrade.purchased) {
|
||||
return (
|
||||
<div className="upgrade-card purchased">
|
||||
<span className="upgrade-name">
|
||||
{"✅ "}
|
||||
{upgrade.name}
|
||||
</span>
|
||||
<span className="upgrade-desc">{upgrade.description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked) {
|
||||
return (
|
||||
<div className="upgrade-card">
|
||||
<div className="upgrade-info">
|
||||
<h3>{upgrade.name}</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">
|
||||
{"×"}
|
||||
{upgrade.multiplier}
|
||||
{" multiplier"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0
|
||||
&& <span>
|
||||
{"🪙 "}
|
||||
{formatNumber(upgrade.costGold)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costEssence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(upgrade.costEssence)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costCrystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(upgrade.costCrystals)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{"Buy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upgrade-card locked">
|
||||
<div className="upgrade-info">
|
||||
<h3>
|
||||
{"🔒 "}
|
||||
{upgrade.name}
|
||||
</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-multiplier">
|
||||
{"×"}
|
||||
{upgrade.multiplier}
|
||||
{" multiplier"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="upgrade-cost">
|
||||
{upgrade.costGold > 0
|
||||
&& <span>
|
||||
{"🪙 "}
|
||||
{formatNumber(upgrade.costGold)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costEssence > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatNumber(upgrade.costEssence)}
|
||||
</span>
|
||||
}
|
||||
{upgrade.costCrystals > 0
|
||||
&& <span>
|
||||
{"💎 "}
|
||||
{formatNumber(upgrade.costCrystals)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span className="upgrade-locked-label">{"Locked"}</span>
|
||||
{unlockHint === undefined
|
||||
? null
|
||||
: <p className="unlock-hint">{unlockHint}</p>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the upgrade panel with all available, locked, and purchased upgrades.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const UpgradePanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, quests, upgrades, resources } = state;
|
||||
const purchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
});
|
||||
const available = upgrades.filter((upgrade) => {
|
||||
return upgrade.unlocked && !upgrade.purchased;
|
||||
});
|
||||
const locked = upgrades.filter((upgrade) => {
|
||||
return !upgrade.unlocked;
|
||||
});
|
||||
|
||||
const upgradeUnlockHints = new Map<string, string>();
|
||||
for (const { upgradeRewards, name: bossName } of bosses) {
|
||||
for (const upgradeId of upgradeRewards) {
|
||||
upgradeUnlockHints.set(upgradeId, `⚔️ Defeat: ${bossName}`);
|
||||
}
|
||||
}
|
||||
for (const { rewards, name: questName } of quests) {
|
||||
for (const reward of rewards) {
|
||||
if (
|
||||
reward.type === "upgrade"
|
||||
&& reward.targetId !== undefined
|
||||
&& !upgradeUnlockHints.has(reward.targetId)
|
||||
) {
|
||||
upgradeUnlockHints.set(reward.targetId, `📜 Complete: ${questName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel upgrade-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Upgrades"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="upgrade-progress">
|
||||
{purchased.length}
|
||||
{" / "}
|
||||
{upgrades.length}
|
||||
{" purchased"}
|
||||
</p>
|
||||
{upgrades.length === 0
|
||||
? <p className="empty-state">
|
||||
{"No upgrades available yet — keep adventuring!"}
|
||||
</p>
|
||||
: <div className="upgrade-list">
|
||||
{available.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={undefined}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{purchased.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={undefined}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showLocked
|
||||
? locked.map((upgrade) => {
|
||||
return (
|
||||
<UpgradeCard
|
||||
currentCrystals={resources.crystals}
|
||||
currentEssence={resources.essence}
|
||||
currentGold={resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
unlockHint={upgradeUnlockHints.get(upgrade.id)}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { UpgradePanel };
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file Zone selector component for choosing the active zone.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { Zone } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ZoneSelectorProperties {
|
||||
readonly zones: Array<Zone>;
|
||||
readonly activeZoneId: string;
|
||||
readonly onSelectZone: (zoneId: string)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a zone selector with buttons for each available zone.
|
||||
* @param props - The zone selector properties.
|
||||
* @param props.zones - The list of zones to display.
|
||||
* @param props.activeZoneId - The currently active zone ID.
|
||||
* @param props.onSelectZone - Callback when a zone is selected.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ZoneSelector = ({
|
||||
zones,
|
||||
activeZoneId,
|
||||
onSelectZone,
|
||||
}: ZoneSelectorProperties): JSX.Element => {
|
||||
return (
|
||||
<div className="zone-selector">
|
||||
{zones.map((zone) => {
|
||||
function handleSelect(): void {
|
||||
onSelectZone(zone.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
zone.id === activeZoneId
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
}`}
|
||||
key={zone.id}
|
||||
onClick={handleSelect}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span className="zone-emoji">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ZoneSelector };
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @file Lock toggle component for showing/hiding locked items.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface LockToggleProperties {
|
||||
readonly showLocked: boolean;
|
||||
readonly onToggle: ()=> void;
|
||||
readonly lockedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a toggle button for showing or hiding locked items.
|
||||
* @param props - The lock toggle properties.
|
||||
* @param props.showLocked - Whether locked items are currently shown.
|
||||
* @param props.onToggle - Callback when the toggle is clicked.
|
||||
* @param props.lockedCount - The number of locked items.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const LockToggle = ({
|
||||
showLocked,
|
||||
onToggle,
|
||||
lockedCount,
|
||||
}: LockToggleProperties): JSX.Element => {
|
||||
const toggleIcon = showLocked
|
||||
? "🔓"
|
||||
: "🔒";
|
||||
const toggleLabel = showLocked
|
||||
? "Hide"
|
||||
: "Show";
|
||||
return (
|
||||
<button
|
||||
className={`lock-toggle ${
|
||||
showLocked
|
||||
? "lock-toggle-on"
|
||||
: "lock-toggle-off"
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
title={showLocked
|
||||
? "Hide locked items"
|
||||
: "Show locked items"}
|
||||
type="button"
|
||||
>
|
||||
{toggleIcon} {toggleLabel}
|
||||
{" locked ("}
|
||||
{lockedCount}
|
||||
{")"}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export { LockToggle };
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @file Resource bar component displaying player resources and profile actions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Large header with many resource and action elements */
|
||||
/* eslint-disable complexity -- Many conditional resource and badge render paths */
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { RESOURCE_CAP } from "../../engine/tick.js";
|
||||
import type { Resource } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ResourceBarProperties {
|
||||
readonly resources: Resource;
|
||||
readonly runestones: number;
|
||||
readonly prestigeCount: number;
|
||||
readonly transcendenceCount: number;
|
||||
readonly apotheosisCount: number;
|
||||
readonly profileUrl: string;
|
||||
readonly onEditProfile: ()=> void;
|
||||
readonly lastSavedAt: number | null;
|
||||
readonly isSyncing: boolean;
|
||||
readonly onForceSync: ()=> Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp as a human-readable relative time string.
|
||||
* @param timestamp - The Unix timestamp in milliseconds.
|
||||
* @returns The relative time string.
|
||||
*/
|
||||
const formatRelativeTime = (timestamp: number): string => {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 10) {
|
||||
return "just now";
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${String(seconds)}s ago`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${String(minutes)}m ago`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${String(hours)}h ago`;
|
||||
};
|
||||
|
||||
const resourceFullTooltip = [
|
||||
"This resource is full!",
|
||||
" Consider spending some or prestiging to keep earning.",
|
||||
].join("");
|
||||
|
||||
/**
|
||||
* Renders the resource bar with player resources and profile actions.
|
||||
* @param props - The resource bar properties.
|
||||
* @param props.resources - The current player resources.
|
||||
* @param props.runestones - The current runestone count.
|
||||
* @param props.prestigeCount - The number of prestiges completed.
|
||||
* @param props.transcendenceCount - The number of transcendences completed.
|
||||
* @param props.apotheosisCount - The number of apotheoses completed.
|
||||
* @param props.profileUrl - The URL of the player's public profile.
|
||||
* @param props.onEditProfile - Callback to open the edit profile modal.
|
||||
* @param props.lastSavedAt - Timestamp of the last cloud save.
|
||||
* @param props.isSyncing - Whether a sync is currently in progress.
|
||||
* @param props.onForceSync - Callback to trigger a forced cloud sync.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ResourceBar = ({
|
||||
resources,
|
||||
runestones,
|
||||
prestigeCount,
|
||||
transcendenceCount,
|
||||
apotheosisCount,
|
||||
profileUrl,
|
||||
onEditProfile,
|
||||
lastSavedAt,
|
||||
isSyncing,
|
||||
onForceSync,
|
||||
}: ResourceBarProperties): JSX.Element => {
|
||||
const { formatNumber, syncError } = useGame();
|
||||
const { gold, essence, crystals } = resources;
|
||||
const resourceValues = [ gold, essence, crystals ];
|
||||
const anyFull = resourceValues.some((v) => {
|
||||
return v >= RESOURCE_CAP;
|
||||
});
|
||||
const goldFull = gold >= RESOURCE_CAP;
|
||||
const essenceFull = essence >= RESOURCE_CAP;
|
||||
const crystalsFull = crystals >= RESOURCE_CAP;
|
||||
|
||||
function handleForceSync(): void {
|
||||
void onForceSync();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="resource-bar">
|
||||
<div className={`resource${goldFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"🪙"}</span>
|
||||
<span className="resource-value">{formatNumber(gold)}</span>
|
||||
<span className="resource-label">{"Gold"}</span>
|
||||
{goldFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className={`resource${essenceFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"✨"}</span>
|
||||
<span className="resource-value">{formatNumber(essence)}</span>
|
||||
<span className="resource-label">{"Essence"}</span>
|
||||
{essenceFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className={`resource${crystalsFull
|
||||
? " resource-full"
|
||||
: ""}`}>
|
||||
<span className="resource-icon">{"💎"}</span>
|
||||
<span className="resource-value">{formatNumber(crystals)}</span>
|
||||
<span className="resource-label">{"Crystals"}</span>
|
||||
{crystalsFull
|
||||
? <span className="resource-cap-badge" title={resourceFullTooltip}>
|
||||
{"FULL"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
<div className="resource">
|
||||
<span className="resource-icon">{"🔮"}</span>
|
||||
<span className="resource-value">{formatNumber(runestones)}</span>
|
||||
<span className="resource-label">{"Runestones"}</span>
|
||||
</div>
|
||||
{apotheosisCount > 0
|
||||
&& <div className="apotheosis-badge">
|
||||
{"✨ Apotheosis "}
|
||||
{apotheosisCount}
|
||||
</div>
|
||||
}
|
||||
{transcendenceCount > 0
|
||||
&& <div className="transcendence-badge">
|
||||
{"🌌 Transcendence "}
|
||||
{transcendenceCount}
|
||||
</div>
|
||||
}
|
||||
{prestigeCount > 0
|
||||
&& <div className="prestige-badge">
|
||||
{"⭐ Prestige "}
|
||||
{prestigeCount}
|
||||
</div>
|
||||
}
|
||||
<div className="profile-buttons">
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://donate.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Support the developer"
|
||||
>
|
||||
{"💜"} <span className="btn-label">{"Donate"}</span>
|
||||
</a>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://chat.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Join our Discord"
|
||||
>
|
||||
{"💬"} <span className="btn-label">{"Discord"}</span>
|
||||
</a>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href="https://support.nhcarrigan.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Get support on our forum"
|
||||
>
|
||||
{"🆘"} <span className="btn-label">{"Support"}</span>
|
||||
</a>
|
||||
{syncError === null
|
||||
? null
|
||||
: <span className="save-status save-error" title={syncError}>
|
||||
{"❌ Save failed"}
|
||||
</span>
|
||||
}
|
||||
{syncError === null && lastSavedAt !== null
|
||||
? <span
|
||||
className="save-status"
|
||||
title={new Date(lastSavedAt).toLocaleString()}
|
||||
>
|
||||
{"☁️ "}
|
||||
{formatRelativeTime(lastSavedAt)}
|
||||
</span>
|
||||
: null}
|
||||
<button
|
||||
className="force-save-button"
|
||||
disabled={isSyncing}
|
||||
onClick={handleForceSync}
|
||||
title="Force cloud save"
|
||||
type="button"
|
||||
>
|
||||
{isSyncing
|
||||
? "⏳"
|
||||
: "💾"}
|
||||
</button>
|
||||
<a
|
||||
className="profile-link-button"
|
||||
href={profileUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View your public profile"
|
||||
>
|
||||
{"👤"} <span className="btn-label">{"Profile"}</span>
|
||||
</a>
|
||||
<button
|
||||
className="profile-edit-button"
|
||||
onClick={onEditProfile}
|
||||
title="Edit your profile"
|
||||
type="button"
|
||||
>
|
||||
{"✏️"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{anyFull
|
||||
? <div className="resource-cap-notice">
|
||||
{"⚠️ One or more resources are full! Consider spending some or"
|
||||
+ " prestiging to keep earning."}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ResourceBar };
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file Equipment set data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and numeric keys are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
import type { EquipmentSet } from "@elysium/types";
|
||||
|
||||
export const EQUIPMENT_SETS: Array<EquipmentSet> = [
|
||||
{
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.1 },
|
||||
3: { combatMultiplier: 1.1 },
|
||||
},
|
||||
description:
|
||||
"The armaments of a seasoned guild soldier — proven steel, reliable gold.",
|
||||
id: "iron_vanguard",
|
||||
name: "Iron Vanguard",
|
||||
pieces: [ "iron_sword", "chainmail", "mages_focus" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.15 },
|
||||
3: { clickMultiplier: 1.2 },
|
||||
},
|
||||
description:
|
||||
"Gear forged from the Shadow Marshes themselves — unseen, unstoppable.",
|
||||
id: "shadow_infiltrator",
|
||||
name: "Shadow Infiltrator",
|
||||
pieces: [ "shadow_dagger", "void_shroud", "void_compass" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.15 },
|
||||
3: { goldMultiplier: 1.15 },
|
||||
},
|
||||
description:
|
||||
"Weapons and armour tempered in the depths of the Volcanic Reaches.",
|
||||
id: "volcanic_forger",
|
||||
name: "Volcanic Forger",
|
||||
pieces: [ "flame_lance", "volcanic_plate", "crystal_shard" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.2 },
|
||||
3: { goldMultiplier: 1.2 },
|
||||
},
|
||||
description:
|
||||
"Relics of the Celestial Reaches — divine power made manifest.",
|
||||
id: "celestial_guardian",
|
||||
name: "Celestial Guardian",
|
||||
pieces: [ "seraph_wing", "celestial_armour", "angels_halo" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.2 },
|
||||
3: { clickMultiplier: 1.25 },
|
||||
},
|
||||
description:
|
||||
"Trophies reclaimed from the deepest trenches of the Abyssal Reaches.",
|
||||
id: "abyssal_predator",
|
||||
name: "Abyssal Predator",
|
||||
pieces: [ "depth_blade", "pressure_plate", "leviathan_eye" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.25 },
|
||||
3: { goldMultiplier: 1.25 },
|
||||
},
|
||||
description:
|
||||
"Forged in the heart of the Infernal Court from the essence of the defeated.",
|
||||
id: "infernal_conqueror",
|
||||
name: "Infernal Conqueror",
|
||||
pieces: [ "hellfire_edge", "demon_hide", "soul_gem" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { clickMultiplier: 1.25 },
|
||||
3: { goldMultiplier: 1.25 },
|
||||
},
|
||||
description:
|
||||
"Instruments of the Crystalline Spire — reality refracted into absolute efficiency.",
|
||||
id: "crystal_domain",
|
||||
name: "Crystal Domain",
|
||||
pieces: [ "prism_blade", "faceted_armour", "prism_eye" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { goldMultiplier: 1.3 },
|
||||
3: { combatMultiplier: 1.3 },
|
||||
},
|
||||
description:
|
||||
"The regalia of the Void Sanctum's lord — power carved from absolute nothingness.",
|
||||
id: "void_emperor",
|
||||
name: "Void Emperor",
|
||||
pieces: [ "void_annihilator", "eternal_shroud", "void_heart_gem" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.35, goldMultiplier: 1.25 },
|
||||
3: { clickMultiplier: 1.35 },
|
||||
},
|
||||
description:
|
||||
"The armaments of the Eternal Throne — weapons and armour that have endured all of time.",
|
||||
id: "eternal_throne",
|
||||
name: "Eternal Throne",
|
||||
pieces: [ "throne_blade", "eternal_armour", "eternity_stone" ],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* @file Exploration area data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
export interface ExplorationAreaSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
zoneId: string;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
export const EXPLORATION_AREAS: Array<ExplorationAreaSummary> = [
|
||||
// Zone 1: verdant_vale
|
||||
{
|
||||
description:
|
||||
"Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.",
|
||||
durationSeconds: 3600,
|
||||
id: "verdant_meadow",
|
||||
name: "The Verdant Meadow",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.",
|
||||
durationSeconds: 7200,
|
||||
id: "whispering_forest",
|
||||
name: "The Whispering Forest",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.",
|
||||
durationSeconds: 10_800,
|
||||
id: "ancient_grove",
|
||||
name: "The Ancient Grove",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.",
|
||||
durationSeconds: 14_400,
|
||||
id: "forbidden_glen",
|
||||
name: "The Forbidden Glen",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
|
||||
// Zone 2: shattered_ruins
|
||||
{
|
||||
description:
|
||||
"What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.",
|
||||
durationSeconds: 7200,
|
||||
id: "collapsed_outpost",
|
||||
name: "The Collapsed Outpost",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.",
|
||||
durationSeconds: 14_400,
|
||||
id: "cursed_lake",
|
||||
name: "The Cursed Lake",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.",
|
||||
durationSeconds: 21_600,
|
||||
id: "runic_archive",
|
||||
name: "The Runic Archive",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.",
|
||||
durationSeconds: 28_800,
|
||||
id: "dragon_throne",
|
||||
name: "The Dragon's Throne",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
|
||||
// Zone 3: frozen_peaks
|
||||
{
|
||||
description:
|
||||
"A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.",
|
||||
durationSeconds: 10_800,
|
||||
id: "glacial_cave",
|
||||
name: "The Glacial Cave",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.",
|
||||
durationSeconds: 21_600,
|
||||
id: "frozen_tundra",
|
||||
name: "The Frozen Tundra",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.",
|
||||
durationSeconds: 32_400,
|
||||
id: "void_rift",
|
||||
name: "The Void Rift",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.",
|
||||
durationSeconds: 43_200,
|
||||
id: "summit_shrine",
|
||||
name: "The Summit Shrine",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
|
||||
// Zone 4: shadow_marshes
|
||||
{
|
||||
description:
|
||||
"A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.",
|
||||
durationSeconds: 18_000,
|
||||
id: "fog_hollow",
|
||||
name: "The Fog Hollow",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.",
|
||||
durationSeconds: 36_000,
|
||||
id: "dark_grotto",
|
||||
name: "The Dark Grotto",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.",
|
||||
durationSeconds: 54_000,
|
||||
id: "cursed_barrow",
|
||||
name: "The Cursed Barrow",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.",
|
||||
durationSeconds: 72_000,
|
||||
id: "marsh_depths",
|
||||
name: "The Marsh Depths",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
|
||||
// Zone 5: volcanic_depths
|
||||
{
|
||||
description:
|
||||
"A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.",
|
||||
durationSeconds: 25_200,
|
||||
id: "magma_tunnel",
|
||||
name: "The Magma Tunnel",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.",
|
||||
durationSeconds: 50_400,
|
||||
id: "forge_chamber",
|
||||
name: "The Forge Chamber",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.",
|
||||
durationSeconds: 75_600,
|
||||
id: "fire_temple",
|
||||
name: "The Fire Temple",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.",
|
||||
durationSeconds: 100_800,
|
||||
id: "core_descent",
|
||||
name: "The Core Descent",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
|
||||
// Zone 6: astral_void
|
||||
{
|
||||
description:
|
||||
"Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.",
|
||||
durationSeconds: 36_000,
|
||||
id: "star_field",
|
||||
name: "The Star Field",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.",
|
||||
durationSeconds: 72_000,
|
||||
id: "probability_sea",
|
||||
name: "The Probability Sea",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.",
|
||||
durationSeconds: 108_000,
|
||||
id: "void_current",
|
||||
name: "The Void Current",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.",
|
||||
durationSeconds: 144_000,
|
||||
id: "null_zenith",
|
||||
name: "The Null Zenith",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
|
||||
// Zone 7: celestial_reaches
|
||||
{
|
||||
description:
|
||||
"A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.",
|
||||
durationSeconds: 43_200,
|
||||
id: "light_spire",
|
||||
name: "The Light Spire",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.",
|
||||
durationSeconds: 86_400,
|
||||
id: "choir_hall",
|
||||
name: "The Choir Hall",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.",
|
||||
durationSeconds: 129_600,
|
||||
id: "divine_court",
|
||||
name: "The Divine Court",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.",
|
||||
durationSeconds: 172_800,
|
||||
id: "celestial_vault",
|
||||
name: "The Celestial Vault",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
|
||||
// Zone 8: abyssal_trench
|
||||
{
|
||||
description:
|
||||
"The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.",
|
||||
durationSeconds: 50_400,
|
||||
id: "trench_entrance",
|
||||
name: "The Trench Entrance",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.",
|
||||
durationSeconds: 100_800,
|
||||
id: "deep_current",
|
||||
name: "The Deep Current",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.",
|
||||
durationSeconds: 151_200,
|
||||
id: "sunless_chamber",
|
||||
name: "The Sunless Chamber",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.",
|
||||
durationSeconds: 201_600,
|
||||
id: "the_waiting_place",
|
||||
name: "The Waiting Place",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
|
||||
// Zone 9: infernal_court
|
||||
{
|
||||
description:
|
||||
"An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.",
|
||||
durationSeconds: 57_600,
|
||||
id: "demon_market",
|
||||
name: "The Demon Market",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.",
|
||||
durationSeconds: 115_200,
|
||||
id: "torment_hall",
|
||||
name: "The Torment Hall",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.",
|
||||
durationSeconds: 172_800,
|
||||
id: "soul_forge",
|
||||
name: "The Soul Forge",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.",
|
||||
durationSeconds: 230_400,
|
||||
id: "lords_chamber",
|
||||
name: "The Lords' Chamber",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
|
||||
// Zone 10: crystalline_spire
|
||||
{
|
||||
description:
|
||||
"The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.",
|
||||
durationSeconds: 64_800,
|
||||
id: "facet_approach",
|
||||
name: "The Facet Approach",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.",
|
||||
durationSeconds: 129_600,
|
||||
id: "calculation_chamber",
|
||||
name: "The Calculation Chamber",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.",
|
||||
durationSeconds: 194_400,
|
||||
id: "mirror_hall",
|
||||
name: "The Mirror Hall",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.",
|
||||
durationSeconds: 259_200,
|
||||
id: "core_access",
|
||||
name: "The Core Access",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
|
||||
// Zone 11: void_sanctum
|
||||
{
|
||||
description:
|
||||
"The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.",
|
||||
durationSeconds: 72_000,
|
||||
id: "threshold",
|
||||
name: "The Threshold",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.",
|
||||
durationSeconds: 144_000,
|
||||
id: "inner_silence",
|
||||
name: "The Inner Silence",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.",
|
||||
durationSeconds: 216_000,
|
||||
id: "resonance_chamber",
|
||||
name: "The Resonance Chamber",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.",
|
||||
durationSeconds: 288_000,
|
||||
id: "sanctum_heart",
|
||||
name: "The Sanctum Heart",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
|
||||
// Zone 12: eternal_throne
|
||||
{
|
||||
description:
|
||||
"The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.",
|
||||
durationSeconds: 79_200,
|
||||
id: "throne_approach",
|
||||
name: "The Throne Approach",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.",
|
||||
durationSeconds: 158_400,
|
||||
id: "dominion_hall",
|
||||
name: "The Dominion Hall",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.",
|
||||
durationSeconds: 237_600,
|
||||
id: "eternity_vault",
|
||||
name: "The Eternity Vault",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.",
|
||||
durationSeconds: 316_800,
|
||||
id: "the_seat",
|
||||
name: "The Seat",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
|
||||
// Zone 13: primordial_chaos
|
||||
{
|
||||
description:
|
||||
"A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.",
|
||||
durationSeconds: 86_400,
|
||||
id: "creation_storm",
|
||||
name: "The Creation Storm",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.",
|
||||
durationSeconds: 172_800,
|
||||
id: "unmaking_sea",
|
||||
name: "The Unmaking Sea",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.",
|
||||
durationSeconds: 259_200,
|
||||
id: "probability_void",
|
||||
name: "The Probability Void",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.",
|
||||
durationSeconds: 345_600,
|
||||
id: "chaos_core",
|
||||
name: "The Chaos Core",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
|
||||
// Zone 14: infinite_expanse
|
||||
{
|
||||
description:
|
||||
"The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.",
|
||||
durationSeconds: 93_600,
|
||||
id: "first_horizon",
|
||||
name: "The First Horizon",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.",
|
||||
durationSeconds: 187_200,
|
||||
id: "middle_nowhere",
|
||||
name: "The Middle of Nowhere",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.",
|
||||
durationSeconds: 280_800,
|
||||
id: "edge_approach",
|
||||
name: "The Edge Approach",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.",
|
||||
durationSeconds: 374_400,
|
||||
id: "the_furthest",
|
||||
name: "The Furthest",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
|
||||
// Zone 15: reality_forge
|
||||
{
|
||||
description:
|
||||
"The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.",
|
||||
durationSeconds: 100_800,
|
||||
id: "workshop_entrance",
|
||||
name: "The Workshop Entrance",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.",
|
||||
durationSeconds: 201_600,
|
||||
id: "creation_floor",
|
||||
name: "The Creation Floor",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.",
|
||||
durationSeconds: 302_400,
|
||||
id: "master_forge",
|
||||
name: "The Master Forge",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.",
|
||||
durationSeconds: 403_200,
|
||||
id: "forge_core",
|
||||
name: "The Forge Core",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
|
||||
// Zone 16: cosmic_maelstrom
|
||||
{
|
||||
description:
|
||||
"The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.",
|
||||
durationSeconds: 108_000,
|
||||
id: "outer_current",
|
||||
name: "The Outer Current",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.",
|
||||
durationSeconds: 216_000,
|
||||
id: "debris_field",
|
||||
name: "The Debris Field",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.",
|
||||
durationSeconds: 324_000,
|
||||
id: "force_confluence",
|
||||
name: "The Force Confluence",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.",
|
||||
durationSeconds: 432_000,
|
||||
id: "eye_approach",
|
||||
name: "The Eye Approach",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
|
||||
// Zone 17: primeval_sanctum
|
||||
{
|
||||
description:
|
||||
"The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.",
|
||||
durationSeconds: 115_200,
|
||||
id: "first_steps",
|
||||
name: "The First Steps",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.",
|
||||
durationSeconds: 230_400,
|
||||
id: "ancient_archive",
|
||||
name: "The Ancient Archive",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.",
|
||||
durationSeconds: 345_600,
|
||||
id: "memory_chamber",
|
||||
name: "The Memory Chamber",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.",
|
||||
durationSeconds: 460_800,
|
||||
id: "the_oldest_place",
|
||||
name: "The Oldest Place",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
|
||||
// Zone 18: the_absolute
|
||||
{
|
||||
description:
|
||||
"The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.",
|
||||
durationSeconds: 129_600,
|
||||
id: "edge_of_everything",
|
||||
name: "The Edge of Everything",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.",
|
||||
durationSeconds: 259_200,
|
||||
id: "truth_approach",
|
||||
name: "The Truth Approach",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.",
|
||||
durationSeconds: 388_800,
|
||||
id: "final_antechamber",
|
||||
name: "The Final Antechamber",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.",
|
||||
durationSeconds: 518_400,
|
||||
id: "the_absolute_heart",
|
||||
name: "The Absolute Heart",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* @file Crafting material data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
import type { Material } from "@elysium/types";
|
||||
|
||||
export const MATERIALS: Array<Material> = [
|
||||
// Zone 1: verdant_vale
|
||||
{
|
||||
description:
|
||||
"Sticky resin tapped from ancient heartwood trees. Smells faintly of spring rain and something older beneath.",
|
||||
id: "verdant_sap",
|
||||
name: "Verdant Sap",
|
||||
rarity: "common",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A translucent gem found buried near the roots of old-growth trees. Pulses with gentle life energy when held.",
|
||||
id: "forest_crystal",
|
||||
name: "Forest Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Bark from a tree that has stood since before the kingdom. Harder than iron and warmer to the touch than it has any right to be.",
|
||||
id: "elder_bark",
|
||||
name: "Elder Bark",
|
||||
rarity: "rare",
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
|
||||
// Zone 2: shattered_ruins
|
||||
{
|
||||
description:
|
||||
"Fine powder ground from fallen masonry. Still carries traces of the civilisation it once was — if you know how to read the patterns.",
|
||||
id: "ruin_dust",
|
||||
name: "Ruin Dust",
|
||||
rarity: "common",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A shard of enchanted stonework. The enchantment is broken, but something lingers in the grain of the stone, waiting.",
|
||||
id: "cursed_fragment",
|
||||
name: "Cursed Fragment",
|
||||
rarity: "uncommon",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment of scale shed during the elder dragon's long reign over the ruins. Resistant to fire and magic alike.",
|
||||
id: "dragonscale_chip",
|
||||
name: "Dragonscale Chip",
|
||||
rarity: "rare",
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
|
||||
// Zone 3: frozen_peaks
|
||||
{
|
||||
description:
|
||||
"Ice from a glacier that has not moved in ten thousand years. Impossibly cold and perfectly clear, with something almost visible within.",
|
||||
id: "glacial_ice",
|
||||
name: "Glacial Ice",
|
||||
rarity: "common",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that formed inside the glacier itself over millennia. It never melts, not even near fire.",
|
||||
id: "frost_crystal",
|
||||
name: "Frost Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment of the reality tear. It hums with wrongness that the fingers instinctively recognise before the mind does.",
|
||||
id: "void_shard",
|
||||
name: "Void Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
|
||||
// Zone 4: shadow_marshes
|
||||
{
|
||||
description:
|
||||
"Roots from the strangler plants that thrive in the fog-choked depths. Toxic without extensive preparation. Worth it, usually.",
|
||||
id: "marsh_root",
|
||||
name: "Marsh Root",
|
||||
rarity: "common",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Distilled darkness, caught in a vial before it could dissipate. Heavy and cold, and absolutely lightless.",
|
||||
id: "shadow_essence",
|
||||
name: "Shadow Essence",
|
||||
rarity: "uncommon",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Bone from something that died in the marsh so long ago it has become part of it. The curse runs deep through the marrow.",
|
||||
id: "cursed_bone",
|
||||
name: "Cursed Bone",
|
||||
rarity: "rare",
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
|
||||
// Zone 5: volcanic_depths
|
||||
{
|
||||
description:
|
||||
"Cooled lava that retained its internal heat. Warm to the touch even centuries after solidifying from whatever it once was.",
|
||||
id: "magma_stone",
|
||||
name: "Magma Stone",
|
||||
rarity: "common",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal grown in the heart of a cooling magma chamber. Burns without being consumed, endlessly.",
|
||||
id: "ember_crystal",
|
||||
name: "Ember Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Ore from a seam that the fire elementals guard jealously. What it forges into is extraordinary by any measure.",
|
||||
id: "legendary_ore",
|
||||
name: "Legendary Ore",
|
||||
rarity: "rare",
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
|
||||
// Zone 6: astral_void
|
||||
{
|
||||
description:
|
||||
"Particulate matter from dying stars, collected from the void between worlds. Glitters even in total darkness.",
|
||||
id: "stardust",
|
||||
name: "Stardust",
|
||||
rarity: "common",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Filaments of solidified probability. Handle with care — they remember every possible future they passed through.",
|
||||
id: "astral_thread",
|
||||
name: "Astral Thread",
|
||||
rarity: "uncommon",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that formed in the spaces between spaces. Technically exists in several places simultaneously. Don't think too hard about it.",
|
||||
id: "void_crystal",
|
||||
name: "Void Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
|
||||
// Zone 7: celestial_reaches
|
||||
{
|
||||
description:
|
||||
"Residue from the celestial host's passing. Warm as sunlight and infinitely patient, as if waiting for something to happen.",
|
||||
id: "celestial_dust",
|
||||
name: "Celestial Dust",
|
||||
rarity: "common",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A chip of something the celestials discarded as imperfect. By mortal standards, it is extraordinary beyond measure.",
|
||||
id: "divine_fragment",
|
||||
name: "Divine Fragment",
|
||||
rarity: "uncommon",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystallised harmonic from the celestial choir. Resonates with a sound felt in the chest rather than heard with the ears.",
|
||||
id: "choir_shard",
|
||||
name: "Choir Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
|
||||
// Zone 8: abyssal_trench
|
||||
{
|
||||
description:
|
||||
"Coral from the deepest trenches where no light reaches and no warmth remains. Black as the water around it.",
|
||||
id: "trench_coral",
|
||||
name: "Trench Coral",
|
||||
rarity: "common",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A gem compressed by aeons of unimaginable pressure at the bottom of all things. Impossibly dense for its size.",
|
||||
id: "pressure_gem",
|
||||
name: "Pressure Gem",
|
||||
rarity: "uncommon",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A tooth from whatever has been waiting in the trench since before your world was made. It is very large.",
|
||||
id: "ancient_tooth",
|
||||
name: "Ancient Tooth",
|
||||
rarity: "rare",
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
|
||||
// Zone 9: infernal_court
|
||||
{
|
||||
description:
|
||||
"Sulphur residue from the court's perpetual fires. The smell never fully fades, no matter how carefully it is stored.",
|
||||
id: "brimstone_flake",
|
||||
name: "Brimstone Flake",
|
||||
rarity: "common",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Extracted from the court's refuse. Corrosive, powerful, and deeply unpleasant in every measurable way.",
|
||||
id: "demon_ichor",
|
||||
name: "Demon Ichor",
|
||||
rarity: "uncommon",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"What remains after a soul has been fully processed by the court. Carries faint echoes of what it was before.",
|
||||
id: "soul_residue",
|
||||
name: "Soul Residue",
|
||||
rarity: "rare",
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
|
||||
// Zone 10: crystalline_spire
|
||||
{
|
||||
description:
|
||||
"Ground from the spire's outer facets. Each particle contains a compressed possibility that has not yet resolved.",
|
||||
id: "prism_dust",
|
||||
name: "Prism Dust",
|
||||
rarity: "common",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment of the spire's core intelligence. Still running calculations on something that may or may not have an answer.",
|
||||
id: "calculation_shard",
|
||||
name: "Calculation Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that contains a future that never happened. Treat carefully. The future remembers being possible.",
|
||||
id: "possibility_crystal",
|
||||
name: "Possibility Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
|
||||
// Zone 11: void_sanctum
|
||||
{
|
||||
description:
|
||||
"Matter that exists in the space between spaces. Lacks most standard properties in ways that should not be possible.",
|
||||
id: "null_matter",
|
||||
name: "Null Matter",
|
||||
rarity: "common",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A shard of the call that drew your guild here. Still resonant, still reaching toward something none of you can name.",
|
||||
id: "resonance_fragment",
|
||||
name: "Resonance Fragment",
|
||||
rarity: "uncommon",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"From the heart of the sanctum itself. What it does is undefined. What it is cannot be satisfactorily described.",
|
||||
id: "sanctum_core",
|
||||
name: "Sanctum Core",
|
||||
rarity: "rare",
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
|
||||
// Zone 12: eternal_throne
|
||||
{
|
||||
description:
|
||||
"Residue from the base of the eternal throne. Old beyond any measurement that applies to things your guild understands.",
|
||||
id: "throne_dust",
|
||||
name: "Throne Dust",
|
||||
rarity: "common",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A chip from one of the throne's crown-like spires. Authority made into something your hands can hold.",
|
||||
id: "crown_fragment",
|
||||
name: "Crown Fragment",
|
||||
rarity: "uncommon",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"From the throne's arm. Carries the weight of every decision ever made here, compressed into splinter-form.",
|
||||
id: "eternity_splinter",
|
||||
name: "Eternity Splinter",
|
||||
rarity: "rare",
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
|
||||
// Zone 13: primordial_chaos
|
||||
{
|
||||
description:
|
||||
"A solidified moment of chaos. Still undecided about its own properties, which change depending on how you look at it.",
|
||||
id: "chaos_fragment",
|
||||
name: "Chaos Fragment",
|
||||
rarity: "common",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment from when something was being made here. What was being made is unclear. Something important, probably.",
|
||||
id: "creation_shard",
|
||||
name: "Creation Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The raw stuff of creation, before it became anything specific. Handle with care. It wants to become things.",
|
||||
id: "primordial_essence",
|
||||
name: "Primordial Essence",
|
||||
rarity: "rare",
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
|
||||
// Zone 14: infinite_expanse
|
||||
{
|
||||
description:
|
||||
"Gathered from somewhere in the expanse. Direction is uncertain. Distance from the collection point is uncertain.",
|
||||
id: "expanse_dust",
|
||||
name: "Expanse Dust",
|
||||
rarity: "common",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that contains compressed distance. It weighs more than its size suggests. Much more. Do not drop it.",
|
||||
id: "distance_crystal",
|
||||
name: "Distance Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment of the expanse's edge, which the expanse does not technically have. This should not exist. It does anyway.",
|
||||
id: "infinity_shard",
|
||||
name: "Infinity Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
|
||||
// Zone 15: reality_forge
|
||||
{
|
||||
description:
|
||||
"Ash from the forge's fires. Contains fragments of unrealised realities that never quite made it to existence.",
|
||||
id: "forge_ash",
|
||||
name: "Forge Ash",
|
||||
rarity: "common",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A worn tool left by whatever worked here before your universe existed. Still functional in ways that are difficult to explain.",
|
||||
id: "creation_tool",
|
||||
name: "Creation Tool",
|
||||
rarity: "uncommon",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A flawed reality, discarded by the forge as below standard. Still contains everything a universe needs.",
|
||||
id: "reality_shard",
|
||||
name: "Reality Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
|
||||
// Zone 16: cosmic_maelstrom
|
||||
{
|
||||
description:
|
||||
"Debris from a galaxy that got too close to the maelstrom. Compressed to a size your guild can actually carry.",
|
||||
id: "maelstrom_debris",
|
||||
name: "Maelstrom Debris",
|
||||
rarity: "common",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A crystal that formed at the intersection of several fundamental forces that should never have been in the same place.",
|
||||
id: "force_crystal",
|
||||
name: "Force Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fragment from the maelstrom's eye. Impossibly calm. Whatever is at the centre has been there since the beginning.",
|
||||
id: "cosmic_fragment",
|
||||
name: "Cosmic Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
|
||||
// Zone 17: primeval_sanctum
|
||||
{
|
||||
description:
|
||||
"Dust from the oldest place. Has been here since before the concept of 'here' had been invented.",
|
||||
id: "ancient_dust",
|
||||
name: "Ancient Dust",
|
||||
rarity: "common",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A shard of something that remembers the moment before the first moment. The memory is in the material itself.",
|
||||
id: "memory_shard",
|
||||
name: "Memory Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An artefact from the first thing to exist in this place. What it did is unknown. That it mattered is beyond doubt.",
|
||||
id: "primeval_relic",
|
||||
name: "Primeval Relic",
|
||||
rarity: "rare",
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
|
||||
// Zone 18: the_absolute
|
||||
{
|
||||
description:
|
||||
"A fragment of the final truth. It is difficult to look at directly, and impossible to look away from once you start.",
|
||||
id: "absolute_fragment",
|
||||
name: "Absolute Fragment",
|
||||
rarity: "common",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"From the edge of everything. On one side: everything. On the other: nothing. This is from the very boundary between them.",
|
||||
id: "boundary_shard",
|
||||
name: "Boundary Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The last crystal. After this, there are no more. It knows this. You can tell from the way it sits in your hand.",
|
||||
id: "omega_crystal",
|
||||
name: "Omega Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* @file Prestige upgrade data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable import/group-exports -- Multiple exports are required for this data module */
|
||||
import type { PrestigeUpgrade } from "@elysium/types";
|
||||
|
||||
export const PRESTIGE_UPGRADES: Array<PrestigeUpgrade> = [
|
||||
// ── Global Income Tiers ───────────────────────────────────────────────────
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"The first runestone awakens dormant power in your guild. All production ×1.25.",
|
||||
id: "income_1",
|
||||
multiplier: 1.25,
|
||||
name: "Runestone Blessing I",
|
||||
runestonesCost: 10,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"Deeper runestone resonance amplifies your workforce. All production ×1.5.",
|
||||
id: "income_2",
|
||||
multiplier: 1.5,
|
||||
name: "Runestone Blessing II",
|
||||
runestonesCost: 25,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description: "The runes sing with accumulated wisdom. All production ×2.",
|
||||
id: "income_3",
|
||||
multiplier: 2,
|
||||
name: "Runestone Blessing III",
|
||||
runestonesCost: 60,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"Runestone energy surges through your guild's operations. All production ×3.",
|
||||
id: "income_4",
|
||||
multiplier: 3,
|
||||
name: "Runic Surge I",
|
||||
runestonesCost: 150,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"The surge intensifies, pushing limits thought impossible. All production ×5.",
|
||||
id: "income_5",
|
||||
multiplier: 5,
|
||||
name: "Runic Surge II",
|
||||
runestonesCost: 350,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"An overwhelming tide of runic energy floods your operations. All production ×10.",
|
||||
id: "income_6",
|
||||
multiplier: 10,
|
||||
name: "Runic Surge III",
|
||||
runestonesCost: 800,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"You decipher ancient runic inscriptions that unlock vast potential. All production ×25.",
|
||||
id: "income_7",
|
||||
multiplier: 25,
|
||||
name: "Ancient Inscription I",
|
||||
runestonesCost: 2000,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"Deeper inscriptions reveal secrets of primordial power. All production ×50.",
|
||||
id: "income_8",
|
||||
multiplier: 50,
|
||||
name: "Ancient Inscription II",
|
||||
runestonesCost: 5000,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"The full inscription blazes with world-shaping power. All production ×100.",
|
||||
id: "income_9",
|
||||
multiplier: 100,
|
||||
name: "Ancient Inscription III",
|
||||
runestonesCost: 12_000,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"The oldest runes, carved before memory began, yield their secrets at last. All production ×500.",
|
||||
id: "income_10",
|
||||
multiplier: 500,
|
||||
name: "Eternal Rune I",
|
||||
runestonesCost: 30_000,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
description:
|
||||
"Eternal runes resonate with the heartbeat of creation itself. All production ×1,000.",
|
||||
id: "income_11",
|
||||
multiplier: 1000,
|
||||
name: "Eternal Rune II",
|
||||
runestonesCost: 80_000,
|
||||
},
|
||||
// ── Click Power ───────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "click",
|
||||
description:
|
||||
"Infuse your personal strikes with runestone energy. Click power ×2.",
|
||||
id: "click_power_1",
|
||||
multiplier: 2,
|
||||
name: "Runic Strike I",
|
||||
runestonesCost: 15,
|
||||
},
|
||||
{
|
||||
category: "click",
|
||||
description:
|
||||
"Your strikes crackle with compounded runic force. Click power ×5.",
|
||||
id: "click_power_2",
|
||||
multiplier: 5,
|
||||
name: "Runic Strike II",
|
||||
runestonesCost: 75,
|
||||
},
|
||||
{
|
||||
category: "click",
|
||||
description:
|
||||
"Every click channels the weight of all your past lives. Click power ×20.",
|
||||
id: "click_power_3",
|
||||
multiplier: 20,
|
||||
name: "Runic Strike III",
|
||||
runestonesCost: 400,
|
||||
},
|
||||
{
|
||||
category: "click",
|
||||
description:
|
||||
"A single click now carries the force of a falling empire. Click power ×100.",
|
||||
id: "click_power_4",
|
||||
multiplier: 100,
|
||||
name: "World-Breaker Click",
|
||||
runestonesCost: 2500,
|
||||
},
|
||||
// ── Essence Production ────────────────────────────────────────────────────
|
||||
{
|
||||
category: "essence",
|
||||
description:
|
||||
"Runestone resonance amplifies your essence gathering. Essence production ×2.",
|
||||
id: "essence_1",
|
||||
multiplier: 2,
|
||||
name: "Essence Attunement I",
|
||||
runestonesCost: 20,
|
||||
},
|
||||
{
|
||||
category: "essence",
|
||||
description:
|
||||
"Deep attunement draws essence from previously invisible sources. Essence production ×5.",
|
||||
id: "essence_2",
|
||||
multiplier: 5,
|
||||
name: "Essence Attunement II",
|
||||
runestonesCost: 120,
|
||||
},
|
||||
{
|
||||
category: "essence",
|
||||
description:
|
||||
"Your guild breathes essence as naturally as air. Essence production ×20.",
|
||||
id: "essence_3",
|
||||
multiplier: 20,
|
||||
name: "Essence Attunement III",
|
||||
runestonesCost: 700,
|
||||
},
|
||||
{
|
||||
category: "essence",
|
||||
description:
|
||||
"Essence flows in torrents from every corner of every world. Essence production ×100.",
|
||||
id: "essence_4",
|
||||
multiplier: 100,
|
||||
name: "Essence Attunement IV",
|
||||
runestonesCost: 4000,
|
||||
},
|
||||
// ── Crystal Production ────────────────────────────────────────────────────
|
||||
{
|
||||
category: "crystals",
|
||||
description:
|
||||
"Runestones vibrate in harmony with crystal structures. Crystal rewards ×2.",
|
||||
id: "crystal_1",
|
||||
multiplier: 2,
|
||||
name: "Crystal Resonance I",
|
||||
runestonesCost: 30,
|
||||
},
|
||||
{
|
||||
category: "crystals",
|
||||
description:
|
||||
"The resonance deepens, shattering crystal barriers. Crystal rewards ×5.",
|
||||
id: "crystal_2",
|
||||
multiplier: 5,
|
||||
name: "Crystal Resonance II",
|
||||
runestonesCost: 200,
|
||||
},
|
||||
{
|
||||
category: "crystals",
|
||||
description:
|
||||
"Pure resonance crystallises reality into abundance. Crystal rewards ×25.",
|
||||
id: "crystal_3",
|
||||
multiplier: 25,
|
||||
name: "Crystal Resonance III",
|
||||
runestonesCost: 1200,
|
||||
},
|
||||
// ── Utility Unlocks ───────────────────────────────────────────────────────
|
||||
{
|
||||
category: "utility",
|
||||
description:
|
||||
"Unlock the Auto-Prestige toggle. When enabled, you will automatically ascend the moment you reach the prestige threshold — using your current character name.",
|
||||
id: "auto_prestige",
|
||||
multiplier: 1,
|
||||
name: "Autonomous Ascension",
|
||||
runestonesCost: 100,
|
||||
},
|
||||
// ── Runestone Meta-Upgrades ───────────────────────────────────────────────
|
||||
{
|
||||
category: "runestones",
|
||||
description:
|
||||
"Your runestone attunement grows with each prestige. Earn 25% more runestones from future prestiges.",
|
||||
id: "runestone_gain_1",
|
||||
multiplier: 1.25,
|
||||
name: "Runic Legacy",
|
||||
runestonesCost: 50,
|
||||
},
|
||||
{
|
||||
category: "runestones",
|
||||
description:
|
||||
"Your legend transcends individual lifetimes. Earn 50% more runestones from future prestiges.",
|
||||
id: "runestone_gain_2",
|
||||
multiplier: 1.5,
|
||||
name: "Eternal Legacy",
|
||||
runestonesCost: 500,
|
||||
},
|
||||
];
|
||||
|
||||
export const PRESTIGE_UPGRADE_CATEGORY_LABELS: Record<string, string> = {
|
||||
click: "👆 Click Power",
|
||||
crystals: "💎 Crystal Rewards",
|
||||
essence: "✨ Essence Production",
|
||||
income: "🪙 Global Income",
|
||||
runestones: "🔮 Runestone Gain",
|
||||
utility: "⚙️ Utility",
|
||||
};
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* @file Crafting recipe data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
import type { CraftingRecipe } from "@elysium/types";
|
||||
|
||||
export const RECIPES: Array<CraftingRecipe> = [
|
||||
// Zone 1: verdant_vale
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.05 },
|
||||
description:
|
||||
"Sap from ancient heartwood trees, refined and bound with forest crystal. The resulting tincture accelerates the flow of wealth through your guild in ways the alchemists cannot fully explain.",
|
||||
id: "heartwood_tincture",
|
||||
name: "Heartwood Tincture",
|
||||
requiredMaterials: [
|
||||
{ materialId: "verdant_sap", quantity: 5 },
|
||||
{ materialId: "forest_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.08 },
|
||||
description:
|
||||
"A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
|
||||
id: "elder_bark_shield",
|
||||
name: "Elder Bark Shield",
|
||||
requiredMaterials: [
|
||||
{ materialId: "elder_bark", quantity: 2 },
|
||||
{ materialId: "verdant_sap", quantity: 8 },
|
||||
],
|
||||
zoneId: "verdant_vale",
|
||||
},
|
||||
|
||||
// Zone 2: shattered_ruins
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.05 },
|
||||
description:
|
||||
"The ruin dust and cursed fragments, carefully worked into a binding that borrows the essence-drawing power of the fallen civilisation's final enchantments.",
|
||||
id: "runic_binding",
|
||||
name: "Runic Binding",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ruin_dust", quantity: 8 },
|
||||
{ materialId: "cursed_fragment", quantity: 4 },
|
||||
],
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.08 },
|
||||
description:
|
||||
"A charm set with a chip of the elder dragon's scale. The dragon would be furious if he knew. He would also be impressed.",
|
||||
id: "dragon_scale_charm",
|
||||
name: "Dragon Scale Charm",
|
||||
requiredMaterials: [
|
||||
{ materialId: "dragonscale_chip", quantity: 2 },
|
||||
{ materialId: "ruin_dust", quantity: 10 },
|
||||
],
|
||||
zoneId: "shattered_ruins",
|
||||
},
|
||||
|
||||
// Zone 3: frozen_peaks
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.08 },
|
||||
description:
|
||||
"Glacial ice ground and shaped into a lens that clarifies and focuses. Holding it, your guild's actions become sharper, more precise, more effective per motion.",
|
||||
id: "glacial_lens",
|
||||
name: "Glacial Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "glacial_ice", quantity: 8 },
|
||||
{ materialId: "frost_crystal", quantity: 4 },
|
||||
],
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.1 },
|
||||
description:
|
||||
"The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
|
||||
id: "void_fragment_amulet",
|
||||
name: "Void Fragment Amulet",
|
||||
requiredMaterials: [
|
||||
{ materialId: "void_shard", quantity: 2 },
|
||||
{ materialId: "frost_crystal", quantity: 6 },
|
||||
],
|
||||
zoneId: "frozen_peaks",
|
||||
},
|
||||
|
||||
// Zone 4: shadow_marshes
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.08 },
|
||||
description:
|
||||
"Marsh roots processed with shadow essence into a refined compound that somehow makes the essence of things flow more freely toward your guild hall.",
|
||||
id: "shadow_extract",
|
||||
name: "Shadow Extract",
|
||||
requiredMaterials: [
|
||||
{ materialId: "marsh_root", quantity: 8 },
|
||||
{ materialId: "shadow_essence", quantity: 4 },
|
||||
],
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.1 },
|
||||
description:
|
||||
"The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
|
||||
id: "cursed_focus",
|
||||
name: "Cursed Focus",
|
||||
requiredMaterials: [
|
||||
{ materialId: "cursed_bone", quantity: 2 },
|
||||
{ materialId: "shadow_essence", quantity: 6 },
|
||||
],
|
||||
zoneId: "shadow_marshes",
|
||||
},
|
||||
|
||||
// Zone 5: volcanic_depths
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.1 },
|
||||
description:
|
||||
"A seal forged in the volcanic depths, using the eternal heat of the magma stone and ember crystal to create something that burns wealth into existence continuously.",
|
||||
id: "magma_core_seal",
|
||||
name: "Magma Core Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "magma_stone", quantity: 8 },
|
||||
{ materialId: "ember_crystal", quantity: 4 },
|
||||
],
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.12 },
|
||||
description:
|
||||
"The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
|
||||
id: "elemental_ore_ingot",
|
||||
name: "Elemental Ore Ingot",
|
||||
requiredMaterials: [
|
||||
{ materialId: "legendary_ore", quantity: 2 },
|
||||
{ materialId: "magma_stone", quantity: 10 },
|
||||
],
|
||||
zoneId: "volcanic_depths",
|
||||
},
|
||||
|
||||
// Zone 6: astral_void
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.12 },
|
||||
description:
|
||||
"Stardust arranged along astral threads into a map of the void that somehow, impossibly, shows your guild where to press and how to press it for maximum effect.",
|
||||
id: "star_chart",
|
||||
name: "Star Chart",
|
||||
requiredMaterials: [
|
||||
{ materialId: "stardust", quantity: 10 },
|
||||
{ materialId: "astral_thread", quantity: 4 },
|
||||
],
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.12 },
|
||||
description:
|
||||
"A void crystal suspended in a matrix of stardust — something that exists in several places simultaneously and draws gold from all of them at once.",
|
||||
id: "void_crystal_matrix",
|
||||
name: "Void Crystal Matrix",
|
||||
requiredMaterials: [
|
||||
{ materialId: "void_crystal", quantity: 2 },
|
||||
{ materialId: "stardust", quantity: 12 },
|
||||
],
|
||||
zoneId: "astral_void",
|
||||
},
|
||||
|
||||
// Zone 7: celestial_reaches
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.12 },
|
||||
description:
|
||||
"Celestial dust and divine fragments ground into a lens that sees the essence in all things and draws a portion of it — gently, as the celestials would prefer.",
|
||||
id: "celestial_lens",
|
||||
name: "Celestial Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "celestial_dust", quantity: 10 },
|
||||
{ materialId: "divine_fragment", quantity: 4 },
|
||||
],
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.15 },
|
||||
description:
|
||||
"A choir shard set in divine fragments, still humming with the celestial harmonic. The resonance makes gold flow in its direction — not compelled, simply invited.",
|
||||
id: "choir_resonator",
|
||||
name: "Choir Resonator",
|
||||
requiredMaterials: [
|
||||
{ materialId: "choir_shard", quantity: 2 },
|
||||
{ materialId: "divine_fragment", quantity: 6 },
|
||||
],
|
||||
zoneId: "celestial_reaches",
|
||||
},
|
||||
|
||||
// Zone 8: abyssal_trench
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
description:
|
||||
"Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
|
||||
id: "pressure_forged_core",
|
||||
name: "Pressure-Forged Core",
|
||||
requiredMaterials: [
|
||||
{ materialId: "trench_coral", quantity: 10 },
|
||||
{ materialId: "pressure_gem", quantity: 4 },
|
||||
],
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.15 },
|
||||
description:
|
||||
"A talisman set with the ancient tooth, suspended in trench coral carvings. Your party fights differently with this at their chest. More deliberately. More completely.",
|
||||
id: "ancient_fang_talisman",
|
||||
name: "Ancient Fang Talisman",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ancient_tooth", quantity: 2 },
|
||||
{ materialId: "trench_coral", quantity: 12 },
|
||||
],
|
||||
zoneId: "abyssal_trench",
|
||||
},
|
||||
|
||||
// Zone 9: infernal_court
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.15 },
|
||||
description:
|
||||
"A seal of infernal court authority, forged from brimstone and ichor. The court doesn't know you have this. It's better that way. It does make trade extremely efficient.",
|
||||
id: "court_seal",
|
||||
name: "Court Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "brimstone_flake", quantity: 10 },
|
||||
{ materialId: "demon_ichor", quantity: 5 },
|
||||
],
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.15 },
|
||||
description:
|
||||
"Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
|
||||
id: "soul_bound_catalyst",
|
||||
name: "Soul-Bound Catalyst",
|
||||
requiredMaterials: [
|
||||
{ materialId: "soul_residue", quantity: 2 },
|
||||
{ materialId: "demon_ichor", quantity: 8 },
|
||||
],
|
||||
zoneId: "infernal_court",
|
||||
},
|
||||
|
||||
// Zone 10: crystalline_spire
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.18 },
|
||||
description:
|
||||
"Prism dust and calculation shards assembled into an array that the spire's intelligence would call elegant, if it had aesthetic preferences, which it might.",
|
||||
id: "prism_array",
|
||||
name: "Prism Array",
|
||||
requiredMaterials: [
|
||||
{ materialId: "prism_dust", quantity: 10 },
|
||||
{ materialId: "calculation_shard", quantity: 4 },
|
||||
],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.18 },
|
||||
description:
|
||||
"A possibility crystal contained within a calculation shard framework. It runs through every possible outcome of every guild action and finds the one with the highest gold yield.",
|
||||
id: "possibility_engine",
|
||||
name: "Possibility Engine",
|
||||
requiredMaterials: [
|
||||
{ materialId: "possibility_crystal", quantity: 2 },
|
||||
{ materialId: "calculation_shard", quantity: 6 },
|
||||
],
|
||||
zoneId: "crystalline_spire",
|
||||
},
|
||||
|
||||
// Zone 11: void_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.18 },
|
||||
description:
|
||||
"Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
|
||||
id: "null_field_generator",
|
||||
name: "Null Field Generator",
|
||||
requiredMaterials: [
|
||||
{ materialId: "null_matter", quantity: 10 },
|
||||
{ materialId: "resonance_fragment", quantity: 4 },
|
||||
],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.18 },
|
||||
description:
|
||||
"A sanctum core and resonance fragments shaped into a key to something. The essence flows through it like it was designed to carry essence, which it may have been.",
|
||||
id: "sanctum_key",
|
||||
name: "Sanctum Key",
|
||||
requiredMaterials: [
|
||||
{ materialId: "sanctum_core", quantity: 2 },
|
||||
{ materialId: "resonance_fragment", quantity: 6 },
|
||||
],
|
||||
zoneId: "void_sanctum",
|
||||
},
|
||||
|
||||
// Zone 12: eternal_throne
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.2 },
|
||||
description:
|
||||
"Throne dust pressed into throne dust-lacquered crown fragments, shaped into a circlet. Wearing it — metaphorically — makes gold accumulate with the inevitability of authority.",
|
||||
id: "crown_circlet",
|
||||
name: "Crown Circlet",
|
||||
requiredMaterials: [
|
||||
{ materialId: "throne_dust", quantity: 10 },
|
||||
{ materialId: "crown_fragment", quantity: 4 },
|
||||
],
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description:
|
||||
"An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
|
||||
id: "eternity_bound_ring",
|
||||
name: "Eternity-Bound Ring",
|
||||
requiredMaterials: [
|
||||
{ materialId: "eternity_splinter", quantity: 2 },
|
||||
{ materialId: "crown_fragment", quantity: 6 },
|
||||
],
|
||||
zoneId: "eternal_throne",
|
||||
},
|
||||
|
||||
// Zone 13: primordial_chaos
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.2 },
|
||||
description:
|
||||
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
|
||||
id: "chaos_lens",
|
||||
name: "Chaos Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "chaos_fragment", quantity: 10 },
|
||||
{ materialId: "creation_shard", quantity: 4 },
|
||||
],
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.22 },
|
||||
description:
|
||||
"Primordial essence held in a creation shard framework. It hums constantly. Gold flows toward it with the enthusiasm of something that wants to become something.",
|
||||
id: "creation_core",
|
||||
name: "Creation Core",
|
||||
requiredMaterials: [
|
||||
{ materialId: "primordial_essence", quantity: 2 },
|
||||
{ materialId: "creation_shard", quantity: 6 },
|
||||
],
|
||||
zoneId: "primordial_chaos",
|
||||
},
|
||||
|
||||
// Zone 14: infinite_expanse
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.2 },
|
||||
description:
|
||||
"Expanse dust wound around distance crystals into a coil that draws essence from distances too vast to measure, compressing it into something your guild can actually use.",
|
||||
id: "distance_coil",
|
||||
name: "Distance Coil",
|
||||
requiredMaterials: [
|
||||
{ materialId: "expanse_dust", quantity: 10 },
|
||||
{ materialId: "distance_crystal", quantity: 4 },
|
||||
],
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.22 },
|
||||
description:
|
||||
"An infinity shard mounted in a distance crystal frame. The prism reflects gold from an infinite number of directions simultaneously. The math works out favourably.",
|
||||
id: "infinity_prism",
|
||||
name: "Infinity Prism",
|
||||
requiredMaterials: [
|
||||
{ materialId: "infinity_shard", quantity: 2 },
|
||||
{ materialId: "distance_crystal", quantity: 6 },
|
||||
],
|
||||
zoneId: "infinite_expanse",
|
||||
},
|
||||
|
||||
// Zone 15: reality_forge
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.22 },
|
||||
description:
|
||||
"Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
|
||||
id: "reality_ingot",
|
||||
name: "Reality Ingot",
|
||||
requiredMaterials: [
|
||||
{ materialId: "forge_ash", quantity: 10 },
|
||||
{ materialId: "creation_tool", quantity: 4 },
|
||||
],
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.22 },
|
||||
description:
|
||||
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
|
||||
id: "universe_seed",
|
||||
name: "Universe Seed",
|
||||
requiredMaterials: [
|
||||
{ materialId: "reality_shard", quantity: 2 },
|
||||
{ materialId: "creation_tool", quantity: 6 },
|
||||
],
|
||||
zoneId: "reality_forge",
|
||||
},
|
||||
|
||||
// Zone 16: cosmic_maelstrom
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.25 },
|
||||
description:
|
||||
"Maelstrom debris and force crystals ground into a lens at the intersection of fundamental forces. Gold flows toward it with the same inevitability that galaxies flow toward gravity.",
|
||||
id: "force_lens",
|
||||
name: "Force Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "maelstrom_debris", quantity: 10 },
|
||||
{ materialId: "force_crystal", quantity: 4 },
|
||||
],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.22 },
|
||||
description:
|
||||
"A cosmic fragment suspended in a force crystal matrix — a piece of the maelstrom's impossible calm, holding the eye of the storm. Essence accumulates in its vicinity.",
|
||||
id: "maelstrom_eye",
|
||||
name: "Maelstrom Eye",
|
||||
requiredMaterials: [
|
||||
{ materialId: "cosmic_fragment", quantity: 2 },
|
||||
{ materialId: "force_crystal", quantity: 6 },
|
||||
],
|
||||
zoneId: "cosmic_maelstrom",
|
||||
},
|
||||
|
||||
// Zone 17: primeval_sanctum
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
description:
|
||||
"Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
|
||||
id: "ancient_memory_array",
|
||||
name: "Ancient Memory Array",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ancient_dust", quantity: 10 },
|
||||
{ materialId: "memory_shard", quantity: 4 },
|
||||
],
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: { type: "click_power", value: 1.25 },
|
||||
description:
|
||||
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
|
||||
id: "first_artefact",
|
||||
name: "First Artefact",
|
||||
requiredMaterials: [
|
||||
{ materialId: "primeval_relic", quantity: 2 },
|
||||
{ materialId: "memory_shard", quantity: 6 },
|
||||
],
|
||||
zoneId: "primeval_sanctum",
|
||||
},
|
||||
|
||||
// Zone 18: the_absolute
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.3 },
|
||||
description:
|
||||
"Absolute fragments and boundary shards ground into a lens that sees to the end of all things — and in seeing, draws the wealth inherent in finality toward your guild.",
|
||||
id: "final_truth_lens",
|
||||
name: "Final Truth Lens",
|
||||
requiredMaterials: [
|
||||
{ materialId: "absolute_fragment", quantity: 10 },
|
||||
{ materialId: "boundary_shard", quantity: 4 },
|
||||
],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
description:
|
||||
"The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
|
||||
id: "omega_convergence",
|
||||
name: "Omega Convergence",
|
||||
requiredMaterials: [
|
||||
{ materialId: "omega_crystal", quantity: 2 },
|
||||
{ materialId: "boundary_shard", quantity: 6 },
|
||||
],
|
||||
zoneId: "the_absolute",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @file Transcendence upgrade data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable import/group-exports -- Multiple exports are required for this data module */
|
||||
import type { TranscendenceUpgrade } from "@elysium/types";
|
||||
|
||||
export const DEFAULT_TRANSCENDENCE_UPGRADES: Array<TranscendenceUpgrade> = [
|
||||
// ── Income multipliers ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "income",
|
||||
cost: 5,
|
||||
description:
|
||||
"The echoes of past runs linger, amplifying your guild's income by 25%.",
|
||||
id: "echo_income_1",
|
||||
multiplier: 1.25,
|
||||
name: "Whisper of Power",
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 10,
|
||||
description:
|
||||
"Your transcendent experience resonates through your guild, boosting income by 50%.",
|
||||
id: "echo_income_2",
|
||||
multiplier: 1.5,
|
||||
name: "Resonance",
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 20,
|
||||
description:
|
||||
"The harmony of multiple timelines surges through your guild, doubling its income.",
|
||||
id: "echo_income_3",
|
||||
multiplier: 2,
|
||||
name: "Harmonic Surge",
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 40,
|
||||
description:
|
||||
"Ethereal energy overflows from your transcendence, tripling your guild's income.",
|
||||
id: "echo_income_4",
|
||||
multiplier: 3,
|
||||
name: "Ethereal Overflow",
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
cost: 80,
|
||||
description:
|
||||
"The infinite chorus of every run you've ever played amplifies your guild fivefold.",
|
||||
id: "echo_income_5",
|
||||
multiplier: 5,
|
||||
name: "Infinite Chorus",
|
||||
},
|
||||
|
||||
// ── Combat multipliers ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "combat",
|
||||
cost: 5,
|
||||
description:
|
||||
"Memories of countless battles harden your adventurers, increasing party DPS by 25%.",
|
||||
id: "echo_combat_1",
|
||||
multiplier: 1.25,
|
||||
name: "Battle-Hardened",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 15,
|
||||
description:
|
||||
"Veterans of transcendence know how to fight smarter, boosting party DPS by 50%.",
|
||||
id: "echo_combat_2",
|
||||
multiplier: 1.5,
|
||||
name: "Veteran's Edge",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 35,
|
||||
description:
|
||||
"Your warriors carry the strength of every fallen timeline, doubling party DPS.",
|
||||
id: "echo_combat_3",
|
||||
multiplier: 2,
|
||||
name: "Transcendent Warrior",
|
||||
},
|
||||
|
||||
// ── Prestige threshold reductions ──────────────────────────────────────────
|
||||
{
|
||||
category: "prestige_threshold",
|
||||
cost: 8,
|
||||
description:
|
||||
"Experience from past lives shortens the road to prestige — threshold reduced by 10%.",
|
||||
id: "echo_prestige_threshold_1",
|
||||
multiplier: 0.9,
|
||||
name: "Accelerated Path",
|
||||
},
|
||||
{
|
||||
category: "prestige_threshold",
|
||||
cost: 20,
|
||||
description:
|
||||
"You've walked this path so many times you know every shortcut — threshold reduced by 20%.",
|
||||
id: "echo_prestige_threshold_2",
|
||||
multiplier: 0.8,
|
||||
name: "Shortcut Through Time",
|
||||
},
|
||||
|
||||
// ── Prestige runestone multipliers ─────────────────────────────────────────
|
||||
{
|
||||
category: "prestige_runestones",
|
||||
cost: 8,
|
||||
description:
|
||||
"Transcendent insight attunes you to the runestones, earning 50% more per prestige.",
|
||||
id: "echo_prestige_runestones_1",
|
||||
multiplier: 1.5,
|
||||
name: "Runic Attunement",
|
||||
},
|
||||
{
|
||||
category: "prestige_runestones",
|
||||
cost: 20,
|
||||
description:
|
||||
"You have mastered the art of runestone crafting, doubling your prestige runestone yield.",
|
||||
id: "echo_prestige_runestones_2",
|
||||
multiplier: 2,
|
||||
name: "Master Runesmith",
|
||||
},
|
||||
|
||||
// ── Echo meta multipliers ───────────────────────────────────────────────────
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 10,
|
||||
description:
|
||||
"Your transcendence resonates deeper, amplifying future echo yields by 25%.",
|
||||
id: "echo_meta_1",
|
||||
multiplier: 1.25,
|
||||
name: "Resonant Awakening",
|
||||
},
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 25,
|
||||
description:
|
||||
"Each loop of existence makes the next more powerful — future echo yields +50%.",
|
||||
id: "echo_meta_2",
|
||||
multiplier: 1.5,
|
||||
name: "Transcendent Loop",
|
||||
},
|
||||
{
|
||||
category: "echo_meta",
|
||||
cost: 50,
|
||||
description:
|
||||
"You have mastered the infinite spiral of transcendence, doubling all future echo yields.",
|
||||
id: "echo_meta_3",
|
||||
multiplier: 2,
|
||||
name: "Infinite Spiral",
|
||||
},
|
||||
];
|
||||
export const TRANSCENDENCE_UPGRADES = DEFAULT_TRANSCENDENCE_UPGRADES;
|
||||
|
||||
export const TRANSCENDENCE_UPGRADE_CATEGORY_LABELS: Record<string, string> = {
|
||||
combat: "⚔️ Combat Multipliers",
|
||||
echo_meta: "🌌 Echo Meta Upgrades",
|
||||
income: "✨ Income Multipliers",
|
||||
prestige_runestones: "🔮 Prestige Quality of Life — Runestones",
|
||||
prestige_threshold: "🎯 Prestige Quality of Life — Threshold",
|
||||
};
|
||||
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* @file Game tick engine for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */
|
||||
/* eslint-disable complexity -- Tick engine is inherently complex with many game systems */
|
||||
/* eslint-disable max-lines-per-function -- Tick engine processes many systems in one pass for performance */
|
||||
/* eslint-disable max-statements -- Tick engine requires many state variables across all game systems */
|
||||
/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */
|
||||
/* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */
|
||||
/* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */
|
||||
/* eslint-disable unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */
|
||||
/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */
|
||||
import {
|
||||
type Achievement,
|
||||
type Equipment,
|
||||
type GameState,
|
||||
computeSetBonuses,
|
||||
getActiveCompanionBonus,
|
||||
} from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
/**
|
||||
* Checks all achievements against the current game state and returns an updated
|
||||
* achievements array, marking newly-met conditions with the current timestamp.
|
||||
* @param state - The current game state to check achievements against.
|
||||
* @returns Updated achievements array with newly unlocked achievements timestamped.
|
||||
*/
|
||||
const checkAchievements = (state: GameState): Array<Achievement> => {
|
||||
const now = Date.now();
|
||||
return state.achievements.map((achievement) => {
|
||||
if (achievement.unlockedAt !== null) {
|
||||
return achievement;
|
||||
}
|
||||
|
||||
const { condition } = achievement;
|
||||
let met = false;
|
||||
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
met = state.player.totalGoldEarned >= condition.amount;
|
||||
break;
|
||||
case "totalClicks":
|
||||
met = state.player.totalClicks >= condition.amount;
|
||||
break;
|
||||
case "bossesDefeated":
|
||||
met
|
||||
= state.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length >= condition.amount;
|
||||
break;
|
||||
case "questsCompleted":
|
||||
met
|
||||
= state.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length >= condition.amount;
|
||||
break;
|
||||
case "adventurerTotal":
|
||||
met
|
||||
= state.adventurers.reduce((sum, adventurer) => {
|
||||
return sum + adventurer.count;
|
||||
}, 0) >= condition.amount;
|
||||
break;
|
||||
case "prestigeCount":
|
||||
met = state.prestige.count >= condition.amount;
|
||||
break;
|
||||
case "equipmentOwned":
|
||||
met
|
||||
= state.equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length >= condition.amount;
|
||||
break;
|
||||
default:
|
||||
/* V8 ignore next -- @preserve */ break;
|
||||
}
|
||||
|
||||
return met
|
||||
? { ...achievement, unlockedAt: now }
|
||||
: achievement;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
|
||||
*/
|
||||
export const RESOURCE_CAP = 1e300;
|
||||
|
||||
/**
|
||||
* Probability of quest failure per zone — scales from 10% (early game) to 40% (end game).
|
||||
* On failure the quest resets to "available" with no rewards; the player must wait the
|
||||
* full duration again on their next attempt.
|
||||
*/
|
||||
const zoneFailureChance: Record<string, number> = {
|
||||
abyssal_trench: 0.24,
|
||||
astral_void: 0.2,
|
||||
celestial_reaches: 0.22,
|
||||
cosmic_maelstrom: 0.4,
|
||||
crystalline_spire: 0.28,
|
||||
eternal_throne: 0.32,
|
||||
frozen_peaks: 0.14,
|
||||
infernal_court: 0.26,
|
||||
infinite_expanse: 0.36,
|
||||
primeval_sanctum: 0.4,
|
||||
primordial_chaos: 0.34,
|
||||
reality_forge: 0.38,
|
||||
shadow_marshes: 0.16,
|
||||
shattered_ruins: 0.12,
|
||||
the_absolute: 0.4,
|
||||
verdant_vale: 0.1,
|
||||
void_sanctum: 0.3,
|
||||
volcanic_depths: 0.18,
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps a resource value at RESOURCE_CAP.
|
||||
* @param value - The resource value to cap.
|
||||
* @returns The capped value.
|
||||
*/
|
||||
const capResource = (value: number): number => {
|
||||
return Math.min(value, RESOURCE_CAP);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function — applies one game tick to the state.
|
||||
* DeltaSeconds: time elapsed since last tick.
|
||||
* Returns a new GameState (does not mutate the original).
|
||||
* @param state - The current game state.
|
||||
* @param deltaSeconds - Time elapsed since last tick in seconds.
|
||||
* @returns A new GameState with the tick applied.
|
||||
*/
|
||||
export const applyTick = (
|
||||
state: GameState,
|
||||
deltaSeconds: number,
|
||||
): GameState => {
|
||||
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
|
||||
return item.equipped;
|
||||
});
|
||||
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
||||
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||
}, 1);
|
||||
const setGoldMultiplier = computeSetBonuses(
|
||||
equippedItems.map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
EQUIPMENT_SETS,
|
||||
).goldMultiplier;
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||
const craftedEssenceMultiplier
|
||||
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionGoldMult
|
||||
= companionBonus?.type === "passiveGold"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
const companionEssenceMult
|
||||
= companionBonus?.type === "essenceIncome"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
const companionQuestTimeReduction
|
||||
= companionBonus?.type === "questTime"
|
||||
? companionBonus.value
|
||||
: 0;
|
||||
|
||||
let goldGained = 0;
|
||||
let essenceGained = 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 isThisAdventurer
|
||||
= upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id;
|
||||
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const prestige = state.prestige.productionMultiplier;
|
||||
|
||||
const goldPerTick
|
||||
= adventurer.goldPerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* prestige
|
||||
* runestonesIncome
|
||||
* echoIncome
|
||||
* equipmentGoldMultiplier
|
||||
* setGoldMultiplier
|
||||
* craftedGoldMultiplier
|
||||
* companionGoldMult
|
||||
* deltaSeconds;
|
||||
goldGained = goldGained + goldPerTick;
|
||||
|
||||
const essencePerTick
|
||||
= adventurer.essencePerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* prestige
|
||||
* runestonesEssence
|
||||
* craftedEssenceMultiplier
|
||||
* companionEssenceMult
|
||||
* deltaSeconds;
|
||||
essenceGained = essenceGained + essencePerTick;
|
||||
}
|
||||
|
||||
// Complete active quests and apply their rewards
|
||||
const now = Date.now();
|
||||
let questGold = 0;
|
||||
let questEssence = 0;
|
||||
let questCrystals = 0;
|
||||
|
||||
let updatedUpgrades = state.upgrades;
|
||||
let updatedAdventurers = state.adventurers;
|
||||
let updatedEquipmentReference = state.equipment;
|
||||
|
||||
const updatedQuests = state.quests.map((quest) => {
|
||||
const effectiveQuestMs
|
||||
= quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000;
|
||||
if (
|
||||
quest.status !== "active"
|
||||
|| quest.startedAt === undefined
|
||||
|| now < quest.startedAt + effectiveQuestMs
|
||||
) {
|
||||
return quest;
|
||||
}
|
||||
|
||||
const failureChance = zoneFailureChance[quest.zoneId] ?? 0.2;
|
||||
if (Math.random() < failureChance) {
|
||||
const { startedAt: _dropped, ...questWithoutStartedAt } = quest;
|
||||
return {
|
||||
...questWithoutStartedAt,
|
||||
lastFailedAt: now,
|
||||
status: "available" as const,
|
||||
};
|
||||
}
|
||||
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "gold" && reward.amount !== undefined) {
|
||||
questGold = questGold + reward.amount;
|
||||
} else if (reward.type === "essence" && reward.amount !== undefined) {
|
||||
questEssence = questEssence + reward.amount;
|
||||
} else if (reward.type === "crystals" && reward.amount !== undefined) {
|
||||
const crystalAmount = reward.amount;
|
||||
const crystalGain = crystalAmount * runestonesCrystal;
|
||||
questCrystals = questCrystals + crystalGain;
|
||||
} else if (reward.type === "upgrade" && reward.targetId !== undefined) {
|
||||
updatedUpgrades = updatedUpgrades.map((upgrade) => {
|
||||
return upgrade.id === reward.targetId
|
||||
? { ...upgrade, unlocked: true }
|
||||
: upgrade;
|
||||
});
|
||||
} else if (
|
||||
reward.type === "adventurer"
|
||||
&& reward.targetId !== undefined
|
||||
) {
|
||||
updatedAdventurers = updatedAdventurers.map((adventurer) => {
|
||||
return adventurer.id === reward.targetId
|
||||
? { ...adventurer, unlocked: true }
|
||||
: adventurer;
|
||||
});
|
||||
} else if (reward.type === "equipment" && reward.targetId !== undefined) {
|
||||
const rewardTargetId = reward.targetId;
|
||||
const currentEquipment = updatedEquipmentReference;
|
||||
updatedEquipmentReference = currentEquipment.map((item) => {
|
||||
if (item.id !== rewardTargetId) {
|
||||
return item;
|
||||
}
|
||||
const slotEmpty = !currentEquipment.some((other) => {
|
||||
return other.type === item.type && other.equipped;
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
equipped: slotEmpty || item.equipped,
|
||||
owned: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ...quest, status: "completed" as const };
|
||||
});
|
||||
|
||||
// Unlock quests whose prerequisites are now all completed and whose zone is unlocked
|
||||
const completedIds = new Set(
|
||||
updatedQuests.
|
||||
filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).
|
||||
map((quest) => {
|
||||
return quest.id;
|
||||
}),
|
||||
);
|
||||
const fullyUpdatedQuests = updatedQuests.map((quest) => {
|
||||
if (quest.status !== "locked") {
|
||||
return quest;
|
||||
}
|
||||
const questZone = state.zones.find((searchZone) => {
|
||||
return searchZone.id === quest.zoneId;
|
||||
});
|
||||
if (questZone?.status === "locked") {
|
||||
return quest;
|
||||
}
|
||||
if (
|
||||
quest.prerequisiteIds.every((id) => {
|
||||
return completedIds.has(id);
|
||||
})
|
||||
) {
|
||||
return { ...quest, status: "available" as const };
|
||||
}
|
||||
return quest;
|
||||
});
|
||||
|
||||
/*
|
||||
* Unlock zones whose both conditions are now satisfied after quest completion:
|
||||
* (1) the gate boss has been defeated, (2) the gate quest is now completed
|
||||
*/
|
||||
const updatedZones = state.zones.map((zone) => {
|
||||
if (zone.status === "unlocked") {
|
||||
return zone;
|
||||
}
|
||||
const bossOk
|
||||
= zone.unlockBossId === null
|
||||
|| state.bosses.some((boss) => {
|
||||
return boss.id === zone.unlockBossId && boss.status === "defeated";
|
||||
});
|
||||
const questOk
|
||||
= zone.unlockQuestId === null || completedIds.has(zone.unlockQuestId);
|
||||
if (bossOk && questOk) {
|
||||
return { ...zone, status: "unlocked" as const };
|
||||
}
|
||||
return zone;
|
||||
});
|
||||
|
||||
// Activate the first boss in any zone that just became unlocked this tick
|
||||
const newlyUnlockedZoneIds = new Set(
|
||||
updatedZones.
|
||||
filter((zone) => {
|
||||
const wasLocked
|
||||
= state.zones.find((originalZone) => {
|
||||
return originalZone.id === zone.id;
|
||||
})?.status === "locked";
|
||||
return zone.status === "unlocked" && wasLocked;
|
||||
}).
|
||||
map((zone) => {
|
||||
return zone.id;
|
||||
}),
|
||||
);
|
||||
let updatedBosses = state.bosses;
|
||||
if (newlyUnlockedZoneIds.size > 0) {
|
||||
updatedBosses = state.bosses.map((boss) => {
|
||||
if (newlyUnlockedZoneIds.has(boss.zoneId)) {
|
||||
const zoneBosses = state.bosses.filter((zoneBoss) => {
|
||||
return zoneBoss.zoneId === boss.zoneId;
|
||||
});
|
||||
const [ firstBoss ] = zoneBosses;
|
||||
if (firstBoss?.id === boss.id && boss.status === "locked") {
|
||||
return { ...boss, status: "available" as const };
|
||||
}
|
||||
}
|
||||
return boss;
|
||||
});
|
||||
}
|
||||
|
||||
// Count quests newly completed this tick and update daily challenge progress
|
||||
const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => {
|
||||
const wasNotCompleted = state.quests[index]?.status !== "completed";
|
||||
return quest.status === "completed" && wasNotCompleted;
|
||||
}).length;
|
||||
|
||||
let updatedDailyChallenges = state.dailyChallenges;
|
||||
let challengeCrystals = 0;
|
||||
if (updatedDailyChallenges !== undefined && newlyCompletedQuestCount > 0) {
|
||||
const result = updateChallengeProgress(
|
||||
updatedDailyChallenges,
|
||||
"questsCompleted",
|
||||
newlyCompletedQuestCount,
|
||||
);
|
||||
updatedDailyChallenges = result.updatedChallenges;
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
}
|
||||
|
||||
const goldValue = capResource(state.resources.gold + goldGained + questGold);
|
||||
const essenceValue = capResource(
|
||||
state.resources.essence + essenceGained + questEssence,
|
||||
);
|
||||
const totalGoldEarnedValue
|
||||
= state.player.totalGoldEarned + goldGained + questGold;
|
||||
|
||||
const partialState: GameState = {
|
||||
...state,
|
||||
resources: {
|
||||
...state.resources,
|
||||
crystals: capResource(
|
||||
state.resources.crystals + questCrystals + challengeCrystals,
|
||||
),
|
||||
essence: essenceValue,
|
||||
gold: goldValue,
|
||||
},
|
||||
...updatedDailyChallenges === undefined
|
||||
? {}
|
||||
: { dailyChallenges: updatedDailyChallenges },
|
||||
adventurers: updatedAdventurers,
|
||||
bosses: updatedBosses,
|
||||
equipment: updatedEquipmentReference,
|
||||
lastTickAt: now,
|
||||
player: {
|
||||
...state.player,
|
||||
totalGoldEarned: totalGoldEarnedValue,
|
||||
},
|
||||
quests: fullyUpdatedQuests,
|
||||
upgrades: updatedUpgrades,
|
||||
zones: updatedZones,
|
||||
};
|
||||
|
||||
// Check achievements and apply crystal rewards for newly unlocked ones
|
||||
const updatedAchievements = checkAchievements(partialState);
|
||||
const crystalsFromAchievements = updatedAchievements.reduce(
|
||||
(sum, achievement, index) => {
|
||||
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
||||
const isNowUnlocked = achievement.unlockedAt !== null;
|
||||
if (wasLocked && isNowUnlocked) {
|
||||
return sum + (achievement.reward?.crystals ?? 0);
|
||||
}
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
...partialState,
|
||||
achievements: updatedAchievements,
|
||||
resources: {
|
||||
...partialState.resources,
|
||||
crystals: capResource(
|
||||
partialState.resources.crystals + crystalsFromAchievements,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the effective click power, including upgrades and equipped trinkets.
|
||||
* @param state - The current game state.
|
||||
* @returns The calculated click power value.
|
||||
*/
|
||||
export const calculateClickPower = (state: GameState): number => {
|
||||
const clickMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
return upgrade.purchased && upgrade.target === "click";
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const equippedItems = state.equipment.filter((item) => {
|
||||
return item.equipped;
|
||||
});
|
||||
const equipmentClickMultiplier = equippedItems.
|
||||
filter((item) => {
|
||||
return item.bonus.clickMultiplier !== undefined;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.clickMultiplier ?? 1);
|
||||
}, 1);
|
||||
const setClickMultiplier = computeSetBonuses(
|
||||
equippedItems.map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
EQUIPMENT_SETS,
|
||||
).clickMultiplier;
|
||||
|
||||
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1;
|
||||
|
||||
const companionClickBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionClickMult
|
||||
= companionClickBonus?.type === "clickGold"
|
||||
? 1 + companionClickBonus.value
|
||||
: 1;
|
||||
|
||||
return (
|
||||
state.baseClickPower
|
||||
* clickMultiplier
|
||||
* state.prestige.productionMultiplier
|
||||
* runestonesClick
|
||||
* echoIncome
|
||||
* equipmentClickMultiplier
|
||||
* setClickMultiplier
|
||||
* craftedClickMultiplier
|
||||
* companionClickMult
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @file Application entry point.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable import/no-unassigned-import -- CSS import has no exports to assign */
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app.js";
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file Daily challenge progress utilities for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { DailyChallengeState, DailyChallengeType } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Increments progress for daily challenges matching the given type.
|
||||
* Returns the updated challenge state and any crystals awarded for newly-completed challenges.
|
||||
*
|
||||
* Note: challenge generation and daily resets are handled server-side only.
|
||||
* This utility is purely for client-side progress tracking.
|
||||
* @param challengeState - The current state of all daily challenges.
|
||||
* @param type - The type of challenge to update progress for.
|
||||
* @param amount - The amount to increment progress by.
|
||||
* @returns The updated challenges and total crystals awarded.
|
||||
*/
|
||||
export 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 progressValue = Math.min(
|
||||
challenge.progress + amount,
|
||||
challenge.target,
|
||||
);
|
||||
const nowCompleted = progressValue >= challenge.target;
|
||||
|
||||
if (nowCompleted) {
|
||||
crystalsAwarded = crystalsAwarded + challenge.rewardCrystals;
|
||||
}
|
||||
|
||||
return {
|
||||
...challenge,
|
||||
completed: nowCompleted,
|
||||
progress: progressValue,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return { crystalsAwarded, updatedChallenges };
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/* eslint-disable stylistic/lines-around-comment -- Need the comment! */
|
||||
/**
|
||||
* @file Number formatting utilities for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type { NumberFormat } from "@elysium/types";
|
||||
|
||||
// Named suffixes up to 1e33 (Decillion). Letter-based suffixes take over from 1e36 onwards.
|
||||
const namedSuffixes: Array<{ threshold: number; suffix: string }> = [
|
||||
// Decillion
|
||||
{ suffix: "Dc", threshold: 1e33 },
|
||||
// Nonillion
|
||||
{ suffix: "No", threshold: 1e30 },
|
||||
// Octillion
|
||||
{ suffix: "Oc", threshold: 1e27 },
|
||||
// Septillion
|
||||
{ suffix: "Sp", threshold: 1e24 },
|
||||
// Sextillion
|
||||
{ suffix: "Sx", threshold: 1e21 },
|
||||
// Quintillion
|
||||
{ suffix: "Qi", threshold: 1e18 },
|
||||
// Quadrillion
|
||||
{ suffix: "Qa", threshold: 1e15 },
|
||||
// Trillion
|
||||
{ suffix: "T", threshold: 1e12 },
|
||||
// Billion
|
||||
{ suffix: "B", threshold: 1e9 },
|
||||
// Million
|
||||
{ suffix: "M", threshold: 1e6 },
|
||||
// Thousand
|
||||
{ suffix: "K", threshold: 1e3 },
|
||||
];
|
||||
|
||||
// Letter suffixes start at 1e36 ("a"), stepping by 1000 each time (i.e. +3 exponent per letter).
|
||||
const letterBaseExp = 36;
|
||||
|
||||
/**
|
||||
* Generates an alphabetic suffix for a given index:
|
||||
* 0 → "a", 1 → "b", ..., 25 → "z",
|
||||
* 26 → "aa", 27 → "ab", ..., 701 → "zz", 702 → "aaa", ...
|
||||
* @param index - The zero-based index to convert to a letter suffix.
|
||||
* @returns The alphabetic suffix string.
|
||||
*/
|
||||
const getLetterSuffix = (index: number): string => {
|
||||
let result = "";
|
||||
let n = index;
|
||||
do {
|
||||
const percent = n % 26;
|
||||
result = String.fromCodePoint(97 + percent) + result;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
} while (n >= 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number with a named or letter-based suffix.
|
||||
* @param value - The number to format.
|
||||
* @returns The formatted string with suffix.
|
||||
*/
|
||||
const formatSuffix = (value: number): string => {
|
||||
if (value >= Math.pow(10, letterBaseExp)) {
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const stepsAboveBase = Math.floor((exp - letterBaseExp) / 3);
|
||||
const steps = stepsAboveBase * 3;
|
||||
const divisorExp = letterBaseExp + steps;
|
||||
const divisor = Math.pow(10, divisorExp);
|
||||
return `${(value / divisor).toFixed(2)}${getLetterSuffix(stepsAboveBase)}`;
|
||||
}
|
||||
for (const { threshold, suffix } of namedSuffixes) {
|
||||
if (value >= threshold) {
|
||||
return `${(value / threshold).toFixed(2)}${suffix}`;
|
||||
}
|
||||
}
|
||||
return value < 1
|
||||
? value.toFixed(2)
|
||||
: value.toFixed(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number in scientific notation: e.g. 1.23e15.
|
||||
* Falls back to K/M/B/T style below 1 million.
|
||||
* @param value - The number to format.
|
||||
* @returns The formatted string in scientific notation.
|
||||
*/
|
||||
const formatScientific = (value: number): string => {
|
||||
if (value < 1e6) {
|
||||
return formatSuffix(value);
|
||||
}
|
||||
// ToExponential handles all magnitudes JS can represent (up to ~1.8e308)
|
||||
return value.toExponential(2).replace("e+", "e");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number in engineering notation (exponent always a multiple of 3):
|
||||
* e.g. 12.35E12, 1.23E300. Falls back to K/M/B/T style below 1 million.
|
||||
* @param value - The number to format.
|
||||
* @returns The formatted string in engineering notation.
|
||||
*/
|
||||
const formatEngineering = (value: number): string => {
|
||||
if (value < 1e6) {
|
||||
return formatSuffix(value);
|
||||
}
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const engExp = Math.floor(exp / 3) * 3;
|
||||
const mantissa = value / Math.pow(10, engExp);
|
||||
return `${mantissa.toFixed(2)}E${String(engExp)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number for display using the player's chosen notation style.
|
||||
* Negative values are formatted with a leading minus sign.
|
||||
* @param value - The number to format.
|
||||
* @param format - The notation style to use.
|
||||
* @returns The formatted number string.
|
||||
*/
|
||||
export const formatNumber = (
|
||||
value: number,
|
||||
format: NumberFormat = "suffix",
|
||||
): string => {
|
||||
if (!Number.isFinite(value) || Number.isNaN(value)) {
|
||||
return "0";
|
||||
}
|
||||
if (value < 0) {
|
||||
return `-${formatNumber(-value, format)}`;
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case "scientific":
|
||||
return formatScientific(value);
|
||||
case "engineering":
|
||||
return formatEngineering(value);
|
||||
case "suffix":
|
||||
return formatSuffix(value);
|
||||
default: {
|
||||
/* V8 ignore next -- @preserve */
|
||||
return formatSuffix(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file Browser notification utilities.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Requests browser notification permission from the user.
|
||||
* @returns Whether permission was granted.
|
||||
*/
|
||||
const requestNotificationPermission = async(): Promise<boolean> => {
|
||||
if (typeof Notification === "undefined") {
|
||||
return false;
|
||||
}
|
||||
if (Notification.permission === "granted") {
|
||||
return true;
|
||||
}
|
||||
if (Notification.permission === "denied") {
|
||||
return false;
|
||||
}
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission === "granted";
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a browser notification if permission has been granted.
|
||||
* @param title - The notification title text.
|
||||
* @param body - The notification message displayed below the title.
|
||||
*/
|
||||
const sendNotification = (title: string, body: string): void => {
|
||||
if (
|
||||
typeof Notification === "undefined"
|
||||
|| Notification.permission !== "granted"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-new -- Notification constructor has side effects
|
||||
new Notification(title, { body: body, icon: "/favicon.ico" });
|
||||
} catch {
|
||||
// Silently ignore — notifications may fail silently
|
||||
}
|
||||
};
|
||||
|
||||
export { requestNotificationPermission, sendNotification };
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file Sound effect utilities using the Web Audio API.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
type SoundEvent =
|
||||
| "achievement"
|
||||
| "apotheosis"
|
||||
| "bossVictory"
|
||||
| "prestige"
|
||||
| "questCompleted"
|
||||
| "questFailed"
|
||||
| "transcendence";
|
||||
|
||||
interface SoundPattern {
|
||||
frequencies: Array<number>;
|
||||
gain: number;
|
||||
noteDuration: number;
|
||||
type: OscillatorType;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
|
||||
const SOUND_PATTERNS: Record<SoundEvent, SoundPattern> = {
|
||||
achievement: {
|
||||
frequencies: [ 523, 659, 784, 1047 ],
|
||||
gain: 0.3,
|
||||
noteDuration: 0.12,
|
||||
type: "triangle",
|
||||
},
|
||||
apotheosis: {
|
||||
frequencies: [ 1047, 880, 784, 659, 523 ],
|
||||
gain: 0.35,
|
||||
noteDuration: 0.25,
|
||||
type: "sine",
|
||||
},
|
||||
bossVictory: {
|
||||
frequencies: [ 523, 784, 1047 ],
|
||||
gain: 0.4,
|
||||
noteDuration: 0.18,
|
||||
type: "square",
|
||||
},
|
||||
prestige: {
|
||||
frequencies: [ 392, 523, 659, 784 ],
|
||||
gain: 0.35,
|
||||
noteDuration: 0.15,
|
||||
type: "sawtooth",
|
||||
},
|
||||
questCompleted: {
|
||||
frequencies: [ 523, 659 ],
|
||||
gain: 0.25,
|
||||
noteDuration: 0.15,
|
||||
type: "sine",
|
||||
},
|
||||
questFailed: {
|
||||
frequencies: [ 392, 330, 261 ],
|
||||
gain: 0.25,
|
||||
noteDuration: 0.18,
|
||||
type: "triangle",
|
||||
},
|
||||
transcendence: {
|
||||
frequencies: [ 261, 329, 392, 523 ],
|
||||
gain: 0.3,
|
||||
noteDuration: 0.3,
|
||||
type: "sine",
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- lazily initialised on first use
|
||||
let audioContext: AudioContext | undefined;
|
||||
|
||||
const getAudioContext = (): AudioContext => {
|
||||
if (audioContext === undefined) {
|
||||
audioContext = new AudioContext();
|
||||
}
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plays a sound effect for a given game event using the Web Audio API.
|
||||
* @param event - The game event to play a sound for.
|
||||
*/
|
||||
const playSound = (event: SoundEvent): void => {
|
||||
try {
|
||||
const context = getAudioContext();
|
||||
const pattern = SOUND_PATTERNS[event];
|
||||
for (const [ index, frequency ] of pattern.frequencies.entries()) {
|
||||
const oscillator = context.createOscillator();
|
||||
const gainNode = context.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
oscillator.type = pattern.type;
|
||||
oscillator.frequency.value = frequency;
|
||||
const noteOffset = index * pattern.noteDuration;
|
||||
const startTime = context.currentTime + noteOffset;
|
||||
const endTime = startTime + pattern.noteDuration;
|
||||
gainNode.gain.setValueAtTime(0, startTime);
|
||||
gainNode.gain.linearRampToValueAtTime(pattern.gain, startTime + 0.01);
|
||||
gainNode.gain.linearRampToValueAtTime(0, endTime);
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(endTime);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — audio may not be available in all environments
|
||||
}
|
||||
};
|
||||
|
||||
export type { SoundEvent };
|
||||
export { playSound };
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @file Vite environment type declarations.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Vite define replacement requires double underscores */
|
||||
/* eslint-disable no-underscore-dangle -- Vite define replacement requires double underscores */
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __WEB_VERSION__: string;
|
||||
Reference in New Issue
Block a user