Merge pull request 'Protocol 2.0/2.1 uo-link integration — boards, cross-links, news gump, account provisioning' (#65) from feature/protocol2-integration into main
Reviewed-on: UOM/website#65 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -19,6 +19,9 @@ import Status from './routes/public/Status.jsx'
|
||||
import Shard from './routes/public/Shard.jsx'
|
||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||
import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -43,6 +46,8 @@ import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
@@ -50,6 +55,7 @@ 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 AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
@@ -84,6 +90,9 @@ export default function App() {
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||
<Route path="/site/guilds" element={<Guilds />} />
|
||||
<Route path="/site/governors" element={<Governors />} />
|
||||
<Route path="/site/houses" element={<Houses />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
@@ -136,11 +145,20 @@ export default function App() {
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="users/:id" element={<UserDetail />} />
|
||||
<Route path="invites" element={<InvitesAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
@@ -148,6 +166,7 @@ export default function App() {
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
|
||||
@@ -48,6 +48,10 @@ export const api = {
|
||||
// optional email. Returns { user } and sets the session cookie on success.
|
||||
register: (username, password, extra = {}) =>
|
||||
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
||||
// Email invites (public, token-gated accept).
|
||||
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
||||
acceptInvite: (token, username, password, extra = {}) =>
|
||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||
loginTotp: (challenge, code) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||
@@ -95,6 +99,13 @@ export const api = {
|
||||
online: () => req('/public/shard/online'),
|
||||
idoc: () => req('/public/shard/idoc'),
|
||||
champs: () => req('/public/shard/champs'),
|
||||
// Protocol 2.0 boards.
|
||||
guilds: () => req('/public/shard/guilds'),
|
||||
governors: () => req('/public/shard/governors'),
|
||||
governorHistory: (city, limit) =>
|
||||
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
|
||||
presence: () => req('/public/shard/presence'),
|
||||
houses: () => req('/public/shard/houses'),
|
||||
},
|
||||
// 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
|
||||
@@ -164,6 +175,11 @@ export const api = {
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
// Email invites.
|
||||
listInvites: () => req('/admin/invites'),
|
||||
createInvite: (email, role, sendEmail = true) =>
|
||||
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
||||
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
||||
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
|
||||
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
|
||||
@@ -176,6 +192,8 @@ export const api = {
|
||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||
online: () => req(`/admin/users/${id}/shard/online`),
|
||||
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
||||
}),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
@@ -242,6 +260,9 @@ export const api = {
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/admin/shard/sales'),
|
||||
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
||||
createAccount: (account, password) =>
|
||||
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
@@ -305,6 +326,9 @@ export const api = {
|
||||
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/player/shard/sales'),
|
||||
houses: () => req('/player/shard/houses'), // the caller's own houses
|
||||
createAccount: (account, password) =>
|
||||
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,38 @@ import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||
// literal string. Without a cliloc table on the site we can only show literals, so
|
||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||
// de-duped list of human-readable title chips.
|
||||
function displayTitles(titles) {
|
||||
if (!titles) return []
|
||||
const out = []
|
||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||
if (titles.skill) out.push(titles.skill)
|
||||
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||
// Prefer the selected reward title; fall back to the first literal one.
|
||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||
return [...new Set(out.filter(Boolean))]
|
||||
}
|
||||
|
||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||
@@ -64,6 +96,21 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||
</div>
|
||||
|
||||
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||
{char.governorOf && char.governorOf.map((city) => (
|
||||
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||
))}
|
||||
{char.guild && (
|
||||
<TitleChip tone="var(--accent)">
|
||||
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||
</TitleChip>
|
||||
)}
|
||||
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||
{moderation && char.acct && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||
|
||||
69
client/src/components/CreateGameAccountForm.jsx
Normal file
69
client/src/components/CreateGameAccountForm.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// Reusable "create a game account" form (its own username + password — the game
|
||||
// client credentials, distinct from the website login). Calls `submit(account,
|
||||
// password)` which should POST /player/shard/account; on success calls onCreated.
|
||||
// Used by the player portal (self-serve) and the invite-accept page alike.
|
||||
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
|
||||
const [account, setAccount] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setMsg(''); setError('')
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
|
||||
return setError('Account name must be 3–30 letters, numbers, . _ or -.')
|
||||
}
|
||||
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await submit(account, password)
|
||||
setMsg(`Game account “${account}” created and linked.`)
|
||||
setAccount(''); setPassword('')
|
||||
if (onCreated) await onCreated()
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That account name is already taken.')
|
||||
else if (err.status === 429) setError('The account limit for your network has been reached.')
|
||||
else if (err.status === 403) setError('Game-account signup is not available right now.')
|
||||
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
|
||||
else setError(err.message || 'Could not create the account right now.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
{!compact && (
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Choose the username and password you’ll type into the game client. These are your
|
||||
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
|
||||
</p>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span className="field-label">Game account name</span>
|
||||
<input
|
||||
type="text" autoComplete="off" value={account}
|
||||
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Game password</span>
|
||||
<input
|
||||
type="password" autoComplete="new-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Creating…' : 'Create game account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from './PageState.jsx'
|
||||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Shared game-account linking + character roster, used by both the player portal
|
||||
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||
@@ -108,9 +110,37 @@ function AccountRoster({ scope, account, charTo }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false }) {
|
||||
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
|
||||
// then calls onUnlink(account) and reloads. Errors surface inline.
|
||||
function UnlinkButton({ account, onUnlink }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
async function go() {
|
||||
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await onUnlink(account)
|
||||
} catch (err) {
|
||||
setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.'))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
|
||||
const [accounts, setAccounts] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
// Whether the site currently offers game-account creation (public flag). Only
|
||||
// relevant for the self-service (non-readOnly) view with a createAccount scope.
|
||||
const [signupOk, setSignupOk] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
@@ -122,6 +152,17 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
|
||||
}, [scope, readOnly])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly || !scope.createAccount) return
|
||||
let active = true
|
||||
api.publicSettings()
|
||||
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||||
.catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [readOnly, scope])
|
||||
|
||||
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!accounts) return <Loading />
|
||||
|
||||
@@ -138,13 +179,21 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
|
||||
)
|
||||
}
|
||||
return (
|
||||
<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 }}>
|
||||
You haven’t linked a game account yet. In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||
</p>
|
||||
<LinkForm scope={scope} onLinked={load} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<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 }}>
|
||||
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||
</p>
|
||||
<LinkForm scope={scope} onLinked={load} />
|
||||
</div>
|
||||
{canCreate && (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -154,8 +203,11 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
|
||||
<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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
{a.account}
|
||||
</div>
|
||||
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
|
||||
</div>
|
||||
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
|
||||
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||
@@ -165,6 +217,12 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||
<LinkForm scope={scope} onLinked={load} compact />
|
||||
{canCreate && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
84
client/src/components/PlayersOnline.jsx
Normal file
84
client/src/components/PlayersOnline.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useAsync } from '../lib/useAsync.js'
|
||||
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||
import { bucketize } from '../data/regionBuckets.js'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
|
||||
// then keeps the total + region breakdown current from the presence.online SSE
|
||||
// kind. The raw byRegion map is rolled up into display buckets (see
|
||||
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
|
||||
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||
|
||||
export default function PlayersOnline() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.presence())
|
||||
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
|
||||
|
||||
// The freshest snapshot wins: the newest buffered presence.online event, else
|
||||
// the initial fetch.
|
||||
const snapshot = events[0] || data
|
||||
|
||||
const { total, rows } = useMemo(() => {
|
||||
const count = Number(snapshot?.count) || 0
|
||||
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
|
||||
return { total: count, rows: bucketRows }
|
||||
}, [snapshot])
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Players online
|
||||
</span>
|
||||
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||
{loading ? '—' : total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||
Population is unavailable right now.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
<span>{r.label}</span>
|
||||
{/* tabular figures keep the right-aligned counts in a clean column */}
|
||||
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,9 @@ const NAV = [
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
{ label: 'Shard', to: '/site/shard' },
|
||||
{ label: 'Champions', to: '/site/champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds' },
|
||||
{ label: 'Governors', to: '/site/governors' },
|
||||
{ label: 'Houses', to: '/site/houses' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
|
||||
31
client/src/data/cityCrests.js
Normal file
31
client/src/data/cityCrests.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
|
||||
// emoji sigil + a ring colour — enough to make the Governors board and the
|
||||
// governor badge read as distinct "crests" today, swappable for real artwork
|
||||
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
|
||||
// public path) onto an entry and update CityCrest to prefer it.
|
||||
//
|
||||
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
|
||||
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
|
||||
|
||||
export const CITY_CRESTS = {
|
||||
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
|
||||
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
|
||||
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
|
||||
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
|
||||
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
|
||||
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
|
||||
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
|
||||
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
|
||||
}
|
||||
|
||||
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
|
||||
|
||||
// Look up a crest by the raw city key, tolerating spacing variants
|
||||
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
|
||||
export function crestFor(city) {
|
||||
if (!city) return FALLBACK
|
||||
const key = String(city).replace(/\s+/g, '')
|
||||
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
|
||||
if (crest) return crest
|
||||
return { ...FALLBACK, label: String(city) }
|
||||
}
|
||||
62
client/src/data/regionBuckets.js
Normal file
62
client/src/data/regionBuckets.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
|
||||
// regions) up into a handful of labelled display buckets for the "Players Online"
|
||||
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
|
||||
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
||||
// so the bucket counts always reconcile to the true total.
|
||||
|
||||
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
|
||||
// membership. First matching bucket wins; the last bucket is the catch-all.
|
||||
export const BUCKETS = [
|
||||
{
|
||||
id: 'britain',
|
||||
label: 'Britain',
|
||||
// Passthrough for the capital + its immediate surrounds.
|
||||
match: (r) => /^britain/i.test(r),
|
||||
},
|
||||
{
|
||||
id: 'towns',
|
||||
label: 'Towns',
|
||||
// The other named cities/towns.
|
||||
match: (r) =>
|
||||
/^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test(
|
||||
r,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'dungeons',
|
||||
label: 'Dungeons',
|
||||
match: (r) =>
|
||||
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
|
||||
r,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'housing',
|
||||
label: 'Housing',
|
||||
// House regions expose themselves as named house/townhouse regions.
|
||||
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
|
||||
},
|
||||
{
|
||||
id: 'wilderness',
|
||||
label: 'Wilderness',
|
||||
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
|
||||
match: () => true,
|
||||
},
|
||||
]
|
||||
|
||||
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
|
||||
// order, dropping empty buckets, with the summed total also returned.
|
||||
export function bucketize(byRegion = {}) {
|
||||
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
|
||||
let total = 0
|
||||
for (const [region, n] of Object.entries(byRegion || {})) {
|
||||
const count = Number(n) || 0
|
||||
total += count
|
||||
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
|
||||
totals.set(bucket.id, totals.get(bucket.id) + count)
|
||||
}
|
||||
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
|
||||
(r) => r.count > 0,
|
||||
)
|
||||
return { rows, total }
|
||||
}
|
||||
@@ -64,12 +64,14 @@ const NAV = [
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
||||
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||
@@ -96,6 +98,7 @@ const TITLES = {
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/shard-ops': 'In-Game Ops',
|
||||
'/admin/houses': 'House Registry',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
@@ -104,6 +107,7 @@ const TITLES = {
|
||||
'/admin/characters': 'My Characters',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
'/admin/invites': 'Invites',
|
||||
'/admin/account': 'Account Security',
|
||||
}
|
||||
|
||||
@@ -141,7 +145,7 @@ export default function AdminLayout() {
|
||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||
// own account security.
|
||||
const isModerator = user?.role === 'moderator'
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/account']
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
const visible = (item) => {
|
||||
if (item.roles && !item.roles.includes(user?.role)) return false
|
||||
if (isModerator) return MOD_PATHS.includes(item.to)
|
||||
|
||||
119
client/src/routes/admin/views/HousesAdmin.jsx
Normal file
119
client/src/routes/admin/views/HousesAdmin.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
|
||||
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
|
||||
// live from the admin SSE channel (house.update / house.remove).
|
||||
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
|
||||
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
function DecayBadge({ decay, isIdoc }) {
|
||||
const label = isIdoc ? 'IDOC' : decay
|
||||
if (!label) return null
|
||||
const tone = DECAY_TONE[label] || 'var(--muted)'
|
||||
return (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ownerLabel(h) {
|
||||
return h.ownerName || h.ownerAcct || null
|
||||
}
|
||||
|
||||
function HouseRow({ h }) {
|
||||
const owner = ownerLabel(h)
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.name || 'An unnamed house'}
|
||||
</strong>
|
||||
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{h.price != null && (
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
|
||||
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HousesAdmin() {
|
||||
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
|
||||
// Full registry deltas ride the admin SSE channel (never the public one).
|
||||
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (!ev.serial) continue
|
||||
if (ev.kind === 'house.update') {
|
||||
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
|
||||
} else if (ev.kind === 'house.remove') {
|
||||
map.delete(ev.serial)
|
||||
} else if (ev.kind === 'house.decay') {
|
||||
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
|
||||
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the house registry." />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
|
||||
{board.length.toLocaleString()} houses
|
||||
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
|
||||
</p>
|
||||
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
|
||||
</div>
|
||||
{board.length === 0 ? (
|
||||
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
)}
|
||||
{board.length > 0 && filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
178
client/src/routes/admin/views/InvitesAdmin.jsx
Normal file
178
client/src/routes/admin/views/InvitesAdmin.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin email invites: send an invite at a chosen access level, see recent
|
||||
// invites and their status, revoke pending ones. When email delivery isn't
|
||||
// configured the create response hands back the accept link to copy manually.
|
||||
|
||||
const ROLES = ['player', 'moderator', 'editor', 'admin']
|
||||
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator', player: 'badge-player' }
|
||||
const STATUS_COLOR = { pending: 'var(--accent)', accepted: '#7fd0a4', revoked: 'var(--muted)' }
|
||||
|
||||
function CopyLink({ url }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
} catch {
|
||||
/* clipboard blocked — the link is selectable in the box regardless */
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||
<code
|
||||
onClick={(e) => { const r = document.createRange(); r.selectNodeContents(e.currentTarget); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r) }}
|
||||
style={{ flex: 1, wordBreak: 'break-all', color: 'var(--head)', background: 'var(--panel-flat)', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--line)', cursor: 'text', fontSize: '0.8rem' }}
|
||||
>
|
||||
{url}
|
||||
</code>
|
||||
<button type="button" onClick={copy} className="btn btn-sq" style={{ flex: 'none' }}>
|
||||
{copied ? 'Copied ✓' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateInvite({ onCreated }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState('player')
|
||||
const [sendEmail, setSendEmail] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError }
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setError(''); setResult(null)
|
||||
if (!email.trim()) return setError('Enter an email address.')
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.admin.createInvite(email.trim(), role, sendEmail)
|
||||
setResult(res)
|
||||
setEmail('')
|
||||
await onCreated()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not create the invite.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Email</span>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="person@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Access level</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className="select">
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: '0.85rem', color: 'var(--ink)', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={sendEmail} onChange={(e) => setSendEmail(e.target.checked)} />
|
||||
Email the invitation (otherwise just generate a link to share)
|
||||
</label>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
{result && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
{result.emailed
|
||||
? 'Invitation emailed. You can also share this single-use link:'
|
||||
: `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
|
||||
</p>
|
||||
<CopyLink url={result.acceptUrl} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InvitesAdmin() {
|
||||
const [invites, setInvites] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setInvites(await api.admin.listInvites())
|
||||
} catch {
|
||||
setError('Could not load invites.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function revoke(id) {
|
||||
if (!window.confirm('Revoke this pending invitation?')) return
|
||||
try {
|
||||
await api.admin.revokeInvite(id)
|
||||
await load()
|
||||
} catch {
|
||||
/* surfaced by the row staying; keep it simple */
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<CreateInvite onCreated={load} />
|
||||
|
||||
{!invites ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Email</th>
|
||||
<th className="adm-th">Role</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Expires</th>
|
||||
<th className="adm-th">Created</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invites.length === 0 && (
|
||||
<tr><td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No invites yet.</td></tr>
|
||||
)}
|
||||
{invites.map((iv) => {
|
||||
const status = iv.status === 'pending' && iv.expired ? 'expired' : iv.status
|
||||
return (
|
||||
<tr key={iv.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>{iv.email}</td>
|
||||
<td className="adm-td"><span className={`badge ${ROLE_BADGE[iv.role] || 'badge-editor'}`}>{iv.role}</span></td>
|
||||
<td className="adm-td" style={{ color: STATUS_COLOR[iv.status] || 'var(--muted)', textTransform: 'capitalize' }}>{status}</td>
|
||||
<td className="adm-td dim">{dateTime(iv.expiresAt)}</td>
|
||||
<td className="adm-td dim">{dateTime(iv.createdAt)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
{iv.status === 'pending' && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }} onClick={() => revoke(iv.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,18 @@ const FIELDS = [
|
||||
],
|
||||
fallback: 'disabled',
|
||||
},
|
||||
{
|
||||
key: 'game_account_signup',
|
||||
label: 'Game-account creation',
|
||||
help: 'Whether players can create a GAME account (for the game client) from the site. The game server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
|
||||
options: [
|
||||
{ value: 'disabled', label: 'Disabled — link an existing account only' },
|
||||
{ value: 'website', label: 'Website — the site creates game accounts' },
|
||||
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
|
||||
{ value: 'game', label: 'Game only — created in the game client, not the site' },
|
||||
],
|
||||
fallback: 'disabled',
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsAdmin() {
|
||||
|
||||
@@ -57,6 +57,33 @@ function OnlineNow({ scope }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Shard "standing": city governorships held and guilds led by this user's
|
||||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||
function Standing({ scope }) {
|
||||
const { data } = useAsync(() => scope.standing(), [scope])
|
||||
if (!data) return null
|
||||
const govs = data.governorOf || []
|
||||
const guilds = data.guildsLed || []
|
||||
if (govs.length === 0 && guilds.length === 0) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Standing</SectionTitle>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{govs.map((g) => (
|
||||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||
Governor of {g.city}
|
||||
</span>
|
||||
))}
|
||||
{guilds.map((g) => (
|
||||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||
function Houses({ scope }) {
|
||||
const { data } = useAsync(() => scope.houses(), [scope])
|
||||
@@ -82,10 +109,12 @@ function Houses({ scope }) {
|
||||
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
||||
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||
{h.stage ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.stage}</div> : null}
|
||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
@@ -101,7 +130,8 @@ function ShardSections({ scope }) {
|
||||
<>
|
||||
<CharacterStats scope={scope} />
|
||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||
<GameAccounts scope={scope} readOnly moderation charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<Standing scope={scope} />
|
||||
<OnlineNow scope={scope} />
|
||||
<Houses scope={scope} />
|
||||
<VendorSales fetchSales={scope.sales} />
|
||||
|
||||
132
client/src/routes/player/AcceptInvite.jsx
Normal file
132
client/src/routes/player/AcceptInvite.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx'
|
||||
|
||||
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
|
||||
// lets the invitee set a username + password (their email + role are pre-assigned),
|
||||
// creates the account at that role and logs them in. For a player invite it then
|
||||
// offers the built-in "create game account" step before sending them to the portal.
|
||||
export default function AcceptInvite() {
|
||||
const { token } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { refresh } = useAuth()
|
||||
|
||||
const [invite, setInvite] = useState(null) // { email, role }
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [signupOk, setSignupOk] = useState(false)
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [company, setCompany] = useState('') // honeypot
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.getInvite(token)
|
||||
.then((iv) => active && setInvite(iv))
|
||||
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
|
||||
api.publicSettings()
|
||||
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||||
.catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [token])
|
||||
|
||||
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.acceptInvite(token, username.trim(), password, { company })
|
||||
await refresh() // pull the freshly-issued session into context
|
||||
setAccepted(true)
|
||||
// Staff invites are web-only — no game step; go straight in.
|
||||
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
|
||||
else if (err.status === 404) setError('This invitation is invalid or has expired.')
|
||||
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
|
||||
else setError('Could not accept the invitation right now.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading / invalid ─────────────────────────────────────────────────────
|
||||
if (loadErr) {
|
||||
return (
|
||||
<PlayerShell subtitle="Invitation">
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Go to sign in</Link>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
if (!invite) {
|
||||
return (
|
||||
<PlayerShell subtitle="Invitation">
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Accepted: optional game-account step (player invites) ──────────────────
|
||||
if (accepted) {
|
||||
return (
|
||||
<PlayerShell subtitle="Set up your game account">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
||||
</p>
|
||||
<CreateGameAccountForm
|
||||
submit={api.player.shard.createAccount}
|
||||
onCreated={() => navigate('/player', { replace: true })}
|
||||
/>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
||||
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
|
||||
Skip for now →
|
||||
</button>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Accept form ────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<PlayerShell subtitle="Accept your invitation">
|
||||
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
You’ve been invited as <strong style={{ color: 'var(--head)' }}>{invite.role}</strong>
|
||||
{invite.email ? <> for <strong style={{ color: 'var(--head)' }}>{invite.email}</strong></> : null}. Choose a username and password to finish.
|
||||
</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Username</span>
|
||||
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Password</span>
|
||||
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||
</label>
|
||||
<div style={honeypotStyle} aria-hidden="true">
|
||||
<label>
|
||||
Company
|
||||
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||
{busy ? 'Creating…' : 'Accept & create account'}
|
||||
</button>
|
||||
</form>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,57 @@
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The logged-in player's characters. Shows the link prompt when no game account
|
||||
// is linked, otherwise their characters grouped by account (shared component),
|
||||
// plus their own recent vendor sales.
|
||||
// plus their own home status and recent vendor sales.
|
||||
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
// The caller's own houses (home status). Only their own — never anyone else's.
|
||||
function MyHouses() {
|
||||
const { data } = useAsync(() => api.player.shard.houses(), [])
|
||||
if (!data || data.length === 0) return null
|
||||
return (
|
||||
<section style={{ marginTop: 30 }}>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => {
|
||||
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
|
||||
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
|
||||
return (
|
||||
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{label && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
|
||||
Keep an eye on the decay status — refresh a house in game before it reaches IDOC.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerCharacters() {
|
||||
return (
|
||||
<div>
|
||||
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
||||
<MyHouses />
|
||||
<VendorSales fetchSales={api.player.shard.sales} />
|
||||
</div>
|
||||
)
|
||||
|
||||
186
client/src/routes/public/Governors.jsx
Normal file
186
client/src/routes/public/Governors.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { crestFor } from '../../data/cityCrests.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
|
||||
// kept live by merging city.update deltas by city. Empty on shards without the
|
||||
// City Loyalty system. Each city card links to its term history (look-back).
|
||||
const GOV_KINDS = new Set(['city.update'])
|
||||
|
||||
const PHASE = {
|
||||
none: null,
|
||||
nominate: { label: 'Nominations open', color: '#7f8fd0' },
|
||||
vote: { label: 'Voting', color: '#e6c26a' },
|
||||
pending: { label: 'Result pending', color: '#c9a24b' },
|
||||
}
|
||||
|
||||
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
|
||||
function until(iso) {
|
||||
if (!iso) return ''
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `in ${hrs}h`
|
||||
return `in ${Math.round(hrs / 24)}d`
|
||||
}
|
||||
|
||||
function fmtDate(ms) {
|
||||
if (ms == null) return ''
|
||||
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function CityCrest({ city, size = 44 }) {
|
||||
const c = crestFor(city)
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
flex: 'none', width: size, height: size, borderRadius: '50%',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
|
||||
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
|
||||
}}
|
||||
>
|
||||
{c.sigil}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible term history for one city, fetched on demand from the ledger.
|
||||
function TermHistory({ city }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { loading, error, data } = useAsync(
|
||||
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
|
||||
[open, city],
|
||||
)
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
|
||||
>
|
||||
{open ? 'Hide past governors' : 'Past governors →'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
|
||||
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
|
||||
{data && data.length === 0 && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{data.map((t, i) => (
|
||||
<li key={i} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.governor?.name || 'Vacant'}
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
|
||||
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CityCard({ c }) {
|
||||
const phase = PHASE[c.electionPhase] || null
|
||||
const gov = c.governor
|
||||
return (
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<CityCrest city={c.city} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||
{crestFor(c.city).label || c.city}
|
||||
</strong>
|
||||
{phase && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{phase.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
|
||||
{gov ? (
|
||||
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
|
||||
) : (
|
||||
'Seat vacant'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||
{c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'}
|
||||
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TermHistory city={c.city} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Governors() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.governors())
|
||||
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const c of data || []) if (c && c.city) map.set(c.city, c)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
|
||||
}, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the governor board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
City Loyalty governance is not enabled on this shard.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{board.map((c) => <CityCard key={c.city} c={c} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
169
client/src/routes/public/Guilds.jsx
Normal file
169
client/src/routes/public/Guilds.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
||||
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
||||
// joined" strip on top of the board.
|
||||
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
||||
|
||||
function Leader({ leader }) {
|
||||
if (!leader || !leader.name) return <span className="dim">—</span>
|
||||
return <span>{leader.name}</span>
|
||||
}
|
||||
|
||||
function GuildRow({ g }) {
|
||||
return (
|
||||
<div
|
||||
className="panel"
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||
{g.abbr && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
flex: 'none',
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid rgba(201,162,75,0.4)',
|
||||
borderRadius: 5,
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{g.abbr}
|
||||
</span>
|
||||
)}
|
||||
<strong
|
||||
className="display"
|
||||
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{g.name || 'A guild'}
|
||||
</strong>
|
||||
</div>
|
||||
{g.alliance && (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{g.alliance}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<div>
|
||||
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
||||
<span className="dim"> / {g.members ?? 0}</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
<Leader leader={g.leader} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Guilds() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
||||
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
||||
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
// Recent joins strip (newest first, deduped, capped).
|
||||
const joins = useMemo(
|
||||
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
||||
[events],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((g) =>
|
||||
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
||||
)
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the guild board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{joins.length > 0 && (
|
||||
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Recently joined
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{joins.map((j) => (
|
||||
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
||||
<span className="dim"> joined </span>
|
||||
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
||||
{board.length} guilds · {totalMembers.toLocaleString()} members
|
||||
</p>
|
||||
<input
|
||||
className="input sans"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search guilds…"
|
||||
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
91
client/src/routes/public/Houses.jsx
Normal file
91
client/src/routes/public/Houses.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
|
||||
// price, decay detail and the full registry are staff-only (admin Houses view).
|
||||
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
|
||||
// house entering IDOC appears, one leaving it drops off.
|
||||
const HOUSE_KINDS = new Set(['house.decay'])
|
||||
|
||||
function HouseRow({ h }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.region || 'The wilderness'}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
|
||||
IDOC
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Houses() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||
|
||||
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
|
||||
// adds/updates the row; anything else (refreshed, collapsed) drops it.
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind !== 'house.decay' || !ev.serial) continue
|
||||
if (String(ev.to).toUpperCase() === 'IDOC') {
|
||||
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
|
||||
} else {
|
||||
map.delete(ev.serial)
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
|
||||
}, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the houses board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
|
||||
{board.length} in danger
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||
|
||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||
function Sparkline({ series }) {
|
||||
@@ -108,12 +109,16 @@ export default function Shard() {
|
||||
</section>
|
||||
|
||||
{/* Stat tiles */}
|
||||
<section className="grid-3" style={{ gap: 14, marginBottom: 24 }}>
|
||||
<Stat value={status?.onlineCount ?? '—'} label="Players online" />
|
||||
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||
</section>
|
||||
|
||||
{/* Live players-online breakdown (total + region buckets) */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<PlayersOnline />
|
||||
</div>
|
||||
|
||||
{/* Staff online — linked staff accounts only, with location */}
|
||||
<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 }}>
|
||||
|
||||
@@ -431,6 +431,111 @@ CREATE TABLE IF NOT EXISTS shard_pages (
|
||||
INDEX idx_shard_pages_handled (handled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
|
||||
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
|
||||
-- actor object flattened into leader_* columns; the full event is kept in
|
||||
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
|
||||
-- projection into our store so the public Guilds page survives a shard outage.
|
||||
CREATE TABLE IF NOT EXISTS shard_guilds (
|
||||
id INT NOT NULL PRIMARY KEY, -- in-game guild id
|
||||
name VARCHAR(120) NULL,
|
||||
abbr VARCHAR(24) NULL,
|
||||
members INT NULL,
|
||||
online INT NULL,
|
||||
alliance VARCHAR(120) NULL,
|
||||
leader_serial VARCHAR(20) NULL,
|
||||
leader_name VARCHAR(120) NULL,
|
||||
leader_acct VARCHAR(120) NULL,
|
||||
leader_web_id INT NULL,
|
||||
payload JSON NOT NULL, -- the full guild.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_guilds_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
|
||||
-- city.update (full-state, emitted only on change; there is no remove event since
|
||||
-- the set of cities is fixed). governor / governorElect are actor objects
|
||||
-- flattened into columns; the full event is kept in `payload`. Empty on shards
|
||||
-- that do not run the City Loyalty system.
|
||||
CREATE TABLE IF NOT EXISTS shard_governors (
|
||||
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
|
||||
governor_serial VARCHAR(20) NULL,
|
||||
governor_name VARCHAR(120) NULL,
|
||||
governor_acct VARCHAR(120) NULL,
|
||||
governor_web_id INT NULL,
|
||||
elect_serial VARCHAR(20) NULL,
|
||||
elect_name VARCHAR(120) NULL,
|
||||
elect_acct VARCHAR(120) NULL,
|
||||
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
|
||||
candidates INT NULL,
|
||||
auto_pick_at DATETIME NULL,
|
||||
payload JSON NOT NULL, -- the full city.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Governor term history — the "who governed when" ledger behind the Governors
|
||||
-- board. Captured from day one (history cannot be backfilled) on every observed
|
||||
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
|
||||
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
|
||||
-- COUNT and election phase, not per-candidate tallies, so we record who governed
|
||||
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
|
||||
-- were all the governors of Britain?") reads this table.
|
||||
CREATE TABLE IF NOT EXISTS shard_governor_terms (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
city VARCHAR(40) NOT NULL,
|
||||
governor_serial VARCHAR(20) NULL,
|
||||
governor_name VARCHAR(120) NULL,
|
||||
governor_acct VARCHAR(120) NULL,
|
||||
governor_web_id INT NULL,
|
||||
started_at BIGINT NOT NULL, -- term start, epoch ms
|
||||
ended_at BIGINT NULL, -- term end epoch ms (NULL = current)
|
||||
votes INT NULL, -- not in the feed (reserved)
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_gov_terms_city (city, started_at),
|
||||
INDEX idx_shard_gov_terms_open (city, ended_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
|
||||
-- latest presence.online aggregate: total count plus per-facet and per-region
|
||||
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
|
||||
-- is the rolled-up headcount the public "Players Online" widget renders. The
|
||||
-- time series, if ever needed, is available from GET /history?kind=presence.online.
|
||||
CREATE TABLE IF NOT EXISTS shard_presence (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
count INT NOT NULL DEFAULT 0,
|
||||
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
|
||||
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
|
||||
t BIGINT NULL, -- snapshot time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
||||
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
||||
-- which creates their website user at that role (and optionally a linked game
|
||||
-- account). Only the sha256 hash of the opaque token is stored — a DB read never
|
||||
-- yields a usable invite link, same as mobile_refresh_tokens. status tracks the
|
||||
-- lifecycle; accepted_user_id back-points at the created user. Single-use +
|
||||
-- expiring (enforced in the model on top of expires_at).
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
||||
email VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'player',
|
||||
status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending',
|
||||
invited_by INT NULL, -- staff user who sent it
|
||||
accepted_user_id INT NULL, -- the user created on accept
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
accepted_at DATETIME NULL,
|
||||
CONSTRAINT fk_user_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_user_invites_user FOREIGN KEY (accepted_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_user_invites_email (email),
|
||||
INDEX idx_user_invites_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
||||
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
||||
-- writes them. They live in the same physical database as everything else
|
||||
@@ -755,6 +860,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
|
||||
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
||||
-- so the system behaves exactly as today until an admin opts in.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
||||
-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user
|
||||
-- may provision a linked game account from the site. Default off; the shard's own
|
||||
-- signup mode still has the final say (a 'game'-mode shard refuses regardless).
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
|
||||
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||
@@ -772,3 +881,20 @@ ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title,
|
||||
-- already keeps the two tables consistent.
|
||||
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
|
||||
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;
|
||||
|
||||
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
|
||||
-- fields than the house.decay transition feed shard_houses was built for. Rather
|
||||
-- than a second table for one entity, extend shard_houses: house.update writes the
|
||||
-- registry columns below (owner display name, co-owner/friend counts, placement
|
||||
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
|
||||
-- upsert only touches its own columns, so the two feeds never clobber each other.
|
||||
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
||||
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
|
||||
-- so the public Houses browser can list registered houses without pulling in rows
|
||||
-- we only ever saw an IDOC transition for.
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
47
server/src/model/invites/invites.db.js
Normal file
47
server/src/model/invites/invites.db.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, token_hash, email, role, status, invited_by, accepted_user_id, expires_at, created_at, accepted_at'
|
||||
|
||||
async function insert({ tokenHash, email, role, invitedBy, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO user_invites (token_hash, email, role, invited_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[tokenHash, email, role, invitedBy ?? null, expiresAt],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function findByTokenHash(tokenHash) {
|
||||
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE token_hash = ? LIMIT 1`, [tokenHash])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listRecent = (limit) =>
|
||||
query(`SELECT ${COLS} FROM user_invites ORDER BY created_at DESC LIMIT ?`, [limit])
|
||||
|
||||
// Mark accepted only if still pending (atomic guard against a double-accept race).
|
||||
// Returns rows changed (1 = we won, 0 = already used/revoked).
|
||||
async function markAccepted(id, userId) {
|
||||
const res = await query(
|
||||
`UPDATE user_invites SET status = 'accepted', accepted_user_id = ?, accepted_at = NOW()
|
||||
WHERE id = ? AND status = 'pending'`,
|
||||
[userId, id],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
async function revoke(id) {
|
||||
const res = await query(
|
||||
`UPDATE user_invites SET status = 'revoked' WHERE id = ? AND status = 'pending'`,
|
||||
[id],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
module.exports = { insert, getById, findByTokenHash, listRecent, markAccepted, revoke }
|
||||
73
server/src/model/invites/invites.model.js
Normal file
73
server/src/model/invites/invites.model.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// Admin email invites. A staff member invites someone by email at a pre-chosen
|
||||
// access level; the invitee accepts via a tokened link that creates their website
|
||||
// user at that role. The opaque token lives only in the emailed link — the DB
|
||||
// stores just its sha256 hash (like mobile refresh tokens), so a DB read never
|
||||
// yields a usable invite. Invites are single-use and expiring.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const db = require('./invites.db')
|
||||
|
||||
const DEFAULT_TTL_DAYS = 7
|
||||
|
||||
function hashToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Public-safe shape (never exposes the token hash).
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
status: row.status,
|
||||
invitedBy: row.invited_by,
|
||||
acceptedUserId: row.accepted_user_id,
|
||||
expiresAt: row.expires_at,
|
||||
createdAt: row.created_at,
|
||||
acceptedAt: row.accepted_at,
|
||||
expired: new Date(row.expires_at).getTime() < Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create an invite. Returns { invite, token } — the plaintext token is returned
|
||||
// ONCE (for the email link) and never stored or recoverable afterwards.
|
||||
async function create({ email, role, invitedBy, ttlDays = DEFAULT_TTL_DAYS }) {
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
|
||||
const id = await db.insert({ tokenHash: hashToken(token), email, role, invitedBy, expiresAt })
|
||||
return { invite: toSafe(await db.getById(id)), token }
|
||||
}
|
||||
|
||||
// Resolve a pending, unexpired invite from its plaintext token, else null. Returns
|
||||
// the RAW row (incl. id) for the accept flow; callers sanitize with publicView.
|
||||
async function findValidByToken(token) {
|
||||
if (!token) return null
|
||||
const row = await db.findByTokenHash(hashToken(token))
|
||||
if (!row || row.status !== 'pending') return null
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return null
|
||||
return row
|
||||
}
|
||||
|
||||
// Atomically consume a pending invite (double-accept-safe). Returns true if this
|
||||
// call won the race and bound the invite to userId.
|
||||
async function accept(id, userId) {
|
||||
return (await db.markAccepted(id, userId)) === 1
|
||||
}
|
||||
|
||||
const revoke = (id) => db.revoke(id)
|
||||
|
||||
async function list(limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
const rows = await db.listRecent(n)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
// A minimal, safe view of an invite for the (unauthenticated) accept page —
|
||||
// only what the form needs, never the token or internal ids.
|
||||
function publicView(row) {
|
||||
if (!row) return null
|
||||
return { email: row.email, role: row.role }
|
||||
}
|
||||
|
||||
module.exports = { create, findValidByToken, accept, revoke, list, publicView, toSafe, hashToken }
|
||||
@@ -32,6 +32,27 @@ function registrationFlags(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
|
||||
// disabled — the site never offers game-account creation (link-only).
|
||||
// website — the site is the authority (offer creation; pair with the shard in
|
||||
// website mode + AutoCreateAccounts=false).
|
||||
// hybrid — either side may create (the site offers creation).
|
||||
// game — the game server is the authority; the site does NOT offer creation.
|
||||
// The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode
|
||||
// (Bridge.cfg) still has the final say and may 403 a call regardless.
|
||||
const GAME_SIGNUP_KEY = 'game_account_signup'
|
||||
const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game']
|
||||
const GAME_SIGNUP_OFFER = ['website', 'hybrid']
|
||||
|
||||
async function getGameSignupMode() {
|
||||
const v = await settingsDb.get(GAME_SIGNUP_KEY)
|
||||
return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled'
|
||||
}
|
||||
|
||||
async function isGameAccountSignupEnabled() {
|
||||
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
return settingsDb.get(key)
|
||||
}
|
||||
@@ -64,6 +85,10 @@ async function getPublic() {
|
||||
// page show/hide the password form and SSO buttons.
|
||||
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
|
||||
out.registration = registrationFlags(mode)
|
||||
// Whether the site offers game-account creation (the shard's own mode still has
|
||||
// the final say when the call is made). Lets the portal show/hide the form.
|
||||
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
|
||||
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -78,4 +103,8 @@ module.exports = {
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
registrationFlags,
|
||||
GAME_SIGNUP_KEY,
|
||||
GAME_SIGNUP_MODES,
|
||||
getGameSignupMode,
|
||||
isGameAccountSignupEnabled,
|
||||
}
|
||||
|
||||
@@ -33,4 +33,10 @@ async function isOwnedBy(account, userId) {
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove }
|
||||
// Drop the mirror for an account regardless of which user held it — used to
|
||||
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
|
||||
@@ -31,4 +31,7 @@ async function getByAccount(account) {
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink }
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
|
||||
@@ -107,12 +107,23 @@ const listHousesByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${HOUSE_COLS} FROM shard_houses
|
||||
`SELECT ${HOUSE_REG_COLS} FROM shard_houses
|
||||
WHERE owner_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY is_idoc DESC, updated_at DESC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
|
||||
// The registry columns extend HOUSE_COLS; a registry row is one we've seen via
|
||||
// house.update (in_registry = 1), as opposed to a decay-only transition row.
|
||||
const HOUSE_REG_COLS = `${HOUSE_COLS}, owner_name, co_owners, friends, price, decay, in_registry`
|
||||
|
||||
const removeHouse = (serial) => query('DELETE FROM shard_houses WHERE serial = ?', [serial])
|
||||
|
||||
// The full registered-house browser: every row we've seen via house.update.
|
||||
const listRegistryHouses = () =>
|
||||
query(`SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE in_registry = 1 ORDER BY name ASC`)
|
||||
|
||||
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||
const CHAMP_COLS =
|
||||
'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
|
||||
@@ -157,6 +168,126 @@ const clearPages = () => query('DELETE FROM shard_pages')
|
||||
// Oldest-open first so the queue reads like a work list.
|
||||
const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`)
|
||||
|
||||
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
|
||||
const GUILD_COLS =
|
||||
'id, name, abbr, members, online, alliance, leader_serial, leader_name, leader_acct, leader_web_id, payload, t, updated_at'
|
||||
|
||||
async function upsertGuild(id, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['id', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_guilds (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[id, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
|
||||
const clearGuilds = () => query('DELETE FROM shard_guilds')
|
||||
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
|
||||
|
||||
// The guild an actor LEADS — matched on the current board (leader_serial or the
|
||||
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
|
||||
// is not modelled (the board carries only counts + leader), so we don't guess it.
|
||||
const findGuildLedByActor = (serial, acct) =>
|
||||
query(
|
||||
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||
WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?)
|
||||
LIMIT 1`,
|
||||
[serial ?? null, acct ?? null],
|
||||
)
|
||||
|
||||
// Guilds led by any of the given game accounts (admin: a user's linked accounts).
|
||||
const listGuildsLedByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||
WHERE leader_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY name ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
|
||||
const GOV_COLS =
|
||||
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
|
||||
|
||||
async function upsertGovernor(city, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['city', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_governors (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[city, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const listGovernors = () => query(`SELECT ${GOV_COLS} FROM shard_governors ORDER BY city ASC`)
|
||||
|
||||
// Cities whose current governor is one of the given game accounts (cross-link:
|
||||
// does this user hold a governorship?). Empty list short-circuits.
|
||||
const listGovernorshipsByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${GOV_COLS} FROM shard_governors
|
||||
WHERE governor_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY city ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// The single open term (ended_at IS NULL) for a city, if any.
|
||||
async function currentGovernorTerm(city) {
|
||||
const rows = await query(
|
||||
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1',
|
||||
[city],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const closeGovernorTerm = (id, endedAt) =>
|
||||
query('UPDATE shard_governor_terms SET ended_at = ? WHERE id = ?', [endedAt, id])
|
||||
|
||||
const openGovernorTerm = ({ city, serial, name, acct, webId, startedAt }) =>
|
||||
query(
|
||||
`INSERT INTO shard_governor_terms
|
||||
(city, governor_serial, governor_name, governor_acct, governor_web_id, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[city, serial ?? null, name ?? null, acct ?? null, webId ?? null, startedAt],
|
||||
)
|
||||
|
||||
const listGovernorTerms = (city, limit) =>
|
||||
query(
|
||||
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? ORDER BY started_at DESC LIMIT ?',
|
||||
[city, limit],
|
||||
)
|
||||
|
||||
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
||||
async function setPresence({ count, byFacet, byRegion, t }) {
|
||||
await query(
|
||||
`INSERT INTO shard_presence (id, count, by_facet, by_region, t) VALUES (1, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE count = VALUES(count), by_facet = VALUES(by_facet),
|
||||
by_region = VALUES(by_region), t = VALUES(t)`,
|
||||
[
|
||||
Number.isFinite(count) ? count : 0,
|
||||
byFacet ? JSON.stringify(byFacet) : null,
|
||||
byRegion ? JSON.stringify(byRegion) : null,
|
||||
Number.isFinite(t) ? t : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async function latestPresence() {
|
||||
const rows = await query('SELECT count, by_facet, by_region, t FROM shard_presence WHERE id = 1')
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
@@ -171,6 +302,23 @@ module.exports = {
|
||||
upsertHouse,
|
||||
listIdocHouses,
|
||||
listHousesByAccounts,
|
||||
removeHouse,
|
||||
listRegistryHouses,
|
||||
upsertGuild,
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
findGuildLedByActor,
|
||||
listGuildsLedByAccounts,
|
||||
upsertGovernor,
|
||||
listGovernors,
|
||||
listGovernorshipsByAccounts,
|
||||
currentGovernorTerm,
|
||||
closeGovernorTerm,
|
||||
openGovernorTerm,
|
||||
listGovernorTerms,
|
||||
setPresence,
|
||||
latestPresence,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
|
||||
@@ -148,6 +148,13 @@ function shapeHouse(r) {
|
||||
name: r.name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerAcct: r.owner_acct,
|
||||
// Registry fields (Protocol 2.0 house.update); undefined on decay-only rows.
|
||||
ownerName: r.owner_name,
|
||||
coOwners: r.co_owners,
|
||||
friends: r.friends,
|
||||
price: r.price == null ? null : Number(r.price),
|
||||
decay: r.decay,
|
||||
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
|
||||
builtOn: r.built_on,
|
||||
lastRefreshed: r.last_refreshed,
|
||||
isIdoc: Boolean(r.is_idoc),
|
||||
@@ -166,6 +173,42 @@ async function listHousesForAccounts(accounts) {
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
|
||||
// Richer per-house snapshot than the decay-transition feed. Writes only the
|
||||
// registry columns (+ shared location/owner fields); is_idoc/stage stay owned by
|
||||
// the house.decay path, so the two feeds never clobber each other. owner is an
|
||||
// actor object (or null for an abandoned house).
|
||||
async function upsertHouseRegistry(data) {
|
||||
if (!data || !data.serial) return
|
||||
const owner = data.owner || null
|
||||
const fields = {
|
||||
name: data.name ?? null,
|
||||
owner_serial: owner ? owner.serial ?? null : null,
|
||||
owner_acct: owner ? owner.acct ?? null : null,
|
||||
owner_name: owner ? owner.name ?? null : null,
|
||||
co_owners: data.coOwners ?? null,
|
||||
friends: data.friends ?? null,
|
||||
price: data.price ?? null,
|
||||
decay: data.decay ?? null,
|
||||
region: data.region ?? null,
|
||||
map: data.map ?? null,
|
||||
x: data.x ?? null,
|
||||
y: data.y ?? null,
|
||||
z: data.z ?? null,
|
||||
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||
in_registry: 1,
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
const removeHouse = (serial) => (serial ? db.removeHouse(serial) : Promise.resolve())
|
||||
|
||||
async function listHouses() {
|
||||
const rows = await db.listRegistryHouses()
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// Online players on the given game accounts (admin: a user's linked accounts).
|
||||
async function listOnlineForAccounts(accounts) {
|
||||
const rows = await db.listOnlineByAccounts(accounts)
|
||||
@@ -287,6 +330,195 @@ async function replacePages(pages) {
|
||||
for (const ev of pages || []) await upsertPage(ev)
|
||||
}
|
||||
|
||||
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
|
||||
// Upsert a guild's roster snapshot (guild.update). The leader is an actor object
|
||||
// flattened into leader_* columns; the full event lives in `payload`.
|
||||
async function upsertGuild(ev) {
|
||||
if (!ev || ev.id == null) return
|
||||
const leader = ev.leader || {}
|
||||
await db.upsertGuild(ev.id, {
|
||||
name: ev.name ?? null,
|
||||
abbr: ev.abbr ?? null,
|
||||
members: ev.members ?? null,
|
||||
online: ev.online ?? null,
|
||||
alliance: ev.alliance ?? null,
|
||||
leader_serial: leader.serial ?? null,
|
||||
leader_name: leader.name ?? null,
|
||||
leader_acct: leader.acct ?? null,
|
||||
leader_web_id: leader.webId ?? null,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
|
||||
const clearGuilds = () => db.clearGuilds()
|
||||
|
||||
function shapeGuild(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'guild.update',
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
abbr: r.abbr,
|
||||
members: r.members,
|
||||
online: r.online,
|
||||
alliance: r.alliance,
|
||||
leader: r.leader_serial
|
||||
? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id }
|
||||
: null,
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listGuilds() {
|
||||
const rows = await db.listGuilds()
|
||||
return rows.map(shapeGuild)
|
||||
}
|
||||
|
||||
// Replace the board with a fresh snapshot (sidecar GET /guilds on connect).
|
||||
async function replaceGuilds(guilds) {
|
||||
await db.clearGuilds()
|
||||
for (const ev of guilds || []) await upsertGuild(ev)
|
||||
}
|
||||
|
||||
// The guild an actor leads (cross-link on the character sheet). Leadership only —
|
||||
// see the db note; membership for rank-and-file isn't in the feed, so we return
|
||||
// null rather than show a possibly-stale guess.
|
||||
async function findGuildForActor({ serial, acct }) {
|
||||
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
|
||||
const g = rows[0]
|
||||
if (!g) return null
|
||||
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
|
||||
}
|
||||
|
||||
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
|
||||
async function listGuildsLedForAccounts(accounts) {
|
||||
const rows = await db.listGuildsLedByAccounts(accounts)
|
||||
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
|
||||
}
|
||||
|
||||
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
|
||||
// Upsert a city's governance snapshot (city.update) AND capture term history.
|
||||
// Term capture runs first (it reads the CURRENT open term to decide whether the
|
||||
// governor changed) and is idempotent: a repeat/backfill of the same governor is a
|
||||
// no-op, so it's safe to call on the live feed and on reconnect snapshots alike.
|
||||
async function upsertGovernor(ev) {
|
||||
if (!ev || !ev.city) return
|
||||
await recordGovernorTransition(ev)
|
||||
const gov = ev.governor || null
|
||||
const elect = ev.governorElect || null
|
||||
await db.upsertGovernor(ev.city, {
|
||||
governor_serial: gov ? gov.serial ?? null : null,
|
||||
governor_name: gov ? gov.name ?? null : null,
|
||||
governor_acct: gov ? gov.acct ?? null : null,
|
||||
governor_web_id: gov ? gov.webId ?? null : null,
|
||||
elect_serial: elect ? elect.serial ?? null : null,
|
||||
elect_name: elect ? elect.name ?? null : null,
|
||||
elect_acct: elect ? elect.acct ?? null : null,
|
||||
election_phase: ev.electionPhase ?? null,
|
||||
candidates: ev.candidates ?? null,
|
||||
auto_pick_at: ev.autoPickAt ? new Date(ev.autoPickAt) : null,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
// Close the open term and open a new one when the governor CHANGES. Idempotent:
|
||||
// same governor as the open term ⇒ nothing happens (so backfill/duplicate
|
||||
// city.update events never spawn spurious terms).
|
||||
async function recordGovernorTransition(ev) {
|
||||
const gov = ev.governor || null
|
||||
const newSerial = gov ? gov.serial ?? null : null
|
||||
const t = Number.isFinite(ev.t) ? ev.t : Date.now()
|
||||
const open = await db.currentGovernorTerm(ev.city)
|
||||
const openSerial = open ? open.governor_serial : null
|
||||
if (open && openSerial === newSerial) return // unchanged — nothing to record
|
||||
if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated
|
||||
if (newSerial) {
|
||||
await db.openGovernorTerm({
|
||||
city: ev.city,
|
||||
serial: newSerial,
|
||||
name: gov.name ?? null,
|
||||
acct: gov.acct ?? null,
|
||||
webId: gov.webId ?? null,
|
||||
startedAt: t,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function shapeGovernor(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'city.update',
|
||||
city: r.city,
|
||||
governor: r.governor_serial
|
||||
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
||||
: null,
|
||||
governorElect: r.elect_serial
|
||||
? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct }
|
||||
: null,
|
||||
electionPhase: r.election_phase,
|
||||
candidates: r.candidates,
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listGovernors() {
|
||||
const rows = await db.listGovernors()
|
||||
return rows.map(shapeGovernor)
|
||||
}
|
||||
|
||||
// Cities the given game accounts currently govern (cross-link badge).
|
||||
async function listGovernorshipsForAccounts(accounts) {
|
||||
const rows = await db.listGovernorshipsByAccounts(accounts)
|
||||
return rows.map(shapeGovernor)
|
||||
}
|
||||
|
||||
// Term history for a city (look-back), newest first.
|
||||
async function listGovernorHistory(city, limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
const rows = await db.listGovernorTerms(city, n)
|
||||
return rows.map((r) => ({
|
||||
city: r.city,
|
||||
governor: r.governor_serial
|
||||
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
||||
: null,
|
||||
startedAt: r.started_at == null ? null : Number(r.started_at),
|
||||
endedAt: r.ended_at == null ? null : Number(r.ended_at),
|
||||
votes: r.votes,
|
||||
}))
|
||||
}
|
||||
|
||||
// Upsert governors without clearing (cities are fixed, no remove event); term
|
||||
// capture inside upsertGovernor stays idempotent across reconnect snapshots.
|
||||
async function replaceGovernors(cities) {
|
||||
for (const ev of cities || []) await upsertGovernor(ev)
|
||||
}
|
||||
|
||||
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
||||
async function setPresence(ev) {
|
||||
if (!ev) return
|
||||
await db.setPresence({
|
||||
count: ev.count,
|
||||
byFacet: ev.byFacet || null,
|
||||
byRegion: ev.byRegion || null,
|
||||
t: ev.t,
|
||||
})
|
||||
}
|
||||
|
||||
async function latestPresence() {
|
||||
const r = await db.latestPresence()
|
||||
if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null }
|
||||
const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {})
|
||||
return {
|
||||
count: Number(r.count) || 0,
|
||||
byFacet: parse(r.by_facet),
|
||||
byRegion: parse(r.by_region),
|
||||
t: r.t == null ? null : Number(r.t),
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(s) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
@@ -309,6 +541,9 @@ module.exports = {
|
||||
upsertHouse,
|
||||
listIdoc,
|
||||
listHousesForAccounts,
|
||||
upsertHouseRegistry,
|
||||
removeHouse,
|
||||
listHouses,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
@@ -319,4 +554,18 @@ module.exports = {
|
||||
clearPages,
|
||||
listPages,
|
||||
replacePages,
|
||||
upsertGuild,
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
replaceGuilds,
|
||||
findGuildForActor,
|
||||
listGuildsLedForAccounts,
|
||||
upsertGovernor,
|
||||
listGovernors,
|
||||
listGovernorshipsForAccounts,
|
||||
listGovernorHistory,
|
||||
replaceGovernors,
|
||||
setPresence,
|
||||
latestPresence,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const settings = require('../../../model/settings/settings.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const newsGump = require('../../../utils/newsGump')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
@@ -22,6 +23,11 @@ const log = require('../../../utils/logger')('admin')
|
||||
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
|
||||
async function announceIfNewlyPublished(post, transition) {
|
||||
await announceJobs.enqueueIfNeeded(post, transition)
|
||||
// Keep the in-game Town Cryer News gump in sync with the same transition: push
|
||||
// the article when it becomes published news, refresh it silently on an edit,
|
||||
// and pull it when it leaves published-news. Best-effort (never throws), so a
|
||||
// sidecar hiccup never breaks saving a post — same guarantee as the enqueue.
|
||||
await newsGump.syncPost(post, transition)
|
||||
}
|
||||
|
||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||
@@ -173,8 +179,11 @@ async function publishPost(req, res) {
|
||||
async function deletePost(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const current = await posts.getById(id)
|
||||
await posts.remove(id)
|
||||
await activity.log({ req, action: 'post.delete', detail: { id } })
|
||||
// If it was live in the News gump, pull it (best-effort).
|
||||
if (newsGump.inGump(current)) await newsGump.removePost(id)
|
||||
return res.json({ id })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
@@ -485,6 +494,12 @@ async function updateSettings(req, res) {
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid player_registration value' })
|
||||
}
|
||||
if (
|
||||
settings.GAME_SIGNUP_KEY in updates &&
|
||||
!settings.GAME_SIGNUP_MODES.includes(updates[settings.GAME_SIGNUP_KEY])
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid game_account_signup value' })
|
||||
}
|
||||
// The homepage teaser is rich text (HTML) from the shared editor — sanitize it
|
||||
// against the same allowlist as post/wiki bodies so a stored value is safe (the
|
||||
// client re-sanitizes on render as defense in depth).
|
||||
|
||||
@@ -14,6 +14,7 @@ const emailConfig = require('./emailConfig.controller')
|
||||
const uoLink = require('./uoLink.controller')
|
||||
const shardOps = require('./shardOps.controller')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const invites = require('./invites.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const moderation = require('./moderation.controller')
|
||||
const pagesCtrl = require('./pages.controller')
|
||||
@@ -181,6 +182,21 @@ adminRouter.get(
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
selfShard.getSales,
|
||||
)
|
||||
adminRouter.post(
|
||||
'/shard/account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
|
||||
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
selfShard.createGameAccount,
|
||||
)
|
||||
|
||||
// ── In-game staff operations (uo-link write plane + support queue) ─────
|
||||
// Privileged live-shard actions and the help-page queue, open to moderators as
|
||||
@@ -287,6 +303,16 @@ adminRouter.get(
|
||||
modAccess,
|
||||
shardOps.listAudit,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
|
||||
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listHouses,
|
||||
)
|
||||
|
||||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||
const UPLOAD_DIR =
|
||||
@@ -1255,6 +1281,73 @@ adminRouter.get(
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/users/:id/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
)
|
||||
|
||||
// ── Email invites (admin only) ─────────────────────────────────────────────
|
||||
adminRouter.post(
|
||||
'/invites',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
adminOnly,
|
||||
body('email').isEmail().isLength({ max: 255 }),
|
||||
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
|
||||
validate,
|
||||
invites.create,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/invites',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'List recent invites (no tokens)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
adminOnly,
|
||||
invites.list,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/invites/:id',
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Revoke a pending invite'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' }
|
||||
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
invites.revoke,
|
||||
)
|
||||
|
||||
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||||
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
||||
|
||||
90
server/src/router/v1/admin/invites.controller.js
Normal file
90
server/src/router/v1/admin/invites.controller.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// ── Admin: email invites ───────────────────────────────────────────────────
|
||||
//
|
||||
// Admin-only. A staff member invites someone by email at a pre-chosen access
|
||||
// level; the invitee accepts via a tokened link (auth/invite.controller) which
|
||||
// creates their website user at that role. The plaintext token exists only in the
|
||||
// emailed link and in the create response (so the admin can copy the link if email
|
||||
// isn't configured); the DB stores only its hash.
|
||||
|
||||
const invites = require('../../../model/invites/invites.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const mailer = require('../../../utils/mailer')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-invites')
|
||||
|
||||
const ROLES = ['admin', 'editor', 'moderator', 'player']
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function acceptUrl(token) {
|
||||
return `${baseUrl()}/invite/${token}`
|
||||
}
|
||||
|
||||
// POST /admin/invites — create an invite. Optionally email it (sendEmail, default
|
||||
// true); the copyable accept link is ALWAYS returned so the admin can hand it over
|
||||
// directly. The token is single-use + expiring and the caller is the authenticated
|
||||
// admin who made it, so echoing the link back to them is safe.
|
||||
async function create(req, res) {
|
||||
const email = String(req.body.email || '').trim()
|
||||
const role = req.body.role
|
||||
const sendEmail = req.body.sendEmail !== false // default true
|
||||
if (!email || !ROLES.includes(role)) {
|
||||
return res.status(400).json({ message: 'A valid email and role are required.' })
|
||||
}
|
||||
try {
|
||||
const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
|
||||
const url = acceptUrl(token)
|
||||
|
||||
// Send the email only if asked. A send failure doesn't delete the invite — the
|
||||
// link is still returned so the admin can share it manually.
|
||||
let emailed = false
|
||||
let emailError = null
|
||||
if (sendEmail) {
|
||||
try {
|
||||
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
|
||||
emailed = Boolean(result.sent)
|
||||
if (!result.sent && result.reason === 'NOT_CONFIGURED') emailError = 'email is not configured'
|
||||
} catch (err) {
|
||||
emailError = err.message
|
||||
log.warn('invite email failed (invite still created)', { id: invite.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
await activity.log({ req, userId: req.user.id, action: 'invite.create', detail: { email, role, emailed } })
|
||||
log.info('invite created', { id: invite.id, email, role, emailed, by: req.user.username })
|
||||
|
||||
// acceptUrl is always returned (copyable link); emailed says whether it also went out.
|
||||
return res.status(201).json({ invite, emailed, acceptUrl: url, emailError })
|
||||
} catch (err) {
|
||||
log.error('create invite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/invites — recent invites (no tokens).
|
||||
async function list(req, res) {
|
||||
try {
|
||||
return res.json(await invites.list(req.query.limit))
|
||||
} catch (err) {
|
||||
log.error('list invites', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/invites/:id — revoke a pending invite.
|
||||
async function revoke(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const changed = await invites.revoke(id)
|
||||
if (!changed) return res.status(404).json({ message: 'No pending invite to revoke.' })
|
||||
await activity.log({ req, userId: req.user.id, action: 'invite.revoke', detail: { id } })
|
||||
return res.json({ id, revoked: true })
|
||||
} catch (err) {
|
||||
log.error('revoke invite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { create, list, revoke }
|
||||
@@ -155,4 +155,17 @@ async function listAudit(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit }
|
||||
// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners,
|
||||
// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC
|
||||
// houses with location; this is the complete board, kept live for staff on the
|
||||
// admin SSE channel (house.update / house.remove).
|
||||
async function listHouses(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listHouses())
|
||||
} catch (err) {
|
||||
log.error('shardOps.listHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses }
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
const users = require('../../../model/users/users.model')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-user-shard')
|
||||
@@ -83,4 +85,58 @@ async function getOnline(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline }
|
||||
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
|
||||
// city governorships they currently hold and guilds they lead. Both are reliable
|
||||
// current-state lookups on the user's linked accounts.
|
||||
async function getStanding(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
const [governorOf, guildsLed] = await Promise.all([
|
||||
shardState.listGovernorshipsForAccounts(ctx.accounts),
|
||||
shardState.listGuildsLedForAccounts(ctx.accounts),
|
||||
])
|
||||
return res.json({ governorOf, guildsLed })
|
||||
} catch (err) {
|
||||
log.error('getStanding', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this
|
||||
// user, site-side. `actor` is stamped from the session (never the browser). On
|
||||
// success the sidecar clears the WebsiteUserId tag on the shard and we drop the
|
||||
// local mirror so attribution stops immediately.
|
||||
async function unlinkAccount(req, res) {
|
||||
const { account } = req.params
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
// Only unlink an account actually linked to THIS user (avoid cross-user unlink).
|
||||
if (!ctx.accounts.includes(account)) {
|
||||
return res.status(404).json({ message: 'That account is not linked to this user.' })
|
||||
}
|
||||
const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account })
|
||||
if (result.ok) {
|
||||
await shardLinks.removeByAccount(account)
|
||||
await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } })
|
||||
log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username })
|
||||
return res.json({ account, unlinked: true })
|
||||
}
|
||||
if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' })
|
||||
if (result.status === 404) {
|
||||
// Not linked on the shard — reconcile our mirror anyway so the two agree.
|
||||
await shardLinks.removeByAccount(account)
|
||||
return res.status(404).json({ message: 'That account is not linked.' })
|
||||
}
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' })
|
||||
} catch (err) {
|
||||
log.error('unlinkAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
|
||||
@@ -188,4 +188,4 @@ async function me(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }
|
||||
module.exports = { login, register, loginTotp, logout, me, needsTotp, issueSession, HONEYPOT_FIELD }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||
const { getInvite, acceptInvite } = require('./invite.controller')
|
||||
const { isLoggedIn } = require('../../../utils/auth')
|
||||
const { attachSession } = require('../../../auth/session.middleware')
|
||||
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
|
||||
@@ -87,6 +88,38 @@ authRouter.post(
|
||||
loginTotp,
|
||||
)
|
||||
|
||||
// ── Email-invite acceptance (public, token-gated) ──────────────────────────
|
||||
authRouter.get(
|
||||
'/invite/:token',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Look up an email invite by token'
|
||||
// #swagger.description = 'Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.'
|
||||
/* #swagger.responses[200] = { description: 'Invite details', content: { "application/json": { schema: { type: "object", properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
validate,
|
||||
getInvite,
|
||||
)
|
||||
authRouter.post(
|
||||
'/invite/:token/accept',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
|
||||
// #swagger.description = 'Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Username taken or invite already used', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
...loginGuards,
|
||||
registerLimiter,
|
||||
param('token').isString().isLength({ min: 8, max: 128 }),
|
||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
body(HONEYPOT_FIELD).optional(),
|
||||
validate,
|
||||
acceptInvite,
|
||||
)
|
||||
|
||||
authRouter.post(
|
||||
'/logout',
|
||||
// #swagger.tags = ['Auth']
|
||||
|
||||
82
server/src/router/v1/auth/invite.controller.js
Normal file
82
server/src/router/v1/auth/invite.controller.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// ── Invite acceptance (public, token-gated) ────────────────────────────────
|
||||
//
|
||||
// The other end of the admin email-invite flow (admin/invites.controller). An
|
||||
// invitee opens the tokened link, sees their pre-assigned email + role, and sets
|
||||
// a username + password. Accepting creates their website user AT THE PRESET ROLE
|
||||
// (bypassing the player_registration gate — the invite is its own authority) and
|
||||
// logs them straight in. The optional "create game account" step afterwards reuses
|
||||
// POST /player/shard/account (players only), so it isn't handled here.
|
||||
|
||||
const invites = require('../../../model/invites/invites.model')
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||
const { issueSession, HONEYPOT_FIELD } = require('./auth.controller')
|
||||
|
||||
const log = require('../../../utils/logger')('auth-invite')
|
||||
|
||||
// GET /auth/invite/:token — validate an invite and return what the accept form
|
||||
// needs (email + role). 404 for anything not currently acceptable so we never
|
||||
// distinguish "expired" from "revoked" from "never existed".
|
||||
async function getInvite(req, res) {
|
||||
try {
|
||||
const row = await invites.findValidByToken(req.params.token)
|
||||
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
|
||||
return res.json(invites.publicView(row))
|
||||
} catch (err) {
|
||||
log.error('getInvite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/invite/:token/accept — create the user at the invite's role and log
|
||||
// them in. Honeypot + validation mirror register; the invite replaces the
|
||||
// registration-mode gate.
|
||||
async function acceptInvite(req, res) {
|
||||
// Honeypot: a filled hidden field means a bot.
|
||||
if (req.body[HONEYPOT_FIELD]) {
|
||||
log.warn('honeypot invite-accept hit', { ip: req.ip })
|
||||
return res.status(400).json({ message: 'Registration failed.' })
|
||||
}
|
||||
try {
|
||||
const row = await invites.findValidByToken(req.params.token)
|
||||
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
|
||||
|
||||
const check = usernamePolicy.validateUsername(req.body.username)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await users.createUser({
|
||||
username: check.name,
|
||||
password: req.body.password,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
emailVerified: true, // they proved control of the address by using the link
|
||||
})
|
||||
} catch (err) {
|
||||
if (users.isDuplicateUsername(err)) {
|
||||
return res.status(409).json({ message: 'That username is already taken.' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Consume the invite atomically. If we lost a double-accept race, roll back the
|
||||
// user we just created so a spent invite never yields two accounts.
|
||||
const won = await invites.accept(row.id, user.id)
|
||||
if (!won) {
|
||||
await users.remove(user.id).catch(() => {})
|
||||
return res.status(409).json({ message: 'This invitation has already been used.' })
|
||||
}
|
||||
|
||||
await activity.log({ req, userId: user.id, action: 'invite.accept', detail: { inviteId: row.id, role: row.role } })
|
||||
log.info('invite accepted', { inviteId: row.id, userId: user.id, role: row.role, ip: req.ip })
|
||||
// New accounts never have TOTP yet — log straight in.
|
||||
return issueSession(req, res, user, 'local')
|
||||
} catch (err) {
|
||||
log.error('acceptInvite', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getInvite, acceptInvite }
|
||||
@@ -148,6 +148,25 @@ playerRouter.post(
|
||||
validate,
|
||||
shard.link,
|
||||
)
|
||||
playerRouter.post(
|
||||
'/shard/account',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
|
||||
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
shard.createGameAccount,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/accounts',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
@@ -203,5 +222,14 @@ playerRouter.get(
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
shard.getSales,
|
||||
)
|
||||
playerRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'The caller’s own houses (home status)'
|
||||
// #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getHouses,
|
||||
)
|
||||
|
||||
module.exports = playerRouter
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../../model/shardState/shardState.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
@@ -16,6 +18,24 @@ const log = require('../../../utils/logger')('player-shard')
|
||||
|
||||
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
||||
|
||||
// Decorate a char.profile with cross-links from our own board data: the guild the
|
||||
// character leads and any city governorship on its account. Best-effort — a
|
||||
// failure here never fails the profile (it's a nicety, not the sheet).
|
||||
async function enrichCharProfile(profile) {
|
||||
if (!profile) return profile
|
||||
try {
|
||||
const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct })
|
||||
if (guild) profile.guild = guild
|
||||
if (profile.acct) {
|
||||
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
|
||||
if (govs.length) profile.governorOf = govs.map((g) => g.city)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// POST /player/shard/link — confirm an in-game link code.
|
||||
async function link(req, res) {
|
||||
const { code } = req.body
|
||||
@@ -102,7 +122,7 @@ async function getChar(req, res) {
|
||||
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)
|
||||
return res.json(await enrichCharProfile(result.data))
|
||||
}
|
||||
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
@@ -128,4 +148,72 @@ async function getSales(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { link, listAccounts, roster, vendors, getChar, getSales }
|
||||
// GET /player/shard/houses — the caller's OWN houses (home status), scoped to
|
||||
// their linked accounts. A player sees their own decay/IDOC standing; never
|
||||
// anyone else's. Full detail is fine here — it's their property.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const links = await shardLinks.listForUser(req.user.id)
|
||||
const accounts = links.map((l) => l.account)
|
||||
return res.json(await shardState.listHousesForAccounts(accounts))
|
||||
} catch (err) {
|
||||
log.error('player.shard.getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
|
||||
// The password is never echoed anywhere; only the mapped reason is returned.
|
||||
function mapCreateAccountError(res, result) {
|
||||
const reason = (result.data && result.data.reason) || ''
|
||||
switch (result.status) {
|
||||
case 409:
|
||||
return res.status(409).json({ message: 'That account name is already taken.' })
|
||||
case 429:
|
||||
return res.status(429).json({ message: 'The account limit for your network has been reached.' })
|
||||
case 403:
|
||||
return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' })
|
||||
case 400:
|
||||
return res.status(400).json({ message: reason || 'The account name or password was not accepted.' })
|
||||
case 503:
|
||||
case 0:
|
||||
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||
default:
|
||||
return res.status(502).json({ message: 'Could not reach the shard to create the account.' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /player/shard/account — provision a GAME account for the signed-in website
|
||||
// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the
|
||||
// invite-accept "create game account" step alike (both act as the signed-in user).
|
||||
// actor + websiteUserId are stamped from the session; the browser IP (req.ip,
|
||||
// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is
|
||||
// never logged. Gated by the game_account_signup setting AND the shard's own mode.
|
||||
async function createGameAccount(req, res) {
|
||||
const { account, password } = req.body
|
||||
try {
|
||||
if (!(await settings.isGameAccountSignupEnabled())) {
|
||||
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
|
||||
}
|
||||
const result = await uoLinkClient.createAccount({
|
||||
actor: req.user.username,
|
||||
account,
|
||||
password,
|
||||
websiteUserId: req.user.id,
|
||||
ip: req.ip,
|
||||
})
|
||||
if (result.ok) {
|
||||
// Mirror the link locally so the portal lists the account immediately.
|
||||
await shardLinks.link({ account, userId: req.user.id })
|
||||
await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } })
|
||||
log.info('game account created', { account, userId: req.user.id, ip: req.ip })
|
||||
return res.status(201).json({ account, linked: true })
|
||||
}
|
||||
return mapCreateAccountError(res, result)
|
||||
} catch (err) {
|
||||
log.error('player.shard.createGameAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount }
|
||||
|
||||
@@ -184,6 +184,50 @@ publicRouter.get(
|
||||
/* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getChamps,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/guilds',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
|
||||
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGuilds,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/governors',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current town-governor board (City Loyalty)'
|
||||
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGovernors,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/governors/:city/history',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Governor term history for a city'
|
||||
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' }
|
||||
/* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
param('city').isString().isLength({ min: 1, max: 40 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 500 }),
|
||||
validate,
|
||||
shard.getGovernorHistory,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/presence',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
|
||||
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
shard.getPresence,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
||||
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getHouses,
|
||||
)
|
||||
publicRouter.get(
|
||||
'/shard/stream',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
|
||||
@@ -104,9 +104,90 @@ async function getChamps(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/guilds — the current guild board. Served from our store;
|
||||
// live via guild.update / guild.remove / guild.join on the public SSE stream.
|
||||
async function getGuilds(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGuilds())
|
||||
} catch (err) {
|
||||
log.error('shard.getGuilds', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/governors — the current town-governor board (empty on shards
|
||||
// without City Loyalty). Live via city.update on the public SSE stream.
|
||||
async function getGovernors(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGovernors())
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernors', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/governors/:city/history — the term ledger for one city
|
||||
// (look-back: "who were all the governors of Britain?"), newest first.
|
||||
async function getGovernorHistory(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit))
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernorHistory', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/presence — the online-population aggregate (count + per-facet
|
||||
// + per-region). Live via presence.online on the public SSE stream.
|
||||
async function getPresence(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.latestPresence())
|
||||
} catch (err) {
|
||||
log.error('shard.getPresence', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only
|
||||
// their location (name + region + map/coords). Owner, price, co-owners and decay
|
||||
// detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay
|
||||
// on the public SSE stream. This is the "where are the falling houses" board.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const idoc = await shardState.listIdoc()
|
||||
const publicHouses = idoc.map((h) => ({
|
||||
serial: h.serial,
|
||||
name: h.name,
|
||||
region: h.region,
|
||||
map: h.map,
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
z: h.z,
|
||||
isIdoc: true,
|
||||
}))
|
||||
return res.json(publicHouses)
|
||||
} catch (err) {
|
||||
log.error('shard.getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
|
||||
function stream(req, res) {
|
||||
broadcast.subscribe(req, res, 'public')
|
||||
}
|
||||
|
||||
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChamps, stream }
|
||||
module.exports = {
|
||||
getStatus,
|
||||
getFeed,
|
||||
getEconomy,
|
||||
getOnline,
|
||||
getIdoc,
|
||||
getChamps,
|
||||
getGuilds,
|
||||
getGovernors,
|
||||
getGovernorHistory,
|
||||
getPresence,
|
||||
getHouses,
|
||||
stream,
|
||||
}
|
||||
|
||||
@@ -122,4 +122,36 @@ async function sendTest(to) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest }
|
||||
/**
|
||||
* Send an account invite. `to` is the invitee's email, `acceptUrl` the tokened
|
||||
* accept link, `role` their assigned access level, `invitedByName` optional. If
|
||||
* email is not configured, returns { sent: false, reason: 'NOT_CONFIGURED' } so
|
||||
* the caller can surface the accept link for the admin to share manually rather
|
||||
* than throwing. Throws only on an actual send failure.
|
||||
*/
|
||||
async function sendInvite({ to, acceptUrl, role, invitedByName }) {
|
||||
const built = await buildTransport()
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
const roleLabel = role && role !== 'player' ? ` as ${role}` : ''
|
||||
const by = invitedByName ? ` by ${invitedByName}` : ''
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
subject: 'Your UOMysticmoon invitation',
|
||||
text:
|
||||
`You have been invited${by} to join UOMysticmoon${roleLabel}.\n\n` +
|
||||
`Accept your invitation and set up your account here:\n${acceptUrl}\n\n` +
|
||||
`This link is single-use and will expire. If you weren't expecting this, you can ignore it.`,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
log.error('invite send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite }
|
||||
|
||||
124
server/src/utils/newsGump.js
Normal file
124
server/src/utils/newsGump.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// ── Town Cryer News gump sync (Protocol 2.1) ───────────────────────────────
|
||||
//
|
||||
// Keeps the in-game Town Cryer *News* gump in sync with the site's published
|
||||
// news posts. Distinct from the scrolling town-crier lines (that's a one-shot
|
||||
// announce leg in announceWorker); this is a STATE SYNC — an article stays in the
|
||||
// gump while its post is published news, and is pulled when the post is
|
||||
// unpublished/deleted/re-categorised.
|
||||
//
|
||||
// The website is the source of truth. POST /news is idempotent (re-post replaces),
|
||||
// so a refresh or a reconnect re-assert is safe. Every call is best-effort and
|
||||
// never throws — a sidecar/shard hiccup must never break saving or deleting a
|
||||
// post. Reliability comes from reassertAll() on every WS (re)connect
|
||||
// (uoLinkSocket.backfill), which re-pushes the current published set silently and
|
||||
// closes the gap if an earlier live push failed.
|
||||
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const { deriveExcerpt } = require('./sanitizeHtml')
|
||||
const log = require('./logger')('news-gump')
|
||||
|
||||
const MAX_TITLE = 120
|
||||
const MAX_BODY = 900
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function clamp(value, max) {
|
||||
const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
|
||||
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
// A post belongs in the gump exactly when it is published AND in the news category.
|
||||
function inGump(post) {
|
||||
return Boolean(post && post.published && post.category === 'news')
|
||||
}
|
||||
|
||||
// Optional UO gump image id for news articles (a shard art id), from the
|
||||
// `news_gump_image` setting. Omitted → the sidecar uses a neutral scroll.
|
||||
async function gumpImage() {
|
||||
try {
|
||||
const raw = await settings.get('news_gump_image')
|
||||
const n = Number(raw)
|
||||
return Number.isInteger(n) && n > 0 ? n : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Build the in-game News article from a post. Body is a compact gump-HTML block
|
||||
// (title centred + a plain-text excerpt) rather than the post's full rich HTML —
|
||||
// the UO gump only supports a small HTML subset, so we keep it predictable. The
|
||||
// "more info" URL is the public news list (news posts have no per-post route).
|
||||
async function buildArticle(post, { announce = true } = {}) {
|
||||
const title = clamp(post.title, MAX_TITLE)
|
||||
const excerpt = clamp(post.excerpt || deriveExcerpt(post.body, MAX_BODY) || '', MAX_BODY)
|
||||
const body = excerpt ? `<CENTER>${title}</CENTER><BR><BR>${excerpt}` : `<CENTER>${title}</CENTER>`
|
||||
return {
|
||||
id: String(post.id),
|
||||
title,
|
||||
body,
|
||||
image: await gumpImage(),
|
||||
url: `${baseUrl()}/site/news`,
|
||||
announce,
|
||||
}
|
||||
}
|
||||
|
||||
// Push a post to the gump (only if it belongs there). announce=true has the criers
|
||||
// proclaim the title; false is a silent refresh/re-assert.
|
||||
async function pushPost(post, { announce = true } = {}) {
|
||||
if (!inGump(post)) return { ok: false, skipped: true }
|
||||
const res = await uoLinkClient.postNews(await buildArticle(post, { announce }))
|
||||
if (!res.ok) log.warn('news gump push failed', { id: post.id, status: res.status, error: res.error })
|
||||
return res
|
||||
}
|
||||
|
||||
// Remove a post from the gump. A 404 (not present) is not an error worth noting.
|
||||
async function removePost(id) {
|
||||
const res = await uoLinkClient.deleteNews(String(id))
|
||||
if (!res.ok && res.status !== 404) {
|
||||
log.warn('news gump remove failed', { id, status: res.status, error: res.error })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Reconcile the gump after a post create/update/publish. `transition`
|
||||
// ({ wasPublished, wasNews }) tells a fresh publish (announce) from an in-place
|
||||
// edit (silent refresh) and catches a post leaving published-news (pull it).
|
||||
async function syncPost(post, transition = {}) {
|
||||
try {
|
||||
if (inGump(post)) {
|
||||
const wasInGump = Boolean(transition.wasPublished && transition.wasNews)
|
||||
await pushPost(post, { announce: !wasInGump })
|
||||
} else if (transition.wasPublished && transition.wasNews) {
|
||||
await removePost(post.id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('news gump sync failed', { id: post && post.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Re-push every currently-published news post, silently — run on each WS
|
||||
// (re)connect to reconcile the gump to our source of truth (also recovers any
|
||||
// article whose original live push failed). Best-effort; never throws.
|
||||
async function reassertAll() {
|
||||
try {
|
||||
const list = await posts.listAll('news')
|
||||
const published = (list || []).filter((p) => p.published)
|
||||
let pushed = 0
|
||||
for (const p of published) {
|
||||
const full = await posts.getById(p.id) // list projection may omit the body
|
||||
if (full) {
|
||||
await pushPost(full, { announce: false })
|
||||
pushed += 1
|
||||
}
|
||||
}
|
||||
if (pushed) log.info('re-asserted news gump articles', { count: pushed })
|
||||
} catch (err) {
|
||||
log.warn('news gump reassert failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { inGump, buildArticle, pushPost, removePost, syncPost, reassertAll }
|
||||
@@ -36,6 +36,17 @@ const PUBLIC_KINDS = new Set([
|
||||
// Champion-spawn board deltas — the public Champions page renders these live.
|
||||
'champ.update',
|
||||
'champ.remove',
|
||||
// Protocol 2.0 boards — public, rendered live on their respective pages.
|
||||
'guild.update',
|
||||
'guild.remove',
|
||||
'guild.join',
|
||||
'city.update',
|
||||
'presence.online',
|
||||
'region.enter',
|
||||
// NOTE: house.update / house.remove (the full registry — owner, price, co-owners)
|
||||
// are deliberately NOT public. The public Houses page shows only IDOC houses (via
|
||||
// house.decay, which is public above) with location only; the full registry is
|
||||
// staff-only and rides the admin SSE channel. See public/shard.controller getHouses.
|
||||
])
|
||||
|
||||
// Open response streams per channel.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
|
||||
const shardStateModel = require('../model/shardState/shardState.model')
|
||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const defaultLog = require('./logger')('shard-ingest')
|
||||
@@ -39,6 +40,11 @@ const LOGGED_KINDS = new Set([
|
||||
'server.hello',
|
||||
'server.shutdown',
|
||||
'server.crashed',
|
||||
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
|
||||
'guild.join',
|
||||
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
|
||||
'account.audit',
|
||||
'account.unlinked',
|
||||
])
|
||||
|
||||
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
|
||||
@@ -146,6 +152,32 @@ async function applyStateChange(event, deps) {
|
||||
case 'page.closed':
|
||||
await shardState.removePage(event.pageId)
|
||||
return
|
||||
// ── Protocol 2.0 boards ──────────────────────────────────────────────
|
||||
case 'guild.update':
|
||||
await shardState.upsertGuild(event)
|
||||
return
|
||||
case 'guild.remove':
|
||||
await shardState.removeGuild(event.id)
|
||||
return
|
||||
case 'city.update':
|
||||
// Upserts the board AND captures term history (idempotent).
|
||||
await shardState.upsertGovernor(event)
|
||||
return
|
||||
case 'presence.online':
|
||||
await shardState.setPresence(event)
|
||||
return
|
||||
case 'house.update':
|
||||
await shardState.upsertHouseRegistry(event)
|
||||
return
|
||||
case 'house.remove':
|
||||
await shardState.removeHouse(event.serial)
|
||||
return
|
||||
case 'account.unlinked':
|
||||
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
|
||||
// our local link mirror so attribution stops immediately.
|
||||
if (event.account) await deps.shardLinks.removeByAccount(event.account)
|
||||
return
|
||||
// guild.join / account.audit → logged; region.enter → broadcast-only.
|
||||
default:
|
||||
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
|
||||
// broadcasting still happen in ingest().
|
||||
@@ -159,6 +191,7 @@ async function ingest(event, deps = {}) {
|
||||
const d = {
|
||||
shardEvents: deps.shardEvents || shardEventsModel,
|
||||
shardState: deps.shardState || shardStateModel,
|
||||
shardLinks: deps.shardLinks || shardLinksModel,
|
||||
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
log: deps.log || defaultLog,
|
||||
|
||||
@@ -104,15 +104,41 @@ const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(li
|
||||
// our own store thereafter.
|
||||
const getChamps = () => call('/champs')
|
||||
const getPages = () => call('/pages')
|
||||
// Protocol 2.0 board projections — same snapshot-on-connect pattern.
|
||||
const getGuilds = () => call('/guilds')
|
||||
const getGovernors = () => call('/governors')
|
||||
const getHouses = () => call('/houses')
|
||||
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────
|
||||
const confirmLink = (code, websiteUserId) =>
|
||||
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
|
||||
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
|
||||
|
||||
// Account provisioning (Protocol 2.0). createAccount provisions a game account and
|
||||
// auto-links it to the website user in one step; `ip` is the END USER's browser IP
|
||||
// (read from the request), which the shard needs for its per-IP account cap — the
|
||||
// sidecar only sees our server. The password is hashed on the shard and never
|
||||
// appears in any reply/event/log. unlinkAccount severs a game account's tie from
|
||||
// the site side. `actor` is the staff/website id, recorded in the shard audit.
|
||||
const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
|
||||
call('/accounts/create', {
|
||||
method: 'POST',
|
||||
body: { actor, account, password, websiteUserId: websiteUserId == null ? undefined : String(websiteUserId), ip },
|
||||
})
|
||||
const unlinkAccount = ({ actor, account }) =>
|
||||
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
|
||||
const postTownCrier = ({ id, lines, durationSec }) =>
|
||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
||||
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
|
||||
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
|
||||
// in the in-game News window; re-posting the same id REPLACES it. `announce`
|
||||
// (default true on the sidecar) controls whether the criers proclaim the title.
|
||||
const postNews = ({ id, title, body, image, url, announce }) =>
|
||||
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
|
||||
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
|
||||
// ── Staff write plane (§6) ─────────────────────────────────────────────────
|
||||
// Every call carries `actor` — the website username of the staff member — set by
|
||||
// the controller from the session, NEVER from the browser. The shard records it
|
||||
@@ -142,10 +168,18 @@ module.exports = {
|
||||
getEconomy,
|
||||
getChamps,
|
||||
getPages,
|
||||
getGuilds,
|
||||
getGovernors,
|
||||
getHouses,
|
||||
getPresence,
|
||||
confirmLink,
|
||||
linkLookup,
|
||||
createAccount,
|
||||
unlinkAccount,
|
||||
postTownCrier,
|
||||
deleteTownCrier,
|
||||
postNews,
|
||||
deleteNews,
|
||||
adminKick,
|
||||
adminBan,
|
||||
adminUnban,
|
||||
|
||||
@@ -17,6 +17,7 @@ const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const shardIngest = require('./shardIngest')
|
||||
const shardState = require('../model/shardState/shardState.model')
|
||||
const newsGump = require('./newsGump')
|
||||
const log = require('./logger')('uo-link-socket')
|
||||
|
||||
const BACKOFF_MIN_MS = 1000
|
||||
@@ -75,6 +76,39 @@ async function backfill() {
|
||||
await shardState.replacePages(pages.data.pages)
|
||||
log.info('snapshotted help-page queue from /pages', { count: pages.data.pages.length })
|
||||
}
|
||||
|
||||
// ── Protocol 2.0 boards ──────────────────────────────────────────────
|
||||
// Same as champs/pages: snapshot the authoritative current state and
|
||||
// reconcile our tables to it. Each call is independently guarded so a
|
||||
// failed/absent board (e.g. no City Loyalty → empty /governors) never wipes
|
||||
// another. Governors are NOT cleared before upsert (cities are fixed and the
|
||||
// term-capture is idempotent, so a reconnect can't spawn spurious terms).
|
||||
const guilds = await uoLinkClient.getGuilds()
|
||||
if (guilds.ok && guilds.data && Array.isArray(guilds.data.guilds)) {
|
||||
await shardState.replaceGuilds(guilds.data.guilds)
|
||||
log.info('snapshotted guild board from /guilds', { count: guilds.data.guilds.length })
|
||||
}
|
||||
const governors = await uoLinkClient.getGovernors()
|
||||
if (governors.ok && governors.data && Array.isArray(governors.data.cities)) {
|
||||
await shardState.replaceGovernors(governors.data.cities)
|
||||
log.info('snapshotted governor board from /governors', { count: governors.data.cities.length })
|
||||
}
|
||||
const houses = await uoLinkClient.getHouses()
|
||||
if (houses.ok && houses.data && Array.isArray(houses.data.houses)) {
|
||||
for (const ev of houses.data.houses) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
log.info('snapshotted house registry from /houses', { count: houses.data.houses.length })
|
||||
}
|
||||
const presence = await uoLinkClient.getPresence()
|
||||
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
||||
await shardState.setPresence(presence.data)
|
||||
log.info('snapshotted online population from /online', { count: presence.data.count })
|
||||
}
|
||||
|
||||
// Re-assert our published news into the in-game Town Cryer News gump. The
|
||||
// website is the source of truth; this reconciles the gump on every
|
||||
// (re)connect (and recovers any article whose original live push failed).
|
||||
// Silent (announce:false) so a reconnect never re-proclaims old news.
|
||||
await newsGump.reassertAll()
|
||||
} catch (err) {
|
||||
log.warn('backfill failed (continuing on live feed)', { message: err.message })
|
||||
}
|
||||
|
||||
@@ -326,6 +326,126 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/invite/{token}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"summary": "Look up an email invite by token",
|
||||
"description": "Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Invite details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Invalid or expired invite",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/invite/{token}/accept": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"summary": "Accept an email invite (creates the account at the invited role)",
|
||||
"description": "Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account created and session issued",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Invalid or expired invite",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Username taken or invite already used",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"requestBody": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -1492,6 +1612,165 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/guilds": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "Current guild board (rosters, alliances, leaders)",
|
||||
"description": "The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Guilds, ordered by name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/governors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "Current town-governor board (City Loyalty)",
|
||||
"description": "One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Cities, ordered by name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/governors/{city}/history": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "Governor term history for a city",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "city",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "City name, e.g. Britain."
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Max terms (default 100, max 500)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Terms, newest first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/presence": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "Online population aggregate (count + per-facet + per-region)",
|
||||
"description": "The latest presence.online snapshot powering the \"Players Online\" widget. Live via presence.online on /shard/stream.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Population snapshot",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/houses": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "House registry (owner, co-owners, price, decay)",
|
||||
"description": "Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Houses, ordered by name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ShardHouse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/stream": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2147,6 +2426,63 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/account": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Account"
|
||||
],
|
||||
"summary": "Create a game account and link it to the caller (staff self-service)",
|
||||
"description": "Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Account created and linked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Game-account signup unavailable (site or shard)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Account name already taken",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/kick": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -2597,6 +2933,41 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/shard/houses": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Shard"
|
||||
],
|
||||
"summary": "Full house registry — owner, price, decay (admin/moderator)",
|
||||
"description": "The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Houses, ordered by name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ShardHouse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/dashboard": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6932,6 +7303,296 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/shard/standing": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "A user’s shard standing — governorships held and guilds led (admin only)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Standing { governorOf, guildsLed }",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/shard/link/{account}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "Unlink a game account from this user (admin only)",
|
||||
"description": "Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
},
|
||||
{
|
||||
"name": "account",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Game account to unlink."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Unlinked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"type": "string"
|
||||
},
|
||||
"unlinked": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Protected staff account (refused by shard)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not linked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"502": {
|
||||
"description": "Bad Gateway"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/invites": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Invites"
|
||||
],
|
||||
"summary": "Create and email an account invite at a chosen access level",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Invite created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {}
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Invites"
|
||||
],
|
||||
"summary": "List recent invites (no tokens)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Invites, newest first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/invites/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Invites"
|
||||
],
|
||||
"summary": "Revoke a pending invite",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Invite id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "No pending invite to revoke",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/uo-link/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7761,6 +8422,100 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/shard/account": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Player · Shard"
|
||||
],
|
||||
"summary": "Create a game account (hybrid signup) and link it to the caller",
|
||||
"description": "Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Account created and linked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"type": "string"
|
||||
},
|
||||
"linked": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error or rejected name/password",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Game-account signup unavailable (site or shard)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Account name already taken",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Per-IP account cap reached",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Shard unavailable — retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/shard/accounts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -8058,6 +8813,47 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/player/shard/houses": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Player · Shard"
|
||||
],
|
||||
"summary": "The caller’s own houses (home status)",
|
||||
"description": "Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The caller’s houses",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ShardHouse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
78
server/test/invites.test.js
Normal file
78
server/test/invites.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Exercise invite create/lookup/single-use accept against an in-memory fake by
|
||||
// monkeypatching the shared db module the model require()s. No DB.
|
||||
const db = require('../src/model/invites/invites.db')
|
||||
const invites = require('../src/model/invites/invites.model')
|
||||
|
||||
let rows
|
||||
let nextId
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
rows = []
|
||||
nextId = 1
|
||||
for (const k of ['insert', 'getById', 'findByTokenHash', 'markAccepted', 'revoke']) saved[k] = db[k]
|
||||
db.insert = async ({ tokenHash, email, role, invitedBy, expiresAt }) => {
|
||||
const id = nextId++
|
||||
rows.push({ id, token_hash: tokenHash, email, role, status: 'pending', invited_by: invitedBy ?? null, accepted_user_id: null, expires_at: expiresAt, created_at: new Date(), accepted_at: null })
|
||||
return id
|
||||
}
|
||||
db.getById = async (id) => rows.find((r) => r.id === id) || null
|
||||
db.findByTokenHash = async (h) => rows.find((r) => r.token_hash === h) || null
|
||||
db.markAccepted = async (id, userId) => {
|
||||
const row = rows.find((r) => r.id === id && r.status === 'pending')
|
||||
if (!row) return 0
|
||||
row.status = 'accepted'
|
||||
row.accepted_user_id = userId
|
||||
return 1
|
||||
}
|
||||
db.revoke = async (id) => {
|
||||
const row = rows.find((r) => r.id === id && r.status === 'pending')
|
||||
if (!row) return 0
|
||||
row.status = 'revoked'
|
||||
return 1
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of Object.keys(saved)) db[k] = saved[k]
|
||||
})
|
||||
|
||||
test('create stores only the token hash, never the plaintext token', async () => {
|
||||
const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
assert.ok(token && token.length >= 20)
|
||||
assert.equal(rows[0].token_hash, invites.hashToken(token))
|
||||
assert.notEqual(rows[0].token_hash, token) // hash, not the raw token
|
||||
assert.equal(invite.email, 'a@b.com')
|
||||
assert.equal(invite.role, 'player')
|
||||
assert.equal(invite.status, 'pending')
|
||||
})
|
||||
|
||||
test('findValidByToken resolves a pending token and rejects a wrong/used one', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'moderator', invitedBy: 1 })
|
||||
assert.ok(await invites.findValidByToken(token))
|
||||
assert.equal(await invites.findValidByToken('not-a-real-token'), null)
|
||||
})
|
||||
|
||||
test('accept is single-use — the second accept loses the race', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
const row = await invites.findValidByToken(token)
|
||||
assert.equal(await invites.accept(row.id, 55), true)
|
||||
assert.equal(await invites.accept(row.id, 66), false) // already consumed
|
||||
assert.equal(await invites.findValidByToken(token), null) // no longer pending
|
||||
})
|
||||
|
||||
test('an expired invite is not valid (exercises the expiry branch, not a bad token)', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1, ttlDays: -1 })
|
||||
// The token itself is correct and the row is pending — only expires_at rejects it.
|
||||
assert.ok(rows[0] && rows[0].status === 'pending')
|
||||
assert.equal(await invites.findValidByToken(token), null)
|
||||
})
|
||||
|
||||
test('revoke makes a pending invite unusable', async () => {
|
||||
const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
assert.equal(await invites.revoke(invite.id), 1)
|
||||
assert.equal(await invites.findValidByToken(token), null)
|
||||
})
|
||||
69
server/test/newsGump.test.js
Normal file
69
server/test/newsGump.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Exercise the News-gump sync decisions against a fake sidecar client by
|
||||
// monkeypatching the shared modules newsGump require()s (same instance) — no DB,
|
||||
// no network.
|
||||
const uoLinkClient = require('../src/utils/uoLinkClient')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const newsGump = require('../src/utils/newsGump')
|
||||
|
||||
let calls
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = { post: [], del: [] }
|
||||
saved.postNews = uoLinkClient.postNews
|
||||
saved.deleteNews = uoLinkClient.deleteNews
|
||||
saved.get = settings.get
|
||||
uoLinkClient.postNews = async (article) => { calls.post.push(article); return { ok: true, status: 200 } }
|
||||
uoLinkClient.deleteNews = async (id) => { calls.del.push(id); return { ok: true, status: 200 } }
|
||||
settings.get = async () => null // no gump image configured
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
uoLinkClient.postNews = saved.postNews
|
||||
uoLinkClient.deleteNews = saved.deleteNews
|
||||
settings.get = saved.get
|
||||
})
|
||||
|
||||
const newsPost = (over = {}) => ({ id: 42, category: 'news', published: true, title: 'Double XP Weekend', excerpt: 'Starts Friday.', body: null, ...over })
|
||||
|
||||
test('buildArticle centres the title, links the news list, and respects announce', async () => {
|
||||
const a = await newsGump.buildArticle(newsPost(), { announce: false })
|
||||
assert.equal(a.id, '42')
|
||||
assert.match(a.body, /<CENTER>Double XP Weekend<\/CENTER>/)
|
||||
assert.match(a.body, /Starts Friday\./)
|
||||
assert.match(a.url, /\/site\/news$/)
|
||||
assert.equal(a.announce, false)
|
||||
})
|
||||
|
||||
test('a fresh publish into news pushes with announce=true', async () => {
|
||||
await newsGump.syncPost(newsPost(), { wasPublished: false, wasNews: false })
|
||||
assert.equal(calls.post.length, 1)
|
||||
assert.equal(calls.post[0].announce, true)
|
||||
assert.equal(calls.del.length, 0)
|
||||
})
|
||||
|
||||
test('an edit of already-published news refreshes silently (announce=false)', async () => {
|
||||
await newsGump.syncPost(newsPost({ title: 'Edited' }), { wasPublished: true, wasNews: true })
|
||||
assert.equal(calls.post.length, 1)
|
||||
assert.equal(calls.post[0].announce, false)
|
||||
})
|
||||
|
||||
test('unpublishing published news pulls the article from the gump', async () => {
|
||||
await newsGump.syncPost(newsPost({ published: false }), { wasPublished: true, wasNews: true })
|
||||
assert.equal(calls.post.length, 0)
|
||||
assert.deepEqual(calls.del, ['42'])
|
||||
})
|
||||
|
||||
test('a draft never-published news post does nothing', async () => {
|
||||
await newsGump.syncPost(newsPost({ published: false }), { wasPublished: false, wasNews: false })
|
||||
assert.equal(calls.post.length, 0)
|
||||
assert.equal(calls.del.length, 0)
|
||||
})
|
||||
|
||||
test('a non-news post (e.g. screenshot) is never pushed', async () => {
|
||||
await newsGump.syncPost(newsPost({ category: 'screenshot' }), { wasPublished: false, wasNews: false })
|
||||
assert.equal(calls.post.length, 0)
|
||||
})
|
||||
119
server/test/shardIngest.protocol2.test.js
Normal file
119
server/test/shardIngest.protocol2.test.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/utils/shardIngest')
|
||||
|
||||
// Stub deps recording the Protocol 2.0 board calls the dispatcher makes. Only the
|
||||
// methods the tested kinds touch need to be real; the rest are no-op async so
|
||||
// ingest() never throws on an unrelated kind.
|
||||
function makeDeps() {
|
||||
const calls = {
|
||||
guildUpsert: [], guildRemove: [],
|
||||
governorUpsert: [],
|
||||
presenceSet: [],
|
||||
houseRegistry: [], houseRemove: [],
|
||||
linkRemove: [],
|
||||
appended: [], broadcast: [],
|
||||
}
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
upsertGuild: async (ev) => { calls.guildUpsert.push(ev) },
|
||||
removeGuild: async (id) => { calls.guildRemove.push(id) },
|
||||
upsertGovernor: async (ev) => { calls.governorUpsert.push(ev) },
|
||||
setPresence: async (ev) => { calls.presenceSet.push(ev) },
|
||||
upsertHouseRegistry: async (ev) => { calls.houseRegistry.push(ev) },
|
||||
removeHouse: async (serial) => { calls.houseRemove.push(serial) },
|
||||
// Present so any stray routing is a harmless no-op.
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||
addEconomySample: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('guild.update routes to upsertGuild and is not logged; guild.remove routes to removeGuild', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest({ kind: 'guild.update', id: 1042, name: 'TSH', t: 1 }, deps)
|
||||
assert.equal(deps.calls.guildUpsert.length, 1)
|
||||
assert.equal(deps.calls.guildUpsert[0].id, 1042)
|
||||
assert.equal(r.logged, false) // board state, not appended to shard_events
|
||||
await shardIngest.ingest({ kind: 'guild.remove', id: 1042, t: 2 }, deps)
|
||||
assert.deepEqual(deps.calls.guildRemove, [1042])
|
||||
})
|
||||
|
||||
test('guild.join is appended to the event log (real-time joins feed) and broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'guild.join', id: 1042, who: { name: 'Bran' }, t: 3 }, deps)
|
||||
assert.equal(r.logged, true)
|
||||
assert.equal(deps.calls.appended.length, 1)
|
||||
assert.equal(deps.calls.appended[0].kind, 'guild.join')
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
})
|
||||
|
||||
test('city.update routes to upsertGovernor (which also captures term history)', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(
|
||||
{ kind: 'city.update', city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 4 }, deps)
|
||||
assert.equal(deps.calls.governorUpsert.length, 1)
|
||||
assert.equal(deps.calls.governorUpsert[0].city, 'Britain')
|
||||
})
|
||||
|
||||
test('presence.online routes to setPresence and is not logged', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'presence.online', count: 42, byRegion: { Britain: 18 }, t: 5 }, deps)
|
||||
assert.equal(deps.calls.presenceSet.length, 1)
|
||||
assert.equal(deps.calls.presenceSet[0].count, 42)
|
||||
assert.equal(r.logged, false)
|
||||
})
|
||||
|
||||
test('house.update routes to upsertHouseRegistry; house.remove routes to removeHouse', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest({ kind: 'house.update', serial: '0x40001234', name: 'Anvil', t: 6 }, deps)
|
||||
assert.equal(deps.calls.houseRegistry.length, 1)
|
||||
assert.equal(deps.calls.houseRegistry[0].serial, '0x40001234')
|
||||
await shardIngest.ingest({ kind: 'house.remove', serial: '0x40001234', t: 7 }, deps)
|
||||
assert.deepEqual(deps.calls.houseRemove, ['0x40001234'])
|
||||
})
|
||||
|
||||
test('region.enter is broadcast-only — not logged, no state side effect', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'region.enter', from: 'Britain', to: 'Despise', who: { name: 'Darrow' }, t: 8 }, deps)
|
||||
assert.equal(r.logged, false)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(deps.calls.broadcast.length, 1) // still surfaced live
|
||||
})
|
||||
|
||||
test('account.unlinked reconciles the local link mirror and is logged', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'account.unlinked', origin: 'in-game', account: 'bob', websiteUserId: '9931', t: 9 }, deps)
|
||||
assert.deepEqual(deps.calls.linkRemove, ['bob']) // mirror dropped
|
||||
assert.equal(r.logged, true) // provisioning audit trail
|
||||
assert.equal(deps.calls.appended[0].kind, 'account.unlinked')
|
||||
})
|
||||
|
||||
test('account.audit is logged (provisioning history) but has no state side effect', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'account.audit', origin: 'web', action: 'create', actor: 'web:jane', target: 'bob', t: 10 }, deps)
|
||||
assert.equal(r.logged, true)
|
||||
assert.equal(deps.calls.linkRemove.length, 0)
|
||||
assert.equal(deps.calls.appended[0].kind, 'account.audit')
|
||||
})
|
||||
|
||||
test('account.audit / account.unlinked are NOT on the public SSE allowlist', () => {
|
||||
const broadcast = require('../src/utils/shardBroadcast')
|
||||
assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false)
|
||||
assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false)
|
||||
})
|
||||
77
server/test/shardState.governorTerms.test.js
Normal file
77
server/test/shardState.governorTerms.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Term capture lives in the model (shardState.model.upsertGovernor →
|
||||
// recordGovernorTransition) and talks to the db module. We exercise the real
|
||||
// logic against an in-memory fake by monkeypatching the shared db module object
|
||||
// (same instance the model require()s) — no DB, no mocking library.
|
||||
const db = require('../src/model/shardState/shardState.db')
|
||||
const model = require('../src/model/shardState/shardState.model')
|
||||
|
||||
let terms // in-memory shard_governor_terms
|
||||
let nextId
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
terms = []
|
||||
nextId = 1
|
||||
for (const k of ['currentGovernorTerm', 'closeGovernorTerm', 'openGovernorTerm', 'upsertGovernor']) {
|
||||
saved[k] = db[k]
|
||||
}
|
||||
db.currentGovernorTerm = async (city) =>
|
||||
terms.find((t) => t.city === city && t.ended_at === null) || null
|
||||
db.closeGovernorTerm = async (id, endedAt) => {
|
||||
const row = terms.find((t) => t.id === id)
|
||||
if (row) row.ended_at = endedAt
|
||||
}
|
||||
db.openGovernorTerm = async ({ city, serial, name, acct, webId, startedAt }) => {
|
||||
terms.push({ id: nextId++, city, governor_serial: serial, governor_name: name,
|
||||
governor_acct: acct, governor_web_id: webId, started_at: startedAt, ended_at: null })
|
||||
}
|
||||
db.upsertGovernor = async () => {} // snapshot write — irrelevant to term capture
|
||||
})
|
||||
|
||||
function restore() {
|
||||
for (const k of Object.keys(saved)) db[k] = saved[k]
|
||||
}
|
||||
|
||||
test('a repeated city.update with the same governor does NOT open a second term', async () => {
|
||||
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
|
||||
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 200 })
|
||||
const open = terms.filter((t) => t.ended_at === null)
|
||||
assert.equal(terms.length, 1)
|
||||
assert.equal(open.length, 1)
|
||||
assert.equal(open[0].governor_serial, '0x1')
|
||||
assert.equal(open[0].started_at, 100)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a governor change closes the old term and opens a new one', async () => {
|
||||
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
|
||||
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x2', name: 'Mira' }, t: 300 })
|
||||
assert.equal(terms.length, 2)
|
||||
const [first, second] = terms
|
||||
assert.equal(first.governor_serial, '0x1')
|
||||
assert.equal(first.ended_at, 300) // closed at the transition time
|
||||
assert.equal(second.governor_serial, '0x2')
|
||||
assert.equal(second.ended_at, null) // now current
|
||||
assert.equal(second.started_at, 300)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a seat going vacant closes the term without opening a new one', async () => {
|
||||
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
|
||||
await model.upsertGovernor({ city: 'Britain', governor: null, t: 400 })
|
||||
assert.equal(terms.length, 1)
|
||||
assert.equal(terms[0].ended_at, 400)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('terms are tracked independently per city', async () => {
|
||||
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 100 })
|
||||
await model.upsertGovernor({ city: 'Minoc', governor: { serial: '0x9' }, t: 120 })
|
||||
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 200 }) // dup, no-op
|
||||
assert.equal(terms.length, 2)
|
||||
assert.equal(terms.filter((t) => t.ended_at === null).length, 2)
|
||||
restore()
|
||||
})
|
||||
Reference in New Issue
Block a user