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,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 };
|
||||
Reference in New Issue
Block a user