diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b7c34e4..613ea91 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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) diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index 91a0e3e..3185fd8 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -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(); 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, }); }); diff --git a/apps/web/src/components/game/AboutPanel.tsx b/apps/web/src/components/game/AboutPanel.tsx index 2eff148..673152d 100644 --- a/apps/web/src/components/game/AboutPanel.tsx +++ b/apps/web/src/components/game/AboutPanel.tsx @@ -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.", diff --git a/apps/web/src/components/game/CharacterSheetPanel.tsx b/apps/web/src/components/game/CharacterSheetPanel.tsx new file mode 100644 index 0000000..2283257 --- /dev/null +++ b/apps/web/src/components/game/CharacterSheetPanel.tsx @@ -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(EMPTY_SHEET); + const [draft, setDraft] = useState(EMPTY_SHEET); + const [editing, setEditing] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const savedSettingsRef = useRef({ ...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 => { + 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

Loading character sheet…

; + } + + if (editing) { + return ( +
+
+

📋 Character Sheet

+
+ +
+
+

⚔️ Character

+ + + { setDraft((d) => ({ ...d, characterName: e.target.value })); }} + /> + {draft.characterName.length} / 32 + + + { setDraft((d) => ({ ...d, pronouns: e.target.value })); }} + /> + {draft.pronouns.length} / 20 + + +