Files
Module-uo/client/src/routes/public/Market.jsx
wtclaude f335531538
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 8m23s
feat(assets): item pictures on the marketplace and the character sheet (Phase 5)
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
2026-09-11 06:13:08 -05:00

325 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../../api.js'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import ItemIcon from '../../components/ItemIcon'
// ── The player-vendor marketplace ───────────────────────────────────────────
//
// What every player vendor on the shard is selling, for how much, and where it
// is standing — the same index the in-game Vendor Search gump reads, honouring
// the same per-vendor opt-out, reachable without logging in to the game.
//
// Three things this page must be honest about, all of them consequences of how
// the data is gathered (docs/link/v3.md §8):
//
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
// A page that implied live prices would send people across the world to a
// vendor whose item sold twenty minutes ago.
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
// publishes only the first N, and saying so beats presenting a partial shop
// as complete.
// • **An item may have no name.** On a shard whose operator has not converted
// a cliloc table, `displayName` is null and the honest render is the item id
// — not an invented name.
//
// There is deliberately no live feed here. The market feature's SSE stream ships
// disabled: a firehose of whole vendor inventories would be the site's single
// biggest bandwidth consumer, and nothing on this page needs it.
const PAGE = 50
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
const SORTS = [
{ key: 'price_asc', label: 'Cheapest' },
{ key: 'price_desc', label: 'Priciest' },
{ key: 'recent', label: 'Recently seen' },
]
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
// this is a worst case rather than an average — which is the number worth
// showing, because the one stale shop is the one that wastes a trip.
function staleness(staleAt) {
if (!staleAt) return null
const ms = Date.now() - new Date(staleAt).getTime()
if (!Number.isFinite(ms) || ms < 0) return null
const mins = Math.round(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
const hours = Math.round(mins / 60)
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${Math.round(hours / 24)} days ago`
}
// The item's name, or an honest statement that we do not have one. Never a
// fabricated label — "Item 3922" would be indistinguishable from a real name.
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
function Chip({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className="sans"
style={{
fontSize: '0.78rem',
padding: '5px 12px',
borderRadius: 999,
cursor: 'pointer',
color: active ? 'var(--bg-deep)' : 'var(--muted)',
background: active ? 'var(--accent)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
}}
>
{children}
</button>
)
}
function ListingRow({ listing }) {
const v = listing.vendor || {}
// `location` is one field the admin can gate away wholesale, so everything
// that reads from it has to tolerate its absence rather than assuming a map.
const loc = v.location || null
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
return (
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
<ItemIcon art={listing.art} name={itemLabel(listing)} />
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
{itemLabel(listing)}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{v.serial ? (
<Link to={`/uo/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
{v.shopName || 'an unnamed shop'}
</Link>
) : (
v.shopName || 'an unnamed shop'
)}
{v.ownerName ? ` · ${v.ownerName}` : ''}
{where ? ` · ${where}` : ''}
{/* Priced by the container it sits in, exactly as the in-game search
reports it — the price buys the whole container, not this item. */}
{listing.child ? ' · sold with its container' : ''}
</div>
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
</div>
</div>
)
}
export default function Market() {
const [input, setInput] = useState('')
const [q, setQ] = useState('')
const [map, setMap] = useState('')
const [region, setRegion] = useState('')
const [sort, setSort] = useState('price_asc')
const [minPrice, setMinPrice] = useState('')
const [maxPrice, setMaxPrice] = useState('')
// Applied prices are separate from the typed ones so the search fires when the
// user is done, not on every digit of "250000".
const [prices, setPrices] = useState({ min: '', max: '' })
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
const [more, setMore] = useState(false)
const meta = useAsync(() => api.shard.marketMeta())
// Debounced: typing "vanquishing" should be one request, not eleven — and the
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
useEffect(() => {
const timer = setTimeout(() => setQ(input.trim()), 300)
return () => clearTimeout(timer)
}, [input])
useEffect(() => {
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
return () => clearTimeout(timer)
}, [minPrice, maxPrice])
const load = useCallback(
(offset) =>
api.shard.market({
q,
map,
region,
sort,
minPrice: prices.min,
maxPrice: prices.max,
limit: PAGE,
offset,
}),
[q, map, region, sort, prices],
)
useEffect(() => {
let alive = true
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
load(0)
.then((page) => {
if (!alive) return
setState({
loading: false,
error: null,
listings: page.listings || [],
total: page.total || 0,
staleAt: page.staleAt || null,
})
})
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
return () => {
alive = false
}
}, [load])
const loadMore = async () => {
setMore(true)
try {
const page = await load(state.listings.length)
setState((s) => ({
...s,
listings: [...s.listings, ...(page.listings || [])],
total: page.total ?? s.total,
staleAt: page.staleAt ?? s.staleAt,
}))
} catch {
// A failed "load more" leaves what is on screen alone; the button stays
// available to retry.
} finally {
setMore(false)
}
}
const maps = meta.data?.maps || []
const regions = meta.data?.regions || []
const age = staleness(state.staleAt)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow="Marketplace"
title="Player vendors"
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
/>
{/* Not decoration. The sweep is round-robin, so the index is inherently
up to one full cycle old and the page has to say so. */}
{age && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
Prices last refreshed {age}
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
</p>
)}
<input
className="input"
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search listings…"
style={{ width: '100%', marginBottom: 10 }}
/>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
type="number"
min="0"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="Min price"
style={{ maxWidth: 140 }}
/>
<input
className="input"
type="number"
min="0"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
placeholder="Max price"
style={{ maxWidth: 140 }}
/>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
{SORTS.map((s) => (
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
{s.label}
</Chip>
))}
</div>
{/* Facet and region names come from the shard's own data, never a list in
this file — a shard running custom maps gets its own names here with
no code change (docs/link/v3.md §6.1 R2). */}
{maps.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
{maps.map((m) => (
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
))}
</div>
)}
{regions.length > 0 && (
<select
className="input"
value={region}
onChange={(e) => setRegion(e.target.value)}
style={{ width: '100%', marginBottom: 18 }}
>
<option value="">Anywhere</option>
{regions.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
)}
{state.loading && <Loading />}
{state.error && <ErrorState message="Could not load the marketplace right now." />}
{!state.loading && !state.error && state.listings.length === 0 && (
<EmptyState>
{meta.data?.vendors
? 'Nothing on the shard matches that.'
: 'No player vendors have been indexed yet.'}
</EmptyState>
)}
{!state.loading && !state.error && state.listings.length > 0 && (
<>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
Showing {num(state.listings.length)} of {num(state.total)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.listings.map((l) => (
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
))}
</div>
{state.listings.length < state.total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" className="btn" onClick={loadMore} disabled={more}>
{more ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}