// 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' } // What to call an equipped item. // // Items on the wire carry a `LabelNumber`, not a name, so this used to be able // to show nothing but the layer and `id 12345`. The server now resolves the // cliloc against its own table and attaches `clilocName` (see // docs/website/CLILOCS.md); a shard with no cliloc file configured sends none, // and the layer fallback below is exactly what the sheet did before. // // A player-given `name` outranks the resolved type name — "Bob's lucky axe" // should not be relabelled "hatchet" — and the server applies the same // precedence, so this only re-states it for a profile that arrived with both. const itemName = (it) => it.name || it.clilocName || it.layer || 'Item' // 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. // // `rewardResolved` is the server's parallel array with the numeric entries turned // into words (null where the cliloc table had nothing, or is not configured at // all). Prefer it, and keep the literal-only path as the fallback for a profile // served before the cliloc table existed — a numeric entry with no resolution is // still skipped rather than shown as a raw number. function displayTitles(titles) { if (!titles) return [] const out = [] if (titles.fameKarma) out.push(titles.fameKarma) if (titles.skill) out.push(titles.skill) const raw = Array.isArray(titles.reward) ? titles.reward : [] const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r))) const sel = typeof titles.selected === 'number' ? titles.selected : -1 // Prefer the selected reward title; fall back to the first one that resolved. // The `??` matters: a selected title whose cliloc did not resolve must fall // through to the fallback rather than suppress the chip entirely. const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean) if (candidate) out.push(String(candidate)) return [...new Set(out.filter(Boolean))] } // The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system // the character actually holds a score in. Systems at zero are omitted by the // shard, so an empty list means "this character has earned nothing anywhere", // which is a normal state for a new character and renders as nothing at all. // // `nameString` may be null when the system's name is a cliloc; fall back to // humanising the PointsType key, exactly as the leaderboards page does. `rank` is // absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and // "unranked" are different, so the chip only appears when it was actually sent. const humanisePoints = (key) => String(key || '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/^./, (c) => c.toUpperCase()) function PointsRow({ entry }) { const label = entry.nameString || humanisePoints(entry.system) const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0 const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0 return (
{label} {Number.isFinite(entry.rank) && ( · #{entry.rank} )} {(entry.points ?? 0).toLocaleString()} {max > 0 && / {max.toLocaleString()}}
{/* Only systems with a real cap get a bar; an uncapped score has nothing to be a fraction of, and a full-width bar would imply completion. */} {max > 0 && (
)}
) } 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 || [] // Best standing first, so the character's strongest loyalty leads. Guarded for // an older shard plugin that sends no `points` block at all. const points = (Array.isArray(char.points) ? char.points : []) .filter((p) => p && (p.points || 0) > 0) .sort((a, b) => (b.points || 0) - (a.points || 0)) 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}
) })}
)} {/* Loyalty & points — one entry per system this character has scored in */} {points.length > 0 && (
Loyalty & points ({points.length})
{points.map((p) => ( ))}
)} {/* Equipment */} {equipment.length > 0 && (
Equipment
{equipment.map((it) => { const label = itemName(it) const layer = it.layer || 'Item' // The layer only earns its own line once the headline is a real // name; when it IS the headline, repeating it is just noise. const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null] return (
{label}
{detail.filter(Boolean).join(' · ')}
{it.mods && Object.keys(it.mods).length > 0 && (
{Object.entries(it.mods).map(([k, v]) => ( {k} {v} ))}
)}
) })}
)}
) }