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,681 @@
/**
* @file Character sheet panel for viewing and editing the player's character.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many fields */
/* eslint-disable complexity -- Many conditional render paths for optional fields */
/* eslint-disable max-statements -- Component requires many state declarations */
/* eslint-disable max-lines -- Large component with editing and view modes */
import {
DEFAULT_PROFILE_SETTINGS,
STORY_CHAPTERS,
type EquipmentBonus,
type EquipmentRarity,
type EquipmentType,
type ProfileSettings,
} from "@elysium/types";
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js";
interface EquippedItem {
name: string;
type: EquipmentType;
rarity: EquipmentRarity;
bonus: EquipmentBonus;
}
interface CharacterSheetData {
characterName: string;
pronouns: string;
characterRace: string;
characterClass: string;
bio: string;
guildName: string;
guildDescription: string;
activeTitle: string;
unlockedTitles: Array<{ id: string; name: string }>;
equippedItems: Array<EquippedItem>;
}
const emptySheet: CharacterSheetData = {
activeTitle: "",
bio: "",
characterClass: "",
characterName: "",
characterRace: "",
equippedItems: [],
guildDescription: "",
guildName: "",
pronouns: "",
unlockedTitles: [],
};
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 character sheet panel for viewing and editing player profile.
* @returns The JSX element.
*/
const CharacterSheetPanel = (): JSX.Element => {
const { state, loginStreak } = useGame();
const player = state?.player;
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
const [ draft, setDraft ] = useState<CharacterSheetData>(emptySheet);
const [ editing, setEditing ] = useState(false);
const [ loading, setLoading ] = useState(true);
const [ saving, setSaving ] = useState(false);
const [ error, setError ] = useState<string | null>(null);
const [ saved, setSaved ] = useState(false);
const [ copied, setCopied ] = useState(false);
const savedSettingsReference = useRef<ProfileSettings>({
...DEFAULT_PROFILE_SETTINGS,
});
useEffect(() => {
if (player?.discordId === undefined || player.discordId === "") {
return;
}
fetch(`/api/profile/${player.discordId}`).
then(async(response) => {
if (!response.ok) {
return;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
const data = (await response.json()) as {
characterName: string;
pronouns: string;
characterRace: string;
characterClass: string;
bio: string;
guildName: string;
guildDescription: string;
profileSettings: ProfileSettings;
activeTitle: string;
unlockedTitles: Array<{ id: string; name: string }>;
equippedItems: Array<EquippedItem>;
};
const loaded: CharacterSheetData = {
activeTitle: data.activeTitle,
bio: data.bio,
characterClass: data.characterClass,
characterName: data.characterName,
characterRace: data.characterRace,
equippedItems: data.equippedItems,
guildDescription: data.guildDescription,
guildName: data.guildName,
pronouns: data.pronouns,
unlockedTitles: data.unlockedTitles,
};
setSheet(loaded);
setDraft(loaded);
savedSettingsReference.current = {
...DEFAULT_PROFILE_SETTINGS,
...data.profileSettings,
};
}).
catch(() => {
/* Fall back to empty */
}).
finally(() => {
setLoading(false);
});
}, [ player?.discordId ]);
function handleEdit(): void {
setDraft({ ...sheet });
setEditing(true);
setError(null);
setSaved(false);
}
function handleCancel(): void {
setEditing(false);
setError(null);
}
async function handleSave(): Promise<void> {
setSaving(true);
setError(null);
try {
const characterName
= draft.characterName === ""
? player?.characterName ?? ""
: draft.characterName;
await updateProfile({
activeTitle: draft.activeTitle,
bio: draft.bio,
characterClass: draft.characterClass,
characterName: characterName,
characterRace: draft.characterRace,
guildDescription: draft.guildDescription,
guildName: draft.guildName,
profileSettings: savedSettingsReference.current,
pronouns: draft.pronouns,
});
setSheet({ ...draft });
setSaved(true);
setTimeout(() => {
setEditing(false);
setSaved(false);
}, 900);
} catch (error_) {
setError(error_ instanceof Error
? error_.message
: "Failed to save");
} finally {
setSaving(false);
}
}
function handleSaveClick(): void {
void handleSave();
}
function handleShareClick(): void {
const discordId = player?.discordId ?? "";
const url = `${window.location.origin}/character/${discordId}`;
void navigator.clipboard.writeText(url).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
});
}
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, characterName: value };
});
}
function handlePronounsChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, pronouns: value };
});
}
function handleRaceChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, characterRace: value };
});
}
function handleClassChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, characterClass: value };
});
}
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, bio: value };
});
}
function handleTitleChange(event: ChangeEvent<HTMLSelectElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, activeTitle: value };
});
}
function handleGuildNameChange(event: ChangeEvent<HTMLInputElement>): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, guildName: value };
});
}
function handleGuildDescChange(
event: ChangeEvent<HTMLTextAreaElement>,
): void {
const { value } = event.target;
setDraft((current) => {
return { ...current, guildDescription: value };
});
}
if (loading) {
return (
<section className="panel">
<p>{"Loading character sheet…"}</p>
</section>
);
}
if (editing) {
const isSaveDisabled = saving || draft.characterName.trim() === "";
let saveLabel = "Save";
if (saving) {
saveLabel = "Saving…";
}
if (saved) {
saveLabel = "✓ Saved!";
}
return (
<section className="panel character-sheet-panel">
<div className="panel-header">
<h2>{"📋 Character Sheet"}</h2>
</div>
<div className="character-sheet-form">
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
<label className="character-sheet-label" htmlFor="cs-name">
{"Character Name"}
</label>
<input
className="character-sheet-input"
id="cs-name"
maxLength={32}
onChange={handleNameChange}
placeholder="Your character's name"
type="text"
value={draft.characterName}
/>
<span className="character-sheet-hint">
{draft.characterName.length}
{" / 32"}
</span>
<label className="character-sheet-label" htmlFor="cs-pronouns">
{"Pronouns"}
</label>
<input
className="character-sheet-input"
id="cs-pronouns"
maxLength={20}
onChange={handlePronounsChange}
placeholder="e.g. she/her, he/him, they/them"
type="text"
value={draft.pronouns}
/>
<span className="character-sheet-hint">
{draft.pronouns.length}
{" / 20"}
</span>
<label className="character-sheet-label" htmlFor="cs-race">
{"Race"}
</label>
<input
className="character-sheet-input"
id="cs-race"
maxLength={32}
onChange={handleRaceChange}
placeholder="e.g. Elf, Dwarf, Human, Tiefling…"
type="text"
value={draft.characterRace}
/>
<span className="character-sheet-hint">
{draft.characterRace.length}
{" / 32"}
</span>
<label className="character-sheet-label" htmlFor="cs-class">
{"Class"}
</label>
<input
className="character-sheet-input"
id="cs-class"
maxLength={32}
onChange={handleClassChange}
placeholder="e.g. Paladin, Archmage, Shadow Rogue…"
type="text"
value={draft.characterClass}
/>
<span className="character-sheet-hint">
{draft.characterClass.length}
{" / 32"}
</span>
<label className="character-sheet-label" htmlFor="cs-bio">
{"About Your Character"}
</label>
<textarea
className="character-sheet-textarea"
id="cs-bio"
maxLength={200}
onChange={handleBioChange}
placeholder={
"Describe your character's story, personality, or appearance…"
}
rows={4}
value={draft.bio}
/>
<span className="character-sheet-hint">
{draft.bio.length}
{" / 200"}
</span>
{draft.unlockedTitles.length > 0
&& <>
<label className="character-sheet-label" htmlFor="cs-title">
{"Active Title"}
</label>
<select
className="character-sheet-input"
id="cs-title"
onChange={handleTitleChange}
value={draft.activeTitle}
>
<option value="">{"— None —"}</option>
{draft.unlockedTitles.map((title) => {
return (
<option key={title.id} value={title.id}>
{title.name}
</option>
);
})}
</select>
</>
}
</div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
<label className="character-sheet-label" htmlFor="cs-guild-name">
{"Guild Name"}
</label>
<input
className="character-sheet-input"
id="cs-guild-name"
maxLength={64}
onChange={handleGuildNameChange}
placeholder="Name your guild"
type="text"
value={draft.guildName}
/>
<span className="character-sheet-hint">
{draft.guildName.length}
{" / 64"}
</span>
<label className="character-sheet-label" htmlFor="cs-guild-desc">
{"Guild Description"}
</label>
<textarea
className="character-sheet-textarea"
id="cs-guild-desc"
maxLength={500}
onChange={handleGuildDescChange}
placeholder="Describe your guild's history, goals, or lore…"
rows={6}
value={draft.guildDescription}
/>
<span className="character-sheet-hint">
{draft.guildDescription.length}
{" / 500"}
</span>
</div>
{error === null
? null
: <p className="character-sheet-error">{error}</p>
}
<div className="character-sheet-actions">
<button
className="character-sheet-cancel"
onClick={handleCancel}
type="button"
>
{"Cancel"}
</button>
<button
className="character-sheet-save"
disabled={isSaveDisabled}
onClick={handleSaveClick}
type="button"
>
{saveLabel}
</button>
</div>
</div>
</section>
);
}
const subtitleParts = [ sheet.characterRace, sheet.characterClass ].filter(
(part) => {
return part !== "";
},
);
const subtitle = subtitleParts.join(" · ");
const completedChapters = state?.story?.completedChapters ?? [];
return (
<section className="panel character-sheet-panel">
<div className="panel-header">
<h2>{"📋 Character Sheet"}</h2>
<div className="character-sheet-header-actions">
<button
className="character-sheet-edit-btn"
onClick={handleShareClick}
type="button"
>
{copied
? "✓ Copied!"
: "🔗 Share"}
</button>
<a className="character-sheet-edit-btn" href="/leaderboards">
{"🏆 Boards"}
</a>
<button
className="character-sheet-edit-btn"
onClick={handleEdit}
type="button"
>
{"✏️ Edit"}
</button>
</div>
</div>
<div className="character-sheet-view">
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
<div className="character-sheet-field">
<span className="character-sheet-field-label">{"Name"}</span>
<span className="character-sheet-field-value">
{sheet.characterName === ""
? <em className="character-sheet-empty">{"Not set"}</em>
: sheet.characterName
}
</span>
</div>
<div className="character-sheet-field">
<span className="character-sheet-field-label">{"Streak"}</span>
<span className="character-sheet-streak">
{"🔥 "}
{loginStreak}
{"-day login streak"}
</span>
</div>
{sheet.activeTitle === ""
? null
: <div className="character-sheet-field">
<span className="character-sheet-field-label">{"Title"}</span>
<span
className={"character-sheet-field-value character-sheet-title"}
>
{sheet.unlockedTitles.find((title) => {
return title.id === sheet.activeTitle;
})?.name ?? sheet.activeTitle}
</span>
</div>
}
{sheet.pronouns === ""
? null
: <div className="character-sheet-field">
<span className="character-sheet-field-label">{"Pronouns"}</span>
<span className="character-sheet-field-value">
{sheet.pronouns}
</span>
</div>
}
{subtitle === ""
? null
: <div className="character-sheet-field">
<span className="character-sheet-field-label">{"Identity"}</span>
<span className="character-sheet-field-value">{subtitle}</span>
</div>
}
{sheet.bio === ""
? null
: <div className="character-sheet-bio">
<span className="character-sheet-field-label">{"About"}</span>
<p className="character-sheet-bio-text">{sheet.bio}</p>
</div>
}
</div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
{sheet.equippedItems.length > 0
? <div className="character-sheet-equipment-list">
{sheet.equippedItems.map((item) => {
return (
<div
className="character-sheet-equipment-item"
key={item.type}
>
<div className="character-sheet-equipment-header">
<span className="character-sheet-equipment-slot">
{slotIcons[item.type]}
</span>
<span
className={
"character-sheet-equipment-name"
+ ` character-sheet-rarity--${item.rarity}`
}
>
{item.name}
</span>
<span
className={
"character-sheet-equipment-rarity"
+ ` character-sheet-rarity--${item.rarity}`
}
>
{item.rarity}
</span>
</div>
<p className="character-sheet-equipment-bonus">
{formatBonus(item.bonus)}
</p>
</div>
);
})}
</div>
: <p className="character-sheet-empty">
{"No equipment found. Defeat bosses to earn gear!"}
</p>
}
</div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
{sheet.guildName === ""
? <p className="character-sheet-empty">
{"No guild registered yet. Click ✏️ Edit to add one!"}
</p>
: <>
<div className="character-sheet-field">
<span className="character-sheet-field-label">{"Name"}</span>
<span className="character-sheet-field-value">
{sheet.guildName}
</span>
</div>
{sheet.guildDescription === ""
? null
: <div className="character-sheet-bio">
<span className="character-sheet-field-label">{"Lore"}</span>
<p className="character-sheet-bio-text">
{sheet.guildDescription}
</p>
</div>
}
</>
}
</div>
{completedChapters.length === 0
? null
: <div className="character-sheet-section">
<h3 className="character-sheet-section-title">
{"📖 Story Choices"}
</h3>
{completedChapters.map((completion) => {
const chapter = STORY_CHAPTERS.find((candidate) => {
return candidate.id === completion.chapterId;
});
if (chapter === undefined) {
return null;
}
const choice = chapter.choices.find((candidate) => {
return candidate.id === completion.choiceId;
});
if (choice === undefined) {
return null;
}
return (
<div
className="character-sheet-story-entry"
key={completion.chapterId}
>
<span className="character-sheet-story-chapter">
{chapter.title}
</span>
<span className="character-sheet-story-choice">
{choice.label}
</span>
</div>
);
})}
</div>
}
</div>
</section>
);
};
export { CharacterSheetPanel };