Restrict public presence to staff + let admins view any character
Public "Online now" now lists only players whose game account is linked to a STAFF website user (admin/editor/moderator) — linked players are no longer exposed publicly with their name and location. listOnlineLinked joins through to users and filters on role; the section is relabeled "Staff online". Character/roster/vendor reads gain an admin bypass: admins may view any character's data, while players (and editor/moderator staff) stay limited to accounts they have personally linked. The bypass lives in the shared player controller and only ever widens access for genuine admins. Also finalizes the uo-link character/vendor front end (player + admin character sheets, VendorSales component, ShardChar removed) and regenerates swagger-output.json. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
This commit is contained in:
@@ -17,7 +17,6 @@ import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
|||||||
import About from './routes/public/About.jsx'
|
import About from './routes/public/About.jsx'
|
||||||
import Status from './routes/public/Status.jsx'
|
import Status from './routes/public/Status.jsx'
|
||||||
import Shard from './routes/public/Shard.jsx'
|
import Shard from './routes/public/Shard.jsx'
|
||||||
import ShardChar from './routes/public/ShardChar.jsx'
|
|
||||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
@@ -77,7 +76,6 @@ export default function App() {
|
|||||||
<Route path="/site/status" element={<Status />} />
|
<Route path="/site/status" element={<Status />} />
|
||||||
<Route path="/site/shard" element={<Shard />} />
|
<Route path="/site/shard" element={<Shard />} />
|
||||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||||
<Route path="/site/shard/char/:serial" element={<ShardChar />} />
|
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ export const api = {
|
|||||||
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||||
online: () => req('/public/shard/online'),
|
online: () => req('/public/shard/online'),
|
||||||
idoc: () => req('/public/shard/idoc'),
|
idoc: () => req('/public/shard/idoc'),
|
||||||
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
|
|
||||||
},
|
},
|
||||||
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
||||||
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
||||||
@@ -222,6 +221,8 @@ export const api = {
|
|||||||
accounts: () => req('/admin/shard/accounts'),
|
accounts: () => req('/admin/shard/accounts'),
|
||||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req('/admin/shard/sales'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ----- auth providers / SSO config (admin only) -----
|
// ----- auth providers / SSO config (admin only) -----
|
||||||
@@ -269,6 +270,8 @@ export const api = {
|
|||||||
accounts: () => req('/player/shard/accounts'),
|
accounts: () => req('/player/shard/accounts'),
|
||||||
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||||
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req('/player/shard/sales'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
41
client/src/components/VendorSales.jsx
Normal file
41
client/src/components/VendorSales.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ago } from '../lib/format.js'
|
||||||
|
|
||||||
|
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
|
||||||
|
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
|
||||||
|
// sales for accounts linked to the caller.
|
||||||
|
export default function VendorSales({ fetchSales }) {
|
||||||
|
const [sales, setSales] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
fetchSales()
|
||||||
|
.then((rows) => active && setSales(rows))
|
||||||
|
.catch(() => active && setError('Could not load your vendor sales.'))
|
||||||
|
return () => { active = false }
|
||||||
|
}, [fetchSales])
|
||||||
|
|
||||||
|
if (error) return null
|
||||||
|
if (!sales) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
|
||||||
|
{sales.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{sales.map((s, i) => (
|
||||||
|
<li key={`${s.t}-${i}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -62,9 +62,11 @@ export function describe(ev) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Category grouping for the filter tabs.
|
// Category grouping for the filter tabs.
|
||||||
|
// Vendor sales are intentionally NOT a public category — they are owner-private
|
||||||
|
// (a linked player sees their own under the portal). The admin live feed still
|
||||||
|
// describes vendor.sale via describe() below.
|
||||||
export const CATEGORIES = [
|
export const CATEGORIES = [
|
||||||
{ id: 'all', label: 'All', kinds: null },
|
{ id: 'all', label: 'All', kinds: null },
|
||||||
{ id: 'sales', label: 'Vendor sales', kinds: ['vendor.sale'] },
|
|
||||||
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||||
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||||
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import CharacterSheet from '../../../components/CharacterSheet.jsx'
|
|||||||
import { useAsync } from '../../../lib/useAsync.js'
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
// A staff member's character sheet inside the admin shell. Character data is
|
// A staff member's own character sheet inside the admin shell. Owner-checked —
|
||||||
// public MMO data, so it uses the same cached public endpoint.
|
// the endpoint only returns a sheet for a character on the caller's linked account.
|
||||||
export default function AdminCharacter() {
|
export default function AdminCharacter() {
|
||||||
const { serial } = useParams()
|
const { serial } = useParams()
|
||||||
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
|
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
|
||||||
const restarting = error && error.status === 503
|
const restarting = error && error.status === 503
|
||||||
|
const forbidden = error && error.status === 403
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 760 }}>
|
<div style={{ maxWidth: 760 }}>
|
||||||
@@ -20,7 +21,8 @@ export default function AdminCharacter() {
|
|||||||
</p>
|
</p>
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
{error && !restarting && <ErrorState message="Could not load that character right now." />}
|
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||||
|
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||||
{!loading && !error && data && <CharacterSheet char={data} />}
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../../components/VendorSales.jsx'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
// Staff link their OWN in-game account and view their characters — the same
|
// Staff link their OWN in-game account and view their characters — the same
|
||||||
@@ -10,6 +11,7 @@ export default function AdminCharacters() {
|
|||||||
Link your own game account to view your characters, stats, skills and vendors.
|
Link your own game account to view your characters, stats, skills and vendors.
|
||||||
</p>
|
</p>
|
||||||
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
|
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||||
|
<VendorSales fetchSales={api.admin.shard.sales} />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import CharacterSheet from '../../components/CharacterSheet.jsx'
|
|||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
// A player's character sheet inside the portal. Character data is public MMO
|
// A player's character sheet inside the portal. Owner-checked: the endpoint only
|
||||||
// data, so it uses the same cached public endpoint the site does.
|
// returns a sheet for a character on an account linked to the caller.
|
||||||
export default function PlayerCharacter() {
|
export default function PlayerCharacter() {
|
||||||
const { serial } = useParams()
|
const { serial } = useParams()
|
||||||
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
|
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
|
||||||
const restarting = error && error.status === 503
|
const restarting = error && error.status === 503
|
||||||
|
const forbidden = error && error.status === 403
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -20,7 +21,8 @@ export default function PlayerCharacter() {
|
|||||||
</p>
|
</p>
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
{error && !restarting && <ErrorState message="Could not load that character right now." />}
|
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||||
|
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||||
{!loading && !error && data && <CharacterSheet char={data} />}
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../components/VendorSales.jsx'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
// The logged-in player's characters. Shows the link prompt when no game account
|
// The logged-in player's characters. Shows the link prompt when no game account
|
||||||
// is linked, otherwise their characters grouped by account (shared component).
|
// is linked, otherwise their characters grouped by account (shared component),
|
||||||
|
// plus their own recent vendor sales.
|
||||||
export default function PlayerCharacters() {
|
export default function PlayerCharacters() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="display" style={{ margin: '0 0 18px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
<h1 className="display" style={{ margin: '0 0 18px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
||||||
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
||||||
|
<VendorSales fetchSales={api.player.shard.sales} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,10 @@ export default function Shard() {
|
|||||||
const { loading, error, data } = useAsync(() =>
|
const { loading, error, data } = useAsync(() =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.shard.status(),
|
api.shard.status(),
|
||||||
api.shard.feed({ kind: 'vendor.sale', limit: 8 }),
|
|
||||||
api.shard.idoc(),
|
api.shard.idoc(),
|
||||||
api.shard.economy(60),
|
api.shard.economy(60),
|
||||||
api.shard.online(),
|
api.shard.online(),
|
||||||
]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })),
|
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||||
)
|
)
|
||||||
const { events, connected } = useShardFeed({ max: 30 })
|
const { events, connected } = useShardFeed({ max: 30 })
|
||||||
|
|
||||||
@@ -115,26 +114,25 @@ export default function Shard() {
|
|||||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Online now */}
|
{/* Staff online — linked staff accounts only, with location */}
|
||||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
<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 }}>
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
Online now
|
Staff online
|
||||||
</div>
|
</div>
|
||||||
{(!data.online || data.online.length === 0) ? (
|
{(!data.online || data.online.length === 0) ? (
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No one is online right now.</p>
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{data.online.map((p) => (
|
{data.online.map((p) => (
|
||||||
<Link
|
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
key={p.serial}
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
to={`/site/shard/char/${p.serial}`}
|
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||||
className="sans"
|
{p.name || p.serial}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px', border: '1px solid var(--line)', borderRadius: 999, color: 'var(--ink)', textDecoration: 'none' }}
|
</span>
|
||||||
>
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||||
{p.name || p.serial}
|
</span>
|
||||||
{p.map && <span className="dim" style={{ fontSize: '0.76rem' }}>· {p.map}</span>}
|
</div>
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -150,13 +148,7 @@ export default function Shard() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid-2" style={{ gap: 18, marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
{/* Recent vendor sales */}
|
|
||||||
<FeedList
|
|
||||||
title="Recent vendor sales"
|
|
||||||
empty="No sales recorded yet."
|
|
||||||
items={data.sales.map((s) => ({ id: s.id, text: describe(s), when: s.t }))}
|
|
||||||
/>
|
|
||||||
{/* Latest IDOC */}
|
{/* Latest IDOC */}
|
||||||
<FeedList
|
<FeedList
|
||||||
title="Houses in danger (IDOC)"
|
title="Houses in danger (IDOC)"
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,26 @@ async function countOnline() {
|
|||||||
const listOnline = () =>
|
const listOnline = () =>
|
||||||
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
||||||
|
|
||||||
|
// Staff roles whose online presence is shown on the public Shard page. Players
|
||||||
|
// who link an account are NOT surfaced publicly — only staff opt into visibility
|
||||||
|
// by virtue of being staff.
|
||||||
|
const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
|
||||||
|
|
||||||
|
// Online players whose game account is linked to a STAFF website user. Joined
|
||||||
|
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
|
||||||
|
// effect immediately, regardless of whether the player has re-logged since
|
||||||
|
// linking, then through to users so only staff roles are surfaced publicly.
|
||||||
|
const listOnlineLinked = () =>
|
||||||
|
query(
|
||||||
|
`SELECT ${ONLINE_COLS.split(', ').map((c) => `o.${c}`).join(', ')}
|
||||||
|
FROM shard_online o
|
||||||
|
JOIN shard_account_links l ON l.account = o.acct
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')})
|
||||||
|
ORDER BY o.name ASC`,
|
||||||
|
PUBLIC_ONLINE_ROLES,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Economy supply series ────────────────────────────────────────────────
|
// ── Economy supply series ────────────────────────────────────────────────
|
||||||
const insertEconomy = ({ accounts, gold, t }) =>
|
const insertEconomy = ({ accounts, gold, t }) =>
|
||||||
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
|
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
|
||||||
@@ -75,6 +95,7 @@ module.exports = {
|
|||||||
clearOnline,
|
clearOnline,
|
||||||
countOnline,
|
countOnline,
|
||||||
listOnline,
|
listOnline,
|
||||||
|
listOnlineLinked,
|
||||||
insertEconomy,
|
insertEconomy,
|
||||||
listEconomy,
|
listEconomy,
|
||||||
latestEconomy,
|
latestEconomy,
|
||||||
|
|||||||
@@ -44,6 +44,35 @@ const setOffline = (serial) => db.removeOnline(serial)
|
|||||||
const clearOnline = () => db.clearOnline()
|
const clearOnline = () => db.clearOnline()
|
||||||
const onlineCount = () => db.countOnline()
|
const onlineCount = () => db.countOnline()
|
||||||
|
|
||||||
|
function shapeOnline(r) {
|
||||||
|
return {
|
||||||
|
serial: r.serial,
|
||||||
|
name: r.name,
|
||||||
|
acct: r.acct,
|
||||||
|
webId: r.web_id,
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
hits: r.hits,
|
||||||
|
hitsMax: r.hits_max,
|
||||||
|
mana: r.mana,
|
||||||
|
manaMax: r.mana_max,
|
||||||
|
stam: r.stam,
|
||||||
|
stamMax: r.stam_max,
|
||||||
|
str: r.str,
|
||||||
|
dex: r.dex,
|
||||||
|
int: r.int,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only players whose account is linked to a website user (opt-in visibility).
|
||||||
|
async function listOnlineLinked() {
|
||||||
|
const rows = await db.listOnlineLinked()
|
||||||
|
return rows.map(shapeOnline)
|
||||||
|
}
|
||||||
|
|
||||||
async function listOnline() {
|
async function listOnline() {
|
||||||
const rows = await db.listOnline()
|
const rows = await db.listOnline()
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
@@ -133,6 +162,7 @@ module.exports = {
|
|||||||
clearOnline,
|
clearOnline,
|
||||||
onlineCount,
|
onlineCount,
|
||||||
listOnline,
|
listOnline,
|
||||||
|
listOnlineLinked,
|
||||||
addEconomySample,
|
addEconomySample,
|
||||||
listEconomy,
|
listEconomy,
|
||||||
latestEconomy,
|
latestEconomy,
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ adminRouter.get(
|
|||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
'/shard/roster/:account',
|
'/shard/roster/:account',
|
||||||
// #swagger.tags = ['Admin · Account']
|
// #swagger.tags = ['Admin · Account']
|
||||||
// #swagger.summary = 'Character roster for a linked account (self)'
|
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
@@ -150,7 +150,7 @@ adminRouter.get(
|
|||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
'/shard/vendors/:account',
|
'/shard/vendors/:account',
|
||||||
// #swagger.tags = ['Admin · Account']
|
// #swagger.tags = ['Admin · Account']
|
||||||
// #swagger.summary = 'Player vendors for a linked account (self)'
|
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
@@ -159,6 +159,26 @@ adminRouter.get(
|
|||||||
validate,
|
validate,
|
||||||
selfShard.vendors,
|
selfShard.vendors,
|
||||||
)
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/char/:serial',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
validate,
|
||||||
|
selfShard.getChar,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/sales',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
|
selfShard.getSales,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||||
const UPLOAD_DIR =
|
const UPLOAD_DIR =
|
||||||
|
|||||||
@@ -182,5 +182,26 @@ playerRouter.get(
|
|||||||
validate,
|
validate,
|
||||||
shard.vendors,
|
shard.vendors,
|
||||||
)
|
)
|
||||||
|
playerRouter.get(
|
||||||
|
'/shard/char/:serial',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Character sheet — only for a character on the caller’s linked account'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
validate,
|
||||||
|
shard.getChar,
|
||||||
|
)
|
||||||
|
playerRouter.get(
|
||||||
|
'/shard/sales',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
|
shard.getSales,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = playerRouter
|
module.exports = playerRouter
|
||||||
|
|||||||
@@ -9,10 +9,13 @@
|
|||||||
|
|
||||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||||
|
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('player-shard')
|
const log = require('../../../utils/logger')('player-shard')
|
||||||
|
|
||||||
|
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
||||||
|
|
||||||
// POST /player/shard/link — confirm an in-game link code.
|
// POST /player/shard/link — confirm an in-game link code.
|
||||||
async function link(req, res) {
|
async function link(req, res) {
|
||||||
const { code } = req.body
|
const { code } = req.body
|
||||||
@@ -51,12 +54,18 @@ async function listAccounts(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admins may view any character's data; everyone else is limited to accounts
|
||||||
|
// they have personally linked. The same handlers back /player/shard (role
|
||||||
|
// `player`, never admin) and /admin/shard (staff), so this bypass only ever
|
||||||
|
// widens access for genuine admins.
|
||||||
|
const isAdmin = (req) => req.user && req.user.role === 'admin'
|
||||||
|
|
||||||
// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the
|
// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the
|
||||||
// uoLinkClient method to call with the account.
|
// uoLinkClient method to call with the account.
|
||||||
async function ownedRoundTrip(req, res, fetcher, label) {
|
async function ownedRoundTrip(req, res, fetcher, label) {
|
||||||
const { account } = req.params
|
const { account } = req.params
|
||||||
try {
|
try {
|
||||||
const owns = await shardLinks.ownsAccount(account, req.user.id)
|
const owns = isAdmin(req) || (await shardLinks.ownsAccount(account, req.user.id))
|
||||||
if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' })
|
if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' })
|
||||||
|
|
||||||
const result = await fetcher(account)
|
const result = await fetcher(account)
|
||||||
@@ -78,4 +87,58 @@ const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'r
|
|||||||
// GET /player/shard/vendors/:account — player vendors on a linked account.
|
// GET /player/shard/vendors/:account — player vendors on a linked account.
|
||||||
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors')
|
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors')
|
||||||
|
|
||||||
module.exports = { link, listAccounts, roster, vendors }
|
// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's
|
||||||
|
// account is linked to the caller. The sidecar returns the owning account in the
|
||||||
|
// profile, which we check against the caller's links before returning anything.
|
||||||
|
async function getChar(req, res) {
|
||||||
|
const { serial } = req.params
|
||||||
|
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' })
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.getCharBySerial(serial)
|
||||||
|
if (result.ok) {
|
||||||
|
// Admins see any character; others only characters on an account they linked.
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
const acct = result.data && result.data.acct
|
||||||
|
const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
|
||||||
|
if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
|
||||||
|
}
|
||||||
|
return res.json(result.data)
|
||||||
|
}
|
||||||
|
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.getChar', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /player/shard/sales — recent player-vendor sales for the caller's linked
|
||||||
|
// accounts only (as seller/owner). Read from the site's own event log.
|
||||||
|
async function getSales(req, res) {
|
||||||
|
try {
|
||||||
|
const links = await shardLinks.listForUser(req.user.id)
|
||||||
|
const accounts = new Set(links.map((l) => l.account))
|
||||||
|
if (accounts.size === 0) return res.json([])
|
||||||
|
const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 })
|
||||||
|
const mine = events
|
||||||
|
.filter((e) => e.payload && accounts.has(e.payload.ownerAcct))
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((e) => ({
|
||||||
|
t: e.t,
|
||||||
|
itemType: e.payload.itemType,
|
||||||
|
amount: e.payload.amount,
|
||||||
|
price: e.payload.price,
|
||||||
|
commission: e.payload.commission,
|
||||||
|
ownerAcct: e.payload.ownerAcct,
|
||||||
|
}))
|
||||||
|
return res.json(mine)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.getSales', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { link, listAccounts, roster, vendors, getChar, getSales }
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ publicRouter.get(
|
|||||||
publicRouter.get(
|
publicRouter.get(
|
||||||
'/shard/online',
|
'/shard/online',
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Players online now (name + serial + map only)'
|
// #swagger.summary = 'Staff online now (linked staff accounts; name + serial + map only)'
|
||||||
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||||
shard.getOnline,
|
shard.getOnline,
|
||||||
)
|
)
|
||||||
@@ -176,19 +176,6 @@ publicRouter.get(
|
|||||||
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
shard.getIdoc,
|
shard.getIdoc,
|
||||||
)
|
)
|
||||||
publicRouter.get(
|
|
||||||
'/shard/char/:serial',
|
|
||||||
// #swagger.tags = ['Public · Shard']
|
|
||||||
// #swagger.summary = 'Live character sheet by serial (cached; degrades on shard restart)'
|
|
||||||
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Invalid serial', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Character not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[503] = { description: 'Shard restarting — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
|
||||||
validate,
|
|
||||||
shard.getChar,
|
|
||||||
)
|
|
||||||
publicRouter.get(
|
publicRouter.get(
|
||||||
'/shard/stream',
|
'/shard/stream',
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
|||||||
@@ -12,19 +12,10 @@
|
|||||||
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||||
const shardState = require('../../../model/shardState/shardState.model')
|
const shardState = require('../../../model/shardState/shardState.model')
|
||||||
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
|
||||||
const broadcast = require('../../../utils/shardBroadcast')
|
const broadcast = require('../../../utils/shardBroadcast')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('public-shard')
|
const log = require('../../../utils/logger')('public-shard')
|
||||||
|
|
||||||
// Serials are opaque hex keys like "0x24C" — validate before hitting the sidecar.
|
|
||||||
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
|
||||||
|
|
||||||
// Tiny in-memory cache for live character sheets (the sidecar warns these hit the
|
|
||||||
// live shard, so cache them). Keyed by serial; short TTL.
|
|
||||||
const CHAR_TTL_MS = 20000
|
|
||||||
const charCache = new Map()
|
|
||||||
|
|
||||||
// GET /public/shard/status — connection state + online count + latest economy.
|
// GET /public/shard/status — connection state + online count + latest economy.
|
||||||
async function getStatus(req, res) {
|
async function getStatus(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -78,13 +69,13 @@ async function getEconomy(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /public/shard/online — who is online now (redacted: name + serial + map,
|
// GET /public/shard/online — players online now whose account is linked to a
|
||||||
// no coordinates, vitals or account). Feeds the public "online now" list, which
|
// STAFF website user (admin/editor/moderator). Shows name + location (map +
|
||||||
// links to the public character sheet.
|
// coordinates); no vitals or account. Non-staff players are never listed.
|
||||||
async function getOnline(req, res) {
|
async function getOnline(req, res) {
|
||||||
try {
|
try {
|
||||||
const rows = await shardState.listOnline()
|
const rows = await shardState.listOnlineLinked()
|
||||||
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map })))
|
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map, x: r.x, y: r.y, z: r.z })))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getOnline', err)
|
log.error('shard.getOnline', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -101,43 +92,9 @@ async function getIdoc(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /public/shard/char/:serial — live character sheet (cached briefly). A 503
|
|
||||||
// from the sidecar means the shard is restarting: report it as such so the UI
|
|
||||||
// can show a retry banner instead of an error.
|
|
||||||
async function getChar(req, res) {
|
|
||||||
const { serial } = req.params
|
|
||||||
if (!SERIAL_RE.test(serial)) {
|
|
||||||
return res.status(400).json({ message: 'Invalid serial.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = charCache.get(serial)
|
|
||||||
if (cached && Date.now() - cached.at < CHAR_TTL_MS) {
|
|
||||||
return res.json(cached.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await uoLinkClient.getCharBySerial(serial)
|
|
||||||
if (result.ok) {
|
|
||||||
charCache.set(serial, { at: Date.now(), data: result.data })
|
|
||||||
return res.json(result.data)
|
|
||||||
}
|
|
||||||
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
|
||||||
if (result.status === 503) {
|
|
||||||
// Serve a stale cache if we have one; otherwise the restart banner.
|
|
||||||
if (cached) return res.json(cached.data)
|
|
||||||
return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
|
|
||||||
}
|
|
||||||
if (result.status === 0) return res.status(503).json({ message: 'Shard data is unavailable right now.' })
|
|
||||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
|
||||||
} catch (err) {
|
|
||||||
log.error('shard.getChar', err)
|
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
|
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
|
||||||
function stream(req, res) {
|
function stream(req, res) {
|
||||||
broadcast.subscribe(req, res, 'public')
|
broadcast.subscribe(req, res, 'public')
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChar, stream }
|
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, stream }
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
|
|
||||||
const log = require('./logger')('shard-broadcast')
|
const log = require('./logger')('shard-broadcast')
|
||||||
|
|
||||||
// Kinds safe to expose to unauthenticated browsers.
|
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
|
||||||
|
// deliberately NOT here — sales are owner-private (a linked player sees only
|
||||||
|
// their own, via /player/shard/sales).
|
||||||
const PUBLIC_KINDS = new Set([
|
const PUBLIC_KINDS = new Set([
|
||||||
'vendor.sale',
|
|
||||||
'player.death',
|
'player.death',
|
||||||
'player.murdered',
|
'player.murdered',
|
||||||
'mob.killed',
|
'mob.killed',
|
||||||
|
|||||||
@@ -1415,7 +1415,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Public · Shard"
|
"Public · Shard"
|
||||||
],
|
],
|
||||||
"summary": "Players online now (name + serial + map only)",
|
"summary": "Staff online now (linked staff accounts; name + serial + map only)",
|
||||||
"description": "",
|
"description": "",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -1464,75 +1464,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/public/shard/char/{serial}": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Public · Shard"
|
|
||||||
],
|
|
||||||
"summary": "Live character sheet by serial (cached; degrades on shard restart)",
|
|
||||||
"description": "",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "serial",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"description": "Mobile serial, e.g. 0x24C."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Character profile",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Invalid serial",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Character not found",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Internal Server Error"
|
|
||||||
},
|
|
||||||
"502": {
|
|
||||||
"description": "Bad Gateway"
|
|
||||||
},
|
|
||||||
"503": {
|
|
||||||
"description": "Shard restarting — retry",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/public/shard/stream": {
|
"/api/v1/public/shard/stream": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1984,7 +1915,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Admin · Account"
|
"Admin · Account"
|
||||||
],
|
],
|
||||||
"summary": "Character roster for a linked account (self)",
|
"summary": "Character roster for an account (self; admins: any account)",
|
||||||
"description": "",
|
"description": "",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -2038,7 +1969,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Admin · Account"
|
"Admin · Account"
|
||||||
],
|
],
|
||||||
"summary": "Player vendors for a linked account (self)",
|
"summary": "Player vendors for an account (self; admins: any account)",
|
||||||
"description": "",
|
"description": "",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -2087,6 +2018,107 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/shard/char/{serial}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Account"
|
||||||
|
],
|
||||||
|
"summary": "Character sheet (self-linked characters; admins: any character)",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "serial",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Mobile serial, e.g. 0x24C."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Character profile",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Character not on an account linked to the caller",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Bad Gateway"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/shard/sales": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Account"
|
||||||
|
],
|
||||||
|
"summary": "Recent player-vendor sales for the caller’s linked accounts (self)",
|
||||||
|
"description": "",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Vendor sales",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ShardVendorSale"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/dashboard": {
|
"/api/v1/admin/dashboard": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7002,6 +7034,123 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/player/shard/char/{serial}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Player · Shard"
|
||||||
|
],
|
||||||
|
"summary": "Character sheet — only for a character on the caller’s linked account",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "serial",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Mobile serial, e.g. 0x24C."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Character profile",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Character not on an account linked to the caller",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Bad Gateway"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Shard unavailable — retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/player/shard/sales": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Player · Shard"
|
||||||
|
],
|
||||||
|
"summary": "Recent player-vendor sales for the caller’s linked accounts",
|
||||||
|
"description": "",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Vendor sales",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ShardVendorSale"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -10544,7 +10693,7 @@
|
|||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "A player online now (redacted for the public list)."
|
"example": "A LINKED player online now (only accounts linked to a website user are listed)."
|
||||||
},
|
},
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -10591,6 +10740,161 @@
|
|||||||
"example": "Trammel"
|
"example": "Trammel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"x": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 1402
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 1604
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ShardVendorSale": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "A player-vendor sale (visible only to the linked owner)."
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"t": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Sale time, epoch ms."
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 1783720195626
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"itemType": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Longsword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"commission": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ownerAcct": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "whitlocktech"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -546,11 +546,26 @@ const doc = {
|
|||||||
},
|
},
|
||||||
ShardOnlinePlayer: {
|
ShardOnlinePlayer: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'A player online now (redacted for the public list).',
|
description: 'A LINKED player online now (only accounts linked to a website user are listed).',
|
||||||
properties: {
|
properties: {
|
||||||
serial: { type: 'string', example: '0x24C' },
|
serial: { type: 'string', example: '0x24C' },
|
||||||
name: { type: 'string', example: 'Darrow' },
|
name: { type: 'string', example: 'Darrow' },
|
||||||
map: { type: 'string', nullable: true, example: 'Trammel' },
|
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||||
|
x: { type: 'integer', nullable: true, example: 1402 },
|
||||||
|
y: { type: 'integer', nullable: true, example: 1604 },
|
||||||
|
z: { type: 'integer', nullable: true, example: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardVendorSale: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'A player-vendor sale (visible only to the linked owner).',
|
||||||
|
properties: {
|
||||||
|
t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 },
|
||||||
|
itemType: { type: 'string', example: 'Longsword' },
|
||||||
|
amount: { type: 'integer', example: 1 },
|
||||||
|
price: { type: 'integer', example: 100 },
|
||||||
|
commission: { type: 'integer', nullable: true, example: 5 },
|
||||||
|
ownerAcct: { type: 'string', example: 'whitlocktech' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ShardHouse: {
|
ShardHouse: {
|
||||||
|
|||||||
Reference in New Issue
Block a user