Files
website/client/src/components/CharacterSheet.jsx
wtclaude b61a4d6721 feat(shard): resolve cliloc names for items and reward titles
Protocol 3.0 §8.6 (docs/link/v3.md), the dependency order 5 was sequenced
behind. Items on the wire carry a LabelNumber, not a name — the bridge has
always sent it (char.profile.equipment.cliloc, reward titles as a cliloc
number in string form, and one per marketplace listing) but the site had no
table to resolve it against, so a character sheet could only render
`id 1023721` where the game renders "quarter staff".

The number was never the missing piece. The table was.

Sourced from a file the operator converts once from their own client, at a
path from the `cliloc_client_path` setting falling back to UO_CLIENT_PATH.
Nothing client-derived is committed: UO's strings are EA's, exactly as the
creature sprites are. A shard with nothing configured is fully supported —
names render as ids, as they did before.

The conversion step is not avoidable, and that is the substantive finding
here: every current client ships its cliloc files COMPRESSED (first DWORD's
high byte 0x8E, the Mythic container), and ServUO's own bundled
Ultima.StringList cannot read that either — so VendorSearch.GetItemName is
already inert on such a shard and the plugin could not supply names instead.
v3.md's original "read the client's Cliloc.enu" recommendation was therefore
not implementable as written, and its committed db/data/clilocs.json artifact
also predates the Part C corrections (no committed derived snapshots, nothing
EA-derived shipped). Replaced with the spawn-atlas pattern: parse on boot from
an operator-configured path, hash-gated, output gitignored.

- utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI.
  Accepts the plain binary layout and delimited text, sniffed by header rather
  than extension. Rejects a compressed file BY NAME: without that check the
  plain parser reads it as ~19k records of negative ids and 60 KB "strings"
  before dying mid-file, and the resulting error names the wrong problem.
  displayText() drops the ~1_val~ arguments the bridge never sends.
- utils/clilocSource.js — the fs layer. hashSource reports `compressed` so the
  admin panel can flag an unconverted file WITHOUT parsing 5 MB per poll;
  otherwise pointing at a client directory reports a healthy file with pending
  drift ("ready to import") and the operator only finds out on failure.
- model/shardClilocs — refresh/status/lookup. All-or-nothing replace (DELETE,
  not TRUNCATE — TRUNCATE is DDL in MariaDB and implicitly commits). Batched
  server-side resolution behind a capped cache; never throws, because a cliloc
  lookup is decoration on a character sheet.
- Deliberately NO staged-approval flow, unlike the atlas: the atlas escalates
  facet loss because a half-copied tree and a real map change are
  indistinguishable from inside the process, whereas a partial cliloc copy
  makes the parser fail on a truncated record. The ambiguity the atlas must
  escalate is one this parser simply detects.
- No public route. The table is never served AS a table: 67k rows would dwarf
  any page using them, and the Android client consumes the same resolved JSON.

Two parser bugs found by building it, both now covered by tests: trimming a
text line before splitting ate the trailing separator on empty-text entries
and silently dropped 55,994 of 123,490 while still reporting success; and
Number('') is 0, not NaN, so a line starting with a separator imported as a
bogus cliloc 0.

Verified against the real client table (123,490 entries) and the live MariaDB:
import 663 ms, hash-gated boot no-op 14 ms, cold resolve 4.2 ms / warm 0.015 ms.
Binary and TSV imports converge on the same 67,496 rows with identical keys
(blank entries — half the table — are dropped at import). A file truncated to
half its length is refused with TRUNCATED and leaves the previous table
serving. Boot logs verified for both the import and the compressed-file
warning; neither blocks startup. All three admin routes exercised over HTTP
with a real session. 629 server tests pass; client builds clean; swagger,
routes.manifest.json and routes.guards.json regenerated.

Not covered by an automated test: the character sheet renders resolved names
in presentational React with no DOM test harness in this repo, and was not
rendered against a live linked-player profile — that needs a logged-in player
with a linked game account and a shard answering a profile RPC.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:21:38 -05:00

294 lines
14 KiB
JavaScript

// 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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
{label}
{Number.isFinite(entry.rank) && (
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
)}
</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
{(entry.points ?? 0).toLocaleString()}
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
</span>
</div>
{/* 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 && (
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
)}
</div>
)
}
function TitleChip({ children, tone = 'var(--muted)' }) {
return (
<span
className="sans"
style={{
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
}}
>
{children}
</span>
)
}
function StatTile({ value, label }) {
return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
</div>
)
}
function Vital({ label, cur, max }) {
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
return (
<div className="panel" style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? ''}</span></span>
</div>
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
{/* Identity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
border: '1px solid var(--line)', fontSize: '0.74rem',
color: char.online ? '#7fd0a4' : 'var(--muted)',
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
{char.online ? 'Online' : 'Offline'}
</span>
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div>
{/* Titles + standing (guild led / governorship) — all optional */}
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
{char.governorOf && char.governorOf.map((city) => (
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
))}
{char.guild && (
<TitleChip tone="var(--accent)">
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
</TitleChip>
)}
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
</div>
)}
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */}
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
<div className="grid-3" style={{ gap: 12 }}>
<StatTile value={stats.str ?? '—'} label="Strength" />
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
<StatTile value={stats.int ?? '—'} label="Intelligence" />
</div>
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
</div>
</section>
{/* Resistances */}
{Object.keys(resist).length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
</div>
))}
</div>
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{skills.map((s) => {
const cap = s.cap || 100
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
return (
<div key={s.n}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
})}
</div>
</section>
)}
{/* Loyalty & points — one entry per system this character has scored in */}
{points.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>
Loyalty &amp; points <span className="dim">({points.length})</span>
</div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{points.map((p) => (
<PointsRow key={p.system} entry={p} />
))}
</div>
</section>
)}
{/* Equipment */}
{equipment.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{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 (
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
</div>
{it.mods && Object.keys(it.mods).length > 0 && (
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
{Object.entries(it.mods).map(([k, v]) => (
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
))}
</div>
)}
</div>
)
})}
</div>
</section>
)}
</div>
)
}