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,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 };