Both places this site already knew an item's (ItemID, hue) and could only print it as text now show the picture, hued the way the client would draw it. The shard does the hueing: whether a hue repaints every pixel or only the grey ones is a flag in `tiledata.mul`, which a browser has no way to read. **Ingest warms; the route only serves** (org lead, 2026-09-11). A page never waits on the shard and never causes a fetch -- it renders what is stored and leaves out what is not, which is the state every install was in before this phase. Fetching happens behind that, on a timer, from the keys the site's own rows name. The alternative, fetching on first request, was rejected on one number: the shard's asset plane serves ONE request at a time, so a URL that fetched would let any visitor walk 49,152 ids times 3,000 hues through that slot and park an operator's own import behind it. The wanted set is DERIVED (`SELECT DISTINCT item_id, hue`) rather than queued, so it is self-healing: a restart loses nothing, and a key stops being wanted the moment the vendor row naming it is deleted. The in-memory hint set on top is only for the character sheet, which is fetched live from the shard and stored nowhere -- nothing on disk would ever name those keys. Staleness without a manifest (§7): every row records the shard's `catalog` id, a hash of the files that decide its bytes. A client patch changes it and a restart does not, so "is this out of date?" is a per-row question -- and pictures nobody looks at any more are simply never re-fetched, which is why this is lazy rather than a sweep. `shard_asset_meta` is deliberately NOT written here: it is the body catalogue's singleton, and a warm pass touching it would tell the body import that a client it never looked at is unchanged. A key the shard has no art for writes no row at all. An empty row would make the key held and it would never be asked again -- including after the operator patches in the graphic that was missing. `assets.sources` now reports which families an overlay serves, so an overlay older than phase 5 is one reported state with a sentence naming the fix, instead of a refusal per pass forever with no picture ever appearing. 688 server tests pass (14 new); client builds; the frozen manifest regenerates with one added route, all documented, no core URL moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
304 lines
15 KiB
JavaScript
304 lines
15 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'
|
|
import ItemIcon from './ItemIcon'
|
|
|
|
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 & 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 }}>
|
|
{/* The sheet has always drawn an empty swatch here to hold the
|
|
row's alignment. As of phase 5 the shard can hand over the
|
|
item's real picture, hued the way the client would draw it —
|
|
so the swatch becomes the fallback rather than the only
|
|
state, and a row with no picture looks exactly as it did. */}
|
|
{it.art ? (
|
|
<ItemIcon art={it.art} name={label} size={22} />
|
|
) : (
|
|
<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>
|
|
)
|
|
}
|