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
325 lines
12 KiB
JavaScript
325 lines
12 KiB
JavaScript
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>
|
||
)
|
||
}
|