diff --git a/client/src/App.jsx b/client/src/App.jsx
index 1560d18..9149a78 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
{/* CMS pages: top-level /:slug, matched only after the named routes
@@ -123,13 +128,16 @@ export default function App() {
} />
} />
-
+
}
- />
+ >
+ } />
+ } />
+ } />
+
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 8263791..648cf04 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -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)}`),
},
diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx
new file mode 100644
index 0000000..34bdb9e
--- /dev/null
+++ b/client/src/components/CharacterSheet.jsx
@@ -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 (
+
+ )
+}
+
+function Vital({ label, cur, max }) {
+ const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
+ return (
+
+
+ {label}
+ {cur ?? '—'} / {max ?? '—'}
+
+
+
+ )
+}
+
+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 (
+
+ {/* Identity */}
+
+
{char.name || 'Unknown'}
+ {char.title && {char.title} }
+
+
+ {char.online ? 'Online' : 'Offline'}
+
+ {char.serial}
+
+
+ {/* Core stats */}
+
+ Attributes
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Resistances */}
+ {Object.keys(resist).length > 0 && (
+
+ Resistances
+
+ {['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
+
+
{resist[k] ?? 0}
+
{RESIST_LABELS[k]}
+
+ ))}
+
+
+ )}
+
+ {/* Skills */}
+ {skills.length > 0 && (
+
+ Skills ({skills.length})
+
+ {skills.map((s) => {
+ const cap = s.cap || 100
+ const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
+ return (
+
+
+ {s.n}
+ {s.value}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Equipment */}
+ {equipment.length > 0 && (
+
+ Equipment
+
+ {equipment.map((it) => (
+
+
+
+
{it.layer || 'Item'}
+
id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}
+
+ {it.mods && Object.keys(it.mods).length > 0 && (
+
+ {Object.entries(it.mods).map(([k, v]) => (
+ {k} {v}
+ ))}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx
index 6d0ea7d..ee240bd 100644
--- a/client/src/routes/player/PlayerAccount.jsx
+++ b/client/src/routes/player/PlayerAccount.jsx
@@ -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
- if (!accounts) return null
-
- return (
-
-
- Link your in-game account to see your characters and player vendors here. In game, type{' '}
- [link to get a one-time code, then enter it below.
-
-
-
-
-
- {accounts.length > 0 && (
-
- {accounts.map((a) => (
-
-
-
-
{a.account}
-
Linked {new Date(a.linkedAt).toLocaleDateString()}
-
-
setSelected(selected === a.account ? null : a.account)}
- >
- {selected === a.account ? 'Hide' : 'View'}
-
-
- {selected === a.account &&
}
-
- ))}
-
- )}
- {accounts.length === 0 && (
- No game accounts linked yet.
- )}
-
- )
-}
-
-// 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 (
-
-
- The game server is restarting — try again shortly.
-
-
Retry
-
- )
- }
- if (error) return {error}
- if (!roster) return Loading…
-
- const chars = roster.chars || []
- const shops = (vendors && vendors.vendors) || []
-
- return (
-
-
-
Characters
- {chars.length === 0 ? (
-
No characters found.
- ) : (
-
- {chars.map((c) => (
-
- {c.name}
- {c.online ? 'Online' : 'Offline'}
-
- ))}
-
- )}
-
- {shops.length > 0 && (
-
-
Player vendors
-
- {shops.map((s) => (
-
- {s.shopName || 'Vendor'}
- {Number(s.holdGold || 0).toLocaleString()}gp
-
- ))}
-
-
- )}
-
- )
-}
-
// ── 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 (
-
-
-
-
- {loading &&
}
- {error &&
}
- {!loading && !error && account && (
- <>
-
-
- Signed in as {account.username}
- {account.email ? ` · ${account.email}` : ''}
-
-
-
-
-
-
-
- >
- )}
-
-
+
+
Account
+ {loading &&
}
+ {error &&
}
+ {!loading && !error && account && (
+ <>
+
+ Signed in as {account.username}
+ {account.email ? ` · ${account.email}` : ''}
+
+
+
+
+
+ >
+ )}
+
)
}
diff --git a/client/src/routes/player/PlayerCharacter.jsx b/client/src/routes/player/PlayerCharacter.jsx
new file mode 100644
index 0000000..2ddf27e
--- /dev/null
+++ b/client/src/routes/player/PlayerCharacter.jsx
@@ -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 (
+
+
+
+ ← Back to characters
+
+
+ {loading &&
}
+ {restarting &&
}
+ {error && !restarting &&
}
+ {!loading && !error && data &&
}
+
+ )
+}
diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx
new file mode 100644
index 0000000..07cf03a
--- /dev/null
+++ b/client/src/routes/player/PlayerCharacters.jsx
@@ -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 (
+
+ )
+}
+
+// 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 (
+
+
The game server is restarting — try again shortly.
+
Retry
+
+ )
+ }
+ if (error) return {error}
+ if (!roster) return Loading…
+
+ const chars = roster.chars || []
+ if (chars.length === 0) return No characters on this account.
+
+ return (
+
+ {chars.map((c) => (
+
+
+ {(c.name || '?').charAt(0)}
+
+
+
{c.name}
+
{c.online ? 'Online' : 'Offline'}
+
+
›
+
+ ))}
+
+ )
+}
+
+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
+ if (!accounts) return
+
+ // Not linked yet — prompt to link.
+ if (accounts.length === 0) {
+ return (
+
+
Your characters
+
+ You haven’t linked a game account yet. Link one to see your characters, stats, skills and vendors here.
+
+
+
Link your game account
+
+ In game, type [link to get a one-time code, then enter it below.
+
+
+
+
+ )
+ }
+
+ // Linked — show characters grouped by account.
+ return (
+
+
+
Your characters
+
+
+
+ {accounts.map((a) => (
+
+ ))}
+
+
+
+ Link another account
+
+
+
+ )
+}
diff --git a/client/src/routes/player/PlayerLogin.jsx b/client/src/routes/player/PlayerLogin.jsx
index 4958c46..8e054e6 100644
--- a/client/src/routes/player/PlayerLogin.jsx
+++ b/client/src/routes/player/PlayerLogin.jsx
@@ -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)
diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx
new file mode 100644
index 0000000..4c6a878
--- /dev/null
+++ b/client/src/routes/player/PlayerPortalLayout.jsx
@@ -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 . 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 (
+
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/routes/player/PlayerRegister.jsx b/client/src/routes/player/PlayerRegister.jsx
index 9aaf2b1..4da1c5f 100644
--- a/client/src/routes/player/PlayerRegister.jsx
+++ b/client/src/routes/player/PlayerRegister.jsx
@@ -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.')
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
index d6e768a..9917524 100644
--- a/client/src/routes/public/Shard.jsx
+++ b/client/src/routes/public/Shard.jsx
@@ -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() {
+ {/* Online now */}
+
+
+ Online now
+
+ {(!data.online || data.online.length === 0) ? (
+ No one is online right now.
+ ) : (
+
+ {data.online.map((p) => (
+
+
+ {p.name || p.serial}
+ {p.map && · {p.map} }
+
+ ))}
+
+ )}
+
+
{/* Economy sparkline */}
{data.economy && data.economy.length > 1 && (
diff --git a/client/src/routes/public/ShardChar.jsx b/client/src/routes/public/ShardChar.jsx
new file mode 100644
index 0000000..9104d85
--- /dev/null
+++ b/client/src/routes/public/ShardChar.jsx
@@ -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 (
+
+
+
+
+
+
+ ← Back to shard
+
+
+
+ {loading &&
}
+ {restarting &&
}
+ {notFound &&
}
+ {error && !restarting && !notFound &&
}
+ {!loading && !error && data &&
}
+
+
+ )
+}
diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js
index f741838..3f77764 100644
--- a/server/src/router/v1/public/public.routes.js
+++ b/server/src/router/v1/public/public.routes.js
@@ -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']
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index 5b09c04..14e72d7 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -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 }
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index f7770f8..f91b8b0 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -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": {
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 56a1bfe..0d245b8 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -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.',