uo-link: staff-only public presence + admin character access #49
@@ -17,6 +17,7 @@ import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
import About from './routes/public/About.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Shard from './routes/public/Shard.jsx'
|
||||
import ShardChar from './routes/public/ShardChar.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -44,6 +45,9 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
// Player portal
|
||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
|
||||
export default function App() {
|
||||
@@ -69,6 +73,7 @@ export default function App() {
|
||||
<Route path="/site/about" element={<About />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/char/:serial" element={<ShardChar />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
@@ -123,13 +128,16 @@ export default function App() {
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerAccount />
|
||||
<PlayerPortalLayout />
|
||||
</RequirePlayer>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -92,6 +92,7 @@ export const api = {
|
||||
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
||||
},
|
||||
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||
online: () => req('/public/shard/online'),
|
||||
idoc: () => req('/public/shard/idoc'),
|
||||
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
|
||||
},
|
||||
|
||||
141
client/src/components/CharacterSheet.jsx
Normal file
141
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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).
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
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 }) {
|
||||
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 || []
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
<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' }}>{it.layer || 'Item'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
@@ -297,175 +295,6 @@ function LinkedAccounts() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Game accounts (uo-link) ────────────────────────────────────────────────
|
||||
function GameAccounts() {
|
||||
const [accounts, setAccounts] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [linkError, setLinkError] = useState('')
|
||||
const [selected, setSelected] = useState(null) // account being inspected
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAccounts(await api.player.shard.accounts())
|
||||
} catch {
|
||||
setError('Could not load your linked game accounts.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function link(e) {
|
||||
e.preventDefault()
|
||||
setMsg('')
|
||||
setLinkError('')
|
||||
if (!code.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const { account } = await api.player.shard.link(code.trim())
|
||||
setMsg(`Linked ${account}.`)
|
||||
setCode('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setLinkError(err.message || 'Could not link that code.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!accounts) return null
|
||||
|
||||
return (
|
||||
<Section title="Game accounts">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Link your in-game account to see your characters and player vendors here. In game, type{' '}
|
||||
<code style={{ color: 'var(--head)' }}>[link</code> to get a one-time code, then enter it below.
|
||||
</p>
|
||||
|
||||
<form onSubmit={link} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', margin: '14px 0 4px' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Link code</span>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="AB12CD"
|
||||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Linking…' : 'Link account'}
|
||||
</button>
|
||||
</form>
|
||||
<Note msg={msg} error={linkError} />
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '18px 0 0' }}>
|
||||
{accounts.map((a) => (
|
||||
<div key={a.account} style={{ border: '1px solid var(--line)', borderRadius: 8, padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{a.account}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem' }}>Linked {new Date(a.linkedAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<button
|
||||
className="pill"
|
||||
onClick={() => setSelected(selected === a.account ? null : a.account)}
|
||||
>
|
||||
{selected === a.account ? 'Hide' : 'View'}
|
||||
</button>
|
||||
</div>
|
||||
{selected === a.account && <AccountDetail account={a.account} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{accounts.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem', marginBottom: 0 }}>No game accounts linked yet.</p>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// Roster + vendors for one linked account, loaded on demand. Handles the shard
|
||||
// restart (503) path with a retry-able banner.
|
||||
function AccountDetail({ account }) {
|
||||
const [roster, setRoster] = useState(null)
|
||||
const [vendors, setVendors] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
setUnavailable(false)
|
||||
try {
|
||||
const [r, v] = await Promise.all([
|
||||
api.player.shard.roster(account),
|
||||
api.player.shard.vendors(account).catch(() => null),
|
||||
])
|
||||
setRoster(r)
|
||||
setVendors(v)
|
||||
} catch (err) {
|
||||
if (err.status === 503) setUnavailable(true)
|
||||
else setError(err.message || 'Could not load this account.')
|
||||
}
|
||||
}, [account])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="sans" style={{ margin: 0, color: '#e0b070', fontSize: '0.85rem' }}>
|
||||
The game server is restarting — try again shortly.
|
||||
</p>
|
||||
<button className="pill" style={{ marginTop: 8 }} onClick={load}>Retry</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
if (!roster) return <p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.82rem' }}>Loading…</p>
|
||||
|
||||
const chars = roster.chars || []
|
||||
const shops = (vendors && vendors.vendors) || []
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<div className="field-label" style={{ marginBottom: 6 }}>Characters</div>
|
||||
{chars.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>No characters found.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{chars.map((c) => (
|
||||
<div key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.86rem', color: 'var(--ink)' }}>
|
||||
<span>{c.name}</span>
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>{c.online ? 'Online' : 'Offline'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shops.length > 0 && (
|
||||
<div>
|
||||
<div className="field-label" style={{ marginBottom: 6 }}>Player vendors</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{shops.map((s) => (
|
||||
<div key={s.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.86rem', color: 'var(--ink)' }}>
|
||||
<span>{s.shopName || 'Vendor'}</span>
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>{Number(s.holdGold || 0).toLocaleString()}gp</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared bits ────────────────────────────────────────────────────────────
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
@@ -482,7 +311,7 @@ function Note({ msg, error }) {
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
export default function PlayerAccount() {
|
||||
const { logout, refresh } = useAuth()
|
||||
const { refresh } = useAuth()
|
||||
const [account, setAccount] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -505,41 +334,22 @@ export default function PlayerAccount() {
|
||||
}, [load, refresh])
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<MoonDot size={12} glow={0.5} />
|
||||
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
|
||||
My Account
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||
← Site
|
||||
</Link>
|
||||
<button onClick={logout} className="pill">Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!loading && !error && account && (
|
||||
<>
|
||||
<div style={{ paddingTop: 24 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||
{account.email ? ` · ${account.email}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<GameAccounts />
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
<LinkedAccounts />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<div>
|
||||
<h1 className="display" style={{ margin: '0 0 4px', fontSize: '1.6rem', color: 'var(--head)' }}>Account</h1>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!loading && !error && account && (
|
||||
<>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||
{account.email ? ` · ${account.email}` : ''}
|
||||
</p>
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
<LinkedAccounts />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
27
client/src/routes/player/PlayerCharacter.jsx
Normal file
27
client/src/routes/player/PlayerCharacter.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// A player's character sheet inside the portal. Character data is public MMO
|
||||
// data, so it uses the same cached public endpoint the site does.
|
||||
export default function PlayerCharacter() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
|
||||
const restarting = error && error.status === 503
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ margin: '0 0 18px' }}>
|
||||
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||
← Back to characters
|
||||
</Link>
|
||||
</p>
|
||||
{loading && <Loading />}
|
||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||
{error && !restarting && <ErrorState message="Could not load that character right now." />}
|
||||
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
client/src/routes/player/PlayerCharacters.jsx
Normal file
166
client/src/routes/player/PlayerCharacters.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// One-time-code linking form (shared by the empty state and "add another").
|
||||
function LinkForm({ onLinked, compact }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setMsg(''); setError('')
|
||||
if (!code.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const { account } = await api.player.shard.link(code.trim())
|
||||
setMsg(`Linked ${account}.`)
|
||||
setCode('')
|
||||
await onLinked()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not link that code.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
{!compact && <span className="field-label">Link code</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="AB12CD"
|
||||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Linking…' : 'Link account'}
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Roster of one linked account → character cards linking to the sheet.
|
||||
function AccountRoster({ account }) {
|
||||
const [roster, setRoster] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(''); setUnavailable(false)
|
||||
try {
|
||||
setRoster(await api.player.shard.roster(account))
|
||||
} catch (err) {
|
||||
if (err.status === 503) setUnavailable(true)
|
||||
else setError(err.message || 'Could not load this account.')
|
||||
}
|
||||
}, [account])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||
<button className="pill" onClick={load}>Retry</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||
|
||||
const chars = roster.chars || []
|
||||
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||
|
||||
return (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{chars.map((c) => (
|
||||
<Link
|
||||
key={c.serial}
|
||||
to={`/player/char/${c.serial}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||
{(c.name || '?').charAt(0)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||
</div>
|
||||
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerCharacters() {
|
||||
const [accounts, setAccounts] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setAccounts(await api.player.shard.accounts())
|
||||
} catch {
|
||||
setError('Could not load your game accounts.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!accounts) return <Loading />
|
||||
|
||||
// Not linked yet — prompt to link.
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="display" style={{ margin: '0 0 6px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
||||
<p className="sans" style={{ margin: '0 0 22px', color: 'var(--muted)', fontSize: '0.92rem', lineHeight: 1.6 }}>
|
||||
You haven’t linked a game account yet. Link one to see your characters, stats, skills and vendors here.
|
||||
</p>
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a one-time code, then enter it below.
|
||||
</p>
|
||||
<LinkForm onLinked={load} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Linked — show characters grouped by account.
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
{accounts.map((a) => (
|
||||
<section key={a.account}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
{a.account}
|
||||
</div>
|
||||
<AccountRoster account={a.account} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||
<LinkForm onLinked={load} compact />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function PlayerLogin() {
|
||||
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dest = location.state?.from?.pathname || '/account'
|
||||
const dest = location.state?.from?.pathname || '/player'
|
||||
// A staff member who signs in here belongs in the admin shell, not the portal.
|
||||
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
|
||||
|
||||
|
||||
54
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
54
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NavLink, Link, Outlet, useNavigate } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
|
||||
// Shared shell for the logged-in player portal: a header with a nav bar
|
||||
// (Characters / Account) and the page content in an <Outlet />. Matches the
|
||||
// site's dark theme vocabulary.
|
||||
const tab = ({ isActive }) => ({
|
||||
textDecoration: 'none',
|
||||
fontFamily: 'var(--sans)',
|
||||
fontSize: '0.9rem',
|
||||
padding: '8px 4px',
|
||||
color: isActive ? 'var(--head)' : 'var(--muted)',
|
||||
borderBottom: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||
})
|
||||
|
||||
export default function PlayerPortalLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function signOut() {
|
||||
await logout()
|
||||
navigate('/account/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
||||
<header style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<div style={{ maxWidth: 820, margin: '0 auto', padding: '16px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<MoonDot size={12} glow={0.5} />
|
||||
<div>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.05rem', letterSpacing: '0.04em' }}>UOMysticmoon</div>
|
||||
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.64rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>Player Portal</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>{user?.username}</span>
|
||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>← Site</Link>
|
||||
<button onClick={signOut} className="pill">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav style={{ maxWidth: 820, margin: '0 auto', padding: '0 20px', display: 'flex', gap: 22 }}>
|
||||
<NavLink to="/player" end style={tab}>Characters</NavLink>
|
||||
<NavLink to="/account" style={tab}>Account</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div style={{ maxWidth: 820, margin: '0 auto', padding: '28px 20px 60px' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default function PlayerRegister() {
|
||||
const [providers, setProviders] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role === 'player') navigate('/account', { replace: true })
|
||||
if (user && user.role === 'player') navigate('/player', { replace: true })
|
||||
}, [user, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,7 +51,7 @@ export default function PlayerRegister() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
||||
navigate('/account', { replace: true })
|
||||
navigate('/player', { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken.')
|
||||
else if (err.status === 403) setError('Registration is not open right now.')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
@@ -75,9 +76,13 @@ function nameOf(who) {
|
||||
|
||||
export default function Shard() {
|
||||
const { loading, error, data } = useAsync(() =>
|
||||
Promise.all([api.shard.status(), api.shard.feed({ kind: 'vendor.sale', limit: 8 }), api.shard.idoc(), api.shard.economy(60)]).then(
|
||||
([status, sales, idoc, economy]) => ({ status, sales, idoc, economy }),
|
||||
),
|
||||
Promise.all([
|
||||
api.shard.status(),
|
||||
api.shard.feed({ kind: 'vendor.sale', limit: 8 }),
|
||||
api.shard.idoc(),
|
||||
api.shard.economy(60),
|
||||
api.shard.online(),
|
||||
]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })),
|
||||
)
|
||||
const { events, connected } = useShardFeed({ max: 30 })
|
||||
|
||||
@@ -141,6 +146,31 @@ export default function Shard() {
|
||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||
</section>
|
||||
|
||||
{/* Online now */}
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
Online now
|
||||
</div>
|
||||
{(!data.online || data.online.length === 0) ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No one is online right now.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{data.online.map((p) => (
|
||||
<Link
|
||||
key={p.serial}
|
||||
to={`/site/shard/char/${p.serial}`}
|
||||
className="sans"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px', border: '1px solid var(--line)', borderRadius: 999, color: 'var(--ink)', textDecoration: 'none' }}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||
{p.name || p.serial}
|
||||
{p.map && <span className="dim" style={{ fontSize: '0.76rem' }}>· {p.map}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Economy sparkline */}
|
||||
{data.economy && data.economy.length > 1 && (
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
|
||||
37
client/src/routes/public/ShardChar.jsx
Normal file
37
client/src/routes/public/ShardChar.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Public character viewer: /site/shard/char/:serial. Renders the live sheet from
|
||||
// the sidecar (cached server-side). A 503 means the shard is restarting.
|
||||
export default function ShardChar() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
|
||||
|
||||
const restarting = error && error.status === 503
|
||||
const notFound = error && error.status === 404
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Character" title={data?.name || 'Character'} />
|
||||
|
||||
<p style={{ marginTop: -8, marginBottom: 20 }}>
|
||||
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||
← Back to shard
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||
{notFound && <ErrorState message="No character with that serial." />}
|
||||
{error && !restarting && !notFound && <ErrorState message="Could not load that character right now." />}
|
||||
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -162,6 +162,13 @@ publicRouter.get(
|
||||
validate,
|
||||
shard.getEconomy,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/online',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Players online now (name + serial + map only)'
|
||||
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||
shard.getOnline,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/idoc',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
|
||||
@@ -69,6 +69,19 @@ async function getEconomy(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/online — who is online now (redacted: name + serial + map,
|
||||
// no coordinates, vitals or account). Feeds the public "online now" list, which
|
||||
// links to the public character sheet.
|
||||
async function getOnline(req, res) {
|
||||
try {
|
||||
const rows = await shardState.listOnline()
|
||||
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map })))
|
||||
} catch (err) {
|
||||
log.error('shard.getOnline', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
|
||||
async function getIdoc(req, res) {
|
||||
try {
|
||||
@@ -118,4 +131,4 @@ function stream(req, res) {
|
||||
broadcast.subscribe(req, res, 'public')
|
||||
}
|
||||
|
||||
module.exports = { getStatus, getFeed, getEconomy, getIdoc, getChar, stream }
|
||||
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChar, stream }
|
||||
|
||||
@@ -1410,6 +1410,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/online": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "Players online now (name + serial + map only)",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Online players",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ShardOnlinePlayer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/idoc": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -10307,6 +10334,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ShardOnlinePlayer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "A player online now (redacted for the public list)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"serial": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "0x24C"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Darrow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"map": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Trammel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ShardHouse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -544,6 +544,15 @@ const doc = {
|
||||
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
|
||||
},
|
||||
},
|
||||
ShardOnlinePlayer: {
|
||||
type: 'object',
|
||||
description: 'A player online now (redacted for the public list).',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x24C' },
|
||||
name: { type: 'string', example: 'Darrow' },
|
||||
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||
},
|
||||
},
|
||||
ShardHouse: {
|
||||
type: 'object',
|
||||
description: 'A house at its current decay stage.',
|
||||
|
||||
Reference in New Issue
Block a user