diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7fa9c84..ee74207 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ import { bossRouter } from "./routes/boss.js"; import { craftRouter } from "./routes/craft.js"; import { exploreRouter } from "./routes/explore.js"; import { gameRouter } from "./routes/game.js"; +import { leaderboardRouter } from "./routes/leaderboards.js"; import { prestigeRouter } from "./routes/prestige.js"; import { transcendenceRouter } from "./routes/transcendence.js"; import { profileRouter } from "./routes/profile.js"; @@ -34,6 +35,7 @@ app.route("/craft", craftRouter); app.route("/prestige", prestigeRouter); app.route("/transcendence", transcendenceRouter); app.route("/apotheosis", apotheosisRouter); +app.route("/leaderboards", leaderboardRouter); app.route("/profile", profileRouter); app.get("/health", (context) => context.json({ status: "ok" })); diff --git a/apps/api/src/routes/leaderboards.ts b/apps/api/src/routes/leaderboards.ts new file mode 100644 index 0000000..06e7bbd --- /dev/null +++ b/apps/api/src/routes/leaderboards.ts @@ -0,0 +1,86 @@ +import type { GameState } from "@elysium/types"; +import { Hono } from "hono"; +import type { HonoEnv } from "../types/hono.js"; +import { prisma } from "../db/client.js"; +import { TITLES } from "../data/titles.js"; + +export const leaderboardRouter = new Hono(); + +const VALID_CATEGORIES = new Set([ + "totalGold", + "bossesDefeated", + "questsCompleted", + "achievementsUnlocked", + "prestigeCount", + "transcendenceCount", + "apotheosisCount", +]); + +const GAMESTATE_CATEGORIES = new Set(["prestigeCount", "transcendenceCount", "apotheosisCount"]); + +const parseShowOnLeaderboards = (raw: unknown): boolean => { + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + return (raw as Record).showOnLeaderboards !== false; + } + return true; +}; + +leaderboardRouter.get("/", async (context) => { + const category = context.req.query("category") ?? "totalGold"; + const limitRaw = Number(context.req.query("limit") ?? "100"); + const limit = Math.min(Math.max(1, limitRaw), 100); + + if (!VALID_CATEGORIES.has(category)) { + return context.json({ error: "Invalid category" }, 400); + } + + const [players, gameStates] = await Promise.all([ + prisma.player.findMany(), + GAMESTATE_CATEGORIES.has(category) ? prisma.gameState.findMany() : Promise.resolve([]), + ]); + + const stateMap = new Map( + gameStates.map((gs) => [gs.discordId, gs.state as unknown as GameState]), + ); + + const entries = players + .filter((p) => parseShowOnLeaderboards(p.profileSettings)) + .map((p) => { + let value = 0; + if (category === "totalGold") { + value = p.lifetimeGoldEarned; + } else if (category === "bossesDefeated") { + value = p.lifetimeBossesDefeated; + } else if (category === "questsCompleted") { + value = p.lifetimeQuestsCompleted; + } else if (category === "achievementsUnlocked") { + value = p.lifetimeAchievementsUnlocked; + } else { + const state = stateMap.get(p.discordId); + if (category === "prestigeCount") { + value = state?.prestige?.count ?? 0; + } else if (category === "transcendenceCount") { + value = state?.transcendence?.count ?? 0; + } else if (category === "apotheosisCount") { + value = state?.apotheosis?.count ?? 0; + } + } + const titleId = p.activeTitle ?? ""; + const titleName = titleId + ? (TITLES.find((t) => t.id === titleId)?.name ?? titleId) + : ""; + return { + discordId: p.discordId, + characterName: p.characterName, + username: p.username, + avatar: p.avatar ?? null, + activeTitle: titleName, + value, + }; + }) + .sort((a, b) => b.value - a.value) + .slice(0, limit) + .map((entry, index) => ({ ...entry, rank: index + 1 })); + + return context.json({ category, entries }); +}); diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index bf38b2e..6af203b 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -39,6 +39,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => { showAdventurersRecruited: obj.showAdventurersRecruited !== false, showAchievementsUnlocked: obj.showAchievementsUnlocked !== false, numberFormat, + showOnLeaderboards: obj.showOnLeaderboards !== false, }; } return { ...DEFAULT_PROFILE_SETTINGS }; @@ -146,6 +147,7 @@ profileRouter.put("/", authMiddleware, async (context) => { showAdventurersRecruited: body.profileSettings?.showAdventurersRecruited !== false, showAchievementsUnlocked: body.profileSettings?.showAchievementsUnlocked !== false, numberFormat, + showOnLeaderboards: body.profileSettings?.showOnLeaderboards !== false, }; if (!characterName) { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 05fbf71..e37f0bc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { GameProvider } from "./context/GameContext.js"; 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"; @@ -49,6 +50,10 @@ export const App = (): React.JSX.Element => { return ; } + if (window.location.pathname === "/leaderboards") { + return ; + } + if (!loggedIn) { return { setLoggedIn(true); }} />; } diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx index 7f64356..4fed866 100644 --- a/apps/web/src/components/game/AboutPanel.tsx +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -75,6 +75,10 @@ const HOW_TO_PLAY = [ title: "πŸ—‘οΈ Equipment", 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: "πŸ† Leaderboards", + 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: "☁️ Cloud Saves", 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.", diff --git a/apps/web/src/components/game/CharacterSheetPanel.tsx b/apps/web/src/components/game/CharacterSheetPanel.tsx index ad7253c..1f37824 100644 --- a/apps/web/src/components/game/CharacterSheetPanel.tsx +++ b/apps/web/src/components/game/CharacterSheetPanel.tsx @@ -310,6 +310,9 @@ export const CharacterSheetPanel = (): React.JSX.Element => { > {copied ? "βœ“ Copied!" : "πŸ”— Share"} + + πŸ† Boards + diff --git a/apps/web/src/components/game/EditProfileModal.tsx b/apps/web/src/components/game/EditProfileModal.tsx index d332f71..896302f 100644 --- a/apps/web/src/components/game/EditProfileModal.tsx +++ b/apps/web/src/components/game/EditProfileModal.tsx @@ -179,6 +179,21 @@ export const EditProfileModal = ({ onClose }: EditProfileModalProps): React.JSX. +
+

Privacy

+

Control your visibility on public leaderboards.

+ +
+

Number Format

How large numbers appear across the game.

diff --git a/apps/web/src/components/game/LeaderboardPage.tsx b/apps/web/src/components/game/LeaderboardPage.tsx new file mode 100644 index 0000000..23a3607 --- /dev/null +++ b/apps/web/src/components/game/LeaderboardPage.tsx @@ -0,0 +1,177 @@ +import type { LeaderboardCategory, LeaderboardEntry } from "@elysium/types"; +import { useEffect, useState } from "react"; + +interface CategoryConfig { + id: LeaderboardCategory; + label: string; + icon: string; + formatValue: (value: number) => string; +} + +const CATEGORIES: CategoryConfig[] = [ + { + id: "totalGold", + label: "Lifetime Gold", + icon: "πŸͺ™", + formatValue: (v) => formatGold(v), + }, + { + id: "bossesDefeated", + label: "Bosses Defeated", + icon: "πŸ’€", + formatValue: (v) => v.toLocaleString(), + }, + { + id: "questsCompleted", + label: "Quests Completed", + icon: "πŸ“œ", + formatValue: (v) => v.toLocaleString(), + }, + { + id: "achievementsUnlocked", + label: "Achievements", + icon: "πŸ†", + formatValue: (v) => v.toLocaleString(), + }, + { + id: "prestigeCount", + label: "Prestige", + icon: "⭐", + formatValue: (v) => v.toLocaleString(), + }, + { + id: "transcendenceCount", + label: "Transcendence", + icon: "🌌", + formatValue: (v) => v.toLocaleString(), + }, + { + id: "apotheosisCount", + label: "Apotheosis", + icon: "✨", + formatValue: (v) => v.toLocaleString(), + }, +]; + +const SUFFIXES = ["", "K", "M", "B", "T", "Qa", "Qt", "S", "Sp", "O", "N", "D"]; + +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, SUFFIXES.length - 1); + const scaled = value / Math.pow(1000, clamped); + return `${parseFloat(scaled.toFixed(2))}${SUFFIXES[clamped] ?? ""}`; +}; + +const RANK_BADGES: Record = { 1: "πŸ₯‡", 2: "πŸ₯ˆ", 3: "πŸ₯‰" }; + +export const LeaderboardPage = (): React.JSX.Element => { + const [category, setCategory] = useState("totalGold"); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + fetch(`/api/leaderboards?category=${category}&limit=100`) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to load leaderboard"); + const data = (await res.json()) as { entries: LeaderboardEntry[] }; + setEntries(data.entries); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to load leaderboard"); + }) + .finally(() => { setLoading(false); }); + }, [category]); + + const currentConfig = CATEGORIES.find((c) => c.id === category) ?? CATEGORIES[0]; + + return ( +
+
+
+

πŸ† Leaderboards

+

The mightiest adventurers in Elysium

+
+ +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ + {loading && ( +
Loading…
+ )} + + {error && ( +
⚠️ {error}
+ )} + + {!loading && !error && entries.length === 0 && ( +
No entries yet β€” be the first on the board!
+ )} + + {!loading && !error && entries.length > 0 && ( +
+
+ Rank + Player + {currentConfig?.icon} {currentConfig?.label} +
+ {entries.map((entry) => { + const avatarUrl = entry.avatar + ? `https://cdn.discordapp.com/avatars/${entry.discordId}/${entry.avatar}.png?size=32` + : `https://cdn.discordapp.com/embed/avatars/${parseInt(entry.discordId, 10) % 5}.png`; + const displayName = entry.characterName || entry.username; + + return ( + + + {RANK_BADGES[entry.rank] ?? `#${entry.rank}`} + + + {displayName} + + {displayName} + {entry.activeTitle && ( + {entry.activeTitle} + )} + + + + {currentConfig?.formatValue(entry.value)} + + + ); + })} +
+ )} + +
+ βš”οΈ Play Elysium +

+ Players can opt out via their profile settings. +

+
+
+
+ ); +}; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f26c83c..53057ac 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3564,3 +3564,204 @@ body { font-size: 0.78rem; margin: 0.2rem 0 0; } + +/* ── Leaderboard Page ─────────────────────────────────────────────────────── */ +.leaderboard-page { + align-items: flex-start; + background: var(--colour-bg); + display: flex; + justify-content: center; + min-height: 100vh; + padding: 2rem 1rem; +} + +.leaderboard-card { + background: var(--colour-bg-panel); + border: 1px solid var(--colour-border); + border-radius: var(--radius-lg); + max-width: 800px; + padding: 2rem; + width: 100%; +} + +.leaderboard-header { + margin-bottom: 1.5rem; + text-align: center; +} + +.leaderboard-title { + font-size: 2rem; + font-weight: 800; + margin: 0 0 0.25rem; +} + +.leaderboard-subtitle { + color: var(--colour-text-muted); + font-size: 0.9rem; + margin: 0; +} + +.leaderboard-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1.5rem; +} + +.leaderboard-tab { + background: var(--colour-bg-alt); + border: 1px solid var(--colour-border); + border-radius: var(--radius); + color: var(--colour-text-muted); + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + padding: 0.4rem 0.8rem; + transition: all 0.15s ease; +} + +.leaderboard-tab:hover { + border-color: var(--colour-accent); + color: var(--colour-accent); +} + +.leaderboard-tab--active { + background: var(--colour-accent); + border-color: var(--colour-accent); + color: #fff; +} + +.leaderboard-loading, +.leaderboard-error, +.leaderboard-empty { + padding: 2rem; + text-align: center; +} + +.leaderboard-error { + color: var(--colour-danger, #ef4444); +} + +.leaderboard-empty { + color: var(--colour-text-muted); +} + +.leaderboard-table { + border: 1px solid var(--colour-border); + border-radius: var(--radius); + overflow: hidden; +} + +.leaderboard-table-header { + background: var(--colour-bg-alt); + color: var(--colour-text-muted); + display: grid; + font-size: 0.75rem; + font-weight: 700; + grid-template-columns: 3rem 1fr 8rem; + padding: 0.6rem 1rem; + text-transform: uppercase; +} + +.leaderboard-row { + align-items: center; + border-top: 1px solid var(--colour-border); + color: var(--colour-text); + display: grid; + grid-template-columns: 3rem 1fr 8rem; + padding: 0.75rem 1rem; + text-decoration: none; + transition: background 0.15s ease; +} + +.leaderboard-row:hover { + background: var(--colour-bg-alt); +} + +.leaderboard-row--top1 { + background: rgba(251, 191, 36, 0.08); +} + +.leaderboard-row--top2 { + background: rgba(148, 163, 184, 0.08); +} + +.leaderboard-row--top3 { + background: rgba(180, 120, 70, 0.08); +} + +.leaderboard-col-rank { + font-size: 1.1rem; + font-weight: 700; + text-align: center; +} + +.leaderboard-col-player { + align-items: center; + display: flex; + gap: 0.75rem; + min-width: 0; +} + +.leaderboard-avatar { + border-radius: 50%; + flex-shrink: 0; + height: 32px; + width: 32px; +} + +.leaderboard-player-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.leaderboard-player-name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderboard-player-title { + color: var(--colour-accent); + font-size: 0.75rem; + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderboard-col-value { + font-weight: 700; + text-align: right; +} + +.leaderboard-footer { + align-items: center; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1.5rem; +} + +.leaderboard-play-link { + background: var(--colour-accent); + border-radius: var(--radius); + color: #fff; + font-size: 0.9rem; + font-weight: 600; + padding: 0.6rem 1.5rem; + text-decoration: none; +} + +.leaderboard-play-link:hover { + opacity: 0.85; +} + +.leaderboard-privacy-note { + color: var(--colour-text-muted); + font-size: 0.75rem; + margin: 0; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6dd0cc9..f3c785b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -38,6 +38,9 @@ export type { ExploreStartRequest, ExploreStartResponse, GiteaRelease, + LeaderboardCategory, + LeaderboardEntry, + LeaderboardResponse, LoadResponse, PrestigeRequest, PrestigeResponse, diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index d1d3991..1852d47 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -183,6 +183,30 @@ export interface ApiError { error: string; } +export type LeaderboardCategory = + | "totalGold" + | "bossesDefeated" + | "questsCompleted" + | "achievementsUnlocked" + | "prestigeCount" + | "transcendenceCount" + | "apotheosisCount"; + +export interface LeaderboardEntry { + rank: number; + discordId: string; + characterName: string; + username: string; + avatar: string | null; + activeTitle: string; + value: number; +} + +export interface LeaderboardResponse { + category: LeaderboardCategory; + entries: LeaderboardEntry[]; +} + export interface GiteaRelease { tag_name: string; name: string; diff --git a/packages/types/src/interfaces/ProfileSettings.ts b/packages/types/src/interfaces/ProfileSettings.ts index 33c7a6a..bfcd8a0 100644 --- a/packages/types/src/interfaces/ProfileSettings.ts +++ b/packages/types/src/interfaces/ProfileSettings.ts @@ -20,6 +20,8 @@ export interface ProfileSettings { showAdventurersRecruited: boolean; showAchievementsUnlocked: boolean; numberFormat: NumberFormat; + /** Whether this player appears on the public leaderboards */ + showOnLeaderboards: boolean; } export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { @@ -40,4 +42,5 @@ export const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { showAdventurersRecruited: true, showAchievementsUnlocked: true, numberFormat: "suffix", + showOnLeaderboards: true, };