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