Merge pull request 'uo-link: staff-only public presence + admin character access' (#49) from feature/uo-link-sidecar into main
Reviewed-on: UOM/website#49 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
12
.env.example
12
.env.example
@@ -76,3 +76,15 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
||||||
BOT_INTERNAL_URL=http://bot:4100
|
BOT_INTERNAL_URL=http://bot:4100
|
||||||
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
|
||||||
|
# website ingests its live event feed and proxies its read queries/commands
|
||||||
|
# (shard status, online players, player-vendor sales, IDOC houses, character
|
||||||
|
# sheets, account linking, town-crier). In production the sidecar + shard run on
|
||||||
|
# a DIFFERENT host from the website, so both URLs are configurable. The
|
||||||
|
# shared-secret auth token is NOT an env var — it is entered in the admin panel
|
||||||
|
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot
|
||||||
|
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||||
|
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||||
|
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
||||||
|
UOLINK_PROTOCOL=1
|
||||||
|
|||||||
73
README.md
73
README.md
@@ -6,6 +6,7 @@ shard — a full-stack app in one repo:
|
|||||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
|
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
|
||||||
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
||||||
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
||||||
|
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([UOM/link](https://gitea.whitlocktech.com/UOM/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
|
||||||
|
|
||||||
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
- [Pages & routes](#pages--routes)
|
- [Pages & routes](#pages--routes)
|
||||||
- [API endpoints](#api-endpoints)
|
- [API endpoints](#api-endpoints)
|
||||||
- [API documentation (Swagger)](#api-documentation-swagger)
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
|
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
@@ -207,6 +209,9 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
||||||
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
||||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
||||||
|
| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none |
|
||||||
|
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
|
||||||
|
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
|
||||||
|
|
||||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||||
@@ -251,6 +256,74 @@ not crash).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Shard integration (uo-link)
|
||||||
|
|
||||||
|
The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
|
||||||
|
runs next to the ServUO shard. Its source lives in a separate repo:
|
||||||
|
**[UOM/link](https://gitea.whitlocktech.com/UOM/link)**. uo-link speaks the shard's internals and
|
||||||
|
exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
|
||||||
|
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
|
||||||
|
to it.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
ServUO shard ──▶ uo-link sidecar (UOM/link) ──▶ website backend ──▶ browser
|
||||||
|
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret
|
||||||
|
token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
|
||||||
|
**Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
|
||||||
|
the API — it is never returned to any client and never sent to the browser. Every call the backend
|
||||||
|
makes carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header (a protocol
|
||||||
|
mismatch fails fast with `409` instead of being mis-parsed).
|
||||||
|
- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
|
||||||
|
and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`,
|
||||||
|
`vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`,
|
||||||
|
`link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes
|
||||||
|
each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable
|
||||||
|
kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply
|
||||||
|
ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a
|
||||||
|
restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via
|
||||||
|
the sidecar's `/history`.
|
||||||
|
- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly —
|
||||||
|
`/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus
|
||||||
|
commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never
|
||||||
|
throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart
|
||||||
|
degrades to a `503`/retry banner instead of a 500.
|
||||||
|
- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**.
|
||||||
|
Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an
|
||||||
|
**admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login
|
||||||
|
attempts, IPs). Sensitive kinds can never leak onto the public channel.
|
||||||
|
|
||||||
|
### Account linking
|
||||||
|
|
||||||
|
A player (or staff member) proves ownership of a game account without sharing any game credentials:
|
||||||
|
|
||||||
|
1. In game, the player runs **`[link`** and receives a one-time code.
|
||||||
|
2. On the website (Player portal, or Admin → Account for staff) they enter the code.
|
||||||
|
3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the
|
||||||
|
game account with the website user id, and mirrors the link locally in `shard_account_links`.
|
||||||
|
|
||||||
|
That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints
|
||||||
|
are **ownership-checked** so a user only sees accounts they linked. **Admins may view any
|
||||||
|
character**; players and editor/moderator staff are limited to their own linked accounts.
|
||||||
|
|
||||||
|
### What each audience sees
|
||||||
|
|
||||||
|
| Surface | Endpoints | Who | Data |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
|
||||||
|
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
|
||||||
|
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
|
||||||
|
|
||||||
|
The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the
|
||||||
|
shard is offline), every shard surface degrades gracefully — the public page still renders, showing
|
||||||
|
the shard as offline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.env` is git-ignored.**
|
Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.env` is git-ignored.**
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
|||||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||||
import About from './routes/public/About.jsx'
|
import About from './routes/public/About.jsx'
|
||||||
import Status from './routes/public/Status.jsx'
|
import Status from './routes/public/Status.jsx'
|
||||||
|
import Shard from './routes/public/Shard.jsx'
|
||||||
|
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
import CmsPage from './routes/public/CmsPage.jsx'
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
@@ -33,6 +35,9 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
|||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||||
|
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
||||||
|
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||||
|
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
@@ -42,6 +47,9 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
|||||||
// Player portal
|
// Player portal
|
||||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||||
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||||
|
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||||
|
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||||
|
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -66,6 +74,8 @@ export default function App() {
|
|||||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||||
<Route path="/site/about" element={<About />} />
|
<Route path="/site/about" element={<About />} />
|
||||||
<Route path="/site/status" element={<Status />} />
|
<Route path="/site/status" element={<Status />} />
|
||||||
|
<Route path="/site/shard" element={<Shard />} />
|
||||||
|
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
@@ -109,6 +119,9 @@ export default function App() {
|
|||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
|
<Route path="shard" element={<ShardAdmin />} />
|
||||||
|
<Route path="characters" element={<AdminCharacters />} />
|
||||||
|
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
@@ -119,13 +132,16 @@ export default function App() {
|
|||||||
<Route path="/account/login" element={<PlayerLogin />} />
|
<Route path="/account/login" element={<PlayerLogin />} />
|
||||||
<Route path="/account/register" element={<PlayerRegister />} />
|
<Route path="/account/register" element={<PlayerRegister />} />
|
||||||
<Route
|
<Route
|
||||||
path="/account"
|
|
||||||
element={
|
element={
|
||||||
<RequirePlayer>
|
<RequirePlayer>
|
||||||
<PlayerAccount />
|
<PlayerPortalLayout />
|
||||||
</RequirePlayer>
|
</RequirePlayer>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<Route path="/player" element={<PlayerCharacters />} />
|
||||||
|
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||||
|
<Route path="/account" element={<PlayerAccount />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -79,6 +79,28 @@ export const api = {
|
|||||||
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
||||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||||
|
|
||||||
|
// ----- shard live data (uo-link) -----
|
||||||
|
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
||||||
|
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
||||||
|
shard: {
|
||||||
|
status: () => req('/public/shard/status'),
|
||||||
|
feed: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.kind) qs.set('kind', opts.kind)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||||
|
online: () => req('/public/shard/online'),
|
||||||
|
idoc: () => req('/public/shard/idoc'),
|
||||||
|
},
|
||||||
|
// 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
|
||||||
|
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
||||||
|
shardStreamUrl: `${BASE}/public/shard/stream`,
|
||||||
|
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
|
||||||
|
|
||||||
// ----- admin -----
|
// ----- admin -----
|
||||||
admin: {
|
admin: {
|
||||||
dashboard: () => req('/admin/dashboard'),
|
dashboard: () => req('/admin/dashboard'),
|
||||||
@@ -193,6 +215,16 @@ export const api = {
|
|||||||
linkedIdentities: () => req('/admin/account/identities'),
|
linkedIdentities: () => req('/admin/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- game account linking (self-service, staff) -----
|
||||||
|
shard: {
|
||||||
|
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
||||||
|
accounts: () => req('/admin/shard/accounts'),
|
||||||
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req('/admin/shard/sales'),
|
||||||
|
},
|
||||||
|
|
||||||
// ----- auth providers / SSO config (admin only) -----
|
// ----- auth providers / SSO config (admin only) -----
|
||||||
listAuthProviders: () => req('/admin/auth/providers'),
|
listAuthProviders: () => req('/admin/auth/providers'),
|
||||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||||
@@ -203,6 +235,12 @@ export const api = {
|
|||||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||||
|
|
||||||
|
// ----- uo-link sidecar control (admin only) -----
|
||||||
|
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
||||||
|
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||||
|
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||||
|
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||||
getEmailConfig: () => req('/admin/email/config'),
|
getEmailConfig: () => req('/admin/email/config'),
|
||||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||||
@@ -225,6 +263,16 @@ export const api = {
|
|||||||
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
||||||
linkedIdentities: () => req('/player/account/identities'),
|
linkedIdentities: () => req('/player/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- game account linking (uo-link) -----
|
||||||
|
shard: {
|
||||||
|
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
||||||
|
accounts: () => req('/player/shard/accounts'),
|
||||||
|
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req('/player/shard/sales'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
client/src/components/CharacterSheet.jsx
Normal file
141
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Reusable character-sheet renderer for the char.profile shape returned by
|
||||||
|
// /public/shard/char/:serial. Presentational only — the parent handles loading
|
||||||
|
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
|
||||||
|
|
||||||
|
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||||
|
|
||||||
|
function StatTile({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vital({ label, cur, max }) {
|
||||||
|
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '12px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
|
||||||
|
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CharacterSheet({ char }) {
|
||||||
|
if (!char) return null
|
||||||
|
const stats = char.stats || {}
|
||||||
|
const resist = stats.resist || {}
|
||||||
|
// Skills the character actually has, best first.
|
||||||
|
const skills = (char.skills || [])
|
||||||
|
.filter((s) => (s.value || s.base || 0) > 0)
|
||||||
|
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||||
|
const equipment = char.equipment || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
|
{/* Identity */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
|
||||||
|
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)', fontSize: '0.74rem',
|
||||||
|
color: char.online ? '#7fd0a4' : 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{char.online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core stats */}
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||||
|
<div className="grid-3" style={{ gap: 12 }}>
|
||||||
|
<StatTile value={stats.str ?? '—'} label="Strength" />
|
||||||
|
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
|
||||||
|
<StatTile value={stats.int ?? '—'} label="Intelligence" />
|
||||||
|
</div>
|
||||||
|
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
|
||||||
|
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
|
||||||
|
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
|
||||||
|
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Resistances */}
|
||||||
|
{Object.keys(resist).length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
|
||||||
|
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills */}
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
|
||||||
|
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||||
|
{skills.map((s) => {
|
||||||
|
const cap = s.cap || 100
|
||||||
|
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
|
||||||
|
return (
|
||||||
|
<div key={s.n}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Equipment */}
|
||||||
|
{equipment.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{equipment.map((it) => (
|
||||||
|
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||||
|
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||||
|
{Object.entries(it.mods).map(([k, v]) => (
|
||||||
|
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
client/src/components/GameAccounts.jsx
Normal file
156
client/src/components/GameAccounts.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from './PageState.jsx'
|
||||||
|
|
||||||
|
// Shared game-account linking + character roster, used by both the player portal
|
||||||
|
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||||
|
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||||||
|
// maps a serial to the route for that character's sheet.
|
||||||
|
|
||||||
|
function LinkForm({ scope, onLinked, compact }) {
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg(''); setError('')
|
||||||
|
if (!code.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { account } = await scope.link(code.trim())
|
||||||
|
setMsg(`Linked ${account}.`)
|
||||||
|
setCode('')
|
||||||
|
await onLinked()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not link that code.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
{!compact && <span className="field-label">Link code</span>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
className="input"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="AB12CD"
|
||||||
|
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Linking…' : 'Link account'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRoster({ scope, account, charTo }) {
|
||||||
|
const [roster, setRoster] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(''); setUnavailable(false)
|
||||||
|
try {
|
||||||
|
setRoster(await scope.roster(account))
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 503) setUnavailable(true)
|
||||||
|
else setError(err.message || 'Could not load this account.')
|
||||||
|
}
|
||||||
|
}, [scope, account])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (unavailable) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||||
|
<button className="pill" onClick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||||
|
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||||
|
|
||||||
|
const chars = roster.chars || []
|
||||||
|
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{chars.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.serial}
|
||||||
|
to={charTo(c.serial)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||||
|
{(c.name || '?').charAt(0)}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||||
|
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameAccounts({ scope, charTo }) {
|
||||||
|
const [accounts, setAccounts] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setAccounts(await scope.accounts())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your game accounts.')
|
||||||
|
}
|
||||||
|
}, [scope])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!accounts) return <Loading />
|
||||||
|
|
||||||
|
// Not linked yet — prompt to link.
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked — characters grouped by account.
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<section key={a.account}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
{a.account}
|
||||||
|
</div>
|
||||||
|
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
<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 />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +1,37 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link, NavLink } from 'react-router-dom'
|
||||||
import MoonDot from './MoonDot.jsx'
|
import MoonDot from './MoonDot.jsx'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
const NAV = {
|
// One consistent top nav for the whole public site. Every page gets the same
|
||||||
website: [
|
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||||
|
const NAV = [
|
||||||
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||||
{ label: 'About', to: '/site/about' },
|
|
||||||
{ label: 'Wiki', to: '/wiki' },
|
{ label: 'Wiki', to: '/wiki' },
|
||||||
],
|
{ label: 'Shard', to: '/site/shard' },
|
||||||
wiki: [
|
{ label: 'About', to: '/site/about' },
|
||||||
{ label: 'Website', to: '/site' },
|
]
|
||||||
{ label: 'New Player Guide', to: '/wiki/new-player-guide' },
|
|
||||||
{ label: 'Maps & Atlas', to: '/wiki/maps-atlas' },
|
const linkStyle = ({ isActive }) => ({
|
||||||
{ label: 'Systems', to: '/wiki/systems' },
|
background: isActive ? 'var(--accent)' : undefined,
|
||||||
{ label: 'Rules', to: '/wiki/rules' },
|
color: isActive ? 'var(--bg-deep)' : undefined,
|
||||||
],
|
borderColor: isActive ? 'var(--accent)' : undefined,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default function SiteHeader() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||||
|
const account =
|
||||||
|
user && user.role && user.role !== 'player'
|
||||||
|
? { label: 'Admin', to: '/admin' }
|
||||||
|
: user
|
||||||
|
? { label: 'My Account', to: '/player' }
|
||||||
|
: { label: 'Sign in', to: '/account/login' }
|
||||||
|
|
||||||
export default function SiteHeader({ section = 'website' }) {
|
|
||||||
const links = NAV[section] || NAV.website
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
@@ -34,38 +45,31 @@ export default function SiteHeader({ section = 'website' }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="shell"
|
className="shell"
|
||||||
style={{
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, padding: '14px 0', flexWrap: 'wrap' }}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 20,
|
|
||||||
padding: '14px 0',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="display"
|
className="display"
|
||||||
style={{
|
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
color: 'var(--accent-bright)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MoonDot />
|
<MoonDot />
|
||||||
UOMysticmoon
|
UOMysticmoon
|
||||||
</Link>
|
</Link>
|
||||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
{links.map((l) => (
|
{NAV.map((l) => (
|
||||||
<Link key={l.to + l.label} to={l.to} className="pill">
|
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
{!loading && (
|
||||||
|
<NavLink
|
||||||
|
to={account.to}
|
||||||
|
className="pill"
|
||||||
|
style={{ marginLeft: 6, borderColor: 'var(--accent)', color: 'var(--accent-bright)' }}
|
||||||
|
>
|
||||||
|
{account.label}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
41
client/src/components/VendorSales.jsx
Normal file
41
client/src/components/VendorSales.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ago } from '../lib/format.js'
|
||||||
|
|
||||||
|
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
|
||||||
|
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
|
||||||
|
// sales for accounts linked to the caller.
|
||||||
|
export default function VendorSales({ fetchSales }) {
|
||||||
|
const [sales, setSales] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
fetchSales()
|
||||||
|
.then((rows) => active && setSales(rows))
|
||||||
|
.catch(() => active && setError('Could not load your vendor sales.'))
|
||||||
|
return () => { active = false }
|
||||||
|
}, [fetchSales])
|
||||||
|
|
||||||
|
if (error) return null
|
||||||
|
if (!sales) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
|
||||||
|
{sales.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{sales.map((s, i) => (
|
||||||
|
<li key={`${s.t}-${i}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
client/src/lib/shardEvents.js
Normal file
88
client/src/lib/shardEvents.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Shared formatting for shard events — used by the public Shard page, the
|
||||||
|
// Activity feed, and the admin live feed. One place decides how each kind reads
|
||||||
|
// and which category/badge it belongs to.
|
||||||
|
|
||||||
|
function nameOf(who) {
|
||||||
|
if (!who) return 'Someone'
|
||||||
|
if (typeof who === 'string') return who
|
||||||
|
return who.name || who.acct || 'Someone'
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = (v) => Number(v || 0).toLocaleString()
|
||||||
|
|
||||||
|
// A one-line human description of an event. Accepts either a stored event
|
||||||
|
// (with .payload) or a raw live frame (fields at top level).
|
||||||
|
export function describe(ev) {
|
||||||
|
const p = ev.payload || ev
|
||||||
|
switch (ev.kind) {
|
||||||
|
case 'vendor.sale':
|
||||||
|
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp`
|
||||||
|
case 'player.death':
|
||||||
|
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
|
||||||
|
case 'player.murdered':
|
||||||
|
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
|
||||||
|
case 'mob.killed':
|
||||||
|
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
|
||||||
|
case 'skill.gain':
|
||||||
|
return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}`
|
||||||
|
case 'fame.change':
|
||||||
|
return `${nameOf(p.who)}’s fame changed to ${n(p.new)}`
|
||||||
|
case 'karma.change':
|
||||||
|
return `${nameOf(p.who)}’s karma changed to ${n(p.new)}`
|
||||||
|
case 'quest.complete':
|
||||||
|
return `${nameOf(p.who)} completed “${p.quest}”`
|
||||||
|
case 'house.decay':
|
||||||
|
return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}`
|
||||||
|
case 'mob.login':
|
||||||
|
return `${nameOf(p.who)} entered the world`
|
||||||
|
case 'mob.logout':
|
||||||
|
return `${nameOf(p.who)} left the world`
|
||||||
|
case 'economy.supply':
|
||||||
|
return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`
|
||||||
|
case 'server.hello':
|
||||||
|
return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`
|
||||||
|
case 'server.shutdown':
|
||||||
|
return 'Shard shut down'
|
||||||
|
case 'server.crashed':
|
||||||
|
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
|
||||||
|
// Staff / sensitive (admin channel only)
|
||||||
|
case 'audit.set':
|
||||||
|
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`
|
||||||
|
case 'audit.command':
|
||||||
|
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
|
||||||
|
case 'cheat.fastwalk':
|
||||||
|
return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
|
||||||
|
case 'account.login.attempt':
|
||||||
|
return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
|
||||||
|
case 'gold.change':
|
||||||
|
return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}`
|
||||||
|
default:
|
||||||
|
return ev.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category grouping for the filter tabs.
|
||||||
|
// Vendor sales are intentionally NOT a public category — they are owner-private
|
||||||
|
// (a linked player sees their own under the portal). The admin live feed still
|
||||||
|
// describes vendor.sale via describe() below.
|
||||||
|
export const CATEGORIES = [
|
||||||
|
{ id: 'all', label: 'All', kinds: null },
|
||||||
|
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||||
|
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||||
|
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORY_OF = (() => {
|
||||||
|
const m = {}
|
||||||
|
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
|
||||||
|
return m
|
||||||
|
})()
|
||||||
|
|
||||||
|
export function categoryOf(kind) {
|
||||||
|
return CATEGORY_OF[kind] || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short badge label for a kind (the part after the dot, title-cased-ish).
|
||||||
|
export function kindLabel(kind) {
|
||||||
|
return String(kind || '').replace(/[._]/g, ' ')
|
||||||
|
}
|
||||||
54
client/src/lib/useShardFeed.js
Normal file
54
client/src/lib/useShardFeed.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
|
||||||
|
// of the most recent events. The browser talks to our own /public/shard/stream
|
||||||
|
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
|
||||||
|
// stays server-side and it works through any reverse proxy.
|
||||||
|
//
|
||||||
|
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
|
||||||
|
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
||||||
|
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
||||||
|
// buffer length.
|
||||||
|
export function useShardFeed({ url, filter, max = 40 } = {}) {
|
||||||
|
const [events, setEvents] = useState([])
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
||||||
|
const filterRef = useRef(filter)
|
||||||
|
filterRef.current = filter
|
||||||
|
const streamUrl = url || api.shardStreamUrl
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// EventSource isn't available during SSR / very old browsers — degrade to
|
||||||
|
// "no live feed" rather than throwing.
|
||||||
|
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
||||||
|
|
||||||
|
const es = new EventSource(streamUrl, { withCredentials: true })
|
||||||
|
|
||||||
|
es.onopen = () => setConnected(true)
|
||||||
|
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
||||||
|
|
||||||
|
es.onmessage = (msg) => {
|
||||||
|
let event
|
||||||
|
try {
|
||||||
|
event = JSON.parse(msg.data)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!event || !event.kind) return
|
||||||
|
const f = filterRef.current
|
||||||
|
if (f && !f.has(event.kind)) return
|
||||||
|
setEvents((prev) => {
|
||||||
|
// Tag with a stable-ish local id for React keys (events carry t but can
|
||||||
|
// collide within a ms) and cap the buffer.
|
||||||
|
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
|
||||||
|
return next.slice(0, max)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => es.close()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [max, streamUrl])
|
||||||
|
|
||||||
|
return { events, connected }
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
|
|||||||
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
||||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||||
|
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||||
|
|
||||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||||
@@ -72,11 +73,13 @@ const NAV = [
|
|||||||
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||||
|
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
||||||
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
|
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
|
||||||
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -95,6 +98,8 @@ const TITLES = {
|
|||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/bot-activity': 'Web Bot Activity',
|
'/admin/bot-activity': 'Web Bot Activity',
|
||||||
'/admin/discord-bot': 'Discord Bot',
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
|
'/admin/shard': 'Shard (uo-link)',
|
||||||
|
'/admin/characters': 'My Characters',
|
||||||
'/admin/auth-providers': 'Authentication',
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
'/admin/account': 'Account Security',
|
'/admin/account': 'Account Security',
|
||||||
@@ -120,7 +125,11 @@ export default function AdminLayout() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title =
|
const title =
|
||||||
TITLES[location.pathname] ||
|
TITLES[location.pathname] ||
|
||||||
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
|
(location.pathname.startsWith('/admin/moderation')
|
||||||
|
? 'Moderation'
|
||||||
|
: location.pathname.startsWith('/admin/characters')
|
||||||
|
? 'My Characters'
|
||||||
|
: 'Admin')
|
||||||
// The hero canvas editor needs room — let it use the full content width.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
|
|||||||
29
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
29
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import CharacterSheet from '../../../components/CharacterSheet.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// A staff member's own character sheet inside the admin shell. Owner-checked —
|
||||||
|
// the endpoint only returns a sheet for a character on the caller's linked account.
|
||||||
|
export default function AdminCharacter() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
const forbidden = error && error.status === 403
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 760 }}>
|
||||||
|
<p style={{ margin: '0 0 18px' }}>
|
||||||
|
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to my characters
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||||
|
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
17
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../../components/VendorSales.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Staff link their OWN in-game account and view their characters — the same
|
||||||
|
// shared component players use, pointed at the staff self-service endpoints.
|
||||||
|
export default function AdminCharacters() {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 760 }}>
|
||||||
|
<p className="sans" style={{ marginTop: 0, marginBottom: 22, color: 'var(--muted)', fontSize: '0.92rem', lineHeight: 1.6 }}>
|
||||||
|
Link your own game account to view your characters, stats, skills and vendors.
|
||||||
|
</p>
|
||||||
|
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||||
|
<VendorSales fetchSales={api.admin.shard.sales} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
248
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
248
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||||
|
import { describe, kindLabel } from '../../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
|
||||||
|
// cheat detection and login attempts that the public channel never carries.
|
||||||
|
function AdminLiveFeed() {
|
||||||
|
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uo-link sidecar control panel. The auth token is write-only over this API —
|
||||||
|
// stored encrypted, never returned — same convention as the Discord bot token.
|
||||||
|
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
||||||
|
// effect immediately with no redeploy.
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange, label }) {
|
||||||
|
return (
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
connected: '#7fd0a4',
|
||||||
|
reconnecting: '#e0b070',
|
||||||
|
error: '#d98b84',
|
||||||
|
disconnected: 'var(--muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPanel({ config }) {
|
||||||
|
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||||
|
const ingest = config.ingest || {}
|
||||||
|
const health = config.health || {}
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||||
|
{config.status || 'disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.statusDetail && (
|
||||||
|
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||||
|
)}
|
||||||
|
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
|
||||||
|
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
|
||||||
|
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
|
||||||
|
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
|
||||||
|
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
|
||||||
|
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
|
||||||
|
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Town crier ──────────────────────────────────────────────────────────────
|
||||||
|
function TownCrier() {
|
||||||
|
const [id, setId] = useState('')
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [durationSec, setDurationSec] = useState(3600)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function post() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||||
|
if (!id.trim() || lines.length === 0) {
|
||||||
|
setBusy(false)
|
||||||
|
return setError('An id and at least one line are required.')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
|
||||||
|
setMsg(`Posted “${id.trim()}”.`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not post.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function remove() {
|
||||||
|
if (!id.trim()) return setError('Enter the id to remove.')
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.deleteTownCrier(id.trim())
|
||||||
|
setMsg(`Removed “${id.trim()}”.`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not remove.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||||
|
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Message id</span>
|
||||||
|
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Lines (one per line)</span>
|
||||||
|
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Duration (seconds)</span>
|
||||||
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
|
||||||
|
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShardAdmin() {
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
|
const [wsUrl, setWsUrl] = useState('')
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [protocol, setProtocol] = useState(1)
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [saveError, setSaveError] = useState('')
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const c = await api.admin.getUoLinkConfig()
|
||||||
|
setConfig(c)
|
||||||
|
// Seed the editable fields once; later polls only refresh the status panel
|
||||||
|
// so they never clobber what the admin is mid-typing.
|
||||||
|
if (!initializedRef.current) {
|
||||||
|
setBaseUrl(c.baseUrl || '')
|
||||||
|
setWsUrl(c.wsUrl || '')
|
||||||
|
setProtocol(c.protocol || 1)
|
||||||
|
setEnabled(c.enabled)
|
||||||
|
initializedRef.current = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Could not load uo-link config.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
pollRef.current = setInterval(load, 5000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true); setMsg(''); setSaveError('')
|
||||||
|
try {
|
||||||
|
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
|
||||||
|
if (token) body.token = token
|
||||||
|
const saved = await api.admin.saveUoLinkConfig(body)
|
||||||
|
setConfig(saved)
|
||||||
|
setToken('')
|
||||||
|
setMsg('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!config) return <Loading />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
|
||||||
|
|
||||||
|
<StatusPanel config={config} />
|
||||||
|
|
||||||
|
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Base URL (REST)</span>
|
||||||
|
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">WebSocket URL (feed)</span>
|
||||||
|
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Auth token</span>
|
||||||
|
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||||
|
<span className="field-label">Protocol</span>
|
||||||
|
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||||
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TownCrier />
|
||||||
|
|
||||||
|
<AdminLiveFeed />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
|
||||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
@@ -313,7 +311,7 @@ function Note({ msg, error }) {
|
|||||||
|
|
||||||
// ── Page ───────────────────────────────────────────────────────────────────
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
export default function PlayerAccount() {
|
export default function PlayerAccount() {
|
||||||
const { logout, refresh } = useAuth()
|
const { refresh } = useAuth()
|
||||||
const [account, setAccount] = useState(null)
|
const [account, setAccount] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -336,33 +334,16 @@ export default function PlayerAccount() {
|
|||||||
}, [load, refresh])
|
}, [load, refresh])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
<div>
|
||||||
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
|
<h1 className="display" style={{ margin: '0 0 4px', fontSize: '1.6rem', color: 'var(--head)' }}>Account</h1>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<MoonDot size={12} glow={0.5} />
|
|
||||||
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
|
|
||||||
My Account
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
||||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
|
||||||
← Site
|
|
||||||
</Link>
|
|
||||||
<button onClick={logout} className="pill">Sign out</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
|
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} />}
|
||||||
{!loading && !error && account && (
|
{!loading && !error && account && (
|
||||||
<>
|
<>
|
||||||
<div style={{ paddingTop: 24 }}>
|
|
||||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||||
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||||
{account.email ? ` · ${account.email}` : ''}
|
{account.email ? ` · ${account.email}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||||
<ChangePassword account={account} />
|
<ChangePassword account={account} />
|
||||||
<TwoFactor account={account} reload={load} />
|
<TwoFactor account={account} reload={load} />
|
||||||
@@ -370,6 +351,5 @@ export default function PlayerAccount() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
29
client/src/routes/player/PlayerCharacter.jsx
Normal file
29
client/src/routes/player/PlayerCharacter.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// A player's character sheet inside the portal. Owner-checked: the endpoint only
|
||||||
|
// returns a sheet for a character on an account linked to the caller.
|
||||||
|
export default function PlayerCharacter() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
const forbidden = error && error.status === 403
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: '0 0 18px' }}>
|
||||||
|
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to characters
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||||
|
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
client/src/routes/player/PlayerCharacters.jsx
Normal file
16
client/src/routes/player/PlayerCharacters.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../components/VendorSales.jsx'
|
||||||
|
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.
|
||||||
|
export default function PlayerCharacters() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="display" style={{ margin: '0 0 18px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
||||||
|
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
||||||
|
<VendorSales fetchSales={api.player.shard.sales} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export default function PlayerLogin() {
|
|||||||
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/account'
|
const dest = location.state?.from?.pathname || '/player'
|
||||||
// A staff member who signs in here belongs in the admin shell, not the portal.
|
// A staff member who signs in here belongs in the admin shell, not the portal.
|
||||||
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
|
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
|
||||||
|
|
||||||
|
|||||||
54
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
54
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { NavLink, Link, Outlet, useNavigate } from 'react-router-dom'
|
||||||
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Shared shell for the logged-in player portal: a header with a nav bar
|
||||||
|
// (Characters / Account) and the page content in an <Outlet />. Matches the
|
||||||
|
// site's dark theme vocabulary.
|
||||||
|
const tab = ({ isActive }) => ({
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontFamily: 'var(--sans)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
padding: '8px 4px',
|
||||||
|
color: isActive ? 'var(--head)' : 'var(--muted)',
|
||||||
|
borderBottom: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function PlayerPortalLayout() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout()
|
||||||
|
navigate('/account/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
||||||
|
<header style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: '16px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<MoonDot size={12} glow={0.5} />
|
||||||
|
<div>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.05rem', letterSpacing: '0.04em' }}>UOMysticmoon</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.64rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>Player Portal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.82rem' }}>{user?.username}</span>
|
||||||
|
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>← Site</Link>
|
||||||
|
<button onClick={signOut} className="pill">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav style={{ maxWidth: 820, margin: '0 auto', padding: '0 20px', display: 'flex', gap: 22 }}>
|
||||||
|
<NavLink to="/player" end style={tab}>Characters</NavLink>
|
||||||
|
<NavLink to="/account" style={tab}>Account</NavLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: '28px 20px 60px' }}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export default function PlayerRegister() {
|
|||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && user.role === 'player') navigate('/account', { replace: true })
|
if (user && user.role === 'player') navigate('/player', { replace: true })
|
||||||
}, [user, navigate])
|
}, [user, navigate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,7 +51,7 @@ export default function PlayerRegister() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
||||||
navigate('/account', { replace: true })
|
navigate('/player', { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 409) setError('That username is already taken.')
|
if (err.status === 409) setError('That username is already taken.')
|
||||||
else if (err.status === 403) setError('Registration is not open right now.')
|
else if (err.status === 403) setError('Registration is not open right now.')
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function Portal() {
|
|||||||
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout header={false}>
|
<PublicLayout>
|
||||||
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
{PREVIEW && draft && (
|
{PREVIEW && draft && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
222
client/src/routes/public/Shard.jsx
Normal file
222
client/src/routes/public/Shard.jsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||||
|
function Sparkline({ series }) {
|
||||||
|
if (!series || series.length < 2) return null
|
||||||
|
const w = 320
|
||||||
|
const h = 56
|
||||||
|
const golds = series.map((s) => Number(s.gold) || 0)
|
||||||
|
const min = Math.min(...golds)
|
||||||
|
const max = Math.max(...golds)
|
||||||
|
const span = max - min || 1
|
||||||
|
const pts = series
|
||||||
|
.map((s, i) => {
|
||||||
|
const x = (i / (series.length - 1)) * w
|
||||||
|
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
||||||
|
function Stat({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Shard() {
|
||||||
|
const { loading, error, data } = useAsync(() =>
|
||||||
|
Promise.all([
|
||||||
|
api.shard.status(),
|
||||||
|
api.shard.idoc(),
|
||||||
|
api.shard.economy(60),
|
||||||
|
api.shard.online(),
|
||||||
|
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||||
|
)
|
||||||
|
const { events, connected } = useShardFeed({ max: 30 })
|
||||||
|
|
||||||
|
const status = data?.status
|
||||||
|
const online = status?.pluginConnected
|
||||||
|
const gold = status?.economy?.gold
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Live" title="Shard" />
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load shard data right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{/* Connection banner */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
padding: '24px 26px',
|
||||||
|
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: online
|
||||||
|
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||||
|
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||||
|
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||||
|
{online ? 'The shard is online' : 'The shard is offline'}
|
||||||
|
</strong>
|
||||||
|
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||||
|
{online
|
||||||
|
? 'The gate to Britannia stands open.'
|
||||||
|
: status?.enabled
|
||||||
|
? 'The link to the game world is down — checking back automatically.'
|
||||||
|
: 'Live shard data is not configured yet.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stat tiles */}
|
||||||
|
<section className="grid-3" style={{ gap: 14, marginBottom: 24 }}>
|
||||||
|
<Stat value={status?.onlineCount ?? '—'} label="Players online" />
|
||||||
|
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||||
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 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 }}>
|
||||||
|
Staff online
|
||||||
|
</div>
|
||||||
|
{(!data.online || data.online.length === 0) ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.online.map((p) => (
|
||||||
|
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||||
|
{p.name || p.serial}
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||||
|
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Economy sparkline */}
|
||||||
|
{data.economy && data.economy.length > 1 && (
|
||||||
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Gold supply over time
|
||||||
|
</div>
|
||||||
|
<Sparkline series={data.economy} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
{/* Latest IDOC */}
|
||||||
|
<FeedList
|
||||||
|
title="Houses in danger (IDOC)"
|
||||||
|
empty="No houses are collapsing right now."
|
||||||
|
items={data.idoc.map((h) => ({
|
||||||
|
id: h.serial,
|
||||||
|
text: `${h.name || 'A house'}${h.region ? ` — ${h.region}` : ''}`,
|
||||||
|
when: h.updatedAt,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live ticker */}
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||||
|
Live feed
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
||||||
|
View all activity →
|
||||||
|
</Link>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||||
|
Waiting for something to happen in the world…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedList({ title, items, empty }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
client/src/routes/public/ShardActivity.jsx
Normal file
81
client/src/routes/public/ShardActivity.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// Public activity feed: the full shard event log, filterable by category, with a
|
||||||
|
// live tail that prepends new events as they happen.
|
||||||
|
export default function ShardActivity() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
||||||
|
const { events: live } = useShardFeed({ max: 60 })
|
||||||
|
const [cat, setCat] = useState('all')
|
||||||
|
|
||||||
|
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
||||||
|
const merged = useMemo(() => {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const e of [...live, ...(data || [])]) {
|
||||||
|
const key = `${e.kind}-${e.t}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(e)
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
||||||
|
}, [live, data])
|
||||||
|
|
||||||
|
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Live" title="Shard Activity" />
|
||||||
|
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
||||||
|
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setCat(c.id)}
|
||||||
|
className="pill"
|
||||||
|
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the activity feed right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
filtered.length === 0 ? (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{filtered.map((e) => (
|
||||||
|
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
||||||
|
{kindLabel(e.kind)}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -259,6 +259,130 @@ CREATE TABLE IF NOT EXISTS email_config (
|
|||||||
CONSTRAINT chk_email_config_singleton CHECK (id = 1)
|
CONSTRAINT chk_email_config_singleton CHECK (id = 1)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ── uo-link sidecar ────────────────────────────────────────────────────────
|
||||||
|
-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the
|
||||||
|
-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the
|
||||||
|
-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never
|
||||||
|
-- plaintext, and it is only decrypted server-side (to call the sidecar). It is
|
||||||
|
-- never returned to the admin UI — responses expose only `hasToken`. base_url is
|
||||||
|
-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because
|
||||||
|
-- in production the sidecar runs on a different host from the website. `status`/
|
||||||
|
-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known
|
||||||
|
-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId
|
||||||
|
-- so a shard restart can be detected (and caches dropped).
|
||||||
|
CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
|
base_url VARCHAR(255) NULL,
|
||||||
|
ws_url VARCHAR(255) NULL,
|
||||||
|
auth_token_enc TEXT NULL,
|
||||||
|
protocol INT NOT NULL DEFAULT 1,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||||
|
status_detail VARCHAR(500) NULL,
|
||||||
|
plugin_connected TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
last_event_at DATETIME NULL,
|
||||||
|
boot_id VARCHAR(64) NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Append-only log of notable shard events ingested from the uo-link WebSocket
|
||||||
|
-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS
|
||||||
|
-- client writes here, and the public/admin read endpoints + live feeds read from
|
||||||
|
-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed,
|
||||||
|
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
||||||
|
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
||||||
|
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
||||||
|
-- dedupe_key = sha1(kind + t + stable-json(payload)); with the UNIQUE index it
|
||||||
|
-- makes INSERT IGNORE idempotent so WS-reconnect backfill never double-inserts.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_events (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
kind VARCHAR(48) NOT NULL,
|
||||||
|
t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar)
|
||||||
|
boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId)
|
||||||
|
payload JSON NOT NULL, -- the full event object
|
||||||
|
dedupe_key CHAR(40) NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_events_kind_t (kind, t),
|
||||||
|
INDEX idx_shard_events_t (t)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Current online players. Upserted on mob.login, refreshed on char.vitals, and
|
||||||
|
-- removed on mob.logout. Cleared wholesale when the shard restarts (a new
|
||||||
|
-- server.hello.bootId). web_id is the linked website user id (present when the
|
||||||
|
-- account is linked), so the roster can be correlated to site accounts.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_online (
|
||||||
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key)
|
||||||
|
name VARCHAR(120) NULL,
|
||||||
|
acct VARCHAR(120) NULL,
|
||||||
|
web_id INT NULL,
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
hits INT NULL,
|
||||||
|
hits_max INT NULL,
|
||||||
|
mana INT NULL,
|
||||||
|
mana_max INT NULL,
|
||||||
|
stam INT NULL,
|
||||||
|
stam_max INT NULL,
|
||||||
|
str INT NULL,
|
||||||
|
dex INT NULL,
|
||||||
|
`int` INT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_online_acct (acct)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Total-gold-supply time series (from the periodic economy.supply event). Kept
|
||||||
|
-- append-only so the public status page can render a supply-over-time sparkline.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_economy (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
accounts INT NULL, -- number of accounts included in the total
|
||||||
|
gold BIGINT NULL, -- total gold supply across all accounts
|
||||||
|
t BIGINT NOT NULL, -- sample time, epoch ms
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_economy_t (t)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Current decay stage per house, upserted on house.decay. is_idoc is a derived
|
||||||
|
-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap
|
||||||
|
-- indexed lookup rather than a scan.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_houses (
|
||||||
|
serial VARCHAR(20) NOT NULL PRIMARY KEY,
|
||||||
|
stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ...
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
region VARCHAR(120) NULL,
|
||||||
|
name VARCHAR(160) NULL,
|
||||||
|
owner_serial VARCHAR(20) NULL,
|
||||||
|
owner_acct VARCHAR(120) NULL,
|
||||||
|
built_on DATETIME NULL,
|
||||||
|
last_refreshed DATETIME NULL,
|
||||||
|
is_idoc TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_houses_idoc (is_idoc)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Site-side mirror of in-game-account → website-user links. The sidecar is the
|
||||||
|
-- source of truth (it tags the game account with the websiteUserId on
|
||||||
|
-- /link/confirm); this table mirrors it so the player portal can list a user's
|
||||||
|
-- linked accounts and enforce ownership on roster/vendor reads without a shard
|
||||||
|
-- round-trip. account is unique (one game account maps to at most one site user);
|
||||||
|
-- a single user may link several game accounts.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_account_links (
|
||||||
|
account VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
char_name VARCHAR(120) NULL,
|
||||||
|
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_shard_links_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
-- 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
|
-- 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
|
-- writes them. They live in the same physical database as everything else
|
||||||
|
|||||||
24
server/package-lock.json
generated
24
server/package-lock.json
generated
@@ -26,7 +26,8 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sanitize-html": "^2.17.5",
|
"sanitize-html": "^2.17.5",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"swagger-ui-express": "^5.0.1"
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.4",
|
"nodemon": "^3.1.4",
|
||||||
@@ -2386,6 +2387,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sanitize-html": "^2.17.5",
|
"sanitize-html": "^2.17.5",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"swagger-ui-express": "^5.0.1"
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.4",
|
"nodemon": "^3.1.4",
|
||||||
|
|||||||
41
server/src/model/shardEvents/shardEvents.db.js
Normal file
41
server/src/model/shardEvents/shardEvents.db.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
// INSERT IGNORE on the UNIQUE dedupe_key — a re-ingested event (WS-reconnect
|
||||||
|
// backfill overlap) is silently skipped rather than duplicated. Returns true if
|
||||||
|
// a new row was actually inserted.
|
||||||
|
async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT IGNORE INTO shard_events (kind, t, boot_id, payload, dedupe_key)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[kind, t, bootId || null, JSON.stringify(payload), dedupeKey],
|
||||||
|
)
|
||||||
|
return res.affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent events, newest first. Filter by a single `kind`, or an allowlist of
|
||||||
|
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
||||||
|
// staff/sensitive kinds. limit is clamped by the model.
|
||||||
|
async function list({ kind, kinds, limit }) {
|
||||||
|
if (kinds && kinds.length) {
|
||||||
|
const placeholders = kinds.map(() => '?').join(', ')
|
||||||
|
return query(
|
||||||
|
`SELECT id, kind, t, boot_id, payload, created_at
|
||||||
|
FROM shard_events WHERE kind IN (${placeholders}) ORDER BY t DESC LIMIT ?`,
|
||||||
|
[...kinds, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (kind) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, kind, t, boot_id, payload, created_at
|
||||||
|
FROM shard_events WHERE kind = ? ORDER BY t DESC LIMIT ?`,
|
||||||
|
[kind, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT id, kind, t, boot_id, payload, created_at
|
||||||
|
FROM shard_events ORDER BY t DESC LIMIT ?`,
|
||||||
|
[limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { insertIgnore, list }
|
||||||
53
server/src/model/shardEvents/shardEvents.model.js
Normal file
53
server/src/model/shardEvents/shardEvents.model.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Append-only shard event log. The WS ingest dispatcher calls append() for the
|
||||||
|
// notable kinds; the public/admin read endpoints call list(). The DB layer only
|
||||||
|
// sees an already-computed dedupe_key so INSERT IGNORE is idempotent across
|
||||||
|
// WS-reconnect backfill.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const db = require('./shardEvents.db')
|
||||||
|
|
||||||
|
const MAX_LIMIT = 1000
|
||||||
|
const DEFAULT_LIMIT = 100
|
||||||
|
|
||||||
|
// Stable stringify — keys sorted — so the dedupe hash is independent of the
|
||||||
|
// property order the sidecar happened to serialize with.
|
||||||
|
function stableStringify(value) {
|
||||||
|
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
||||||
|
const keys = Object.keys(value).sort()
|
||||||
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupe_key = sha1(kind + t + stable-json(payload)). Two identical events (same
|
||||||
|
// kind, same timestamp, same body) collapse to one row.
|
||||||
|
function dedupeKey(kind, t, payload) {
|
||||||
|
return crypto.createHash('sha1').update(`${kind}|${t}|${stableStringify(payload)}`).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append one event. Returns true if a new row was inserted (false = deduped).
|
||||||
|
async function append({ kind, t, bootId, payload }) {
|
||||||
|
return db.insertIgnore({ kind, t, bootId, payload, dedupeKey: dedupeKey(kind, t, payload) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(limit) {
|
||||||
|
const n = Number(limit)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT
|
||||||
|
return Math.min(Math.floor(n), MAX_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent events, newest first. Each row's JSON payload is parsed back to an
|
||||||
|
// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind.
|
||||||
|
async function list({ kind, kinds, limit } = {}) {
|
||||||
|
const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) })
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
t: row.t,
|
||||||
|
bootId: row.boot_id || null,
|
||||||
|
// mariadb returns JSON columns as strings on some versions; parse defensively.
|
||||||
|
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { append, list, dedupeKey }
|
||||||
36
server/src/model/shardLinks/shardLinks.db.js
Normal file
36
server/src/model/shardLinks/shardLinks.db.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const COLS = 'account, user_id, char_name, linked_at'
|
||||||
|
|
||||||
|
// Upsert a link. account is the PK, so a re-link moves the account to the new
|
||||||
|
// user (the sidecar already treats /link/confirm as authoritative).
|
||||||
|
async function upsert({ account, userId, charName }) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO shard_account_links (account, user_id, char_name)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
|
||||||
|
[account, userId, charName || null],
|
||||||
|
)
|
||||||
|
return getByAccount(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByAccount(account) {
|
||||||
|
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const listByUser = (userId) =>
|
||||||
|
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
|
||||||
|
|
||||||
|
async function isOwnedBy(account, userId) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
|
||||||
|
[account, userId],
|
||||||
|
)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (account, userId) =>
|
||||||
|
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||||
|
|
||||||
|
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove }
|
||||||
34
server/src/model/shardLinks/shardLinks.model.js
Normal file
34
server/src/model/shardLinks/shardLinks.model.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
|
||||||
|
// authoritative link (it tags the game account on /link/confirm); this model
|
||||||
|
// records it locally so the player portal can list links and enforce ownership.
|
||||||
|
|
||||||
|
const db = require('./shardLinks.db')
|
||||||
|
|
||||||
|
function toSafe(row) {
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
account: row.account,
|
||||||
|
userId: row.user_id,
|
||||||
|
charName: row.char_name || null,
|
||||||
|
linkedAt: row.linked_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function link({ account, userId, charName }) {
|
||||||
|
return toSafe(await db.upsert({ account, userId, charName }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listForUser(userId) {
|
||||||
|
const rows = await db.listByUser(userId)
|
||||||
|
return rows.map(toSafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
|
||||||
|
|
||||||
|
async function getByAccount(account) {
|
||||||
|
return toSafe(await db.getByAccount(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlink = (account, userId) => db.remove(account, userId)
|
||||||
|
|
||||||
|
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink }
|
||||||
104
server/src/model/shardState/shardState.db.js
Normal file
104
server/src/model/shardState/shardState.db.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
// ── Online players ─────────────────────────────────────────────────────────
|
||||||
|
const ONLINE_COLS =
|
||||||
|
'serial, name, acct, web_id, map, x, y, z, hits, hits_max, mana, mana_max, stam, stam_max, str, dex, `int`, updated_at'
|
||||||
|
|
||||||
|
// Upsert one online player. `fields` already prepared by the model (only the
|
||||||
|
// columns it wants to write); serial is required and is the primary key.
|
||||||
|
async function upsertOnline(serial, fields) {
|
||||||
|
const cols = Object.keys(fields)
|
||||||
|
const allCols = ['serial', ...cols]
|
||||||
|
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||||
|
const placeholders = allCols.map(() => '?').join(', ')
|
||||||
|
// Never overwrite an existing column with NULL on refresh (a char.vitals frame
|
||||||
|
// that omits acct/name shouldn't blank what mob.login set) — COALESCE keeps the
|
||||||
|
// prior value when the incoming one is NULL.
|
||||||
|
const updates = cols.map((c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`).join(', ')
|
||||||
|
await query(
|
||||||
|
`INSERT INTO shard_online (${insertCols}) VALUES (${placeholders})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||||
|
[serial, ...cols.map((c) => fields[c])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeOnline = (serial) => query('DELETE FROM shard_online WHERE serial = ?', [serial])
|
||||||
|
const clearOnline = () => query('DELETE FROM shard_online')
|
||||||
|
|
||||||
|
async function countOnline() {
|
||||||
|
const rows = await query('SELECT COUNT(*) AS n FROM shard_online')
|
||||||
|
return rows[0] ? Number(rows[0].n) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const listOnline = () =>
|
||||||
|
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
||||||
|
|
||||||
|
// Staff roles whose online presence is shown on the public Shard page. Players
|
||||||
|
// who link an account are NOT surfaced publicly — only staff opt into visibility
|
||||||
|
// by virtue of being staff.
|
||||||
|
const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
|
||||||
|
|
||||||
|
// Online players whose game account is linked to a STAFF website user. Joined
|
||||||
|
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
|
||||||
|
// effect immediately, regardless of whether the player has re-logged since
|
||||||
|
// linking, then through to users so only staff roles are surfaced publicly.
|
||||||
|
const listOnlineLinked = () =>
|
||||||
|
query(
|
||||||
|
`SELECT ${ONLINE_COLS.split(', ').map((c) => `o.${c}`).join(', ')}
|
||||||
|
FROM shard_online o
|
||||||
|
JOIN shard_account_links l ON l.account = o.acct
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')})
|
||||||
|
ORDER BY o.name ASC`,
|
||||||
|
PUBLIC_ONLINE_ROLES,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Economy supply series ────────────────────────────────────────────────
|
||||||
|
const insertEconomy = ({ accounts, gold, t }) =>
|
||||||
|
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
|
||||||
|
accounts ?? null,
|
||||||
|
gold ?? null,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
|
const listEconomy = (limit) =>
|
||||||
|
query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT ?', [limit])
|
||||||
|
|
||||||
|
async function latestEconomy() {
|
||||||
|
const rows = await query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT 1')
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Houses / IDOC ────────────────────────────────────────────────────────
|
||||||
|
const HOUSE_COLS =
|
||||||
|
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
|
||||||
|
|
||||||
|
async function upsertHouse(serial, fields) {
|
||||||
|
const cols = Object.keys(fields)
|
||||||
|
const allCols = ['serial', ...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_houses (${insertCols}) VALUES (${placeholders})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||||
|
[serial, ...cols.map((c) => fields[c])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const listIdocHouses = () =>
|
||||||
|
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertOnline,
|
||||||
|
removeOnline,
|
||||||
|
clearOnline,
|
||||||
|
countOnline,
|
||||||
|
listOnline,
|
||||||
|
listOnlineLinked,
|
||||||
|
insertEconomy,
|
||||||
|
listEconomy,
|
||||||
|
latestEconomy,
|
||||||
|
upsertHouse,
|
||||||
|
listIdocHouses,
|
||||||
|
}
|
||||||
171
server/src/model/shardState/shardState.model.js
Normal file
171
server/src/model/shardState/shardState.model.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Live shard state derived from the WS feed: who is online, the gold-supply
|
||||||
|
// series, and per-house decay stage. The ingest dispatcher calls the write
|
||||||
|
// methods; the public read endpoints call the list/count methods. Writes take
|
||||||
|
// camelCase semantic objects and map to the snake_case columns; only the keys
|
||||||
|
// present are written (so a char.vitals refresh doesn't clobber login fields).
|
||||||
|
|
||||||
|
const db = require('./shardState.db')
|
||||||
|
|
||||||
|
const MAX_ECONOMY = 1000
|
||||||
|
|
||||||
|
// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
|
||||||
|
// partial refresh only touches the fields it carries.
|
||||||
|
function onlineFields(data) {
|
||||||
|
const map = {
|
||||||
|
name: data.name,
|
||||||
|
acct: data.acct,
|
||||||
|
web_id: data.webId,
|
||||||
|
map: data.map,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
z: data.z,
|
||||||
|
hits: data.hits,
|
||||||
|
hits_max: data.hitsMax,
|
||||||
|
mana: data.mana,
|
||||||
|
mana_max: data.manaMax,
|
||||||
|
stam: data.stam,
|
||||||
|
stam_max: data.stamMax,
|
||||||
|
str: data.str,
|
||||||
|
dex: data.dex,
|
||||||
|
int: data.int,
|
||||||
|
}
|
||||||
|
const fields = {}
|
||||||
|
for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert an online player (mob.login) or refresh their vitals (char.vitals).
|
||||||
|
async function upsertOnline(data) {
|
||||||
|
if (!data || !data.serial) return
|
||||||
|
await db.upsertOnline(data.serial, onlineFields(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setOffline = (serial) => db.removeOnline(serial)
|
||||||
|
const clearOnline = () => db.clearOnline()
|
||||||
|
const onlineCount = () => db.countOnline()
|
||||||
|
|
||||||
|
function shapeOnline(r) {
|
||||||
|
return {
|
||||||
|
serial: r.serial,
|
||||||
|
name: r.name,
|
||||||
|
acct: r.acct,
|
||||||
|
webId: r.web_id,
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
hits: r.hits,
|
||||||
|
hitsMax: r.hits_max,
|
||||||
|
mana: r.mana,
|
||||||
|
manaMax: r.mana_max,
|
||||||
|
stam: r.stam,
|
||||||
|
stamMax: r.stam_max,
|
||||||
|
str: r.str,
|
||||||
|
dex: r.dex,
|
||||||
|
int: r.int,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only players whose account is linked to a website user (opt-in visibility).
|
||||||
|
async function listOnlineLinked() {
|
||||||
|
const rows = await db.listOnlineLinked()
|
||||||
|
return rows.map(shapeOnline)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listOnline() {
|
||||||
|
const rows = await db.listOnline()
|
||||||
|
return rows.map((r) => ({
|
||||||
|
serial: r.serial,
|
||||||
|
name: r.name,
|
||||||
|
acct: r.acct,
|
||||||
|
webId: r.web_id,
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
hits: r.hits,
|
||||||
|
hitsMax: r.hits_max,
|
||||||
|
mana: r.mana,
|
||||||
|
manaMax: r.mana_max,
|
||||||
|
stam: r.stam,
|
||||||
|
stamMax: r.stam_max,
|
||||||
|
str: r.str,
|
||||||
|
dex: r.dex,
|
||||||
|
int: r.int,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append a gold-supply sample (economy.supply).
|
||||||
|
async function addEconomySample({ accounts, gold, t }) {
|
||||||
|
await db.insertEconomy({ accounts, gold, t })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listEconomy(limit = 100) {
|
||||||
|
const n = Math.min(Math.max(Number(limit) || 100, 1), MAX_ECONOMY)
|
||||||
|
const rows = await db.listEconomy(n)
|
||||||
|
// Return oldest → newest for charting.
|
||||||
|
return rows
|
||||||
|
.map((r) => ({ accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t }))
|
||||||
|
.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestEconomy() {
|
||||||
|
const r = await db.latestEconomy()
|
||||||
|
return r ? { accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert a house's decay stage (house.decay). is_idoc is derived from the stage.
|
||||||
|
async function upsertHouse(data) {
|
||||||
|
if (!data || !data.serial) return
|
||||||
|
const fields = {
|
||||||
|
stage: data.stage ?? null,
|
||||||
|
map: data.map ?? null,
|
||||||
|
x: data.x ?? null,
|
||||||
|
y: data.y ?? null,
|
||||||
|
z: data.z ?? null,
|
||||||
|
region: data.region ?? null,
|
||||||
|
name: data.name ?? null,
|
||||||
|
owner_serial: data.ownerSerial ?? null,
|
||||||
|
owner_acct: data.ownerAcct ?? null,
|
||||||
|
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||||
|
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||||
|
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
|
||||||
|
}
|
||||||
|
await db.upsertHouse(data.serial, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listIdoc() {
|
||||||
|
const rows = await db.listIdocHouses()
|
||||||
|
return rows.map((r) => ({
|
||||||
|
serial: r.serial,
|
||||||
|
stage: r.stage,
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
region: r.region,
|
||||||
|
name: r.name,
|
||||||
|
ownerSerial: r.owner_serial,
|
||||||
|
ownerAcct: r.owner_acct,
|
||||||
|
builtOn: r.built_on,
|
||||||
|
lastRefreshed: r.last_refreshed,
|
||||||
|
isIdoc: Boolean(r.is_idoc),
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertOnline,
|
||||||
|
setOffline,
|
||||||
|
clearOnline,
|
||||||
|
onlineCount,
|
||||||
|
listOnline,
|
||||||
|
listOnlineLinked,
|
||||||
|
addEconomySample,
|
||||||
|
listEconomy,
|
||||||
|
latestEconomy,
|
||||||
|
upsertHouse,
|
||||||
|
listIdoc,
|
||||||
|
}
|
||||||
28
server/src/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
28
server/src/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const COLS =
|
||||||
|
'id, base_url, ws_url, auth_token_enc, protocol, enabled, status, status_detail, plugin_connected, last_event_at, boot_id, updated_by, created_at, updated_at'
|
||||||
|
|
||||||
|
// Singleton row (id = 1). Returns null until the admin saves it for the first time.
|
||||||
|
async function get() {
|
||||||
|
const rows = await query(`SELECT ${COLS} FROM uo_link_config WHERE id = 1 LIMIT 1`)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the singleton row. `fields` are column values already prepared by the
|
||||||
|
// model (token pre-encrypted). Only the provided columns are written/updated.
|
||||||
|
async function upsert(fields) {
|
||||||
|
const cols = Object.keys(fields)
|
||||||
|
const vals = cols.map((c) => fields[c])
|
||||||
|
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
|
||||||
|
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
|
||||||
|
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||||
|
await query(
|
||||||
|
`INSERT INTO uo_link_config (${insertCols}) VALUES (${placeholders})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
return get()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { get, upsert }
|
||||||
85
server/src/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
85
server/src/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// uo-link sidecar connection config store. Mirrors botConfig/emailConfig: the DB
|
||||||
|
// layer only ever sees ciphertext, and only getWithToken() (used server-side to
|
||||||
|
// call the sidecar over REST/WS) decrypts it. The admin-facing getSafe() never
|
||||||
|
// includes the token — it exposes only `hasToken`. A blank `token` on save means
|
||||||
|
// "leave the existing token unchanged" (same convention as the other configs).
|
||||||
|
|
||||||
|
const db = require('./uoLinkConfig.db')
|
||||||
|
const secretBox = require('../../utils/secretBox')
|
||||||
|
|
||||||
|
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1
|
||||||
|
|
||||||
|
function toSafe(row) {
|
||||||
|
if (!row) {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.UOLINK_BASE_URL || null,
|
||||||
|
wsUrl: process.env.UOLINK_WS_URL || null,
|
||||||
|
protocol: DEFAULT_PROTOCOL,
|
||||||
|
enabled: false,
|
||||||
|
hasToken: false,
|
||||||
|
status: 'disconnected',
|
||||||
|
statusDetail: null,
|
||||||
|
pluginConnected: false,
|
||||||
|
lastEventAt: null,
|
||||||
|
bootId: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: row.base_url || null,
|
||||||
|
wsUrl: row.ws_url || null,
|
||||||
|
protocol: row.protocol || DEFAULT_PROTOCOL,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
hasToken: Boolean(row.auth_token_enc),
|
||||||
|
status: row.status || 'disconnected',
|
||||||
|
statusDetail: row.status_detail || null,
|
||||||
|
pluginConnected: Boolean(row.plugin_connected),
|
||||||
|
lastEventAt: row.last_event_at || null,
|
||||||
|
bootId: row.boot_id || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSafe() {
|
||||||
|
return toSafe(await db.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypted token included — server-side only (calling the sidecar's REST/WS
|
||||||
|
// API). Returns null when nothing has been saved yet.
|
||||||
|
async function getWithToken() {
|
||||||
|
const row = await db.get()
|
||||||
|
if (!row) return null
|
||||||
|
return { ...toSafe(row), token: row.auth_token_enc ? secretBox.decrypt(row.auth_token_enc) : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save admin-supplied config. `token` undefined or '' means "leave the existing
|
||||||
|
// token unchanged" (same convention as botConfig.save).
|
||||||
|
async function save({ baseUrl, wsUrl, token, protocol, enabled, updatedBy }) {
|
||||||
|
const fields = {}
|
||||||
|
if (baseUrl !== undefined) fields.base_url = baseUrl
|
||||||
|
if (wsUrl !== undefined) fields.ws_url = wsUrl
|
||||||
|
if (token) fields.auth_token_enc = secretBox.encrypt(token)
|
||||||
|
if (protocol !== undefined) fields.protocol = protocol
|
||||||
|
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||||
|
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||||
|
const row = await db.upsert(fields)
|
||||||
|
return toSafe(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the sidecar's last-reported connection state into the DB so the admin
|
||||||
|
// panel has something to show between polls and the public status endpoint can
|
||||||
|
// read it without a live round-trip.
|
||||||
|
async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt, bootId }) {
|
||||||
|
const fields = {}
|
||||||
|
if (status !== undefined) fields.status = status
|
||||||
|
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||||
|
if (pluginConnected !== undefined) fields.plugin_connected = pluginConnected ? 1 : 0
|
||||||
|
// lastEventAt may arrive as an ISO string (e.g. "2026-07-10T22:08:27Z"); the
|
||||||
|
// mariadb DATETIME parser rejects the "T"/"Z", so hand it a real Date (same
|
||||||
|
// fix as botConfig.recordStatus's last_connected_at).
|
||||||
|
if (lastEventAt !== undefined) fields.last_event_at = lastEventAt ? new Date(lastEventAt) : null
|
||||||
|
if (bootId !== undefined) fields.boot_id = bootId
|
||||||
|
if (Object.keys(fields).length === 0) return getSafe()
|
||||||
|
const row = await db.upsert(fields)
|
||||||
|
return toSafe(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSafe, getWithToken, save, recordStatus }
|
||||||
@@ -11,6 +11,8 @@ const botActivity = require('./botActivity.controller')
|
|||||||
const authProviders = require('./authProviders.controller')
|
const authProviders = require('./authProviders.controller')
|
||||||
const discordBot = require('./discordBot.controller')
|
const discordBot = require('./discordBot.controller')
|
||||||
const emailConfig = require('./emailConfig.controller')
|
const emailConfig = require('./emailConfig.controller')
|
||||||
|
const uoLink = require('./uoLink.controller')
|
||||||
|
const selfShard = require('../player/shard.controller')
|
||||||
const moderation = require('./moderation.controller')
|
const moderation = require('./moderation.controller')
|
||||||
const pagesCtrl = require('./pages.controller')
|
const pagesCtrl = require('./pages.controller')
|
||||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||||
@@ -109,6 +111,75 @@ adminRouter.delete(
|
|||||||
account.unlinkIdentity,
|
account.unlinkIdentity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Game account linking (self-service, any staff role) ───────────────
|
||||||
|
// Staff link their OWN in-game account here, exactly like players do under
|
||||||
|
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||||||
|
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||||
|
adminRouter.post(
|
||||||
|
'/shard/link',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||||
|
validate,
|
||||||
|
selfShard.link,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/accounts',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||||
|
selfShard.listAccounts,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/roster/:account',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
selfShard.roster,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/vendors/:account',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
selfShard.vendors,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/char/:serial',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
validate,
|
||||||
|
selfShard.getChar,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/sales',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
|
selfShard.getSales,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||||
const UPLOAD_DIR =
|
const UPLOAD_DIR =
|
||||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
||||||
@@ -985,4 +1056,75 @@ adminRouter.delete(
|
|||||||
ctrl.deleteUser,
|
ctrl.deleteUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||||||
|
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
||||||
|
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
|
||||||
|
adminRouter.get(
|
||||||
|
'/uo-link/config',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
uoLink.getConfig,
|
||||||
|
)
|
||||||
|
adminRouter.put(
|
||||||
|
'/uo-link/config',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Save uo-link connection config (admin only)'
|
||||||
|
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
||||||
|
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
|
||||||
|
body('token').optional({ values: 'falsy' }).isString().trim(),
|
||||||
|
body('protocol').optional().isInt({ min: 1, max: 99 }),
|
||||||
|
body('enabled').optional().isBoolean(),
|
||||||
|
validate,
|
||||||
|
uoLink.saveConfig,
|
||||||
|
)
|
||||||
|
adminRouter.post(
|
||||||
|
'/uo-link/towncrier',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||||
|
body('lines').isArray({ min: 1, max: 8 }),
|
||||||
|
body('lines.*').isString().isLength({ max: 200 }),
|
||||||
|
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
|
||||||
|
validate,
|
||||||
|
uoLink.postTownCrier,
|
||||||
|
)
|
||||||
|
adminRouter.delete(
|
||||||
|
'/uo-link/towncrier/:id',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Remove a town-crier message (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
param('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||||
|
validate,
|
||||||
|
uoLink.deleteTownCrier,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/uo-link/stream',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
|
||||||
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||||
|
adminOnly,
|
||||||
|
uoLink.stream,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = adminRouter
|
module.exports = adminRouter
|
||||||
|
|||||||
123
server/src/router/v1/admin/uoLink.controller.js
Normal file
123
server/src/router/v1/admin/uoLink.controller.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// ── Admin: uo-link sidecar control ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Configure the connection to the uo-link sidecar (base/ws URL, shared-secret
|
||||||
|
// token, protocol pin, enabled) and drive the town crier. SECURITY: the token
|
||||||
|
// is write-only over this API — stored encrypted, NEVER returned; responses
|
||||||
|
// expose only `hasToken` (same convention as the Discord bot token). Saving
|
||||||
|
// (re)starts the WS ingest client so a change takes effect with no redeploy.
|
||||||
|
|
||||||
|
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||||
|
const uoLinkSocket = require('../../../utils/uoLinkSocket')
|
||||||
|
const shardBroadcast = require('../../../utils/shardBroadcast')
|
||||||
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('admin-uolink')
|
||||||
|
|
||||||
|
// Assemble the masked config + live health + ingestion stats for the panel.
|
||||||
|
async function buildStatus() {
|
||||||
|
const config = await uoLinkConfig.getSafe()
|
||||||
|
const health = await uoLinkClient.health()
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
|
||||||
|
ingest: uoLinkSocket.getState(),
|
||||||
|
sse: shardBroadcast.stats(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/uo-link/config — masked config + live status + ingestion stats.
|
||||||
|
async function getConfig(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await buildStatus())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uoLink.getConfig', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/uo-link/config — save connection settings + (re)start the socket.
|
||||||
|
async function saveConfig(req, res) {
|
||||||
|
const { baseUrl, wsUrl, token, protocol, enabled } = req.body
|
||||||
|
try {
|
||||||
|
const current = await uoLinkConfig.getSafe()
|
||||||
|
const willHaveToken = Boolean(token) || current.hasToken
|
||||||
|
if (enabled && !willHaveToken) {
|
||||||
|
return res.status(400).json({ message: 'An auth token is required before enabling.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await uoLinkConfig.save({
|
||||||
|
baseUrl,
|
||||||
|
wsUrl,
|
||||||
|
token,
|
||||||
|
protocol: protocol !== undefined ? Number(protocol) : undefined,
|
||||||
|
enabled,
|
||||||
|
updatedBy: req.user.id,
|
||||||
|
})
|
||||||
|
// Drop the client's cached config so the health check below uses the new values.
|
||||||
|
uoLinkClient.invalidateConfig()
|
||||||
|
|
||||||
|
// (Re)start or stop the ingest socket to match the new enabled/URL/token.
|
||||||
|
const saved = await uoLinkConfig.getSafe()
|
||||||
|
if (saved.enabled && saved.hasToken) {
|
||||||
|
await uoLinkSocket.start()
|
||||||
|
} else {
|
||||||
|
uoLinkSocket.stop()
|
||||||
|
await uoLinkConfig.recordStatus({ status: 'disconnected', pluginConnected: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
await activity.log({ req, action: 'uoLink.config.update', detail: { baseUrl: saved.baseUrl, enabled: saved.enabled } })
|
||||||
|
log.info('uo-link config updated', { by: req.user.username, enabled: saved.enabled })
|
||||||
|
return res.json(await buildStatus())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uoLink.saveConfig', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/uo-link/towncrier — publish/replace a town-crier message.
|
||||||
|
async function postTownCrier(req, res) {
|
||||||
|
const { id, lines, durationSec } = req.body
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.postTownCrier({ id, lines, durationSec })
|
||||||
|
if (result.ok) {
|
||||||
|
await activity.log({ req, action: 'uoLink.towncrier.post', detail: { id } })
|
||||||
|
return res.json(result.data || { ok: true, id })
|
||||||
|
}
|
||||||
|
if (result.status === 400) return res.status(400).json({ message: 'The shard rejected that message (over the line/duration caps?).' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uoLink.postTownCrier', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /admin/uo-link/towncrier/:id — remove a town-crier message.
|
||||||
|
async function deleteTownCrier(req, res) {
|
||||||
|
const { id } = req.params
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.deleteTownCrier(id)
|
||||||
|
if (result.ok) {
|
||||||
|
await activity.log({ req, action: 'uoLink.towncrier.delete', detail: { id } })
|
||||||
|
return res.json(result.data || { ok: true, id })
|
||||||
|
}
|
||||||
|
if (result.status === 404) return res.status(404).json({ message: 'No town-crier message with that id.' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uoLink.deleteTownCrier', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/uo-link/stream — the full live feed (incl. audit/cheat), staff only.
|
||||||
|
function stream(req, res) {
|
||||||
|
shardBroadcast.subscribe(req, res, 'admin')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getConfig, saveConfig, postTownCrier, deleteTownCrier, stream }
|
||||||
@@ -10,6 +10,7 @@ const express = require('express')
|
|||||||
const { body, param } = require('express-validator')
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
const account = require('../admin/account.controller')
|
const account = require('../admin/account.controller')
|
||||||
|
const shard = require('./shard.controller')
|
||||||
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
|
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
|
||||||
const noindex = require('../../../middleware/noindex')
|
const noindex = require('../../../middleware/noindex')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -129,4 +130,78 @@ playerRouter.delete(
|
|||||||
account.unlinkIdentity,
|
account.unlinkIdentity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Game account linking (uo-link) ─────────────────────────────────────────
|
||||||
|
// Link an in-game account with a one-time code from [link, then read the
|
||||||
|
// account's roster / vendors (ownership-checked against the local link mirror).
|
||||||
|
const ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||||
|
playerRouter.post(
|
||||||
|
'/shard/link',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Link an in-game account with a one-time code'
|
||||||
|
// #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||||
|
validate,
|
||||||
|
shard.link,
|
||||||
|
)
|
||||||
|
playerRouter.get(
|
||||||
|
'/shard/accounts',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'List the caller’s linked game accounts'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||||
|
shard.listAccounts,
|
||||||
|
)
|
||||||
|
playerRouter.get(
|
||||||
|
'/shard/roster/:account',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Character roster for a linked account'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
shard.roster,
|
||||||
|
)
|
||||||
|
playerRouter.get(
|
||||||
|
'/shard/vendors/:account',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Player vendors for a linked account'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
shard.vendors,
|
||||||
|
)
|
||||||
|
playerRouter.get(
|
||||||
|
'/shard/char/:serial',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Character sheet — only for a character on the caller’s linked account'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
validate,
|
||||||
|
shard.getChar,
|
||||||
|
)
|
||||||
|
playerRouter.get(
|
||||||
|
'/shard/sales',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
|
shard.getSales,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = playerRouter
|
module.exports = playerRouter
|
||||||
|
|||||||
144
server/src/router/v1/player/shard.controller.js
Normal file
144
server/src/router/v1/player/shard.controller.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// ── Player: game-account linking + reads ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// The player-facing surface for the uo-link integration. A logged-in player
|
||||||
|
// runs [link in game, gets a one-time code, and enters it here — the server
|
||||||
|
// confirms it with the sidecar (which permanently tags the game account with the
|
||||||
|
// website user id) and mirrors the link locally. Roster/vendor reads are
|
||||||
|
// ownership-checked against that mirror so a player can only see accounts they
|
||||||
|
// have linked. The sidecar token stays server-side throughout.
|
||||||
|
|
||||||
|
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||||
|
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||||
|
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||||
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('player-shard')
|
||||||
|
|
||||||
|
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
||||||
|
|
||||||
|
// POST /player/shard/link — confirm an in-game link code.
|
||||||
|
async function link(req, res) {
|
||||||
|
const { code } = req.body
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.confirmLink(code, req.user.id)
|
||||||
|
|
||||||
|
if (result.ok && result.data && result.data.kind === 'link.ok') {
|
||||||
|
const account = result.data.account
|
||||||
|
await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null })
|
||||||
|
await activity.log({ req, action: 'uoLink.account.link', detail: { account } })
|
||||||
|
log.info('player linked game account', { user: req.user.username, account })
|
||||||
|
return res.json({ linked: true, account })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidecar reports bad/expired codes as 400 link.error or 404.
|
||||||
|
if (result.status === 400 || result.status === 404) {
|
||||||
|
return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' })
|
||||||
|
}
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not confirm the link with the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.link', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /player/shard/accounts — the caller's linked game accounts.
|
||||||
|
async function listAccounts(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await shardLinks.listForUser(req.user.id))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.listAccounts', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins may view any character's data; everyone else is limited to accounts
|
||||||
|
// they have personally linked. The same handlers back /player/shard (role
|
||||||
|
// `player`, never admin) and /admin/shard (staff), so this bypass only ever
|
||||||
|
// widens access for genuine admins.
|
||||||
|
const isAdmin = (req) => req.user && req.user.role === 'admin'
|
||||||
|
|
||||||
|
// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the
|
||||||
|
// uoLinkClient method to call with the account.
|
||||||
|
async function ownedRoundTrip(req, res, fetcher, label) {
|
||||||
|
const { account } = req.params
|
||||||
|
try {
|
||||||
|
const owns = isAdmin(req) || (await shardLinks.ownsAccount(account, req.user.id))
|
||||||
|
if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' })
|
||||||
|
|
||||||
|
const result = await fetcher(account)
|
||||||
|
if (result.ok) return res.json(result.data)
|
||||||
|
if (result.status === 404) return res.status(404).json({ message: 'Not found.' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`player.shard.${label}`, err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /player/shard/roster/:account — characters on a linked account.
|
||||||
|
const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster')
|
||||||
|
|
||||||
|
// GET /player/shard/vendors/:account — player vendors on a linked account.
|
||||||
|
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors')
|
||||||
|
|
||||||
|
// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's
|
||||||
|
// account is linked to the caller. The sidecar returns the owning account in the
|
||||||
|
// profile, which we check against the caller's links before returning anything.
|
||||||
|
async function getChar(req, res) {
|
||||||
|
const { serial } = req.params
|
||||||
|
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' })
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.getCharBySerial(serial)
|
||||||
|
if (result.ok) {
|
||||||
|
// Admins see any character; others only characters on an account they linked.
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
const acct = result.data && result.data.acct
|
||||||
|
const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
|
||||||
|
if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
|
||||||
|
}
|
||||||
|
return res.json(result.data)
|
||||||
|
}
|
||||||
|
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.getChar', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /player/shard/sales — recent player-vendor sales for the caller's linked
|
||||||
|
// accounts only (as seller/owner). Read from the site's own event log.
|
||||||
|
async function getSales(req, res) {
|
||||||
|
try {
|
||||||
|
const links = await shardLinks.listForUser(req.user.id)
|
||||||
|
const accounts = new Set(links.map((l) => l.account))
|
||||||
|
if (accounts.size === 0) return res.json([])
|
||||||
|
const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 })
|
||||||
|
const mine = events
|
||||||
|
.filter((e) => e.payload && accounts.has(e.payload.ownerAcct))
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((e) => ({
|
||||||
|
t: e.t,
|
||||||
|
itemType: e.payload.itemType,
|
||||||
|
amount: e.payload.amount,
|
||||||
|
price: e.payload.price,
|
||||||
|
commission: e.payload.commission,
|
||||||
|
ownerAcct: e.payload.ownerAcct,
|
||||||
|
}))
|
||||||
|
return res.json(mine)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.getSales', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { link, listAccounts, roster, vendors, getChar, getSales }
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const { body } = require('express-validator')
|
const { body, param, query } = require('express-validator')
|
||||||
|
|
||||||
const ctrl = require('./public.controller')
|
const ctrl = require('./public.controller')
|
||||||
|
const shard = require('./shard.controller')
|
||||||
const siteMode = require('../../../middleware/siteMode')
|
const siteMode = require('../../../middleware/siteMode')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
const { contactLimiter } = require('../../../middleware/rateLimit')
|
const { contactLimiter } = require('../../../middleware/rateLimit')
|
||||||
@@ -128,4 +129,60 @@ publicRouter.get(
|
|||||||
ctrl.getPage,
|
ctrl.getPage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Shard live data (uo-link) ──────────────────────────────────────────────
|
||||||
|
// Token-free, same-origin reads. The status/feed/economy/idoc endpoints read
|
||||||
|
// the site's own ingested data; /char round-trips the live shard (cached). Not
|
||||||
|
// site-mode gated — shard status is useful even during site maintenance.
|
||||||
|
publicRouter.get(
|
||||||
|
'/shard/status',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
||||||
|
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
||||||
|
shard.getStatus,
|
||||||
|
)
|
||||||
|
publicRouter.get(
|
||||||
|
'/shard/feed',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
||||||
|
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale.' }
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||||
|
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||||
|
validate,
|
||||||
|
shard.getFeed,
|
||||||
|
)
|
||||||
|
publicRouter.get(
|
||||||
|
'/shard/economy',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||||
|
validate,
|
||||||
|
shard.getEconomy,
|
||||||
|
)
|
||||||
|
publicRouter.get(
|
||||||
|
'/shard/online',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Staff online now (linked staff accounts; name + serial + map only)'
|
||||||
|
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||||
|
shard.getOnline,
|
||||||
|
)
|
||||||
|
publicRouter.get(
|
||||||
|
'/shard/idoc',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
||||||
|
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
|
shard.getIdoc,
|
||||||
|
)
|
||||||
|
publicRouter.get(
|
||||||
|
'/shard/stream',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Live shard event stream (Server-Sent Events, public/safe kinds)'
|
||||||
|
// #swagger.description = 'text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.'
|
||||||
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||||
|
shard.stream,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = publicRouter
|
module.exports = publicRouter
|
||||||
|
|||||||
100
server/src/router/v1/public/shard.controller.js
Normal file
100
server/src/router/v1/public/shard.controller.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// ── Public: shard live data ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Same-origin, token-free read endpoints backed by the data the WS ingest
|
||||||
|
// pipeline persists (shard_online / shard_events / shard_economy / shard_houses)
|
||||||
|
// plus a live character round-trip to the sidecar. The browser never sees the
|
||||||
|
// sidecar URL or token — every sidecar call is server-side (uoLinkClient).
|
||||||
|
//
|
||||||
|
// The stored-data endpoints are cheap DB reads. The live /char endpoint hits the
|
||||||
|
// running shard, so it is briefly cached and degrades gracefully: a 503 (shard
|
||||||
|
// restarting) surfaces as a retry-able banner rather than an error.
|
||||||
|
|
||||||
|
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||||
|
const shardState = require('../../../model/shardState/shardState.model')
|
||||||
|
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const broadcast = require('../../../utils/shardBroadcast')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('public-shard')
|
||||||
|
|
||||||
|
// GET /public/shard/status — connection state + online count + latest economy.
|
||||||
|
async function getStatus(req, res) {
|
||||||
|
try {
|
||||||
|
const config = await uoLinkConfig.getSafe()
|
||||||
|
const [online, economy] = await Promise.all([
|
||||||
|
shardState.onlineCount(),
|
||||||
|
shardState.latestEconomy(),
|
||||||
|
])
|
||||||
|
return res.json({
|
||||||
|
enabled: config.enabled,
|
||||||
|
status: config.status,
|
||||||
|
pluginConnected: config.pluginConnected,
|
||||||
|
lastEventAt: config.lastEventAt,
|
||||||
|
onlineCount: online,
|
||||||
|
economy,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getStatus', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/feed?kind=&limit= — recent notable events from the log,
|
||||||
|
// restricted to the public-safe allowlist so staff audit / cheat / link events
|
||||||
|
// (which are stored for the admin channel) can never leak to the public.
|
||||||
|
async function getFeed(req, res) {
|
||||||
|
try {
|
||||||
|
const { kind, limit } = req.query
|
||||||
|
let events
|
||||||
|
if (kind) {
|
||||||
|
// A specific kind is only served if it is itself public-safe.
|
||||||
|
if (!broadcast.PUBLIC_KINDS.has(kind)) return res.json([])
|
||||||
|
events = await shardEvents.list({ kind, limit })
|
||||||
|
} else {
|
||||||
|
events = await shardEvents.list({ kinds: [...broadcast.PUBLIC_KINDS], limit })
|
||||||
|
}
|
||||||
|
return res.json(events)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getFeed', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/economy — gold-supply series, oldest → newest.
|
||||||
|
async function getEconomy(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await shardState.listEconomy(req.query.limit))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getEconomy', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/online — players online now whose account is linked to a
|
||||||
|
// STAFF website user (admin/editor/moderator). Shows name + location (map +
|
||||||
|
// coordinates); no vitals or account. Non-staff players are never listed.
|
||||||
|
async function getOnline(req, res) {
|
||||||
|
try {
|
||||||
|
const rows = await shardState.listOnlineLinked()
|
||||||
|
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map, x: r.x, y: r.y, z: r.z })))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getOnline', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
|
||||||
|
async function getIdoc(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await shardState.listIdoc())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getIdoc', 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, stream }
|
||||||
@@ -4,6 +4,10 @@ const http = require('http')
|
|||||||
const app = require('./app')
|
const app = require('./app')
|
||||||
const internalApp = require('./internalApp')
|
const internalApp = require('./internalApp')
|
||||||
const botScore = require('./middleware/botScore')
|
const botScore = require('./middleware/botScore')
|
||||||
|
const uoLinkSocket = require('./utils/uoLinkSocket')
|
||||||
|
const uoLinkClient = require('./utils/uoLinkClient')
|
||||||
|
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const shardBroadcast = require('./utils/shardBroadcast')
|
||||||
const { ensureSchema, close } = require('./utils/db')
|
const { ensureSchema, close } = require('./utils/db')
|
||||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||||
const settings = require('./model/settings/settings.model')
|
const settings = require('./model/settings/settings.model')
|
||||||
@@ -77,9 +81,47 @@ async function start() {
|
|||||||
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
|
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
|
||||||
|
// connects when the admin has enabled the integration and saved a token, so
|
||||||
|
// this is a no-op on shards that haven't configured the sidecar. Never let a
|
||||||
|
// sidecar problem block server startup.
|
||||||
|
try {
|
||||||
|
await uoLinkSocket.start()
|
||||||
|
await checkUoLink()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
setupShutdown(server, internalServer)
|
setupShutdown(server, internalServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort startup probe of the uo-link sidecar: if the integration is
|
||||||
|
// enabled, log whether it is reachable and warn loudly on a protocol mismatch
|
||||||
|
// (fail-fast visibility rather than silently mis-parsing a newer wire format).
|
||||||
|
async function checkUoLink() {
|
||||||
|
const config = await uoLinkConfig.getSafe()
|
||||||
|
if (!config.enabled) return
|
||||||
|
const health = await uoLinkClient.health()
|
||||||
|
if (!health.ok) {
|
||||||
|
log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
error: health.error || `status ${health.status}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
|
||||||
|
log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
|
||||||
|
pinned: config.protocol,
|
||||||
|
sidecar: health.data.protocol,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log.info('uo-link sidecar reachable', {
|
||||||
|
pluginConnected: health.data && health.data.plugin_connected,
|
||||||
|
protocol: health.data && health.data.protocol,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupShutdown(server, internalServer) {
|
function setupShutdown(server, internalServer) {
|
||||||
let closing = false
|
let closing = false
|
||||||
const shutdown = async (signal) => {
|
const shutdown = async (signal) => {
|
||||||
@@ -87,6 +129,8 @@ function setupShutdown(server, internalServer) {
|
|||||||
closing = true
|
closing = true
|
||||||
log.warn(`${signal} received — shutting down gracefully`)
|
log.warn(`${signal} received — shutting down gracefully`)
|
||||||
botScore.stopSweeper() // stop the bot-store cleanup interval
|
botScore.stopSweeper() // stop the bot-store cleanup interval
|
||||||
|
uoLinkSocket.stop() // close the uo-link WS ingest client
|
||||||
|
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
||||||
server.close(() => log.info('http server closed'))
|
server.close(() => log.info('http server closed'))
|
||||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||||
try {
|
try {
|
||||||
|
|||||||
117
server/src/utils/shardBroadcast.js
Normal file
117
server/src/utils/shardBroadcast.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// ── Shard live-feed SSE broadcaster ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
||||||
|
// never reach it, and the WS may be on another host). Instead the server ingests
|
||||||
|
// the WS feed and re-broadcasts curated events to browsers over Server-Sent
|
||||||
|
// Events (plain HTTP — works through any reverse proxy).
|
||||||
|
//
|
||||||
|
// Two channels:
|
||||||
|
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
|
||||||
|
// no account-login attempts, no staff audit / cheat events.
|
||||||
|
// • admin — everything, including the sensitive kinds above.
|
||||||
|
//
|
||||||
|
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
||||||
|
// SSE route handlers call subscribe(req, res, channel).
|
||||||
|
|
||||||
|
const log = require('./logger')('shard-broadcast')
|
||||||
|
|
||||||
|
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
|
||||||
|
// deliberately NOT here — sales are owner-private (a linked player sees only
|
||||||
|
// their own, via /player/shard/sales).
|
||||||
|
const PUBLIC_KINDS = new Set([
|
||||||
|
'player.death',
|
||||||
|
'player.murdered',
|
||||||
|
'mob.killed',
|
||||||
|
'house.decay',
|
||||||
|
'quest.complete',
|
||||||
|
'skill.gain',
|
||||||
|
'fame.change',
|
||||||
|
'karma.change',
|
||||||
|
'mob.login',
|
||||||
|
'mob.logout',
|
||||||
|
'economy.supply',
|
||||||
|
'server.hello',
|
||||||
|
'server.shutdown',
|
||||||
|
'server.crashed',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Open response streams per channel.
|
||||||
|
const clients = { public: new Set(), admin: new Set() }
|
||||||
|
|
||||||
|
const KEEPALIVE_MS = 25000
|
||||||
|
|
||||||
|
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
||||||
|
// comment, keeps the connection warm with periodic pings, and cleans up on close.
|
||||||
|
function subscribe(req, res, channel) {
|
||||||
|
const bucket = clients[channel]
|
||||||
|
if (!bucket) {
|
||||||
|
res.status(400).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately
|
||||||
|
})
|
||||||
|
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
||||||
|
res.write(': connected\n\n')
|
||||||
|
|
||||||
|
bucket.add(res)
|
||||||
|
|
||||||
|
const ping = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n')
|
||||||
|
} catch {
|
||||||
|
/* write after close — cleanup below handles it */
|
||||||
|
}
|
||||||
|
}, KEEPALIVE_MS)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearInterval(ping)
|
||||||
|
bucket.delete(res)
|
||||||
|
}
|
||||||
|
req.on('close', cleanup)
|
||||||
|
res.on('error', cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTo(bucket, payload) {
|
||||||
|
for (const res of bucket) {
|
||||||
|
try {
|
||||||
|
res.write(payload)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('sse write failed; dropping client', { message: err.message })
|
||||||
|
bucket.delete(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan an ingested event out to the admin channel (always) and the public
|
||||||
|
// channel (safe kinds only). A no-op when nobody is subscribed.
|
||||||
|
function broadcast(event) {
|
||||||
|
if (!event || !event.kind) return
|
||||||
|
const frame = `data: ${JSON.stringify(event)}\n\n`
|
||||||
|
if (clients.admin.size) writeTo(clients.admin, frame)
|
||||||
|
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close every open stream (graceful shutdown).
|
||||||
|
function closeAll() {
|
||||||
|
for (const channel of Object.values(clients)) {
|
||||||
|
for (const res of channel) {
|
||||||
|
try {
|
||||||
|
res.end()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channel.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stats() {
|
||||||
|
return { publicClients: clients.public.size, adminClients: clients.admin.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }
|
||||||
187
server/src/utils/shardIngest.js
Normal file
187
server/src/utils/shardIngest.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// ── Shard event ingest dispatcher ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The single entry point for every event that arrives on the uo-link WebSocket
|
||||||
|
// feed (and for backfilled /history events on reconnect). It routes by kind:
|
||||||
|
// • state-changing kinds update shard_online / shard_economy / shard_houses,
|
||||||
|
// • notable kinds are appended to the append-only shard_events log,
|
||||||
|
// • every kind is fanned out to the SSE broadcaster (which decides public vs
|
||||||
|
// admin visibility).
|
||||||
|
// High-frequency kinds (char.vitals, economy.supply) are deliberately NOT logged
|
||||||
|
// to shard_events — they only update state — keeping the event log lean.
|
||||||
|
//
|
||||||
|
// Dependencies are injected (defaulting to the real models) so the routing can
|
||||||
|
// be unit-tested with mocked writes.
|
||||||
|
|
||||||
|
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
|
||||||
|
const shardStateModel = require('../model/shardState/shardState.model')
|
||||||
|
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const broadcaster = require('./shardBroadcast')
|
||||||
|
const defaultLog = require('./logger')('shard-ingest')
|
||||||
|
|
||||||
|
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||||
|
// (char.vitals, economy.supply, mob.login/logout, account.login.attempt,
|
||||||
|
// gold.change, vendor.buy/sell) are excluded on purpose. house.decay is handled
|
||||||
|
// specially — logged only on the transition INTO IDOC.
|
||||||
|
const LOGGED_KINDS = new Set([
|
||||||
|
'vendor.sale',
|
||||||
|
'player.death',
|
||||||
|
'player.murdered',
|
||||||
|
'mob.killed',
|
||||||
|
'quest.complete',
|
||||||
|
'skill.gain',
|
||||||
|
'fame.change',
|
||||||
|
'karma.change',
|
||||||
|
'audit.set',
|
||||||
|
'audit.command',
|
||||||
|
'cheat.fastwalk',
|
||||||
|
'link.request',
|
||||||
|
'server.hello',
|
||||||
|
'server.shutdown',
|
||||||
|
'server.crashed',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
|
||||||
|
// can be detected and stale online state dropped. Module-level so it survives
|
||||||
|
// across events within a process; reset() is exposed for tests.
|
||||||
|
const state = { bootId: null }
|
||||||
|
function reset() {
|
||||||
|
state.bootId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should this event be written to the append-only log?
|
||||||
|
function shouldLog(event) {
|
||||||
|
if (event.kind === 'house.decay') return String(event.to).toUpperCase() === 'IDOC'
|
||||||
|
return LOGGED_KINDS.has(event.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
||||||
|
async function applyStateChange(event, deps) {
|
||||||
|
const { shardState, uoLinkConfig, log } = deps
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'server.hello': {
|
||||||
|
const incoming = event.bootId || null
|
||||||
|
if (incoming && state.bootId && incoming !== state.bootId) {
|
||||||
|
log.warn('shard restarted (bootId changed) — clearing online roster', {
|
||||||
|
from: state.bootId,
|
||||||
|
to: incoming,
|
||||||
|
})
|
||||||
|
await shardState.clearOnline()
|
||||||
|
}
|
||||||
|
if (incoming) state.bootId = incoming
|
||||||
|
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'server.shutdown':
|
||||||
|
case 'server.crashed':
|
||||||
|
// Shard is going away — nobody is online anymore.
|
||||||
|
await shardState.clearOnline()
|
||||||
|
await uoLinkConfig.recordStatus({ pluginConnected: false })
|
||||||
|
return
|
||||||
|
case 'mob.login': {
|
||||||
|
const who = event.who || {}
|
||||||
|
await shardState.upsertOnline({
|
||||||
|
serial: who.serial,
|
||||||
|
name: who.name,
|
||||||
|
acct: who.acct,
|
||||||
|
webId: event.webId,
|
||||||
|
map: event.map,
|
||||||
|
x: event.x,
|
||||||
|
y: event.y,
|
||||||
|
z: event.z,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'mob.logout': {
|
||||||
|
const who = event.who || {}
|
||||||
|
if (who.serial) await shardState.setOffline(who.serial)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'char.vitals':
|
||||||
|
await shardState.upsertOnline({
|
||||||
|
serial: event.serial,
|
||||||
|
hits: event.hits,
|
||||||
|
hitsMax: event.hitsMax,
|
||||||
|
mana: event.mana,
|
||||||
|
manaMax: event.manaMax,
|
||||||
|
stam: event.stam,
|
||||||
|
stamMax: event.stamMax,
|
||||||
|
str: event.str,
|
||||||
|
dex: event.dex,
|
||||||
|
int: event.int,
|
||||||
|
map: event.map,
|
||||||
|
x: event.x,
|
||||||
|
y: event.y,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case 'economy.supply':
|
||||||
|
await shardState.addEconomySample({ accounts: event.accounts, gold: event.gold, t: event.t })
|
||||||
|
return
|
||||||
|
case 'house.decay':
|
||||||
|
await shardState.upsertHouse({
|
||||||
|
serial: event.serial,
|
||||||
|
stage: event.to,
|
||||||
|
map: event.map,
|
||||||
|
x: event.x,
|
||||||
|
y: event.y,
|
||||||
|
z: event.z,
|
||||||
|
region: event.region,
|
||||||
|
name: event.name,
|
||||||
|
ownerSerial: event.ownerSerial,
|
||||||
|
ownerAcct: event.ownerAcct,
|
||||||
|
builtOn: event.builtOn,
|
||||||
|
lastRefreshed: event.lastRefreshed,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
|
||||||
|
// broadcasting still happen in ingest().
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest one event. Returns { logged, stored } for tests/stats. `fromBackfill`
|
||||||
|
// suppresses the SSE broadcast (a reconnect replay shouldn't re-animate the
|
||||||
|
// live ticker). Never throws — a bad single event must not kill the feed.
|
||||||
|
async function ingest(event, deps = {}) {
|
||||||
|
const d = {
|
||||||
|
shardEvents: deps.shardEvents || shardEventsModel,
|
||||||
|
shardState: deps.shardState || shardStateModel,
|
||||||
|
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||||
|
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||||
|
log: deps.log || defaultLog,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event || typeof event.kind !== 'string') return { logged: false, stored: false }
|
||||||
|
// ws.hello / pong are transport frames, not game events.
|
||||||
|
if (event.kind === 'ws.hello' || event.kind === 'pong') return { logged: false, stored: false }
|
||||||
|
|
||||||
|
const t = Number.isFinite(event.t) ? event.t : Date.now()
|
||||||
|
let stored = false
|
||||||
|
let logged = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await applyStateChange(event, d)
|
||||||
|
} catch (err) {
|
||||||
|
d.log.warn('state-change write failed', { kind: event.kind, message: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldLog(event)) {
|
||||||
|
logged = true
|
||||||
|
try {
|
||||||
|
stored = await d.shardEvents.append({ kind: event.kind, t, bootId: state.bootId, payload: event })
|
||||||
|
} catch (err) {
|
||||||
|
d.log.warn('event log write failed', { kind: event.kind, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deps.fromBackfill) {
|
||||||
|
try {
|
||||||
|
d.broadcast(event)
|
||||||
|
} catch (err) {
|
||||||
|
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logged, stored }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ingest, shouldLog, reset, LOGGED_KINDS, state }
|
||||||
125
server/src/utils/uoLinkClient.js
Normal file
125
server/src/utils/uoLinkClient.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// ── uo-link sidecar REST client ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Server-side HTTP client for the uo-link sidecar (the bridge to the ServUO
|
||||||
|
// shard). Same shape as botInternalClient: never throws — every call returns
|
||||||
|
// { ok, data, status, error } so an admin poll or a public page never 500s just
|
||||||
|
// because the sidecar/shard is down or restarting.
|
||||||
|
//
|
||||||
|
// The base URL + shared-secret token come from the DB-backed uoLinkConfig
|
||||||
|
// (admin-managed, encrypted at rest) — NOT env vars, and the token is NEVER sent
|
||||||
|
// to the browser. Every request carries `Authorization: Bearer <token>` and
|
||||||
|
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
|
||||||
|
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
|
||||||
|
// token on every call.
|
||||||
|
|
||||||
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const log = require('./logger')('uo-link-client')
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
|
||||||
|
const CONFIG_TTL_MS = 5000
|
||||||
|
|
||||||
|
let cachedConfig = null
|
||||||
|
let cachedAt = 0
|
||||||
|
|
||||||
|
// Read (and briefly cache) the connection config incl. decrypted token.
|
||||||
|
async function resolveConfig() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (cachedConfig && now - cachedAt < CONFIG_TTL_MS) return cachedConfig
|
||||||
|
cachedConfig = await uoLinkConfig.getWithToken()
|
||||||
|
cachedAt = now
|
||||||
|
return cachedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the cache after a save so the next call picks up new URL/token immediately.
|
||||||
|
function invalidateConfig() {
|
||||||
|
cachedConfig = null
|
||||||
|
cachedAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core request. Returns { ok, data, status, error }. `ok` is true only on a 2xx
|
||||||
|
// with a parseable JSON body. Non-2xx responses still return their status + body
|
||||||
|
// so callers can distinguish 503 (shard restarting — transient) from 404.
|
||||||
|
async function call(path, { method = 'GET', body } = {}) {
|
||||||
|
const config = await resolveConfig()
|
||||||
|
if (!config || !config.baseUrl) {
|
||||||
|
return { ok: false, status: 0, error: 'uo-link is not configured' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-UOLink-Version': String(config.protocol || 1),
|
||||||
|
}
|
||||||
|
if (config.token) headers.Authorization = `Bearer ${config.token}`
|
||||||
|
|
||||||
|
const res = await fetch(`${config.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
let data = null
|
||||||
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch {
|
||||||
|
// Non-JSON (or empty) body — leave data null; status still reported.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) log.warn('uo-link rejected auth token (401)', { path })
|
||||||
|
if (res.status === 409) log.error('uo-link protocol mismatch (409)', { path, body: data })
|
||||||
|
return { ok: false, status: res.status, data, error: `sidecar responded ${res.status}` }
|
||||||
|
}
|
||||||
|
return { ok: true, status: res.status, data }
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('uo-link call failed', { path, message: err.message })
|
||||||
|
return { ok: false, status: 0, error: err.message }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read queries ───────────────────────────────────────────────────────────
|
||||||
|
// Liveness (no auth required by the sidecar, but we send it anyway).
|
||||||
|
const health = () => call('/health')
|
||||||
|
const getCharBySerial = (serial) => call(`/char/serial/${encodeURIComponent(serial)}`)
|
||||||
|
const getCharBySlot = (account, slot) =>
|
||||||
|
call(`/char/${encodeURIComponent(account)}/${encodeURIComponent(slot)}`)
|
||||||
|
const getRoster = (account) => call(`/roster/${encodeURIComponent(account)}`)
|
||||||
|
const getVendors = (account) => call(`/vendors/${encodeURIComponent(account)}`)
|
||||||
|
|
||||||
|
// History / economy series — used for WS-reconnect backfill and public feeds.
|
||||||
|
function getHistory({ kind, limit = 100 } = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (kind) params.set('kind', kind)
|
||||||
|
if (limit) params.set('limit', String(limit))
|
||||||
|
const qs = params.toString()
|
||||||
|
return call(`/history${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
|
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
|
||||||
|
|
||||||
|
// ── Commands ──────────────────────────────────────────────────────────────
|
||||||
|
const confirmLink = (code, websiteUserId) =>
|
||||||
|
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
|
||||||
|
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
|
||||||
|
const postTownCrier = ({ id, lines, durationSec }) =>
|
||||||
|
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
||||||
|
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
invalidateConfig,
|
||||||
|
health,
|
||||||
|
getCharBySerial,
|
||||||
|
getCharBySlot,
|
||||||
|
getRoster,
|
||||||
|
getVendors,
|
||||||
|
getHistory,
|
||||||
|
getEconomy,
|
||||||
|
confirmLink,
|
||||||
|
linkLookup,
|
||||||
|
postTownCrier,
|
||||||
|
deleteTownCrier,
|
||||||
|
}
|
||||||
195
server/src/utils/uoLinkSocket.js
Normal file
195
server/src/utils/uoLinkSocket.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
// ── uo-link WebSocket ingest client ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Long-lived client that connects to the sidecar's push-only WS feed and pumps
|
||||||
|
// every frame through the ingest dispatcher. This is the server's first
|
||||||
|
// outbound WebSocket. Lifecycle:
|
||||||
|
// • start() — connect if the config is enabled and has a token; verify the
|
||||||
|
// ws.hello protocol; backfill missed events via /history on every
|
||||||
|
// (re)connect (INSERT IGNORE dedupes the overlap); reconnect with
|
||||||
|
// capped backoff.
|
||||||
|
// • stop() — close the socket and stop reconnecting (graceful shutdown).
|
||||||
|
// Connection state is mirrored into uo_link_config (plugin_connected / status /
|
||||||
|
// last_event_at) so the admin panel and public status endpoint have live data.
|
||||||
|
|
||||||
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const uoLinkClient = require('./uoLinkClient')
|
||||||
|
const shardIngest = require('./shardIngest')
|
||||||
|
const log = require('./logger')('uo-link-socket')
|
||||||
|
|
||||||
|
const BACKOFF_MIN_MS = 1000
|
||||||
|
const BACKOFF_MAX_MS = 30000
|
||||||
|
const BACKFILL_LIMIT = 500
|
||||||
|
|
||||||
|
let ws = null
|
||||||
|
let reconnectTimer = null
|
||||||
|
let backoff = BACKOFF_MIN_MS
|
||||||
|
let running = false // set by start()/stop(); guards auto-reconnect
|
||||||
|
let helloSeen = false
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
connected: false,
|
||||||
|
lastEventAt: null,
|
||||||
|
lastConnectedAt: null,
|
||||||
|
reconnects: 0,
|
||||||
|
protocol: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(wsUrl, token) {
|
||||||
|
const sep = wsUrl.includes('?') ? '&' : '?'
|
||||||
|
return token ? `${wsUrl}${sep}token=${encodeURIComponent(token)}` : wsUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull recent events from the sidecar's own store and replay them through the
|
||||||
|
// dispatcher (fromBackfill = no SSE re-broadcast). dedupe_key + INSERT IGNORE
|
||||||
|
// make this idempotent, so overlap with what we already stored is harmless.
|
||||||
|
async function backfill() {
|
||||||
|
try {
|
||||||
|
const hist = await uoLinkClient.getHistory({ limit: BACKFILL_LIMIT })
|
||||||
|
if (hist.ok && hist.data && Array.isArray(hist.data.events)) {
|
||||||
|
// History is newest-first; replay oldest-first so latest-wins state (e.g.
|
||||||
|
// house.decay stage) settles correctly.
|
||||||
|
const events = [...hist.data.events].reverse()
|
||||||
|
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||||
|
log.info('backfilled events from /history', { count: events.length })
|
||||||
|
}
|
||||||
|
const eco = await uoLinkClient.getEconomy(200)
|
||||||
|
if (eco.ok && eco.data && Array.isArray(eco.data.series)) {
|
||||||
|
const series = [...eco.data.series].reverse()
|
||||||
|
for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('backfill failed (continuing on live feed)', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (!running) return
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = setTimeout(connect, backoff)
|
||||||
|
log.info(`reconnecting in ${backoff}ms`)
|
||||||
|
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (!running) return
|
||||||
|
let config
|
||||||
|
try {
|
||||||
|
config = await uoLinkConfig.getWithToken()
|
||||||
|
} catch (err) {
|
||||||
|
log.error('could not read uo-link config', err)
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!config || !config.enabled || !config.wsUrl || !config.token) {
|
||||||
|
log.info('uo-link WS not started (disabled or missing url/token)')
|
||||||
|
running = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.protocol = config.protocol || 1
|
||||||
|
helloSeen = false
|
||||||
|
const url = buildUrl(config.wsUrl, config.token)
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('failed to open WS', err)
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('open', async () => {
|
||||||
|
log.info('uo-link WS connected')
|
||||||
|
state.connected = true
|
||||||
|
state.lastConnectedAt = Date.now()
|
||||||
|
backoff = BACKOFF_MIN_MS
|
||||||
|
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
|
||||||
|
await backfill()
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('message', async (raw) => {
|
||||||
|
let event
|
||||||
|
try {
|
||||||
|
event = JSON.parse(raw.toString())
|
||||||
|
} catch {
|
||||||
|
log.warn('dropping non-JSON WS frame')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === 'ws.hello') {
|
||||||
|
helloSeen = true
|
||||||
|
if (event.protocol && event.protocol !== state.protocol) {
|
||||||
|
log.error('uo-link protocol mismatch on ws.hello — closing', {
|
||||||
|
expected: state.protocol,
|
||||||
|
got: event.protocol,
|
||||||
|
})
|
||||||
|
await uoLinkConfig
|
||||||
|
.recordStatus({ status: 'error', statusDetail: `protocol mismatch: expected ${state.protocol}, got ${event.protocol}` })
|
||||||
|
.catch(() => {})
|
||||||
|
running = false
|
||||||
|
try {
|
||||||
|
ws.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.kind === 'pong') return // sidecar heartbeat — ignore
|
||||||
|
|
||||||
|
state.lastEventAt = Number.isFinite(event.t) ? event.t : Date.now()
|
||||||
|
await shardIngest.ingest(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', async () => {
|
||||||
|
state.connected = false
|
||||||
|
if (running) state.reconnects += 1
|
||||||
|
log.warn('uo-link WS closed')
|
||||||
|
await uoLinkConfig
|
||||||
|
.recordStatus({ status: running ? 'reconnecting' : 'disconnected', pluginConnected: false })
|
||||||
|
.catch(() => {})
|
||||||
|
ws = null
|
||||||
|
scheduleReconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
log.warn('uo-link WS error', { message: err.message })
|
||||||
|
// 'close' fires after 'error'; reconnect is scheduled there.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin (or restart) the WS client. Idempotent — a running client is stopped
|
||||||
|
// first so a config save can re-point it at a new URL/token.
|
||||||
|
async function start() {
|
||||||
|
stop()
|
||||||
|
running = true
|
||||||
|
backoff = BACKOFF_MIN_MS
|
||||||
|
await connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the client and cancel any pending reconnect. Called on shutdown and
|
||||||
|
// before a restart.
|
||||||
|
function stop() {
|
||||||
|
running = false
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.removeAllListeners()
|
||||||
|
ws.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
state.connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingestion stats for the admin panel.
|
||||||
|
function getState() {
|
||||||
|
return { ...state, running }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop, getState }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -46,8 +46,10 @@ const doc = {
|
|||||||
{ name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' },
|
{ name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' },
|
||||||
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
||||||
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
|
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
|
||||||
|
{ name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' },
|
||||||
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
||||||
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
|
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
|
||||||
|
{ name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
|
||||||
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
||||||
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
||||||
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
||||||
@@ -55,6 +57,7 @@ const doc = {
|
|||||||
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
||||||
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
||||||
{ name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' },
|
{ name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' },
|
||||||
|
{ name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' },
|
||||||
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
||||||
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
||||||
],
|
],
|
||||||
@@ -506,6 +509,118 @@ const doc = {
|
|||||||
removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true },
|
removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── uo-link shard data ──────────────────────────────────────────────
|
||||||
|
ShardStatus: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Public shard status (GET /public/shard/status).',
|
||||||
|
properties: {
|
||||||
|
enabled: { type: 'boolean', example: true },
|
||||||
|
status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' },
|
||||||
|
pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true },
|
||||||
|
lastEventAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
onlineCount: { type: 'integer', example: 12 },
|
||||||
|
economy: { $ref: '#/components/schemas/ShardEconomyPoint' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardEvent: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'A logged shard event.',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer', example: 4821 },
|
||||||
|
kind: { type: 'string', example: 'vendor.sale' },
|
||||||
|
t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 },
|
||||||
|
bootId: { type: 'string', nullable: true, example: 'boot-abc123' },
|
||||||
|
payload: { type: 'object', additionalProperties: true, description: 'The full event object.' },
|
||||||
|
createdAt: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardEconomyPoint: {
|
||||||
|
type: 'object',
|
||||||
|
nullable: true,
|
||||||
|
description: 'One gold-supply sample.',
|
||||||
|
properties: {
|
||||||
|
accounts: { type: 'integer', nullable: true, example: 240 },
|
||||||
|
gold: { type: 'integer', nullable: true, example: 1028983421 },
|
||||||
|
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardOnlinePlayer: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'A LINKED player online now (only accounts linked to a website user are listed).',
|
||||||
|
properties: {
|
||||||
|
serial: { type: 'string', example: '0x24C' },
|
||||||
|
name: { type: 'string', example: 'Darrow' },
|
||||||
|
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||||
|
x: { type: 'integer', nullable: true, example: 1402 },
|
||||||
|
y: { type: 'integer', nullable: true, example: 1604 },
|
||||||
|
z: { type: 'integer', nullable: true, example: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardVendorSale: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'A player-vendor sale (visible only to the linked owner).',
|
||||||
|
properties: {
|
||||||
|
t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 },
|
||||||
|
itemType: { type: 'string', example: 'Longsword' },
|
||||||
|
amount: { type: 'integer', example: 1 },
|
||||||
|
price: { type: 'integer', example: 100 },
|
||||||
|
commission: { type: 'integer', nullable: true, example: 5 },
|
||||||
|
ownerAcct: { type: 'string', example: 'whitlocktech' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardHouse: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'A house at its current decay stage.',
|
||||||
|
properties: {
|
||||||
|
serial: { type: 'string', example: '0x4004705F' },
|
||||||
|
stage: { type: 'string', example: 'IDOC' },
|
||||||
|
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||||
|
x: { type: 'integer', nullable: true },
|
||||||
|
y: { type: 'integer', nullable: true },
|
||||||
|
z: { type: 'integer', nullable: true },
|
||||||
|
region: { type: 'string', nullable: true },
|
||||||
|
name: { type: 'string', nullable: true, example: 'An Unnamed House' },
|
||||||
|
ownerSerial: { type: 'string', nullable: true },
|
||||||
|
ownerAcct: { type: 'string', nullable: true },
|
||||||
|
builtOn: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
lastRefreshed: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
isIdoc: { type: 'boolean', example: true },
|
||||||
|
updatedAt: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardLinkRequest: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['code'],
|
||||||
|
properties: {
|
||||||
|
code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardLinkResult: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
linked: { type: 'boolean', example: true },
|
||||||
|
account: { type: 'string', example: 'whitlocktech' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ShardLink: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'A linked in-game account (GET /player/shard/accounts).',
|
||||||
|
properties: {
|
||||||
|
account: { type: 'string', example: 'whitlocktech' },
|
||||||
|
userId: { type: 'integer', example: 42 },
|
||||||
|
charName: { type: 'string', nullable: true, example: 'Darrow' },
|
||||||
|
linkedAt: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TownCrierRequest: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'lines'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', maxLength: 64, description: 'Re-posting the same id replaces the prior entry.', example: 'news-42' },
|
||||||
|
lines: { type: 'array', items: { type: 'string', maxLength: 200 }, example: ['Hear ye!', 'Market tax is now 5%.'] },
|
||||||
|
durationSec: { type: 'integer', minimum: 1, maximum: 86400, example: 3600 },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user