// Reusable character-sheet renderer for the char.profile shape returned by // /public/shard/char/:serial. Presentational only — the parent handles loading // and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles). // // `moderation` opts in the in-game kick/ban controls for the character's account; // they self-gate to staff (ShardAccountActions), so passing it from a page a // player can reach is safe. import ShardAccountActions from './ShardAccountActions.jsx' const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } // The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already // computed display strings; reward entries may be a cliloc NUMBER-as-string or a // literal string. Without a cliloc table on the site we can only show literals, so // numeric reward entries are skipped rather than shown as a raw number. Returns a // de-duped list of human-readable title chips. function displayTitles(titles) { if (!titles) return [] const out = [] if (titles.fameKarma) out.push(titles.fameKarma) if (titles.skill) out.push(titles.skill) const reward = Array.isArray(titles.reward) ? titles.reward : [] const sel = typeof titles.selected === 'number' ? titles.selected : -1 // Prefer the selected reward title; fall back to the first literal one. const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r))) if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate)) return [...new Set(out.filter(Boolean))] } function TitleChip({ children, tone = 'var(--muted)' }) { return ( {children} ) } function StatTile({ value, label }) { return (
{value}
{label}
) } function Vital({ label, cur, max }) { const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0 return (
{label} {cur ?? '—'} / {max ?? '—'}
) } export default function CharacterSheet({ char, moderation = false }) { if (!char) return null const stats = char.stats || {} const resist = stats.resist || {} // Skills the character actually has, best first. const skills = (char.skills || []) .filter((s) => (s.value || s.base || 0) > 0) .sort((a, b) => (b.value || 0) - (a.value || 0)) const equipment = char.equipment || [] return (
{/* Identity */}

{char.name || 'Unknown'}

{char.title && {char.title}} {char.online ? 'Online' : 'Offline'} {char.serial}
{/* Titles + standing (guild led / governorship) — all optional */} {(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
{char.governorOf && char.governorOf.map((city) => ( Governor of {city} ))} {char.guild && ( Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name} )} {displayTitles(char.titles).map((t) => {t})}
)} {/* Staff moderation for this character's account (self-gates to staff). */} {moderation && char.acct && (
Account {char.acct}
)} {/* Core stats */}
Attributes
{/* Resistances */} {Object.keys(resist).length > 0 && (
Resistances
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
{resist[k] ?? 0}
{RESIST_LABELS[k]}
))}
)} {/* Skills */} {skills.length > 0 && (
Skills ({skills.length})
{skills.map((s) => { const cap = s.cap || 100 const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100)) return (
{s.n} {s.value}
) })}
)} {/* Equipment */} {equipment.length > 0 && (
Equipment
{equipment.map((it) => (
{it.layer || 'Item'}
id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}
{it.mods && Object.keys(it.mods).length > 0 && (
{Object.entries(it.mods).map(([k, v]) => ( {k} {v} ))}
)}
))}
)}
) }