feat: add character sheet panel with guild info

Adds pronouns, guildName, and guildDescription fields to the player
profile. A new Character tab provides an in-game view/edit panel for
character and guild lore.

Closes #16
This commit is contained in:
2026-03-07 13:49:04 -08:00
committed by Naomi Carrigan
parent 4706f3f7f8
commit 924b9f541d
7 changed files with 460 additions and 5 deletions
+6 -3
View File
@@ -13,9 +13,12 @@ model Player {
username String
discriminator String
avatar String?
characterName String @default("")
bio String @default("")
profileSettings Json?
characterName String @default("")
pronouns String @default("")
bio String @default("")
guildName String @default("")
guildDescription String @default("")
profileSettings Json?
createdAt Float
lastSavedAt Float
totalGoldEarned Float @default(0)
+10 -1
View File
@@ -69,9 +69,12 @@ profileRouter.get("/:discordId", async (context) => {
return context.json({
characterName: player.characterName,
pronouns: player.pronouns ?? "",
username: player.username,
avatar: player.avatar ?? null,
bio: player.bio ?? "",
guildName: player.guildName ?? "",
guildDescription: player.guildDescription ?? "",
profileSettings,
createdAt: player.createdAt,
// All Time stats — cumulative across all runs, never reset
@@ -99,7 +102,10 @@ profileRouter.put("/", authMiddleware, async (context) => {
const body = await context.req.json<UpdateProfileRequest>();
const characterName = (body.characterName ?? "").trim().slice(0, 32);
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
const bio = (body.bio ?? "").trim().slice(0, 200);
const guildName = (body.guildName ?? "").trim().slice(0, 64);
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
const numberFormat = VALID_NUMBER_FORMATS.has(body.profileSettings?.numberFormat as string)
? (body.profileSettings?.numberFormat as ProfileSettings["numberFormat"])
: "suffix";
@@ -129,12 +135,15 @@ profileRouter.put("/", authMiddleware, async (context) => {
const updated = await prisma.player.update({
where: { discordId },
data: { characterName, bio, profileSettings: profileSettings as object },
data: { characterName, pronouns, bio, guildName, guildDescription, profileSettings: profileSettings as object },
});
return context.json({
characterName: updated.characterName,
pronouns: updated.pronouns,
bio: updated.bio,
guildName: updated.guildName,
guildDescription: updated.guildDescription,
profileSettings,
});
});
@@ -63,6 +63,10 @@ const HOW_TO_PLAY = [
title: "📖 Codex",
body: "Defeating bosses, completing quests, acquiring equipment, hiring adventurers, purchasing upgrades, unlocking prestige upgrades, discovering new zones, collecting from exploration areas, and crafting recipes all permanently unlock lore entries in the Codex. A badge appears on the Codex tab and a toast notification pops up each time new lore is discovered. Collect all 472 entries to build a complete picture of the world of Elysium.",
},
{
title: "📋 Character Sheet",
body: "Visit the Character tab to write about your character and guild. Fill in your character's name, pronouns, and backstory, then create a guild with its own name and lore. Your character sheet is visible on your public profile page.",
},
{
title: "☁️ Cloud Saves",
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
@@ -0,0 +1,255 @@
import type { ProfileSettings } from "@elysium/types";
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
import { useEffect, useRef, useState } from "react";
import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/GameContext.js";
interface CharacterSheetData {
characterName: string;
pronouns: string;
bio: string;
guildName: string;
guildDescription: string;
}
const EMPTY_SHEET: CharacterSheetData = {
characterName: "",
pronouns: "",
bio: "",
guildName: "",
guildDescription: "",
};
export const CharacterSheetPanel = (): React.JSX.Element => {
const { state } = useGame();
const player = state?.player;
const [sheet, setSheet] = useState<CharacterSheetData>(EMPTY_SHEET);
const [draft, setDraft] = useState<CharacterSheetData>(EMPTY_SHEET);
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 savedSettingsRef = useRef<ProfileSettings>({ ...DEFAULT_PROFILE_SETTINGS });
useEffect(() => {
if (!player?.discordId) return;
fetch(`/api/profile/${player.discordId}`)
.then(async (res) => {
if (!res.ok) return;
const data = (await res.json()) as {
characterName: string;
pronouns: string;
bio: string;
guildName: string;
guildDescription: string;
profileSettings: ProfileSettings;
};
const loaded: CharacterSheetData = {
characterName: data.characterName ?? "",
pronouns: data.pronouns ?? "",
bio: data.bio ?? "",
guildName: data.guildName ?? "",
guildDescription: data.guildDescription ?? "",
};
setSheet(loaded);
setDraft(loaded);
savedSettingsRef.current = { ...DEFAULT_PROFILE_SETTINGS, ...data.profileSettings };
})
.catch(() => { /* fall back to empty */ })
.finally(() => { setLoading(false); });
}, [player?.discordId]);
const handleEdit = (): void => {
setDraft({ ...sheet });
setEditing(true);
setError(null);
setSaved(false);
};
const handleCancel = (): void => {
setEditing(false);
setError(null);
};
const handleSave = async (): Promise<void> => {
setSaving(true);
setError(null);
try {
await updateProfile({
characterName: draft.characterName || (player?.characterName ?? ""),
pronouns: draft.pronouns,
bio: draft.bio,
guildName: draft.guildName,
guildDescription: draft.guildDescription,
profileSettings: savedSettingsRef.current,
});
setSheet({ ...draft });
setSaved(true);
setTimeout(() => {
setEditing(false);
setSaved(false);
}, 900);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
if (loading) {
return <section className="panel"><p>Loading character sheet</p></section>;
}
if (editing) {
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}
placeholder="Your character's name"
type="text"
value={draft.characterName}
onChange={(e) => { setDraft((d) => ({ ...d, characterName: e.target.value })); }}
/>
<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}
placeholder="e.g. she/her, he/him, they/them"
type="text"
value={draft.pronouns}
onChange={(e) => { setDraft((d) => ({ ...d, pronouns: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.pronouns.length} / 20</span>
<label className="character-sheet-label" htmlFor="cs-bio">About Your Character</label>
<textarea
className="character-sheet-textarea"
id="cs-bio"
maxLength={200}
placeholder="Describe your character's story, personality, or appearance…"
rows={4}
value={draft.bio}
onChange={(e) => { setDraft((d) => ({ ...d, bio: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.bio.length} / 200</span>
</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}
placeholder="Name your guild"
type="text"
value={draft.guildName}
onChange={(e) => { setDraft((d) => ({ ...d, guildName: e.target.value })); }}
/>
<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}
placeholder="Describe your guild's history, goals, or lore…"
rows={6}
value={draft.guildDescription}
onChange={(e) => { setDraft((d) => ({ ...d, guildDescription: e.target.value })); }}
/>
<span className="character-sheet-hint">{draft.guildDescription.length} / 500</span>
</div>
{error && <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={saving || !draft.characterName.trim()}
onClick={() => { void handleSave(); }}
type="button"
>
{saved ? "✓ Saved!" : saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</section>
);
}
return (
<section className="panel character-sheet-panel">
<div className="panel-header">
<h2>📋 Character Sheet</h2>
<button className="character-sheet-edit-btn" onClick={handleEdit} type="button">
Edit
</button>
</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>}
</span>
</div>
{sheet.pronouns && (
<div className="character-sheet-field">
<span className="character-sheet-field-label">Pronouns</span>
<span className="character-sheet-field-value">{sheet.pronouns}</span>
</div>
)}
{sheet.bio && (
<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">🏰 Guild</h3>
{sheet.guildName ? (
<>
<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 && (
<div className="character-sheet-bio">
<span className="character-sheet-field-label">Lore</span>
<p className="character-sheet-bio-text">{sheet.guildDescription}</p>
</div>
)}
</>
) : (
<p className="character-sheet-empty">No guild registered yet. Click Edit to add one!</p>
)}
</div>
</div>
</section>
);
};
+4 -1
View File
@@ -21,9 +21,10 @@ import { StatisticsPanel } from "./StatisticsPanel.js";
import { UpgradePanel } from "./UpgradePanel.js";
import { DailyChallengePanel } from "./DailyChallengePanel.js";
import { ExplorationPanel } from "./ExplorationPanel.js";
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
import { CraftingPanel } from "./CraftingPanel.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character";
const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" },
@@ -38,6 +39,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "transcendence", label: "🌌 Transcendence" },
{ id: "apotheosis", label: "✨ Apotheosis" },
{ id: "statistics", label: "📊 Statistics" },
{ id: "character", label: "📋 Character" },
{ id: "achievements", label: "🏆 Achievements" },
{ id: "codex", label: "📖 Codex" },
{ id: "about", label: "️ About" },
@@ -129,6 +131,7 @@ export const GameLayout = (): React.JSX.Element => {
{activeTab === "crafting" && <CraftingPanel />}
{activeTab === "statistics" && <StatisticsPanel />}
{activeTab === "daily" && <DailyChallengePanel />}
{activeTab === "character" && <CharacterSheetPanel />}
{activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />}
</div>
+172
View File
@@ -3070,3 +3070,175 @@ body {
padding: 0.3rem 0.6rem;
}
}
/* ===================== CHARACTER SHEET ===================== */
.character-sheet-panel .panel-header {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.character-sheet-edit-btn {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
cursor: pointer;
font-size: 0.85rem;
padding: 0.3rem 0.75rem;
transition: border-color 0.15s, color 0.15s;
}
.character-sheet-edit-btn:hover {
border-color: var(--colour-accent);
color: var(--colour-accent);
}
.character-sheet-view,
.character-sheet-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.character-sheet-section {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
}
.character-sheet-section-title {
color: var(--colour-accent);
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.character-sheet-field {
align-items: baseline;
display: flex;
gap: 0.75rem;
}
.character-sheet-field-label {
color: var(--colour-text-muted);
font-size: 0.8rem;
font-weight: 600;
min-width: 5rem;
text-transform: uppercase;
}
.character-sheet-field-value {
color: var(--colour-text);
font-size: 0.95rem;
}
.character-sheet-bio {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.25rem;
}
.character-sheet-bio-text {
color: var(--colour-text);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
}
.character-sheet-empty {
color: var(--colour-text-muted);
font-size: 0.85rem;
font-style: italic;
}
.character-sheet-label {
color: var(--colour-text-muted);
display: block;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.character-sheet-input {
background: var(--colour-bg);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
width: 100%;
}
.character-sheet-input:focus {
border-color: var(--colour-accent);
outline: none;
}
.character-sheet-textarea {
background: var(--colour-bg);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
font-family: inherit;
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
resize: vertical;
width: 100%;
}
.character-sheet-textarea:focus {
border-color: var(--colour-accent);
outline: none;
}
.character-sheet-hint {
color: var(--colour-text-muted);
font-size: 0.75rem;
text-align: right;
}
.character-sheet-error {
color: #e74c3c;
font-size: 0.85rem;
}
.character-sheet-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.character-sheet-cancel {
background: transparent;
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text-muted);
cursor: pointer;
font-size: 0.9rem;
padding: 0.5rem 1.25rem;
}
.character-sheet-save {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
padding: 0.5rem 1.25rem;
transition: opacity 0.15s;
}
.character-sheet-save:disabled {
cursor: not-allowed;
opacity: 0.5;
}