/** * @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"; import { logError } from "../../utils/logError.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; } const emptySheet: CharacterSheetData = { activeTitle: "", bio: "", characterClass: "", characterName: "", characterRace: "", equippedItems: [], guildDescription: "", guildName: "", pronouns: "", unlockedTitles: [], }; const slotIcons: Record = { 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 = []; 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(emptySheet); const [ draft, setDraft ] = useState(emptySheet); 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 [ copied, setCopied ] = useState(false); const savedSettingsReference = useRef({ ...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; }; 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 { 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); }). catch((error_: unknown) => { logError("clipboard_copy", error_); }); } function handleNameChange(event: ChangeEvent): void { const { value } = event.target; setDraft((current) => { return { ...current, characterName: value }; }); } function handlePronounsChange(event: ChangeEvent): void { const { value } = event.target; setDraft((current) => { return { ...current, pronouns: value }; }); } function handleRaceChange(event: ChangeEvent): void { const { value } = event.target; setDraft((current) => { return { ...current, characterRace: value }; }); } function handleClassChange(event: ChangeEvent): void { const { value } = event.target; setDraft((current) => { return { ...current, characterClass: value }; }); } function handleBioChange(event: ChangeEvent): void { const { value } = event.target; setDraft((current) => { return { ...current, bio: value }; }); } function handleTitleChange(event: ChangeEvent): void { const { value } = event.target; setDraft((current) => { return { ...current, activeTitle: value }; }); } function handleGuildNameChange(event: ChangeEvent): void { const { value } = event.target; setDraft((current) => { return { ...current, guildName: value }; }); } function handleGuildDescChange( event: ChangeEvent, ): void { const { value } = event.target; setDraft((current) => { return { ...current, guildDescription: value }; }); } if (loading) { return (

{"Loading character sheet…"}

); } if (editing) { const isSaveDisabled = saving || draft.characterName.trim() === ""; let saveLabel = "Save"; if (saving) { saveLabel = "Saving…"; } if (saved) { saveLabel = "✓ Saved!"; } return (

{"📋 Character Sheet"}

{"⚔️ Character"}

{draft.characterName.length} {" / 32"} {draft.pronouns.length} {" / 20"} {draft.characterRace.length} {" / 32"} {draft.characterClass.length} {" / 32"}