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

## Summary

This PR represents the full v1 prototype, implementing the core game systems for Elysium.

- Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests
- Adventurer hiring with batch size selector and progressive tier cost scaling
- Prestige, transcendence, and apotheosis systems with auto-prestige support
- Character sheet, titles, leaderboards, companion system, and daily login bonuses
- Auto-quest and auto-boss toggles
- Discord webhook notifications on prestige/transcendence/apotheosis
- Discord role awarded on apotheosis
- Responsive design and overarching story/lore system
- In-game sound effects and browser notifications for key events
- Support link button in the resource bar
- Full test coverage (100% on `apps/api` and `packages/types`)
- CI pipeline: lint → build → test

## Closes

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8
Closes #9
Closes #10
Closes #11
Closes #12
Closes #13
Closes #14
Closes #16
Closes #19
Closes #20
Closes #21
Closes #22
Closes #23
Closes #24
Closes #25
Closes #26
Closes #27
Closes #29

 This issue was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #30
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
2026-03-08 15:53:39 -07:00
committed by Naomi Carrigan
parent c69e155de3
commit 29c817230d
172 changed files with 50706 additions and 0 deletions
@@ -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 };