33 Commits

Author SHA1 Message Date
e0fadbdcc2 Merge pull request 'chore(org): retarget org paths to RunicGateway' (#66) from chore/org-rename-runicgateway into main
All checks were successful
Build container images / build (push) Successful in 1m14s
Build container images / deploy (push) Successful in 26s
Reviewed-on: #66
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 05:06:43 +00:00
6c967d9a6c chore(org): retarget org paths to RunicGateway
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m30s
PR Checks / server-tests (pull_request) Successful in 10m34s
PR Checks / bot-install (pull_request) Successful in 9m21s
Repo was transferred UOM -> RunicGateway. build-images.yml already
derives its registry owner from github.repository_owner, so it now
publishes to gitea.whitlocktech.com/runicgateway/*, but docker-compose
still pulled from /uom/*. Point the app+bot images at the new owner so
deploys pull the images CI actually publishes. Also update the two
README links to the uo-link repo (UOM/link -> RunicGateway/link).

No branding text touched (that is handled separately).
2026-07-17 23:46:36 -05:00
ee085496ab Merge pull request 'Protocol 2.0/2.1 uo-link integration — boards, cross-links, news gump, account provisioning' (#65) from feature/protocol2-integration into main
All checks were successful
Build container images / build (push) Successful in 1m35s
Build container images / deploy (push) Successful in 35s
Reviewed-on: UOM/website#65
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 02:34:25 +00:00
3ef1c8e438 feat(provisioning): admin game-signup mode setting, invite link option, staff self-create
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 10m18s
PR Checks / bot-install (pull_request) Successful in 9m22s
Follow-ups from live testing:

- Game-account creation is now an admin Settings control (disabled / website /
  hybrid / game) instead of a hidden on/off flag. The site offers creation for
  website+hybrid; help text notes the shard's SignupMode (Bridge.cfg) has the final
  say. game_account_signup setting widened to a 4-value enum + validated on save.
- Invites: the accept link is ALWAYS returned and shown with a Copy button, and a
  "Email the invitation" toggle lets an admin create a link-only invite (no email)
  or email it. Backend takes sendEmail (default true) and always returns acceptUrl.
- Staff can create a game account from their own /admin/characters page too
  (POST /admin/shard/account → the shared createGameAccount controller), so the
  form is reachable in both the player and admin portals.

Note: the admin Houses view (/admin/houses) already worked; the earlier failure
was a stale Vite HMR state for the new route (needs a hard refresh).

Client build clean; server routes load; swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:08:59 -05:00
1629796235 feat(houses): tier house visibility — public IDOC-only, staff full, player own
Per request, split the single public house registry into three role-scoped views:

- Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/
  coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger";
  kept live via the public house.decay feed. The full-registry deltas
  (house.update / house.remove — which carry owner/price) are REMOVED from the
  public SSE allowlist so they never reach the public channel.
- Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS)
  backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay
  and search, kept live on the admin SSE channel.
- Player portal → "My houses" home-status section (own houses only, with decay/
  IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts.

Server tests green, client build clean, swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:50:37 -05:00
a165c90c62 fix(schema): remove semicolons from shard_governor_terms inline comments
ensureSchema() splits schema.sql on ';' and is not comment-aware, so the inline
comments "epoch ms; NULL = current" and "not in the feed; reserved" shattered the
CREATE TABLE into invalid fragments (ER_PARSE_ERROR on a fresh boot). Reworded both
to drop the semicolons. Caught during live-stack bring-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:25:14 -05:00
2976d5982f feat(provisioning): provisioning UI — signup, invites, accept page, unlink
Phase 6: the UI for the Phase 5 provisioning backend.

- CreateGameAccountForm: reusable game-account form (own username + password),
  mapping the sidecar errors (409/429/403/503) to friendly messages. Wired into
  GameAccounts (self-serve) — shown alongside the [link flow when the
  game_account_signup flag is on (exposed via public settings), so a registered
  player can create + link a game account from their portal.
- Admin Invites view (/admin/invites, admin-only): send an invite at a chosen
  access level, list invites with status, revoke pending ones. When email isn't
  configured the create response's accept link is surfaced to copy manually.
- Public accept page (/invite/:token): validates the invite, sets username +
  password (email + role pre-assigned), creates the account at that role and logs
  in; for a player invite it then offers the built-in "create game account" step
  before the portal. Honeypot-guarded like registration.
- Admin unlink wired into UserDetail via GameAccounts (per-account Unlink button,
  confirm + reconcile).
- Backend: expose gameAccountSignup availability in public settings.

Client build clean; server 193/193.

Refs .plans/protocol2-integration.md (Phase 6). Completes the Protocol 2.0/2.1 integration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:06:01 -05:00
91c206bf76 feat(provisioning): game-account signup, admin email invites, unlink (2.0)
Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.

- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
  shard (hashed there) and never stored/logged; the end-user browser IP is passed
  for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
  username + password) for the signed-in user and mirrors the link locally. Gated
  by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
  429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
  invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
  mailer.sendInvite (falls back to returning the accept link if email is off);
  public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
  user at the invite's preset role and logs them in, bypassing the registration
  gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
  mirror drop; account.unlinked ingest reconciles the mirror when a player runs
  [unlink in game. account.audit / account.unlinked are logged (admin channel
  only — never on the public SSE allowlist).

Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:50:49 -05:00
55a3adea99 feat(news): auto-push published news to the in-game Town Cryer News gump (2.1)
Phase 4: sync the site's published news posts into the Protocol 2.1 News gump.

- uoLinkClient.postNews / deleteNews.
- utils/newsGump.js — a STATE SYNC (not a one-shot announce leg): an article
  stays in the gump while its post is published news and is pulled when it leaves
  that state. buildArticle renders a compact gump-HTML block (centred title +
  plain-text excerpt — the gump supports only a small HTML subset) with a
  "more info" link to /site/news and an optional gump image from the
  `news_gump_image` setting. Every call is best-effort / never-throws.
- Hooked into the posts pipeline alongside the existing announce enqueue:
  syncPost on create/update/publish (fresh publish announces; edits refresh
  silently; leaving published-news pulls the article), removePost on delete.
- reassertAll() runs in uoLinkSocket.backfill on every WS (re)connect —
  reconciles the gump to our source of truth and recovers any article whose
  original live push failed (silent, so a reconnect never re-proclaims old news).

Server 185/185, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:40:47 -05:00
2957708bab feat(shard): Protocol 2.0 cross-links — titles, guild, governor, houses
Phase 3: surface the new board data on existing character/user pages.

- Character sheet: render the char.profile titles block (fame/karma + skill +
  selected reward title; numeric clilocs skipped since the site has no cliloc
  table yet), plus "Guildmaster" and "Governor of <city>" chips.
- Char profile enrichment (player/admin /shard/char/:serial, one shared path):
  attach guild + governorOf from our own boards. Guild is LEADERSHIP-ONLY — it's
  verifiable from current board state, whereas guessing membership from stale
  guild.join events risks showing a wrong guild, so we return null instead.
- Admin user detail (/admin/users/:id): new "Standing" section (governorships
  held + guilds led) via GET /users/:id/shard/standing; Houses rows now show the
  registry fields (decay level, placement price, co-owner/friend counts) already
  returned by listHousesForAccounts.

Server 179/179, client build clean, swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:45:15 -05:00
e9aa19a83d feat(shard): Protocol 2.0 boards UI — guilds, governors, houses, players-online
Phase 2: the public UI for the four new boards, following the ChampSpawns live
pattern (snapshot via useAsync + merge SSE deltas with useShardFeed).

- Players Online widget (components/PlayersOnline.jsx): total + region breakdown
  rolled up into display buckets (data/regionBuckets.js — the one place to retune
  the grouping); live via presence.online. Placed on the Shard page, replacing the
  static players-online stat tile.
- Guilds (/site/guilds): searchable board of rosters/alliances/leaders with a
  "recently joined" strip from guild.join.
- Governors (/site/governors): one card per city with a placeholder crest
  (data/cityCrests.js — swap for real art without touching components), election
  phase badge + autoPickAt countdown, and an on-demand "past governors" term
  history (the look-back reads the ledger captured in Phase 1). Clean empty state
  when City Loyalty isn't enabled.
- Houses (/site/houses): searchable registry with decay badges; price labelled
  "placement value", not a for-sale flag.
- API client methods + nav links (Guilds / Governors / Houses).

Client build clean (240 modules).

Refs .plans/protocol2-integration.md (Phase 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:33:57 -05:00
080478c4a1 feat(shard): ingest Protocol 2.0 boards — guilds, governors, presence, houses
Phase 1 of the Protocol 2.0/2.1 integration: the read/ingest backend for the four
new uo-link boards, following the established champs/pages pattern (ingest → our
MariaDB + snapshot-on-reconnect + public SSE + token-free public endpoint).

- Schema: shard_guilds, shard_governors, shard_governor_terms, shard_presence;
  extend shard_houses with the house.update registry columns (owner_name,
  co_owners, friends, price, decay, in_registry) so the decay-transition and
  registry feeds share one house row without clobbering each other.
- Ingest: route guild.update/remove, city.update, presence.online,
  house.update/remove; log guild.join (real-time joins feed); region.enter is
  broadcast-only. All new public kinds added to the SSE allowlist.
- Governor term history captured from day one: on every observed governor CHANGE
  the open term is closed and a new one opened, idempotent so backfill/duplicate
  city.update never spawn spurious terms. votes stays NULL (the feed carries only
  candidate count, not tallies) — we never fabricate vote numbers.
- Client + backfill: getGuilds/getGovernors/getHouses/getPresence; snapshot each
  board on every WS (re)connect, independently guarded so an empty/failed board
  (e.g. no City Loyalty) never wipes another.
- Public endpoints: /shard/{guilds,governors,governors/:city/history,presence,houses}.
- Tests: ingest routing for all new kinds + governor term-capture idempotency
  (15 new; full suite 179/179). Swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:28:54 -05:00
3b333b1b49 Merge pull request 'ci(deploy): correct deploy runner label to uom-deploy-runner' (#64) from ci/fix-deploy-runner-label into main
All checks were successful
Build container images / build (push) Successful in 2m24s
Build container images / deploy (push) Successful in 43s
Reviewed-on: UOM/website#64
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 10:52:00 +00:00
0facdb2b2a ci(deploy): correct deploy runner label to uom-deploy-runner
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m29s
PR Checks / client-build (pull_request) Successful in 9m57s
PR Checks / bot-install (pull_request) Successful in 9m23s
The deploy job referenced a runner labelled `uom_deploy`, which doesn't
match the actual self-hosted runner (`uom-deploy-runner`), so the job
would queue forever waiting for a runner that never picks it up. Update
the `runs-on` label (and the two doc comments) to the real label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 05:32:02 -05:00
49ad6891cf Merge pull request 'ci(deploy): auto-deploy prod stack after image build on merge to main' (#63) from ci/auto-deploy-on-merge into main
Some checks failed
Build container images / deploy (push) Has been cancelled
Build container images / build (push) Has been cancelled
Reviewed-on: UOM/website#63
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 10:29:04 +00:00
97d95052db ci(deploy): auto-deploy prod stack after image build on merge to main
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / bot-install (pull_request) Successful in 9m39s
PR Checks / client-build (pull_request) Successful in 13m0s
Add a `deploy` job to build-images.yml that runs on the self-hosted
`uom_deploy` runner and, via `needs: build`, fires only after a clean
image build+push. It pulls the fresh :latest images and recreates the
stack (pull → down → up -d) from /home/perry/website. Guarded on
refs/heads/main so a workflow_dispatch off another branch can't deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 05:03:01 -05:00
e744723db2 Merge pull request 'fix(bot): retry boot-time config fetch so bot self-heals on cold start' (#62) from fix/bot-boot-config-retry into main
All checks were successful
Build container images / build (push) Successful in 1m43s
Reviewed-on: UOM/website#62
2026-07-15 19:17:22 +00:00
fc2554e5c3 fix(bot): retry boot-time config fetch so bot self-heals on cold start
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m25s
PR Checks / client-build (pull_request) Successful in 9m53s
PR Checks / bot-install (pull_request) Successful in 10m0s
On `docker compose up`/restart the bot and app start together. The bot's
`depends_on: app` uses `condition: service_started`, which only waits for the
app container to launch — not for its internal server (3001) to be listening
after it reaches the DB and boots Express. bootstrap.js did a single un-retried
fetch, lost that race, gave up, and left the bot disconnected while the DB
`enabled` flag stayed true — so the admin panel showed "enabled but
disconnected" until an admin toggled off/on to force a pushConfig.

Retry the boot config fetch with backoff (~1 min, 2s apart) until the app
answers: retry on network errors and 5xx, bail on 4xx (a real misconfig, not a
startup race). Also wrap discordManager.start in try/catch so a bad-token boot
logs and keeps the process alive instead of crashing it via server.js's
exit-on-start-failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-15 09:39:19 -05:00
70122f3626 Merge pull request 'fix(public): always show real hero + drop nav from landing page' (#61) from fix/hero-stale-gate-and-nav into main
All checks were successful
Build container images / build (push) Successful in 1m24s
Reviewed-on: UOM/website#61
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-15 11:03:51 +00:00
64da0067f1 Merge branch 'main' into fix/hero-stale-gate-and-nav
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m4s
PR Checks / client-build (pull_request) Successful in 9m41s
PR Checks / bot-install (pull_request) Successful in 9m37s
2026-07-15 03:59:42 +00:00
5b6b63e1bc fix(public): always show real hero; drop nav from landing page
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m43s
PR Checks / client-build (pull_request) Successful in 9m39s
PR Checks / bot-install (pull_request) Successful in 9m40s
Bug 1 — Logged-out visitors saw the coming-soon Maintenance page while
admins saw the real hero. That difference is produced client-side by
MaintenanceGate (site_mode=maintenance && no user). Pull the `/` hero
route out from behind the gate so every visitor always lands on the real
Portal hero; the MaintenanceGate stays on all other public routes, so
content pages remain gated during maintenance and admins still preview
through it.

Bug 2 — The landing hero rendered the site nav because Portal used
PublicLayout with the default header=true. Pass header={false} so the
hero has no top nav (footer retained), using the layout's existing
escape hatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 22:57:49 -05:00
01b3bb52bf Merge pull request 'feat(shard): admin write plane, help-page queue, and public champion board' (#58) from feature/shard-admin-champs into main
All checks were successful
Build container images / build (push) Successful in 1m30s
Reviewed-on: UOM/website#58
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-14 18:46:50 +00:00
c31553aeb6 feat(shard): admin write plane, help-page queue, and public champion board
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / client-build (pull_request) Successful in 9m49s
PR Checks / bot-install (pull_request) Successful in 9m33s
Wire up the three uo-link sidecar surfaces that weren't integrated yet.

Champion spawns
- Ingest champ.update/champ.remove into a new shard_champs table (served from
  our own store, like online/houses); public /site/champs board with a nav link,
  live via the existing SSE feed (champ.* added to the public allowlist).

Staff write plane (admin + moderator)
- kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side
  from the session, never the browser. Sidecar status codes mapped (403 disabled/
  protected, 404 unknown, 503/504 transient). admin.audit events are logged and
  surfaced at /admin/shard/audit.
- New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban
  on the user-detail and character views (ShardAccountActions, self-gated to staff).

Help-page (support) queue
- Ingest page.new/updated/closed into a new shard_pages table; respond/close via
  /admin/shard/pages/*. Champ board and page queue are snapshotted from the
  sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call
  never wipes local state).

Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests
cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 13:16:10 -05:00
2dc360ca48 Merge pull request 'ci: gate PRs into main on server tests + client build' (#57) from ci/pr-checks into main
All checks were successful
Build container images / build (push) Successful in 1m14s
Reviewed-on: UOM/website#57
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-12 15:40:17 +00:00
34c511c8d0 ci: gate PRs into main on server tests + client build
All checks were successful
PR Checks / server-tests (pull_request) Successful in 11m35s
PR Checks / client-build (pull_request) Successful in 9m42s
PR Checks / bot-install (pull_request) Successful in 9m34s
Add .gitea/workflows/pr-checks.yml running on pull_request into main.
Three parallel jobs on the existing ubuntu-latest runner:

  • server-tests  — npm ci + node --test (164 tests, no DB needed:
    the suite stubs models and points the pool at a dead port)
  • client-build  — npm ci + vite build
  • bot-install   — npm ci only (catches a broken/stale lockfile)

No ESLint exists in the repo yet, so no lint step. Complements
build-images.yml, which publishes images post-merge.

Enable in Branch Protection with status check pattern: PR Checks / *

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-12 10:07:16 -05:00
4fe90ea368 Merge pull request 'feat(admin): view a user's shard footprint at /admin/users/:id' (#56) from feature/admin-user-view into main
All checks were successful
Build container images / build (push) Successful in 1m48s
Reviewed-on: UOM/website#56
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-12 14:41:59 +00:00
ba4d758eab feat(admin): view a user's shard footprint at /admin/users/:id
Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.

Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
  so no new routes for roster/vendors/char

Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title

Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-12 09:36:43 -05:00
696d82f114 Merge pull request 'deploy: split build into docker-compose.dev.yml (prod compose pulls only)' (#55) from deploy/compose-dev-prod-split into main
All checks were successful
Build container images / build (push) Successful in 1m1s
Reviewed-on: UOM/website#55
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:45:41 +00:00
f4e7fc7e20 Merge branch 'main' into deploy/compose-dev-prod-split 2026-07-11 23:45:28 +00:00
6d4cd91bcc deploy: split build into docker-compose.dev.yml overlay
Make the base docker-compose.yml strictly production-shaped — image: only, no
build: — so a production host can only ever pull, never accidentally build
(compose gives build precedence for `up --build`/`build`, which mixed the two
modes). Local builds move to an explicit, non-auto-loaded overlay.

  Production:   docker compose pull && docker compose up -d
  Development:  docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build

Verified with `docker compose config`: base renders image-only (no build) for
both services; the dev overlay adds build back (app -> Dockerfile,
bot -> bot/Dockerfile). README quick-start updated to the two-file flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 18:42:05 -05:00
3628268dda Merge pull request 'deploy: pull prebuilt registry images in compose (IMAGE_TAG)' (#54) from deploy/compose-use-registry-images into main
All checks were successful
Build container images / build (push) Successful in 56s
Reviewed-on: UOM/website#54
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:36:55 +00:00
25ff5aa836 deploy: pull prebuilt registry images in compose (IMAGE_TAG)
Point the `app` and `bot` services at the images published to the Gitea
registry by the build-images workflow, so deploys pull instead of building:

  image: gitea.whitlocktech.com/uom/website-app:${IMAGE_TAG:-latest}
  image: gitea.whitlocktech.com/uom/website-bot:${IMAGE_TAG:-latest}

`build:` is kept, so `up --build` still works locally; the server runs
`docker compose pull && up -d`. IMAGE_TAG defaults to `latest` for routine
deploys and pins to an immutable `sha-<7>` build for reproducible deploys /
rollback — no per-deploy compose edits. Documented in .env.example + README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 18:34:31 -05:00
042a151358 Merge pull request 'ci: build & publish app + bot images to Gitea registry on merge' (#53) from ci/gitea-actions-image-build into main
All checks were successful
Build container images / build (push) Successful in 24s
Reviewed-on: UOM/website#53
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:01:04 +00:00
68 changed files with 7091 additions and 85 deletions

View File

@@ -1,6 +1,13 @@
# ─── UOMysticmoon — root environment (used by docker-compose) ───
# Copy to .env and fill in. NEVER commit the real .env.
# Container image tag pulled by docker-compose (app + bot). Published by the
# Gitea Actions workflow on every merge to main as `latest` and `sha-<7>`.
# Leave as `latest` for routine deploys; pin to a specific build for a
# reproducible deploy or rollback, e.g. IMAGE_TAG=sha-042a151.
# Deploy: `docker compose pull && docker compose up -d`.
IMAGE_TAG=latest
# App
NODE_ENV=production
PORT=3000

View File

@@ -1,11 +1,21 @@
# Build and publish the app + bot container images to Gitea's container registry
# on every merge to main. Production then pulls prebuilt images instead of
# building on the host.
# Build the app + bot container images, publish them to Gitea's container
# registry, then roll the production stack onto the fresh images — all on every
# merge to main. Production only ever pulls prebuilt images; it never builds.
#
# Two jobs run in sequence:
# build — builds & pushes website-app / website-bot images (on ubuntu-latest)
# deploy — `needs: build`, so it starts only after a clean build+push, and
# pulls + recreates the stack on the production host (on uom-deploy-runner)
#
# Prerequisites (one-time):
# • An always-on Gitea runner with label `ubuntu-latest` whose jobs have the
# host Docker socket mounted (/var/run/docker.sock), so `docker build` talks
# to the host daemon. This also gives free layer caching between runs.
# • A second self-hosted runner labelled `uom-deploy-runner` ON the production host,
# with access to the Docker daemon and to /home/perry/website (the directory
# holding the production docker-compose.yml + .env). This is what actually
# rolls the stack; it must be able to `docker compose pull` from the registry
# (log in once on the host, or ensure the images are public-read).
# • Two repo secrets (Settings → Actions → Secrets):
# REGISTRY_USER — the Gitea username that owns the token below
# REGISTRY_TOKEN — a Gitea access token with `write:package` (+ read:package)
@@ -14,6 +24,7 @@
# Produces, in gitea.whitlocktech.com/<owner>/ :
# website-app:latest + website-app:sha-<7>
# website-bot:latest + website-bot:sha-<7>
# then deploys the `:latest` images (docker-compose.yml defaults IMAGE_TAG=latest).
name: Build container images
@@ -85,3 +96,25 @@ jobs:
- name: Log out (clear cached credentials from the runner)
if: always()
run: docker logout "${REGISTRY}" || true
deploy:
# Roll production onto the images `build` just pushed. `needs: build` makes
# this wait for a clean build+push — if the build fails, deploy never fires,
# so the running stack is left untouched rather than torn down for nothing.
needs: build
runs-on: uom-deploy-runner
# Guard against a workflow_dispatch fired from a non-main branch: only ever
# deploy the main line to production.
if: github.ref == 'refs/heads/main'
steps:
- name: Pull the fresh images and recreate the stack
# `pull` grabs the new :latest images the build job published; `down`
# then `up -d` recreates the containers on them. Compose only recreates
# services whose image digest changed, so the DB stays put.
run: |
set -euo pipefail
cd /home/perry/website
docker compose pull
docker compose down
docker compose up -d
docker compose ps

View File

@@ -0,0 +1,70 @@
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
# build or failing test can't reach the deployable branch. Complements
# build-images.yml, which runs only AFTER merge (on push to main) to publish
# images — this one runs BEFORE merge.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check
# • Status check patterns: PR Checks / *
# Note: Gitea only lists a context in its dropdown after it has reported once,
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
# without needing the dropdown.
#
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
# only Node (no Docker socket), and the server tests stub their models + point the
# DB pool at a dead port, so no MariaDB service is required.
name: PR Checks
on:
pull_request:
branches: [main]
# A newer push to the same PR cancels the in-flight run.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
jobs:
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install server deps
run: npm ci --prefix server
- name: Run server tests
run: npm test --prefix server
client-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install client deps
run: npm ci --prefix client
- name: Build client
run: npm run build --prefix client
bot-install:
# No tests/build to run; a clean install still catches a broken or
# out-of-sync lockfile before it ships in the bot image.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: bot/package-lock.json
- name: Install bot deps
run: npm ci --prefix bot

View File

@@ -6,7 +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).
- **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.
- **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).
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/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).
@@ -92,8 +92,10 @@ UOMSITE/
### Option A — Docker Compose (full stack)
The simplest way to run everything. The image installs server deps, **builds the React client**,
and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are
`docker-compose.yml` is **production-shaped**: it *pulls* the prebuilt `app` and `bot` images from
the Gitea container registry (published by `.gitea/workflows/build-images.yml` on every merge to
`main`) — it never builds. Each image already bundles the server deps and the built React client,
which Express serves. MariaDB runs in its own container; tables + defaults + the first admin are
created automatically on first boot.
```bash
@@ -103,7 +105,9 @@ cp .env.example .env
# JWT_SECRET (a long random string)
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
docker compose up -d --build
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
# pin a specific build (reproducible deploy / rollback):
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
```
- App: **http://localhost:3000** (binds `0.0.0.0`)
@@ -111,6 +115,16 @@ docker compose up -d --build
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
the dev file, which adds `build:` back:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
Keeping `build:` out of the base file means a production host can only ever pull — it can never
accidentally build.
### Option B — Local development (hot reload)
Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads`
@@ -260,7 +274,7 @@ not crash).
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
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/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.
@@ -268,7 +282,7 @@ to it.
### How it works
```
ServUO shard ──▶ uo-link sidecar (UOM/link) ──▶ website backend ──▶ browser
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
```

62
bot/src/bootstrap.js vendored
View File

@@ -4,11 +4,50 @@
// container restart (crash, `docker compose restart`, host reboot) self-heals
// without any admin-panel interaction. Node 20's built-in fetch is used; no
// extra HTTP client dependency needed for a single startup call.
//
// The fetch RETRIES with backoff: on `docker compose up`, the bot and the app
// start together and the bot's `depends_on: app` only waits for the container
// to *start*, not for the app's internal server to be listening (it still has
// to reach the DB and boot Express). Without retries the very first fetch loses
// that race, bootstrap gives up, and the bot sits disconnected while the DB
// still says enabled — the exact "enabled but disconnected until I toggle it"
// bug. Retrying until the site answers makes a cold whole-stack start heal on
// its own.
const discordManager = require('./discord/discordManager')
const createLogger = require('./utils/logger')
const log = createLogger('bootstrap')
const MAX_ATTEMPTS = 30 // ~30 tries * ~2s ≈ 1 min of patience for the app to come up
const RETRY_DELAY_MS = 2000
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Fetch config from the site, retrying while the site is unreachable or not yet
// ready (network error or 5xx). Returns the parsed config, or null if we gave
// up after MAX_ATTEMPTS. A 4xx (e.g. bad internal key) is a real misconfig, not
// a transient startup race, so we don't retry those.
async function fetchConfig(siteUrl, key) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
if (res.ok) return await res.json()
if (res.status >= 400 && res.status < 500) {
log.error('boot-time config fetch rejected — not retrying', { status: res.status })
return null
}
log.warn('boot-time config fetch not ready — retrying', { status: res.status, attempt })
} catch (err) {
log.warn('boot-time config fetch errored — retrying', { message: err.message, attempt })
}
if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS)
}
log.error('boot-time config fetch gave up after retries — staying disconnected until the admin panel pushes config', {
attempts: MAX_ATTEMPTS,
})
return null
}
async function bootstrap() {
const siteUrl = process.env.SITE_INTERNAL_URL
const key = process.env.BOT_INTERNAL_KEY
@@ -17,21 +56,18 @@ async function bootstrap() {
return
}
try {
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
if (!res.ok) {
log.error('boot-time config fetch failed', { status: res.status })
return
}
const config = await res.json()
if (config.enabled) {
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
const config = await fetchConfig(siteUrl, key)
if (!config) return
if (config.enabled) {
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
try {
await discordManager.start({ token: config.token, guildId: config.guildId })
} else {
log.info('boot-time config says disabled — staying disconnected')
} catch (err) {
log.error('boot-time reconnect failed', { message: err.message })
}
} catch (err) {
log.error('boot-time config fetch errored', { message: err.message })
} else {
log.info('boot-time config says disabled — staying disconnected')
}
}

View File

@@ -18,6 +18,10 @@ import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -36,10 +40,14 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
import ShardOps from './routes/admin/views/ShardOps.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 UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
@@ -47,6 +55,7 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
@@ -57,7 +66,12 @@ export default function App() {
<AuthProvider>
<SiteProvider>
<Routes>
{/* Public site — gated by maintenance mode (admins preview through it) */}
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
<Route path="/" element={<Portal />} />
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
<Route
element={
<MaintenanceGate>
@@ -65,7 +79,6 @@ export default function App() {
</MaintenanceGate>
}
>
<Route path="/" element={<Portal />} />
<Route path="/site" element={<Website />} />
<Route path="/site/news" element={<News />} />
<Route path="/site/screenshots" element={<Screenshots />} />
@@ -76,6 +89,10 @@ export default function App() {
<Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/activity" element={<ShardActivity />} />
<Route path="/site/champs" element={<ChampSpawns />} />
<Route path="/site/guilds" element={<Guilds />} />
<Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes
@@ -120,10 +137,28 @@ export default function App() {
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} />
<Route
path="shard-ops"
element={
<RoleGate roles={['admin', 'moderator']}>
<ShardOps />
</RoleGate>
}
/>
<Route
path="houses"
element={
<RoleGate roles={['admin', 'moderator']}>
<HousesAdmin />
</RoleGate>
}
/>
<Route path="characters" element={<AdminCharacters />} />
<Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
@@ -131,6 +166,7 @@ export default function App() {
{/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
<Route
element={
<RequirePlayer>

View File

@@ -48,6 +48,10 @@ export const api = {
// optional email. Returns { user } and sets the session cookie on success.
register: (username, password, extra = {}) =>
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
// Email invites (public, token-gated accept).
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
acceptInvite: (token, username, password, extra = {}) =>
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
@@ -94,6 +98,14 @@ export const api = {
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) =>
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'),
},
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
// fetch-only, so SSE subscribers build the URL from here. The admin stream
@@ -159,9 +171,30 @@ export const api = {
botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
listUsers: () => req('/admin/users'),
getUser: (id) => req(`/admin/users/${id}`),
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// Email invites.
listInvites: () => req('/admin/invites'),
createInvite: (email, role, sendEmail = true) =>
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
// account) so the shared GameAccounts component works unchanged.
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/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/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
@@ -227,6 +260,9 @@ export const api = {
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/admin/shard/sales'),
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
createAccount: (account, password) =>
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- auth providers / SSO config (admin only) -----
@@ -245,6 +281,20 @@ export const api = {
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
// `actor` is stamped server-side from the session — never sent from here.
shardOps: {
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
pages: () => req('/admin/shard/pages'),
respondPage: (id, data) =>
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
},
// ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
@@ -276,6 +326,9 @@ export const api = {
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'),
houses: () => req('/player/shard/houses'), // the caller's own houses
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
},
},
}

View File

@@ -1,9 +1,47 @@
// 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).
//
// `moderation` opts in the in-game kick/ban controls for the character's account;
// they self-gate to staff (ShardAccountActions), so passing it from a page a
// player can reach is safe.
import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
// literal string. Without a cliloc table on the site we can only show literals, so
// numeric reward entries are skipped rather than shown as a raw number. Returns a
// de-duped list of human-readable title chips.
function displayTitles(titles) {
if (!titles) return []
const out = []
if (titles.fameKarma) out.push(titles.fameKarma)
if (titles.skill) out.push(titles.skill)
const reward = Array.isArray(titles.reward) ? titles.reward : []
const sel = typeof titles.selected === 'number' ? titles.selected : -1
// Prefer the selected reward title; fall back to the first literal one.
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
return [...new Set(out.filter(Boolean))]
}
function TitleChip({ children, tone = 'var(--muted)' }) {
return (
<span
className="sans"
style={{
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
}}
>
{children}
</span>
)
}
function StatTile({ value, label }) {
return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
@@ -28,7 +66,7 @@ function Vital({ label, cur, max }) {
)
}
export default function CharacterSheet({ char }) {
export default function CharacterSheet({ char, moderation = false }) {
if (!char) return null
const stats = char.stats || {}
const resist = stats.resist || {}
@@ -58,6 +96,29 @@ export default function CharacterSheet({ char }) {
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div>
{/* Titles + standing (guild led / governorship) — all optional */}
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
{char.governorOf && char.governorOf.map((city) => (
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
))}
{char.guild && (
<TitleChip tone="var(--accent)">
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
</TitleChip>
)}
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
</div>
)}
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */}
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
// Reusable "create a game account" form (its own username + password — the game
// client credentials, distinct from the website login). Calls `submit(account,
// password)` which should POST /player/shard/account; on success calls onCreated.
// Used by the player portal (self-serve) and the invite-accept page alike.
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
const [account, setAccount] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function onSubmit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
return setError('Account name must be 330 letters, numbers, . _ or -.')
}
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await submit(account, password)
setMsg(`Game account “${account}” created and linked.`)
setAccount(''); setPassword('')
if (onCreated) await onCreated()
} catch (err) {
if (err.status === 409) setError('That account name is already taken.')
else if (err.status === 429) setError('The account limit for your network has been reached.')
else if (err.status === 403) setError('Game-account signup is not available right now.')
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
else setError(err.message || 'Could not create the account right now.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit}>
{!compact && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose the username and password youll type into the game client. These are your
<strong style={{ color: 'var(--head)' }}> game</strong> credentials separate from your website login.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">Game account name</span>
<input
type="text" autoComplete="off" value={account}
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
/>
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Game password</span>
<input
type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} className="input"
/>
</label>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : 'Create game account'}
</button>
</form>
)
}

View File

@@ -1,11 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx'
import ShardAccountActions from './ShardAccountActions.jsx'
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
import { api } from '../api/client.js'
// Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api
// object with { link, accounts, roster } (player or admin self-service); `charTo`
// maps a serial to the route for that character's sheet.
// maps a serial to the route for that character's sheet. `readOnly` drops the
// link forms and self-voice copy for the admin case where staff view *another*
// user's accounts (no `scope.link`) at /admin/users/:id.
function LinkForm({ scope, onLinked, compact }) {
const [code, setCode] = useState('')
@@ -105,33 +110,90 @@ function AccountRoster({ scope, account, charTo }) {
)
}
export default function GameAccounts({ scope, charTo }) {
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
// then calls onUnlink(account) and reloads. Errors surface inline.
function UnlinkButton({ account, onUnlink }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function go() {
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
setBusy(true); setError('')
try {
await onUnlink(account)
} catch (err) {
setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.'))
setBusy(false)
}
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
</span>
)
}
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
// Whether the site currently offers game-account creation (public flag). Only
// relevant for the self-service (non-readOnly) view with a createAccount scope.
const [signupOk, setSignupOk] = useState(false)
const load = useCallback(async () => {
setError('')
try {
setAccounts(await scope.accounts())
} catch {
setError('Could not load your game accounts.')
setError(readOnly ? 'Could not load this users game accounts.' : 'Could not load your game accounts.')
}
}, [scope])
}, [scope, readOnly])
useEffect(() => { load() }, [load])
useEffect(() => {
if (readOnly || !scope.createAccount) return
let active = true
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [readOnly, scope])
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
if (error) return <ErrorState message={error} />
if (!accounts) return <Loading />
// Not linked yet — prompt to link.
// No linked accounts. In read-only (admin viewing another user) this is just an
// empty state; otherwise it's the link-your-account prompt.
if (accounts.length === 0) {
if (readOnly) {
return (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
This user has not linked a game account.
</p>
</div>
)
}
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 havent linked a game account yet. In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
one-time code, then enter it below to see your characters, stats, skills and vendors here.
</p>
<LinkForm scope={scope} onLinked={load} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
one-time code, then enter it below to see your characters, stats, skills and vendors here.
</p>
<LinkForm scope={scope} onLinked={load} />
</div>
{canCreate && (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
</div>
)}
</div>
)
}
@@ -141,16 +203,28 @@ export default function GameAccounts({ scope, charTo }) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => (
<section key={a.account}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
{a.account}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{a.account}
</div>
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
</div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
</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>
{!readOnly && (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact />
{canCreate && (
<div style={{ marginTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
</div>
)}
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { useAsync } from '../lib/useAsync.js'
import { useShardFeed } from '../lib/useShardFeed.js'
import { bucketize } from '../data/regionBuckets.js'
import { api } from '../api/client.js'
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
// then keeps the total + region breakdown current from the presence.online SSE
// kind. The raw byRegion map is rolled up into display buckets (see
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
const PRESENCE_KINDS = new Set(['presence.online'])
export default function PlayersOnline() {
const { loading, error, data } = useAsync(() => api.shard.presence())
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
// The freshest snapshot wins: the newest buffered presence.online event, else
// the initial fetch.
const snapshot = events[0] || data
const { total, rows } = useMemo(() => {
const count = Number(snapshot?.count) || 0
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
return { total: count, rows: bucketRows }
}, [snapshot])
return (
<section className="panel" style={{ padding: 20 }}>
<div
className="sans"
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
>
<span
style={{
color: 'var(--accent)',
fontSize: '0.7rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
Players online
</span>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
{loading ? '—' : total}
</span>
</div>
{error && (
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
Population is unavailable right now.
</p>
)}
{!loading && !error && (
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
</p>
) : (
rows.map((r) => (
<div
key={r.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
fontSize: '0.9rem',
color: 'var(--ink)',
}}
>
<span>{r.label}</span>
{/* tabular figures keep the right-aligned counts in a clean column */}
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
</div>
))
)}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,84 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.js'
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
// game account. Reused wherever a linked account or character is shown to staff:
// the admin user-detail account list and the character sheet. Self-gates on role
// (admin/moderator) so it is safe to render inside components that players also
// see — a player never gets the controls, and the API enforces the same gate.
//
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
// confirm with an optional duration + reason before it fires.
export default function ShardAccountActions({ account, style }) {
const { user } = useAuth()
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const [banOpen, setBanOpen] = useState(false)
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
// Only staff who can actually use the write plane see the controls.
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
async function run(label, fn, done) {
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account }), (r) =>
`Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`,
)
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
const ban = () =>
run('ban', () =>
api.admin.shardOps.ban({
account,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
setBanOpen(false)
return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`
})
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
return (
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
{banOpen && (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (sec, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
<span className="field-label">Reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
</button>
</div>
)}
</div>
)
}

View File

@@ -12,6 +12,10 @@ const NAV = [
{ label: 'Newsletter', to: '/site/newsletter' },
{ label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard' },
{ label: 'Champions', to: '/site/champs' },
{ label: 'Guilds', to: '/site/guilds' },
{ label: 'Governors', to: '/site/governors' },
{ label: 'Houses', to: '/site/houses' },
{ label: 'About', to: '/site/about' },
]

View File

@@ -0,0 +1,31 @@
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
// emoji sigil + a ring colour — enough to make the Governors board and the
// governor badge read as distinct "crests" today, swappable for real artwork
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
// public path) onto an entry and update CityCrest to prefer it.
//
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
export const CITY_CRESTS = {
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
}
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
// Look up a crest by the raw city key, tolerating spacing variants
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
export function crestFor(city) {
if (!city) return FALLBACK
const key = String(city).replace(/\s+/g, '')
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
if (crest) return crest
return { ...FALLBACK, label: String(city) }
}

View File

@@ -0,0 +1,62 @@
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
// regions) up into a handful of labelled display buckets for the "Players Online"
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
// membership) and the widget follows. Anything not matched lands in "Wilderness"
// so the bucket counts always reconcile to the true total.
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
// membership. First matching bucket wins; the last bucket is the catch-all.
export const BUCKETS = [
{
id: 'britain',
label: 'Britain',
// Passthrough for the capital + its immediate surrounds.
match: (r) => /^britain/i.test(r),
},
{
id: 'towns',
label: 'Towns',
// The other named cities/towns.
match: (r) =>
/^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test(
r,
),
},
{
id: 'dungeons',
label: 'Dungeons',
match: (r) =>
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
r,
),
},
{
id: 'housing',
label: 'Housing',
// House regions expose themselves as named house/townhouse regions.
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
},
{
id: 'wilderness',
label: 'Wilderness',
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
match: () => true,
},
]
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
// order, dropping empty buckets, with the summed total also returned.
export function bucketize(byRegion = {}) {
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
let total = 0
for (const [region, n] of Object.entries(byRegion || {})) {
const count = Number(n) || 0
total += count
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
totals.set(bucket.id, totals.get(bucket.id) + count)
}
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
(r) => r.count > 0,
)
return { rows, total }
}

View File

@@ -45,6 +45,24 @@ export function describe(ev) {
return 'Shard shut down'
case 'server.crashed':
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
case 'champ.update': {
const where = p.name || p.type || 'A champion spawn'
if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}`
if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}`
if (p.status === 'cooldown') return `${where} is on cooldown`
return `${where} is ${p.status || 'idle'}`
}
case 'champ.remove':
return `A champion spawn ended`
// Support (help-page) queue + in-game moderation (admin channel only)
case 'page.new':
return `New ${p.type || 'help'} page from ${nameOf(p.sender)}`
case 'page.updated':
return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}`
case 'page.closed':
return `Help page ${p.pageId || ''} closed`
case 'admin.audit':
return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}`
// 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})`

View File

@@ -63,12 +63,15 @@ const NAV = [
title: 'Moderation',
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
@@ -94,6 +97,8 @@ const TITLES = {
'/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
@@ -102,6 +107,7 @@ const TITLES = {
'/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
}
@@ -129,16 +135,20 @@ export default function AdminLayout() {
? 'Moderation'
: location.pathname.startsWith('/admin/characters')
? 'My Characters'
: 'Admin')
: location.pathname.startsWith('/admin/users/')
? 'User'
: 'Admin')
// The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Moderators only get the moderation section + their own account security.
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const isModerator = user?.role === 'moderator'
const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account']
const visible = (item) => {
if (item.roles && !item.roles.includes(user?.role)) return false
if (isModerator) return item.to === '/admin/moderation' || item.to === '/admin/account'
if (isModerator) return MOD_PATHS.includes(item.to)
return true
}
// Drop items the current role can't see, then drop any now-empty group so an
@@ -176,7 +186,9 @@ export default function AdminLayout() {
useEffect(() => {
if (!isModerator) return
const p = location.pathname
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
const allowed =
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
if (!allowed) {
navigate('/admin/moderation', { replace: true })
}
}, [isModerator, location.pathname, navigate])

View File

@@ -23,7 +23,7 @@ export default function AdminCharacter() {
{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} />}
{!loading && !error && data && <CharacterSheet char={data} moderation />}
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { api } from '../../../api/client.js'
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
// live from the admin SSE channel (house.update / house.remove).
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
function DecayBadge({ decay, isIdoc }) {
const label = isIdoc ? 'IDOC' : decay
if (!label) return null
const tone = DECAY_TONE[label] || 'var(--muted)'
return (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
function ownerLabel(h) {
return h.ownerName || h.ownerAcct || null
}
function HouseRow({ h }) {
const owner = ownerLabel(h)
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
</div>
)}
</div>
)
}
export default function HousesAdmin() {
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
// Full registry deltas ride the admin SSE channel (never the public one).
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
const [q, setQ] = useState('')
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev.serial) continue
if (ev.kind === 'house.update') {
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
} else if (ev.kind === 'house.remove') {
map.delete(ev.serial)
} else if (ev.kind === 'house.decay') {
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
}
}
return [...map.values()]
}, [data, events])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the house registry." />
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
{board.length.toLocaleString()} houses
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
</p>
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
</div>
{board.length === 0 ? (
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
)}
{board.length > 0 && filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match {q}.</p>
)}
</section>
)
}

View File

@@ -0,0 +1,178 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Admin email invites: send an invite at a chosen access level, see recent
// invites and their status, revoke pending ones. When email delivery isn't
// configured the create response hands back the accept link to copy manually.
const ROLES = ['player', 'moderator', 'editor', 'admin']
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator', player: 'badge-player' }
const STATUS_COLOR = { pending: 'var(--accent)', accepted: '#7fd0a4', revoked: 'var(--muted)' }
function CopyLink({ url }) {
const [copied, setCopied] = useState(false)
async function copy() {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 1800)
} catch {
/* clipboard blocked — the link is selectable in the box regardless */
}
}
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
<code
onClick={(e) => { const r = document.createRange(); r.selectNodeContents(e.currentTarget); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r) }}
style={{ flex: 1, wordBreak: 'break-all', color: 'var(--head)', background: 'var(--panel-flat)', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--line)', cursor: 'text', fontSize: '0.8rem' }}
>
{url}
</code>
<button type="button" onClick={copy} className="btn btn-sq" style={{ flex: 'none' }}>
{copied ? 'Copied ✓' : 'Copy'}
</button>
</div>
)
}
function CreateInvite({ onCreated }) {
const [email, setEmail] = useState('')
const [role, setRole] = useState('player')
const [sendEmail, setSendEmail] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError }
async function submit(e) {
e.preventDefault()
setError(''); setResult(null)
if (!email.trim()) return setError('Enter an email address.')
setBusy(true)
try {
const res = await api.admin.createInvite(email.trim(), role, sendEmail)
setResult(res)
setEmail('')
await onCreated()
} catch (err) {
setError(err.message || 'Could not create the invite.')
} finally {
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Email</span>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="person@example.com" />
</label>
<label>
<span className="field-label">Access level</span>
<select value={role} onChange={(e) => setRole(e.target.value)} className="select">
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')}
</button>
</form>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: '0.85rem', color: 'var(--ink)', cursor: 'pointer' }}>
<input type="checkbox" checked={sendEmail} onChange={(e) => setSendEmail(e.target.checked)} />
Email the invitation (otherwise just generate a link to share)
</label>
{error && <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{result && (
<div style={{ marginTop: 14 }}>
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
{result.emailed
? 'Invitation emailed. You can also share this single-use link:'
: `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
</p>
<CopyLink url={result.acceptUrl} />
</div>
)}
</div>
)
}
export default function InvitesAdmin() {
const [invites, setInvites] = useState(null)
const [error, setError] = useState('')
const load = useCallback(async () => {
setError('')
try {
setInvites(await api.admin.listInvites())
} catch {
setError('Could not load invites.')
}
}, [])
useEffect(() => { load() }, [load])
async function revoke(id) {
if (!window.confirm('Revoke this pending invitation?')) return
try {
await api.admin.revokeInvite(id)
await load()
} catch {
/* surfaced by the row staying; keep it simple */
}
}
if (error) return <ErrorState message={error} />
return (
<section>
<CreateInvite onCreated={load} />
{!invites ? (
<Loading />
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Email</th>
<th className="adm-th">Role</th>
<th className="adm-th">Status</th>
<th className="adm-th">Expires</th>
<th className="adm-th">Created</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{invites.length === 0 && (
<tr><td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No invites yet.</td></tr>
)}
{invites.map((iv) => {
const status = iv.status === 'pending' && iv.expired ? 'expired' : iv.status
return (
<tr key={iv.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{iv.email}</td>
<td className="adm-td"><span className={`badge ${ROLE_BADGE[iv.role] || 'badge-editor'}`}>{iv.role}</span></td>
<td className="adm-td" style={{ color: STATUS_COLOR[iv.status] || 'var(--muted)', textTransform: 'capitalize' }}>{status}</td>
<td className="adm-td dim">{dateTime(iv.expiresAt)}</td>
<td className="adm-td dim">{dateTime(iv.createdAt)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
{iv.status === 'pending' && (
<button type="button" className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }} onClick={() => revoke(iv.id)}>
Revoke
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -35,6 +35,18 @@ const FIELDS = [
],
fallback: 'disabled',
},
{
key: 'game_account_signup',
label: 'Game-account creation',
help: 'Whether players can create a GAME account (for the game client) from the site. The game servers own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
options: [
{ value: 'disabled', label: 'Disabled — link an existing account only' },
{ value: 'website', label: 'Website — the site creates game accounts' },
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
{ value: 'game', label: 'Game only — created in the game client, not the site' },
],
fallback: 'disabled',
},
]
export default function SettingsAdmin() {

View File

@@ -0,0 +1,269 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
// unban) and the help-page support queue, plus a live audit log. Open to admins
// and moderators. The acting staff member (`actor`) is attached server-side from
// the session — nothing here sends it — so every action is attributable.
function Flash({ ok, err }) {
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
return null
}
// ── Broadcast ────────────────────────────────────────────────────────────────
function Broadcast() {
const [text, setText] = useState('')
const [hue, setHue] = useState('')
const [busy, setBusy] = useState(false)
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
async function send() {
if (!text.trim()) return setErr('Enter a message.')
setBusy(true); setOk(''); setErr('')
try {
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
setOk('Broadcast sent.')
setText('')
} catch (e) {
setErr(e.message || 'Could not broadcast.')
} finally {
setBusy(false)
}
}
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
A system message shown to everyone online right now.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Hue (optional)</span>
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
function AccountActions() {
const [account, setAccount] = useState('')
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const acct = account.trim()
function guard() {
if (!acct) {
setErr('Enter an account name.')
return false
}
return true
}
async function run(label, fn, done) {
if (!guard()) return
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => `Kicked ${acct}${r?.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`)
const ban = () =>
run('ban', () => api.admin.shardOps.ban({ account: acct, durationSec: durationSec === '' ? undefined : Number(durationSec), reason: reason.trim() || undefined }), () => `Banned ${acct}${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`)
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
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)' }}>Account actions</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Account</span>
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'block', maxWidth: 200 }}>
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
<span className="field-label">Ban reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Support (help-page) queue ────────────────────────────────────────────────
function PageRow({ page, onDone }) {
const [message, setMessage] = useState('')
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function respond(close) {
if (!message.trim()) return setErr('Enter a reply first.')
setBusy(close ? 'respond-close' : 'respond'); setErr('')
try {
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
onDone()
} catch (e) {
setErr(e.message || 'Could not send.')
setBusy('')
}
}
async function close() {
setBusy('close'); setErr('')
try {
await api.admin.shardOps.closePage(page.pageId)
onDone()
} catch (e) {
setErr(e.message || 'Could not close.')
setBusy('')
}
}
return (
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{page.sender?.name || page.pageId}
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
</div>
</div>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
</div>
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
</div>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
</div>
)
}
function SupportQueue() {
const [pages, setPages] = useState(null)
const [err, setErr] = useState('')
const pollRef = useRef(null)
const load = useCallback(async () => {
try {
setPages(await api.admin.shardOps.pages())
} catch {
setErr('Could not load the support queue.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 7000)
return () => clearInterval(pollRef.current)
}, [load])
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)' }}>Support queue</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Open help pages from players. A reply reaches them in game (or on their next login).
</p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{pages == null ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
) : pages.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)}
</section>
)
}
// ── Audit log ────────────────────────────────────────────────────────────────
// Seeded from the stored admin.audit history, then kept live from the admin SSE
// channel (which carries every kind — we filter to admin.audit here).
function AuditLog() {
const [seed, setSeed] = useState([])
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
useEffect(() => {
api.admin.shardOps
.audit(50)
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
.catch(() => setSeed([]))
}, [])
// Live events on top; fall back to the seed for anything older than the live tail.
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
{rows.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<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>
)
}
export default function ShardOps() {
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
<Broadcast />
<AccountActions />
<SupportQueue />
<AuditLog />
</section>
)
}

View File

@@ -0,0 +1,182 @@
import { useMemo } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime, ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// Admin read-only view of one user's shard (uo-link) footprint: linked game
// accounts + character rosters, currently-online characters, houses (incl.
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
// Reached from the Users table's "View" action; Edit stays a separate modal.
const ROLE_BADGE = {
admin: 'badge-admin',
editor: 'badge-editor',
moderator: 'badge-moderator',
player: 'badge-player',
}
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<li
key={h.serial}
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
))}
</ul>
)}
</section>
)
}
function ShardSections({ scope }) {
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}
export default function UserDetail() {
const { id } = useParams()
// Memoize so the child components' effects (keyed on `scope`) don't refetch
// on every render.
const scope = useMemo(() => api.admin.userShard(id), [id])
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this user." />
return (
<section>
<Link to="/admin/users" className="link-accent" style={{ fontSize: '0.85rem' }}>
Back to users
</Link>
{/* Header */}
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 24px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
{user.username}
</span>
<span className={`badge ${ROLE_BADGE[user.role] || 'badge-editor'}`}>{user.role}</span>
<span
className="sans"
style={{ fontSize: '0.82rem', color: user.status && user.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
>
{user.status || 'active'}
</span>
</div>
<div className="sans dim" style={{ display: 'flex', gap: 18, marginTop: 10, flexWrap: 'wrap', fontSize: '0.8rem' }}>
{user.email && <span>{user.email}</span>}
<span>Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}</span>
{user.created_at && <span>Joined: {dateTime(user.created_at)}</span>}
</div>
</div>
<ShardSections scope={scope} />
</section>
)
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime } from '../../../lib/format.js'
@@ -13,6 +14,7 @@ const ROLE_BADGE = {
}
export default function UsersAdmin() {
const navigate = useNavigate()
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
@@ -64,8 +66,13 @@ export default function UsersAdmin() {
</td>
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(u)}>
Edit
<span style={{ display: 'inline-flex', gap: 16, justifyContent: 'flex-end' }}>
<span className="link-accent" onClick={() => navigate(`/admin/users/${u.id}`)}>
View
</span>
<span className="link-accent" onClick={() => setEditing(u)}>
Edit
</span>
</span>
</td>
</tr>

View File

@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx'
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
// lets the invitee set a username + password (their email + role are pre-assigned),
// creates the account at that role and logs them in. For a player invite it then
// offers the built-in "create game account" step before sending them to the portal.
export default function AcceptInvite() {
const { token } = useParams()
const navigate = useNavigate()
const { refresh } = useAuth()
const [invite, setInvite] = useState(null) // { email, role }
const [loadErr, setLoadErr] = useState('')
const [signupOk, setSignupOk] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [company, setCompany] = useState('') // honeypot
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [accepted, setAccepted] = useState(false)
useEffect(() => {
let active = true
api.getInvite(token)
.then((iv) => active && setInvite(iv))
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [token])
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
async function onSubmit(e) {
e.preventDefault()
setError('')
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await api.acceptInvite(token, username.trim(), password, { company })
await refresh() // pull the freshly-issued session into context
setAccepted(true)
// Staff invites are web-only — no game step; go straight in.
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
else if (err.status === 404) setError('This invitation is invalid or has expired.')
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
else setError('Could not accept the invitation right now.')
setBusy(false)
}
}
// ── Loading / invalid ─────────────────────────────────────────────────────
if (loadErr) {
return (
<PlayerShell subtitle="Invitation">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Go to sign in</Link>
</p>
</PlayerShell>
)
}
if (!invite) {
return (
<PlayerShell subtitle="Invitation">
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
</PlayerShell>
)
}
// ── Accepted: optional game-account step (player invites) ──────────────────
if (accepted) {
return (
<PlayerShell subtitle="Set up your game account">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
</p>
<CreateGameAccountForm
submit={api.player.shard.createAccount}
onCreated={() => navigate('/player', { replace: true })}
/>
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
Skip for now
</button>
</p>
</PlayerShell>
)
}
// ── Accept form ────────────────────────────────────────────────────────────
return (
<PlayerShell subtitle="Accept your invitation">
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Youve been invited as <strong style={{ color: 'var(--head)' }}>{invite.role}</strong>
{invite.email ? <> for <strong style={{ color: 'var(--head)' }}>{invite.email}</strong></> : null}. Choose a username and password to finish.
</p>
<form onSubmit={onSubmit}>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Username</span>
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
</label>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Password</span>
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
</label>
<div style={honeypotStyle} aria-hidden="true">
<label>
Company
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
</label>
</div>
{error && <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
{busy ? 'Creating…' : 'Accept & create account'}
</button>
</form>
</PlayerShell>
)
}

View File

@@ -1,14 +1,57 @@
import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// The logged-in player's characters. Shows the link prompt when no game account
// is linked, otherwise their characters grouped by account (shared component),
// plus their own recent vendor sales.
// plus their own home status and recent vendor sales.
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
// The caller's own houses (home status). Only their own — never anyone else's.
function MyHouses() {
const { data } = useAsync(() => api.player.shard.houses(), [])
if (!data || data.length === 0) return null
return (
<section style={{ marginTop: 30 }}>
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => {
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
return (
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
{label && (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
{label}
</span>
)}
</div>
)
})}
</div>
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
Keep an eye on the decay status refresh a house in game before it reaches IDOC.
</p>
</section>
)
}
export default function PlayerCharacters() {
return (
<div>
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
<MyHouses />
<VendorSales fetchSales={api.player.shard.sales} />
</div>
)

View File

@@ -0,0 +1,203 @@
import { useMemo } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
// families share the board, split by category into their own sections.
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
const SECTIONS = [
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
]
const STATUS_STYLE = {
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
}
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
function until(iso) {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (!Number.isFinite(ms)) return ''
if (ms <= 0) return 'due'
const mins = Math.round(ms / 60000)
if (mins < 60) return `in ${mins}m`
const hrs = Math.round(mins / 60)
return `in ${hrs}h`
}
function StatusBadge({ status }) {
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
return (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.68rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
padding: '3px 9px',
borderRadius: 999,
color: s.fg,
background: s.bg,
border: `1px solid ${s.border}`,
}}
>
{s.label}
</span>
)
}
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
function Meter({ value, max, tone = 'var(--accent)' }) {
if (!max) return null
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
return (
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
</div>
)
}
// Category-specific middle line + meter for one spawn.
function ChampDetail({ s }) {
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
if (s.category === 'sea') {
return (
<>
<div className="sans" style={line}>
<span>{s.boss || s.type}</span>
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
</div>
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
</>
)
}
if (s.category === 'mini') {
return (
<div className="sans" style={line}>
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
</div>
)
}
// champion
return (
<>
<div className="sans" style={line}>
<span>
Level {s.level ?? 0}
{s.bossUp && s.boss ? `${s.boss}` : ''}
</span>
<span>
{s.status === 'cooldown'
? until(s.restartAt) || 'restarting'
: s.status === 'active'
? `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
: ''}
</span>
</div>
{s.status === 'active' && (
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
)}
</>
)
}
function ChampCard({ s }) {
return (
<div className="panel" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.name || s.type || 'Spawn'}
</strong>
<StatusBadge status={s.status} />
</div>
<ChampDetail s={s} />
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
</div>
</div>
)
}
export default function ChampSpawns() {
const { loading, error, data } = useAsync(() => api.shard.champs())
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
// buffered events oldest → newest (the buffer is newest-first) so live wins.
const board = useMemo(() => {
const map = new Map()
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev || !ev.serial) continue
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
}
return [...map.values()]
}, [data, events])
const byCategory = (id) =>
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
const activeCount = board.filter((s) => s.status === 'active').length
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the champion board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
{activeCount} active · {board.length} tracked
</p>
{SECTIONS.map((sec) => {
const rows = byCategory(sec.id)
if (rows.length === 0) return null
return (
<section key={sec.id} style={{ marginBottom: 28 }}>
<div style={{ marginBottom: 12 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
</div>
<div className="grid-2" style={{ gap: 12 }}>
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
</div>
</section>
)
})}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,186 @@
import { useMemo, useState } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { crestFor } from '../../data/cityCrests.js'
import { api } from '../../api/client.js'
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
// kept live by merging city.update deltas by city. Empty on shards without the
// City Loyalty system. Each city card links to its term history (look-back).
const GOV_KINDS = new Set(['city.update'])
const PHASE = {
none: null,
nominate: { label: 'Nominations open', color: '#7f8fd0' },
vote: { label: 'Voting', color: '#e6c26a' },
pending: { label: 'Result pending', color: '#c9a24b' },
}
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
function until(iso) {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (!Number.isFinite(ms) || ms <= 0) return ''
const mins = Math.round(ms / 60000)
if (mins < 60) return `in ${mins}m`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `in ${hrs}h`
return `in ${Math.round(hrs / 24)}d`
}
function fmtDate(ms) {
if (ms == null) return ''
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function CityCrest({ city, size = 44 }) {
const c = crestFor(city)
return (
<span
aria-hidden="true"
style={{
flex: 'none', width: size, height: size, borderRadius: '50%',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
}}
>
{c.sigil}
</span>
)
}
// Collapsible term history for one city, fetched on demand from the ledger.
function TermHistory({ city }) {
const [open, setOpen] = useState(false)
const { loading, error, data } = useAsync(
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
[open, city],
)
return (
<div style={{ marginTop: 12 }}>
<button
type="button"
className="sans"
onClick={() => setOpen((v) => !v)}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
>
{open ? 'Hide past governors' : 'Past governors →'}
</button>
{open && (
<div style={{ marginTop: 8 }}>
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading</p>}
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
{data && data.length === 0 && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
)}
{data && data.length > 0 && (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
{data.map((t, i) => (
<li key={i} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.governor?.name || 'Vacant'}
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
{fmtDate(t.startedAt)}{t.endedAt ? ` ${fmtDate(t.endedAt)}` : ' present'}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
function CityCard({ c }) {
const phase = PHASE[c.electionPhase] || null
const gov = c.governor
return (
<div className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<CityCrest city={c.city} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
{crestFor(c.city).label || c.city}
</strong>
{phase && (
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
{phase.label}
</span>
)}
</div>
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
{gov ? (
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
) : (
'Seat vacant'
)}
</div>
</div>
</div>
{c.electionPhase && c.electionPhase !== 'none' && (
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
{c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'}
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
</div>
)}
<TermHistory city={c.city} />
</div>
)
}
export default function Governors() {
const { loading, error, data } = useAsync(() => api.shard.governors())
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
const board = useMemo(() => {
const map = new Map()
for (const c of data || []) if (c && c.city) map.set(c.city, c)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
}
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
}, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the governor board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
City Loyalty governance is not enabled on this shard.
</p>
</section>
) : (
<div className="grid-2" style={{ gap: 12 }}>
{board.map((c) => <CityCard key={c.city} c={c} />)}
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,169 @@
import { useMemo, useState } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// The guild board. Loaded once from /public/shard/guilds, then kept live by
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
// joined" strip on top of the board.
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
function Leader({ leader }) {
if (!leader || !leader.name) return <span className="dim"></span>
return <span>{leader.name}</span>
}
function GuildRow({ g }) {
return (
<div
className="panel"
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
{g.abbr && (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.72rem',
letterSpacing: '0.06em',
color: 'var(--accent)',
border: '1px solid rgba(201,162,75,0.4)',
borderRadius: 5,
padding: '1px 6px',
}}
>
{g.abbr}
</span>
)}
<strong
className="display"
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{g.name || 'A guild'}
</strong>
</div>
{g.alliance && (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{g.alliance}
</div>
)}
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
<div>
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
<span className="dim"> / {g.members ?? 0}</span>
</div>
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
<Leader leader={g.leader} />
</div>
</div>
</div>
)
}
export default function Guilds() {
const { loading, error, data } = useAsync(() => api.shard.guilds())
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
const [q, setQ] = useState('')
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
const board = useMemo(() => {
const map = new Map()
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
}
return [...map.values()]
}, [data, events])
// Recent joins strip (newest first, deduped, capped).
const joins = useMemo(
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
[events],
)
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((g) =>
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
)
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the guild board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
</section>
) : (
<>
{joins.length > 0 && (
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
Recently joined
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{joins.map((j) => (
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
<span className="dim"> joined </span>
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
</div>
))}
</div>
</section>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
{board.length} guilds · {totalMembers.toLocaleString()} members
</p>
<input
className="input sans"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search guilds…"
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
</div>
{filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match {q}.</p>
)}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,91 @@
import { useMemo } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
// price, decay detail and the full registry are staff-only (admin Houses view).
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
// house entering IDOC appears, one leaving it drops off.
const HOUSE_KINDS = new Set(['house.decay'])
function HouseRow({ h }) {
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<span
aria-hidden="true"
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.region || 'The wilderness'}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
IDOC
</span>
</div>
)
}
export default function Houses() {
const { loading, error, data } = useAsync(() => api.shard.houses())
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
// adds/updates the row; anything else (refreshed, collapsed) drops it.
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind !== 'house.decay' || !ev.serial) continue
if (String(ev.to).toUpperCase() === 'IDOC') {
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
} else {
map.delete(ev.serial)
}
}
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
}, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the houses board right now." />}
{!loading && !error && (
board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
{board.length} in danger
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
</>
)
)}
</div>
</PublicLayout>
)
}

View File

@@ -40,7 +40,7 @@ export default function Portal() {
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
return (
<PublicLayout>
<PublicLayout header={false}>
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{PREVIEW && draft && (
<div

View File

@@ -7,6 +7,7 @@ import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
import PlayersOnline from '../../components/PlayersOnline.jsx'
// ── Gold-supply sparkline ───────────────────────────────────────────────────
function Sparkline({ series }) {
@@ -108,12 +109,16 @@ export default function Shard() {
</section>
{/* Stat tiles */}
<section className="grid-3" style={{ gap: 14, marginBottom: 24 }}>
<Stat value={status?.onlineCount ?? '—'} label="Players online" />
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
</section>
{/* Live players-online breakdown (total + region buckets) */}
<div style={{ marginBottom: 24 }}>
<PlayersOnline />
</div>
{/* Staff online — linked staff accounts only, with location */}
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>

24
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,24 @@
# Development overlay — build the app + bot images locally instead of pulling the
# prebuilt ones from the Gitea registry.
#
# The base docker-compose.yml is production-shaped (image: only, no build:), so a
# production host can never accidentally build — it only pulls. Use this overlay
# EXPLICITLY for local work (it is not auto-loaded like docker-compose.override.yml
# would be):
#
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
#
# Production stays:
#
# docker compose pull && docker compose up -d
#
# The `image:` tags inherited from the base file double as the local build tags,
# so a built image and a pulled one are interchangeable.
services:
app:
build: .
bot:
build:
context: .
dockerfile: bot/Dockerfile

View File

@@ -21,7 +21,14 @@ services:
# - "3306:3306"
app:
build: .
# Prebuilt image from the Gitea registry (published by
# .gitea/workflows/build-images.yml on every merge to main). This file is
# production-shaped — image only, NO build: — so a production host can only
# ever pull, never accidentally build. IMAGE_TAG defaults to `latest`; pin a
# specific build for a reproducible deploy / rollback, e.g.
# IMAGE_TAG=sha-042a151 (see .env / .env.example). To build locally instead,
# overlay docker-compose.dev.yml (see README).
image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}
restart: unless-stopped
env_file: .env
environment:
@@ -44,9 +51,9 @@ services:
- "3000:3000"
bot:
build:
context: .
dockerfile: bot/Dockerfile
# Same as app: prebuilt bot image, pulled in production. Build locally via
# docker-compose.dev.yml.
image: gitea.whitlocktech.com/runicgateway/website-bot:${IMAGE_TAG:-latest}
restart: unless-stopped
env_file: .env
environment:

View File

@@ -383,6 +383,159 @@ CREATE TABLE IF NOT EXISTS shard_account_links (
INDEX idx_shard_links_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Current champion-spawn board, upserted on champ.update and removed on
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
-- the public Champions page (and its live deltas) survive a shard outage, the
-- same way shard_online / shard_houses do. Three families share one table, told
-- apart by `category` (champion | mini | sea); category-specific fields (level,
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
-- not have to model every variant.
CREATE TABLE IF NOT EXISTS shard_champs (
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
category VARCHAR(16) NULL, -- champion | mini | sea
type VARCHAR(80) NULL,
name VARCHAR(120) NULL,
status VARCHAR(16) NULL, -- active | cooldown | dormant
active TINYINT(1) NOT NULL DEFAULT 0,
map VARCHAR(40) NULL,
x INT NULL,
y INT NULL,
z INT NULL,
boss_up TINYINT(1) NOT NULL DEFAULT 0,
payload JSON NOT NULL, -- the full champ.update object
t BIGINT NULL, -- event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_champs_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
-- player). Staff-only data — served on the admin channel, never public.
CREATE TABLE IF NOT EXISTS shard_pages (
page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
sender_name VARCHAR(120) NULL,
sender_acct VARCHAR(120) NULL,
web_id INT NULL, -- linked website user id, if any
message TEXT NULL,
map VARCHAR(40) NULL,
x INT NULL,
y INT NULL,
z INT NULL,
sent_ms BIGINT NULL, -- when the page was opened, epoch ms
handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
handler VARCHAR(120) NULL,
payload JSON NOT NULL, -- the full page.new/updated object
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_pages_handled (handled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
-- actor object flattened into leader_* columns; the full event is kept in
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
-- projection into our store so the public Guilds page survives a shard outage.
CREATE TABLE IF NOT EXISTS shard_guilds (
id INT NOT NULL PRIMARY KEY, -- in-game guild id
name VARCHAR(120) NULL,
abbr VARCHAR(24) NULL,
members INT NULL,
online INT NULL,
alliance VARCHAR(120) NULL,
leader_serial VARCHAR(20) NULL,
leader_name VARCHAR(120) NULL,
leader_acct VARCHAR(120) NULL,
leader_web_id INT NULL,
payload JSON NOT NULL, -- the full guild.update object
t BIGINT NULL, -- event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
-- city.update (full-state, emitted only on change; there is no remove event since
-- the set of cities is fixed). governor / governorElect are actor objects
-- flattened into columns; the full event is kept in `payload`. Empty on shards
-- that do not run the City Loyalty system.
CREATE TABLE IF NOT EXISTS shard_governors (
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
governor_serial VARCHAR(20) NULL,
governor_name VARCHAR(120) NULL,
governor_acct VARCHAR(120) NULL,
governor_web_id INT NULL,
elect_serial VARCHAR(20) NULL,
elect_name VARCHAR(120) NULL,
elect_acct VARCHAR(120) NULL,
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
candidates INT NULL,
auto_pick_at DATETIME NULL,
payload JSON NOT NULL, -- the full city.update object
t BIGINT NULL, -- event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Governor term history — the "who governed when" ledger behind the Governors
-- board. Captured from day one (history cannot be backfilled) on every observed
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
-- COUNT and election phase, not per-candidate tallies, so we record who governed
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
-- were all the governors of Britain?") reads this table.
CREATE TABLE IF NOT EXISTS shard_governor_terms (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
city VARCHAR(40) NOT NULL,
governor_serial VARCHAR(20) NULL,
governor_name VARCHAR(120) NULL,
governor_acct VARCHAR(120) NULL,
governor_web_id INT NULL,
started_at BIGINT NOT NULL, -- term start, epoch ms
ended_at BIGINT NULL, -- term end epoch ms (NULL = current)
votes INT NULL, -- not in the feed (reserved)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_shard_gov_terms_city (city, started_at),
INDEX idx_shard_gov_terms_open (city, ended_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
-- latest presence.online aggregate: total count plus per-facet and per-region
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
-- is the rolled-up headcount the public "Players Online" widget renders. The
-- time series, if ever needed, is available from GET /history?kind=presence.online.
CREATE TABLE IF NOT EXISTS shard_presence (
id INT PRIMARY KEY DEFAULT 1,
count INT NOT NULL DEFAULT 0,
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
t BIGINT NULL, -- snapshot time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
-- which creates their website user at that role (and optionally a linked game
-- account). Only the sha256 hash of the opaque token is stored — a DB read never
-- yields a usable invite link, same as mobile_refresh_tokens. status tracks the
-- lifecycle; accepted_user_id back-points at the created user. Single-use +
-- expiring (enforced in the model on top of expires_at).
CREATE TABLE IF NOT EXISTS user_invites (
id INT AUTO_INCREMENT PRIMARY KEY,
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
email VARCHAR(255) NOT NULL,
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'player',
status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending',
invited_by INT NULL, -- staff user who sent it
accepted_user_id INT NULL, -- the user created on accept
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
accepted_at DATETIME NULL,
CONSTRAINT fk_user_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_user_invites_user FOREIGN KEY (accepted_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_user_invites_email (email),
INDEX idx_user_invites_status (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
-- writes them. They live in the same physical database as everything else
@@ -707,6 +860,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
-- Player self-registration mode: disabled | password | sso | both. Default off,
-- so the system behaves exactly as today until an admin opts in.
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user
-- may provision a linked game account from the site. Default off; the shard's own
-- signup mode still has the final say (a 'game'-mode shard refuses regardless).
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
@@ -724,3 +881,20 @@ ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title,
-- already keeps the two tables consistent.
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
-- fields than the house.decay transition feed shard_houses was built for. Rather
-- than a second table for one entity, extend shard_houses: house.update writes the
-- registry columns below (owner display name, co-owner/friend counts, placement
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
-- upsert only touches its own columns, so the two feeds never clobber each other.
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
-- so the public Houses browser can list registered houses without pulling in rows
-- we only ever saw an IDOC transition for.
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,47 @@
const { query } = require('../../utils/db')
const COLS =
'id, token_hash, email, role, status, invited_by, accepted_user_id, expires_at, created_at, accepted_at'
async function insert({ tokenHash, email, role, invitedBy, expiresAt }) {
const res = await query(
`INSERT INTO user_invites (token_hash, email, role, invited_by, expires_at)
VALUES (?, ?, ?, ?, ?)`,
[tokenHash, email, role, invitedBy ?? null, expiresAt],
)
return res.insertId
}
async function getById(id) {
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function findByTokenHash(tokenHash) {
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE token_hash = ? LIMIT 1`, [tokenHash])
return rows[0] || null
}
const listRecent = (limit) =>
query(`SELECT ${COLS} FROM user_invites ORDER BY created_at DESC LIMIT ?`, [limit])
// Mark accepted only if still pending (atomic guard against a double-accept race).
// Returns rows changed (1 = we won, 0 = already used/revoked).
async function markAccepted(id, userId) {
const res = await query(
`UPDATE user_invites SET status = 'accepted', accepted_user_id = ?, accepted_at = NOW()
WHERE id = ? AND status = 'pending'`,
[userId, id],
)
return res.affectedRows || 0
}
async function revoke(id) {
const res = await query(
`UPDATE user_invites SET status = 'revoked' WHERE id = ? AND status = 'pending'`,
[id],
)
return res.affectedRows || 0
}
module.exports = { insert, getById, findByTokenHash, listRecent, markAccepted, revoke }

View File

@@ -0,0 +1,73 @@
// Admin email invites. A staff member invites someone by email at a pre-chosen
// access level; the invitee accepts via a tokened link that creates their website
// user at that role. The opaque token lives only in the emailed link — the DB
// stores just its sha256 hash (like mobile refresh tokens), so a DB read never
// yields a usable invite. Invites are single-use and expiring.
const crypto = require('crypto')
const db = require('./invites.db')
const DEFAULT_TTL_DAYS = 7
function hashToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex')
}
// Public-safe shape (never exposes the token hash).
function toSafe(row) {
if (!row) return null
return {
id: row.id,
email: row.email,
role: row.role,
status: row.status,
invitedBy: row.invited_by,
acceptedUserId: row.accepted_user_id,
expiresAt: row.expires_at,
createdAt: row.created_at,
acceptedAt: row.accepted_at,
expired: new Date(row.expires_at).getTime() < Date.now(),
}
}
// Create an invite. Returns { invite, token } — the plaintext token is returned
// ONCE (for the email link) and never stored or recoverable afterwards.
async function create({ email, role, invitedBy, ttlDays = DEFAULT_TTL_DAYS }) {
const token = crypto.randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
const id = await db.insert({ tokenHash: hashToken(token), email, role, invitedBy, expiresAt })
return { invite: toSafe(await db.getById(id)), token }
}
// Resolve a pending, unexpired invite from its plaintext token, else null. Returns
// the RAW row (incl. id) for the accept flow; callers sanitize with publicView.
async function findValidByToken(token) {
if (!token) return null
const row = await db.findByTokenHash(hashToken(token))
if (!row || row.status !== 'pending') return null
if (new Date(row.expires_at).getTime() < Date.now()) return null
return row
}
// Atomically consume a pending invite (double-accept-safe). Returns true if this
// call won the race and bound the invite to userId.
async function accept(id, userId) {
return (await db.markAccepted(id, userId)) === 1
}
const revoke = (id) => db.revoke(id)
async function list(limit = 100) {
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
const rows = await db.listRecent(n)
return rows.map(toSafe)
}
// A minimal, safe view of an invite for the (unauthenticated) accept page —
// only what the form needs, never the token or internal ids.
function publicView(row) {
if (!row) return null
return { email: row.email, role: row.role }
}
module.exports = { create, findValidByToken, accept, revoke, list, publicView, toSafe, hashToken }

View File

@@ -32,6 +32,27 @@ function registrationFlags(mode) {
}
}
// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
// disabled — the site never offers game-account creation (link-only).
// website — the site is the authority (offer creation; pair with the shard in
// website mode + AutoCreateAccounts=false).
// hybrid — either side may create (the site offers creation).
// game — the game server is the authority; the site does NOT offer creation.
// The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode
// (Bridge.cfg) still has the final say and may 403 a call regardless.
const GAME_SIGNUP_KEY = 'game_account_signup'
const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game']
const GAME_SIGNUP_OFFER = ['website', 'hybrid']
async function getGameSignupMode() {
const v = await settingsDb.get(GAME_SIGNUP_KEY)
return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled'
}
async function isGameAccountSignupEnabled() {
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
}
async function get(key) {
return settingsDb.get(key)
}
@@ -64,6 +85,10 @@ async function getPublic() {
// page show/hide the password form and SSO buttons.
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
out.registration = registrationFlags(mode)
// Whether the site offers game-account creation (the shard's own mode still has
// the final say when the call is made). Lets the portal show/hide the form.
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
return out
}
@@ -78,4 +103,8 @@ module.exports = {
REGISTRATION_MODES,
getRegistrationMode,
registrationFlags,
GAME_SIGNUP_KEY,
GAME_SIGNUP_MODES,
getGameSignupMode,
isGameAccountSignupEnabled,
}

View File

@@ -33,4 +33,10 @@ async function isOwnedBy(account, userId) {
const remove = (account, userId) =>
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove }
// Drop the mirror for an account regardless of which user held it — used to
// reconcile when the tie is severed at the source (an in-game [unlink →
// account.unlinked event, or a site-side DELETE /link/{account}).
const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }

View File

@@ -31,4 +31,7 @@ async function getByAccount(account) {
const unlink = (account, userId) => db.remove(account, userId)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink }
// Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }

View File

@@ -33,6 +33,18 @@ async function countOnline() {
const listOnline = () =>
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
// Online players on any of the given game accounts (admin: a user's linked
// accounts). Empty list short-circuits so we never emit `IN ()`.
const listOnlineByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`SELECT ${ONLINE_COLS} FROM shard_online
WHERE acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY name ASC`,
accounts,
)
// 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.
@@ -89,6 +101,193 @@ async function upsertHouse(serial, fields) {
const listIdocHouses = () =>
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
// Houses owned by any of the given game accounts (admin: a user's linked
// accounts). IDOC houses first, then newest-refreshed. Empty list short-circuits.
const listHousesByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`SELECT ${HOUSE_REG_COLS} FROM shard_houses
WHERE owner_acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY is_idoc DESC, updated_at DESC`,
accounts,
)
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
// The registry columns extend HOUSE_COLS; a registry row is one we've seen via
// house.update (in_registry = 1), as opposed to a decay-only transition row.
const HOUSE_REG_COLS = `${HOUSE_COLS}, owner_name, co_owners, friends, price, decay, in_registry`
const removeHouse = (serial) => query('DELETE FROM shard_houses WHERE serial = ?', [serial])
// The full registered-house browser: every row we've seen via house.update.
const listRegistryHouses = () =>
query(`SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE in_registry = 1 ORDER BY name ASC`)
// ── Champion spawns ────────────────────────────────────────────────────────
const CHAMP_COLS =
'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
async function upsertChamp(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_champs (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[serial, ...cols.map((c) => fields[c])],
)
}
const removeChamp = (serial) => query('DELETE FROM shard_champs WHERE serial = ?', [serial])
const clearChamps = () => query('DELETE FROM shard_champs')
// Ordered by name (matches the sidecar's /champs ordering).
const listChamps = () => query(`SELECT ${CHAMP_COLS} FROM shard_champs ORDER BY name ASC`)
// ── Help-page (support) queue ──────────────────────────────────────────────
const PAGE_COLS =
'page_id, type, sender_name, sender_acct, web_id, message, map, x, y, z, sent_ms, handled, handler, payload, updated_at'
async function upsertPage(pageId, fields) {
const cols = Object.keys(fields)
const allCols = ['page_id', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_pages (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[pageId, ...cols.map((c) => fields[c])],
)
}
const removePage = (pageId) => query('DELETE FROM shard_pages WHERE page_id = ?', [pageId])
const clearPages = () => query('DELETE FROM shard_pages')
// Oldest-open first so the queue reads like a work list.
const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`)
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
const GUILD_COLS =
'id, name, abbr, members, online, alliance, leader_serial, leader_name, leader_acct, leader_web_id, payload, t, updated_at'
async function upsertGuild(id, fields) {
const cols = Object.keys(fields)
const allCols = ['id', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_guilds (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[id, ...cols.map((c) => fields[c])],
)
}
const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
const findGuildLedByActor = (serial, acct) =>
query(
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?)
LIMIT 1`,
[serial ?? null, acct ?? null],
)
// Guilds led by any of the given game accounts (admin: a user's linked accounts).
const listGuildsLedByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
WHERE leader_acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY name ASC`,
accounts,
)
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
const GOV_COLS =
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
async function upsertGovernor(city, fields) {
const cols = Object.keys(fields)
const allCols = ['city', ...cols]
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
const placeholders = allCols.map(() => '?').join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO shard_governors (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[city, ...cols.map((c) => fields[c])],
)
}
const listGovernors = () => query(`SELECT ${GOV_COLS} FROM shard_governors ORDER BY city ASC`)
// Cities whose current governor is one of the given game accounts (cross-link:
// does this user hold a governorship?). Empty list short-circuits.
const listGovernorshipsByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`SELECT ${GOV_COLS} FROM shard_governors
WHERE governor_acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY city ASC`,
accounts,
)
// The single open term (ended_at IS NULL) for a city, if any.
async function currentGovernorTerm(city) {
const rows = await query(
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1',
[city],
)
return rows[0] || null
}
const closeGovernorTerm = (id, endedAt) =>
query('UPDATE shard_governor_terms SET ended_at = ? WHERE id = ?', [endedAt, id])
const openGovernorTerm = ({ city, serial, name, acct, webId, startedAt }) =>
query(
`INSERT INTO shard_governor_terms
(city, governor_serial, governor_name, governor_acct, governor_web_id, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[city, serial ?? null, name ?? null, acct ?? null, webId ?? null, startedAt],
)
const listGovernorTerms = (city, limit) =>
query(
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? ORDER BY started_at DESC LIMIT ?',
[city, limit],
)
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
async function setPresence({ count, byFacet, byRegion, t }) {
await query(
`INSERT INTO shard_presence (id, count, by_facet, by_region, t) VALUES (1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE count = VALUES(count), by_facet = VALUES(by_facet),
by_region = VALUES(by_region), t = VALUES(t)`,
[
Number.isFinite(count) ? count : 0,
byFacet ? JSON.stringify(byFacet) : null,
byRegion ? JSON.stringify(byRegion) : null,
Number.isFinite(t) ? t : null,
],
)
}
async function latestPresence() {
const rows = await query('SELECT count, by_facet, by_region, t FROM shard_presence WHERE id = 1')
return rows[0] || null
}
module.exports = {
upsertOnline,
removeOnline,
@@ -96,9 +295,36 @@ module.exports = {
countOnline,
listOnline,
listOnlineLinked,
listOnlineByAccounts,
insertEconomy,
listEconomy,
latestEconomy,
upsertHouse,
listIdocHouses,
listHousesByAccounts,
removeHouse,
listRegistryHouses,
upsertGuild,
removeGuild,
clearGuilds,
listGuilds,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,
listGovernors,
listGovernorshipsByAccounts,
currentGovernorTerm,
closeGovernorTerm,
openGovernorTerm,
listGovernorTerms,
setPresence,
latestPresence,
upsertChamp,
removeChamp,
clearChamps,
listChamps,
upsertPage,
removePage,
clearPages,
listPages,
}

View File

@@ -136,9 +136,8 @@ async function upsertHouse(data) {
await db.upsertHouse(data.serial, fields)
}
async function listIdoc() {
const rows = await db.listIdocHouses()
return rows.map((r) => ({
function shapeHouse(r) {
return {
serial: r.serial,
stage: r.stage,
map: r.map,
@@ -149,13 +148,385 @@ async function listIdoc() {
name: r.name,
ownerSerial: r.owner_serial,
ownerAcct: r.owner_acct,
// Registry fields (Protocol 2.0 house.update); undefined on decay-only rows.
ownerName: r.owner_name,
coOwners: r.co_owners,
friends: r.friends,
price: r.price == null ? null : Number(r.price),
decay: r.decay,
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
builtOn: r.built_on,
lastRefreshed: r.last_refreshed,
isIdoc: Boolean(r.is_idoc),
updatedAt: r.updated_at,
}
}
async function listIdoc() {
const rows = await db.listIdocHouses()
return rows.map(shapeHouse)
}
// Houses owned by the given game accounts (admin: a user's linked accounts).
async function listHousesForAccounts(accounts) {
const rows = await db.listHousesByAccounts(accounts)
return rows.map(shapeHouse)
}
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
// Richer per-house snapshot than the decay-transition feed. Writes only the
// registry columns (+ shared location/owner fields); is_idoc/stage stay owned by
// the house.decay path, so the two feeds never clobber each other. owner is an
// actor object (or null for an abandoned house).
async function upsertHouseRegistry(data) {
if (!data || !data.serial) return
const owner = data.owner || null
const fields = {
name: data.name ?? null,
owner_serial: owner ? owner.serial ?? null : null,
owner_acct: owner ? owner.acct ?? null : null,
owner_name: owner ? owner.name ?? null : null,
co_owners: data.coOwners ?? null,
friends: data.friends ?? null,
price: data.price ?? null,
decay: data.decay ?? null,
region: data.region ?? null,
map: data.map ?? null,
x: data.x ?? null,
y: data.y ?? null,
z: data.z ?? null,
built_on: data.builtOn ? new Date(data.builtOn) : null,
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
in_registry: 1,
}
await db.upsertHouse(data.serial, fields)
}
const removeHouse = (serial) => (serial ? db.removeHouse(serial) : Promise.resolve())
async function listHouses() {
const rows = await db.listRegistryHouses()
return rows.map(shapeHouse)
}
// Online players on the given game accounts (admin: a user's linked accounts).
async function listOnlineForAccounts(accounts) {
const rows = await db.listOnlineByAccounts(accounts)
return rows.map(shapeOnline)
}
// ── Champion spawns ────────────────────────────────────────────────────────
// Upsert a champ spawn's state (champ.update). The full event is stored in
// `payload` for the category-specific fields; a few columns are hoisted out for
// querying/ordering. is-boss-up is derived from bossUp (sea bosses are always up).
async function upsertChamp(ev) {
if (!ev || !ev.serial) return
await db.upsertChamp(ev.serial, {
category: ev.category ?? null,
type: ev.type ?? null,
name: ev.name ?? null,
status: ev.status ?? null,
active: ev.active ? 1 : 0,
map: ev.map ?? null,
x: ev.x ?? null,
y: ev.y ?? null,
z: ev.z ?? null,
boss_up: ev.bossUp ? 1 : 0,
payload: JSON.stringify(ev),
t: Number.isFinite(ev.t) ? ev.t : null,
})
}
const removeChamp = (serial) => (serial ? db.removeChamp(serial) : Promise.resolve())
const clearChamps = () => db.clearChamps()
// Return the stored champ.update payload (the shape the sidecar/UI expect),
// falling back to the hoisted columns if an older row lacks a payload.
function shapeChamp(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return payload || {
kind: 'champ.update',
serial: r.serial,
category: r.category,
type: r.type,
name: r.name,
status: r.status,
active: Boolean(r.active),
map: r.map,
x: r.x,
y: r.y,
z: r.z,
bossUp: Boolean(r.boss_up),
t: r.t,
}
}
async function listChamps() {
const rows = await db.listChamps()
return rows.map(shapeChamp)
}
// Replace the whole board with a fresh snapshot (sidecar GET /champs on connect).
async function replaceChamps(spawns) {
await db.clearChamps()
for (const ev of spawns || []) await upsertChamp(ev)
}
// ── Help-page (support) queue ──────────────────────────────────────────────
// Upsert a page (page.new / page.updated). The `sender` actor object carries the
// name/acct/webId; the rest are top-level fields.
async function upsertPage(ev) {
const pageId = ev && (ev.pageId || (ev.sender && ev.sender.serial))
if (!pageId) return
const sender = ev.sender || {}
await db.upsertPage(pageId, {
type: ev.type ?? null,
sender_name: sender.name ?? null,
sender_acct: sender.acct ?? null,
web_id: sender.webId ?? null,
message: ev.message ?? null,
map: ev.map ?? null,
x: ev.x ?? null,
y: ev.y ?? null,
z: ev.z ?? null,
sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
handled: ev.handled ? 1 : 0,
handler: ev.handler ?? null,
payload: JSON.stringify(ev),
})
}
const removePage = (pageId) => (pageId ? db.removePage(pageId) : Promise.resolve())
const clearPages = () => db.clearPages()
function shapePage(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return {
pageId: r.page_id,
type: r.type,
sender: { serial: r.page_id, name: r.sender_name, acct: r.sender_acct, webId: r.web_id },
message: r.message,
map: r.map,
x: r.x,
y: r.y,
z: r.z,
sentMs: r.sent_ms == null ? null : Number(r.sent_ms),
handled: Boolean(r.handled),
handler: r.handler,
updatedAt: r.updated_at,
// Keep the raw payload available for any field not hoisted above.
payload: payload || undefined,
}
}
async function listPages() {
const rows = await db.listPages()
return rows.map(shapePage)
}
// Replace the whole queue with a fresh snapshot (sidecar GET /pages on connect).
async function replacePages(pages) {
await db.clearPages()
for (const ev of pages || []) await upsertPage(ev)
}
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
// Upsert a guild's roster snapshot (guild.update). The leader is an actor object
// flattened into leader_* columns; the full event lives in `payload`.
async function upsertGuild(ev) {
if (!ev || ev.id == null) return
const leader = ev.leader || {}
await db.upsertGuild(ev.id, {
name: ev.name ?? null,
abbr: ev.abbr ?? null,
members: ev.members ?? null,
online: ev.online ?? null,
alliance: ev.alliance ?? null,
leader_serial: leader.serial ?? null,
leader_name: leader.name ?? null,
leader_acct: leader.acct ?? null,
leader_web_id: leader.webId ?? null,
payload: JSON.stringify(ev),
t: Number.isFinite(ev.t) ? ev.t : null,
})
}
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
const clearGuilds = () => db.clearGuilds()
function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return payload || {
kind: 'guild.update',
id: r.id,
name: r.name,
abbr: r.abbr,
members: r.members,
online: r.online,
alliance: r.alliance,
leader: r.leader_serial
? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id }
: null,
t: r.t,
}
}
async function listGuilds() {
const rows = await db.listGuilds()
return rows.map(shapeGuild)
}
// Replace the board with a fresh snapshot (sidecar GET /guilds on connect).
async function replaceGuilds(guilds) {
await db.clearGuilds()
for (const ev of guilds || []) await upsertGuild(ev)
}
// The guild an actor leads (cross-link on the character sheet). Leadership only —
// see the db note; membership for rank-and-file isn't in the feed, so we return
// null rather than show a possibly-stale guess.
async function findGuildForActor({ serial, acct }) {
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
const g = rows[0]
if (!g) return null
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
}
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
async function listGuildsLedForAccounts(accounts) {
const rows = await db.listGuildsLedByAccounts(accounts)
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
}
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
// Upsert a city's governance snapshot (city.update) AND capture term history.
// Term capture runs first (it reads the CURRENT open term to decide whether the
// governor changed) and is idempotent: a repeat/backfill of the same governor is a
// no-op, so it's safe to call on the live feed and on reconnect snapshots alike.
async function upsertGovernor(ev) {
if (!ev || !ev.city) return
await recordGovernorTransition(ev)
const gov = ev.governor || null
const elect = ev.governorElect || null
await db.upsertGovernor(ev.city, {
governor_serial: gov ? gov.serial ?? null : null,
governor_name: gov ? gov.name ?? null : null,
governor_acct: gov ? gov.acct ?? null : null,
governor_web_id: gov ? gov.webId ?? null : null,
elect_serial: elect ? elect.serial ?? null : null,
elect_name: elect ? elect.name ?? null : null,
elect_acct: elect ? elect.acct ?? null : null,
election_phase: ev.electionPhase ?? null,
candidates: ev.candidates ?? null,
auto_pick_at: ev.autoPickAt ? new Date(ev.autoPickAt) : null,
payload: JSON.stringify(ev),
t: Number.isFinite(ev.t) ? ev.t : null,
})
}
// Close the open term and open a new one when the governor CHANGES. Idempotent:
// same governor as the open term ⇒ nothing happens (so backfill/duplicate
// city.update events never spawn spurious terms).
async function recordGovernorTransition(ev) {
const gov = ev.governor || null
const newSerial = gov ? gov.serial ?? null : null
const t = Number.isFinite(ev.t) ? ev.t : Date.now()
const open = await db.currentGovernorTerm(ev.city)
const openSerial = open ? open.governor_serial : null
if (open && openSerial === newSerial) return // unchanged — nothing to record
if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated
if (newSerial) {
await db.openGovernorTerm({
city: ev.city,
serial: newSerial,
name: gov.name ?? null,
acct: gov.acct ?? null,
webId: gov.webId ?? null,
startedAt: t,
})
}
}
function shapeGovernor(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return payload || {
kind: 'city.update',
city: r.city,
governor: r.governor_serial
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
: null,
governorElect: r.elect_serial
? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct }
: null,
electionPhase: r.election_phase,
candidates: r.candidates,
t: r.t,
}
}
async function listGovernors() {
const rows = await db.listGovernors()
return rows.map(shapeGovernor)
}
// Cities the given game accounts currently govern (cross-link badge).
async function listGovernorshipsForAccounts(accounts) {
const rows = await db.listGovernorshipsByAccounts(accounts)
return rows.map(shapeGovernor)
}
// Term history for a city (look-back), newest first.
async function listGovernorHistory(city, limit = 100) {
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
const rows = await db.listGovernorTerms(city, n)
return rows.map((r) => ({
city: r.city,
governor: r.governor_serial
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
: null,
startedAt: r.started_at == null ? null : Number(r.started_at),
endedAt: r.ended_at == null ? null : Number(r.ended_at),
votes: r.votes,
}))
}
// Upsert governors without clearing (cities are fixed, no remove event); term
// capture inside upsertGovernor stays idempotent across reconnect snapshots.
async function replaceGovernors(cities) {
for (const ev of cities || []) await upsertGovernor(ev)
}
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
async function setPresence(ev) {
if (!ev) return
await db.setPresence({
count: ev.count,
byFacet: ev.byFacet || null,
byRegion: ev.byRegion || null,
t: ev.t,
})
}
async function latestPresence() {
const r = await db.latestPresence()
if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null }
const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {})
return {
count: Number(r.count) || 0,
byFacet: parse(r.by_facet),
byRegion: parse(r.by_region),
t: r.t == null ? null : Number(r.t),
}
}
function safeJson(s) {
try {
return JSON.parse(s)
} catch {
return null
}
}
module.exports = {
upsertOnline,
setOffline,
@@ -163,9 +534,38 @@ module.exports = {
onlineCount,
listOnline,
listOnlineLinked,
listOnlineForAccounts,
addEconomySample,
listEconomy,
latestEconomy,
upsertHouse,
listIdoc,
listHousesForAccounts,
upsertHouseRegistry,
removeHouse,
listHouses,
upsertChamp,
removeChamp,
clearChamps,
listChamps,
replaceChamps,
upsertPage,
removePage,
clearPages,
listPages,
replacePages,
upsertGuild,
removeGuild,
clearGuilds,
listGuilds,
replaceGuilds,
findGuildForActor,
listGuildsLedForAccounts,
upsertGovernor,
listGovernors,
listGovernorshipsForAccounts,
listGovernorHistory,
replaceGovernors,
setPresence,
latestPresence,
}

View File

@@ -4,6 +4,7 @@ const settings = require('../../../model/settings/settings.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const newsGump = require('../../../utils/newsGump')
const { cleanBody } = require('../../../utils/sanitizeHtml')
const log = require('../../../utils/logger')('admin')
@@ -22,6 +23,11 @@ const log = require('../../../utils/logger')('admin')
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
async function announceIfNewlyPublished(post, transition) {
await announceJobs.enqueueIfNeeded(post, transition)
// Keep the in-game Town Cryer News gump in sync with the same transition: push
// the article when it becomes published news, refresh it silently on an edit,
// and pull it when it leaves published-news. Best-effort (never throws), so a
// sidecar hiccup never breaks saving a post — same guarantee as the enqueue.
await newsGump.syncPost(post, transition)
}
// ── Dashboard & site mode ─────────────────────────────────────────────
@@ -173,8 +179,11 @@ async function publishPost(req, res) {
async function deletePost(req, res) {
const id = Number(req.params.id)
try {
const current = await posts.getById(id)
await posts.remove(id)
await activity.log({ req, action: 'post.delete', detail: { id } })
// If it was live in the News gump, pull it (best-effort).
if (newsGump.inGump(current)) await newsGump.removePost(id)
return res.json({ id })
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
@@ -485,6 +494,12 @@ async function updateSettings(req, res) {
) {
return res.status(400).json({ message: 'Invalid player_registration value' })
}
if (
settings.GAME_SIGNUP_KEY in updates &&
!settings.GAME_SIGNUP_MODES.includes(updates[settings.GAME_SIGNUP_KEY])
) {
return res.status(400).json({ message: 'Invalid game_account_signup value' })
}
// The homepage teaser is rich text (HTML) from the shared editor — sanitize it
// against the same allowlist as post/wiki bodies so a stored value is safe (the
// client re-sanitizes on render as defense in depth).

View File

@@ -12,6 +12,9 @@ const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const uoLink = require('./uoLink.controller')
const shardOps = require('./shardOps.controller')
const usersShard = require('./usersShard.controller')
const invites = require('./invites.controller')
const selfShard = require('../player/shard.controller')
const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
@@ -179,6 +182,137 @@ adminRouter.get(
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
selfShard.getSales,
)
adminRouter.post(
'/shard/account',
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shards mode; the password is never stored or logged.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
body('password').isString().isLength({ min: 8, max: 64 }),
validate,
selfShard.createGameAccount,
)
// ── In-game staff operations (uo-link write plane + support queue) ─────
// Privileged live-shard actions and the help-page queue, open to moderators as
// well as admins (modAccess). `actor` is stamped server-side from the session in
// the controller — the body never carries it. See shardOps.controller.js.
adminRouter.post(
'/shard/kick',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Kick every live session of an account (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */
/* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
modAccess,
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
validate,
shardOps.kick,
)
adminRouter.post(
'/shard/ban',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */
/* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
modAccess,
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
body('durationSec').optional().isInt({ min: 0, max: 315360000 }),
body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
validate,
shardOps.ban,
)
adminRouter.post(
'/shard/unban',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Clear an account ban (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
modAccess,
body('account').matches(SHARD_ACCOUNT_RE),
validate,
shardOps.unban,
)
adminRouter.post(
'/shard/broadcast',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */
/* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
modAccess,
body('text').isString().trim().isLength({ min: 1, max: 300 }),
body('hue').optional().isInt({ min: 0, max: 3000 }),
validate,
shardOps.broadcast,
)
adminRouter.get(
'/shard/pages',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Open help-page (support) queue (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
modAccess,
shardOps.listPages,
)
adminRouter.post(
'/shard/pages/:id/respond',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */
/* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
modAccess,
param('id').matches(/^0x[0-9a-fA-F]+$/),
body('message').isString().trim().isLength({ min: 1, max: 500 }),
body('close').optional().isBoolean(),
validate,
shardOps.respondPage,
)
adminRouter.post(
'/shard/pages/:id/close',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Resolve a help page without a reply (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
/* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
modAccess,
param('id').matches(/^0x[0-9a-fA-F]+$/),
validate,
shardOps.closePage,
)
adminRouter.get(
'/shard/audit',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
modAccess,
shardOps.listAudit,
)
adminRouter.get(
'/shard/houses',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
modAccess,
shardOps.listHouses,
)
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =
@@ -1082,6 +1216,139 @@ adminRouter.delete(
ctrl.deleteUser,
)
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
// scoped to those accounts, their vendor sales / houses / online characters.
// Live character rosters are fetched by the client through /admin/shard/* (which
// already grants admins a bypass to any account), so no routes for them here.
adminRouter.get(
'/users/:id',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Get a single user (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getUser,
)
adminRouter.get(
'/users/:id/shard/accounts',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users linked game accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.listAccounts,
)
adminRouter.get(
'/users/:id/shard/sales',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Recent vendor sales on a users accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getSales,
)
adminRouter.get(
'/users/:id/shard/houses',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Houses owned by a users accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getHouses,
)
adminRouter.get(
'/users/:id/shard/online',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users characters currently online (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getOnline,
)
adminRouter.get(
'/users/:id/shard/standing',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users shard standing — governorships held and guilds led (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getStanding,
)
adminRouter.delete(
'/users/:id/shard/link/:account',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Unlink a game account from this user (admin only)'
// #swagger.description = 'Severs a game accounts tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('id').isInt(),
param('account').matches(SHARD_ACCOUNT_RE),
validate,
usersShard.unlinkAccount,
)
// ── Email invites (admin only) ─────────────────────────────────────────────
adminRouter.post(
'/invites',
// #swagger.tags = ['Admin · Invites']
// #swagger.summary = 'Create and email an account invite at a chosen access level'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
adminOnly,
body('email').isEmail().isLength({ max: 255 }),
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
validate,
invites.create,
)
adminRouter.get(
'/invites',
// #swagger.tags = ['Admin · Invites']
// #swagger.summary = 'List recent invites (no tokens)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
adminOnly,
invites.list,
)
adminRouter.delete(
'/invites/:id',
// #swagger.tags = ['Admin · Invites']
// #swagger.summary = 'Revoke a pending invite'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' }
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('id').isInt(),
validate,
invites.revoke,
)
// ── uo-link sidecar control (admin only) ──────────────────────────────────
// Connection config (base/ws URL + token + protocol + enabled) and the town
// crier. The token is write-only (SECURITY note in uoLink.controller.js).

View File

@@ -0,0 +1,90 @@
// ── Admin: email invites ───────────────────────────────────────────────────
//
// Admin-only. A staff member invites someone by email at a pre-chosen access
// level; the invitee accepts via a tokened link (auth/invite.controller) which
// creates their website user at that role. The plaintext token exists only in the
// emailed link and in the create response (so the admin can copy the link if email
// isn't configured); the DB stores only its hash.
const invites = require('../../../model/invites/invites.model')
const activity = require('../../../model/activity/activity.model')
const mailer = require('../../../utils/mailer')
const log = require('../../../utils/logger')('admin-invites')
const ROLES = ['admin', 'editor', 'moderator', 'player']
function baseUrl() {
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
}
function acceptUrl(token) {
return `${baseUrl()}/invite/${token}`
}
// POST /admin/invites — create an invite. Optionally email it (sendEmail, default
// true); the copyable accept link is ALWAYS returned so the admin can hand it over
// directly. The token is single-use + expiring and the caller is the authenticated
// admin who made it, so echoing the link back to them is safe.
async function create(req, res) {
const email = String(req.body.email || '').trim()
const role = req.body.role
const sendEmail = req.body.sendEmail !== false // default true
if (!email || !ROLES.includes(role)) {
return res.status(400).json({ message: 'A valid email and role are required.' })
}
try {
const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
const url = acceptUrl(token)
// Send the email only if asked. A send failure doesn't delete the invite — the
// link is still returned so the admin can share it manually.
let emailed = false
let emailError = null
if (sendEmail) {
try {
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
emailed = Boolean(result.sent)
if (!result.sent && result.reason === 'NOT_CONFIGURED') emailError = 'email is not configured'
} catch (err) {
emailError = err.message
log.warn('invite email failed (invite still created)', { id: invite.id, message: err.message })
}
}
await activity.log({ req, userId: req.user.id, action: 'invite.create', detail: { email, role, emailed } })
log.info('invite created', { id: invite.id, email, role, emailed, by: req.user.username })
// acceptUrl is always returned (copyable link); emailed says whether it also went out.
return res.status(201).json({ invite, emailed, acceptUrl: url, emailError })
} catch (err) {
log.error('create invite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/invites — recent invites (no tokens).
async function list(req, res) {
try {
return res.json(await invites.list(req.query.limit))
} catch (err) {
log.error('list invites', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// DELETE /admin/invites/:id — revoke a pending invite.
async function revoke(req, res) {
const id = Number(req.params.id)
try {
const changed = await invites.revoke(id)
if (!changed) return res.status(404).json({ message: 'No pending invite to revoke.' })
await activity.log({ req, userId: req.user.id, action: 'invite.revoke', detail: { id } })
return res.json({ id, revoked: true })
} catch (err) {
log.error('revoke invite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { create, list, revoke }

View File

@@ -0,0 +1,171 @@
// ── Admin: in-game staff operations (uo-link write plane + support queue) ────
//
// The privileged "write plane" (§6 of the sidecar guide): kick / ban / unban /
// broadcast against the live shard, plus the help-page (support ticket) queue.
// Gated admin+moderator at the route (modAccess) — the sidecar trusts the
// loopback socket, so authorization is entirely the site's responsibility.
//
// SECURITY: `actor` (who is taking the action) is ALWAYS set here from the
// authenticated session (req.user.username), never from the request body, so an
// action can't be attributed to someone else. The shard records it in its console
// log, the ban's BanDealer tag, and the admin.audit event it echoes back.
const uoLinkClient = require('../../../utils/uoLinkClient')
const shardState = require('../../../model/shardState/shardState.model')
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('admin-shard-ops')
// Map a never-throw uoLinkClient result onto an HTTP response. `okData` shapes the
// success body. Mirrors the sidecar's documented status codes so the UI can tell a
// transient outage (503/504 — retry) from a real rejection (403/404).
function relay(res, result, okData) {
if (result.ok) return res.json(okData(result.data))
switch (result.status) {
case 400:
return res.status(400).json({ message: (result.data && result.data.error) || 'The shard rejected that request.' })
case 403:
return res.status(403).json({
message:
(result.data && result.data.error) ||
'That action was refused — the target is protected, or the write plane is disabled on the shard.',
})
case 404:
return res.status(404).json({ message: 'No such account or target on the shard.' })
case 503:
case 504:
case 0:
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
default:
return res.status(502).json({ message: 'Could not reach the shard.' })
}
}
// POST /admin/shard/kick — disconnect every live session of an account (or serial).
async function kick(req, res) {
const { account, serial } = req.body
const actor = req.user.username
try {
const result = await uoLinkClient.adminKick({ actor, account, serial })
if (result.ok) await activity.log({ req, action: 'shard.kick', detail: { account, serial } })
return relay(res, result, (d) => d || { ok: true })
} catch (err) {
log.error('shardOps.kick', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/shard/ban — ban an account (works offline); durationSec 0/absent = indefinite.
async function ban(req, res) {
const { account, serial, durationSec, reason } = req.body
const actor = req.user.username
try {
const result = await uoLinkClient.adminBan({ actor, account, serial, durationSec, reason })
if (result.ok) await activity.log({ req, action: 'shard.ban', detail: { account, serial, durationSec, reason } })
return relay(res, result, (d) => d || { ok: true })
} catch (err) {
log.error('shardOps.ban', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/shard/unban — clear an account's ban.
async function unban(req, res) {
const { account } = req.body
const actor = req.user.username
try {
const result = await uoLinkClient.adminUnban({ actor, account })
if (result.ok) await activity.log({ req, action: 'shard.unban', detail: { account } })
return relay(res, result, (d) => d || { ok: true })
} catch (err) {
log.error('shardOps.unban', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/shard/broadcast — a system message to everyone online.
async function broadcast(req, res) {
const { text, hue } = req.body
const actor = req.user.username
try {
const result = await uoLinkClient.adminBroadcast({ actor, text, hue })
if (result.ok) await activity.log({ req, action: 'shard.broadcast', detail: { text } })
return relay(res, result, (d) => d || { ok: true })
} catch (err) {
log.error('shardOps.broadcast', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/shard/pages — the open help-page (support) queue, from our store.
async function listPages(req, res) {
try {
return res.json(await shardState.listPages())
} catch (err) {
log.error('shardOps.listPages', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/shard/pages/:id/respond — reply to a player (optionally close).
async function respondPage(req, res) {
const { id } = req.params
const { message, close } = req.body
try {
const result = await uoLinkClient.respondPage(id, { message, close: Boolean(close) })
if (result.ok) {
await activity.log({ req, action: 'shard.page.respond', detail: { pageId: id, close: Boolean(close) } })
// Close removes the page from the queue; reflect it locally at once (the
// page.closed event will confirm it, but the UI shouldn't wait a poll cycle).
if (close) await shardState.removePage(id).catch(() => {})
}
return relay(res, result, (d) => d || { ok: true })
} catch (err) {
log.error('shardOps.respondPage', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/shard/pages/:id/close — resolve a page without a reply.
async function closePage(req, res) {
const { id } = req.params
try {
const result = await uoLinkClient.closePage(id)
if (result.ok) {
await activity.log({ req, action: 'shard.page.close', detail: { pageId: id } })
await shardState.removePage(id).catch(() => {})
}
return relay(res, result, (d) => d || { ok: true })
} catch (err) {
log.error('shardOps.closePage', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/shard/audit — recent moderation audit events (admin.audit), from the
// ingested event log. Seeds the live audit log the panel keeps current over SSE.
async function listAudit(req, res) {
try {
const limit = req.query.limit
return res.json(await shardEvents.list({ kind: 'admin.audit', limit }))
} catch (err) {
log.error('shardOps.listAudit', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners,
// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC
// houses with location; this is the complete board, kept live for staff on the
// admin SSE channel (house.update / house.remove).
async function listHouses(req, res) {
try {
return res.json(await shardState.listHouses())
} catch (err) {
log.error('shardOps.listHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses }

View File

@@ -0,0 +1,142 @@
// ── Admin: a single user's shard (uo-link) footprint ──────────────────────────
//
// Backs the /admin/users/:id detail page. Every read is scoped to the target
// user's linked game accounts (from the local shard_account_links mirror): their
// vendor sales, houses, and currently-online characters. The live character
// rosters are fetched separately by the client through the existing admin-bypass
// /admin/shard/* endpoints, so nothing here round-trips the sidecar — these are
// fast, DB-backed reads. Admin-only (registered under adminOnly in the router).
const users = require('../../../model/users/users.model')
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
const shardState = require('../../../model/shardState/shardState.model')
const uoLinkClient = require('../../../utils/uoLinkClient')
const activity = require('../../../model/activity/activity.model')
const { salesForAccounts } = require('../../../utils/shardSales')
const log = require('../../../utils/logger')('admin-user-shard')
// Resolve the target user's linked game accounts, or null if the user id is
// unknown (so the handler can 404 rather than silently returning an empty set).
async function accountsForUser(id) {
const user = await users.getById(id)
if (!user) return null
const links = await shardLinks.listForUser(id)
return { user, links, accounts: links.map((l) => l.account) }
}
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
async function getUser(req, res) {
try {
const user = await users.getById(Number(req.params.id))
if (!user) return res.status(404).json({ message: 'Not found' })
return res.json(user)
} catch (err) {
log.error('getUser', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
async function listAccounts(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json(ctx.links)
} catch (err) {
log.error('listAccounts', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/sales — recent vendor sales on the user's accounts.
async function getSales(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json(await salesForAccounts(ctx.accounts))
} catch (err) {
log.error('getSales', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/houses — houses owned by the user's accounts.
async function getHouses(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json(await shardState.listHousesForAccounts(ctx.accounts))
} catch (err) {
log.error('getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/online — the user's characters currently online.
async function getOnline(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json(await shardState.listOnlineForAccounts(ctx.accounts))
} catch (err) {
log.error('getOnline', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
// city governorships they currently hold and guilds they lead. Both are reliable
// current-state lookups on the user's linked accounts.
async function getStanding(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
const [governorOf, guildsLed] = await Promise.all([
shardState.listGovernorshipsForAccounts(ctx.accounts),
shardState.listGuildsLedForAccounts(ctx.accounts),
])
return res.json({ governorOf, guildsLed })
} catch (err) {
log.error('getStanding', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this
// user, site-side. `actor` is stamped from the session (never the browser). On
// success the sidecar clears the WebsiteUserId tag on the shard and we drop the
// local mirror so attribution stops immediately.
async function unlinkAccount(req, res) {
const { account } = req.params
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
// Only unlink an account actually linked to THIS user (avoid cross-user unlink).
if (!ctx.accounts.includes(account)) {
return res.status(404).json({ message: 'That account is not linked to this user.' })
}
const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account })
if (result.ok) {
await shardLinks.removeByAccount(account)
await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } })
log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username })
return res.json({ account, unlinked: true })
}
if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' })
if (result.status === 404) {
// Not linked on the shard — reconcile our mirror anyway so the two agree.
await shardLinks.removeByAccount(account)
return res.status(404).json({ message: 'That account is not linked.' })
}
if (result.status === 503 || result.status === 0) {
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
}
return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' })
} catch (err) {
log.error('unlinkAccount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }

View File

@@ -188,4 +188,4 @@ async function me(req, res) {
}
}
module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }
module.exports = { login, register, loginTotp, logout, me, needsTotp, issueSession, HONEYPOT_FIELD }

View File

@@ -1,7 +1,8 @@
const express = require('express')
const { body } = require('express-validator')
const { body, param } = require('express-validator')
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
const { getInvite, acceptInvite } = require('./invite.controller')
const { isLoggedIn } = require('../../../utils/auth')
const { attachSession } = require('../../../auth/session.middleware')
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
@@ -87,6 +88,38 @@ authRouter.post(
loginTotp,
)
// ── Email-invite acceptance (public, token-gated) ──────────────────────────
authRouter.get(
'/invite/:token',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Look up an email invite by token'
// #swagger.description = 'Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.'
/* #swagger.responses[200] = { description: 'Invite details', content: { "application/json": { schema: { type: "object", properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('token').isString().isLength({ min: 8, max: 128 }),
validate,
getInvite,
)
authRouter.post(
'/invite/:token/accept',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
// #swagger.description = 'Creates the website user at the invites pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Username taken or invite already used', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
registerLimiter,
param('token').isString().isLength({ min: 8, max: 128 }),
body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }),
body(HONEYPOT_FIELD).optional(),
validate,
acceptInvite,
)
authRouter.post(
'/logout',
// #swagger.tags = ['Auth']

View File

@@ -0,0 +1,82 @@
// ── Invite acceptance (public, token-gated) ────────────────────────────────
//
// The other end of the admin email-invite flow (admin/invites.controller). An
// invitee opens the tokened link, sees their pre-assigned email + role, and sets
// a username + password. Accepting creates their website user AT THE PRESET ROLE
// (bypassing the player_registration gate — the invite is its own authority) and
// logs them straight in. The optional "create game account" step afterwards reuses
// POST /player/shard/account (players only), so it isn't handled here.
const invites = require('../../../model/invites/invites.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const usernamePolicy = require('../../../auth/usernamePolicy')
const { issueSession, HONEYPOT_FIELD } = require('./auth.controller')
const log = require('../../../utils/logger')('auth-invite')
// GET /auth/invite/:token — validate an invite and return what the accept form
// needs (email + role). 404 for anything not currently acceptable so we never
// distinguish "expired" from "revoked" from "never existed".
async function getInvite(req, res) {
try {
const row = await invites.findValidByToken(req.params.token)
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
return res.json(invites.publicView(row))
} catch (err) {
log.error('getInvite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/invite/:token/accept — create the user at the invite's role and log
// them in. Honeypot + validation mirror register; the invite replaces the
// registration-mode gate.
async function acceptInvite(req, res) {
// Honeypot: a filled hidden field means a bot.
if (req.body[HONEYPOT_FIELD]) {
log.warn('honeypot invite-accept hit', { ip: req.ip })
return res.status(400).json({ message: 'Registration failed.' })
}
try {
const row = await invites.findValidByToken(req.params.token)
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
const check = usernamePolicy.validateUsername(req.body.username)
if (!check.ok) return res.status(400).json({ message: check.message })
let user
try {
user = await users.createUser({
username: check.name,
password: req.body.password,
email: row.email,
role: row.role,
emailVerified: true, // they proved control of the address by using the link
})
} catch (err) {
if (users.isDuplicateUsername(err)) {
return res.status(409).json({ message: 'That username is already taken.' })
}
throw err
}
// Consume the invite atomically. If we lost a double-accept race, roll back the
// user we just created so a spent invite never yields two accounts.
const won = await invites.accept(row.id, user.id)
if (!won) {
await users.remove(user.id).catch(() => {})
return res.status(409).json({ message: 'This invitation has already been used.' })
}
await activity.log({ req, userId: user.id, action: 'invite.accept', detail: { inviteId: row.id, role: row.role } })
log.info('invite accepted', { inviteId: row.id, userId: user.id, role: row.role, ip: req.ip })
// New accounts never have TOTP yet — log straight in.
return issueSession(req, res, user, 'local')
} catch (err) {
log.error('acceptInvite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getInvite, acceptInvite }

View File

@@ -148,6 +148,25 @@ playerRouter.post(
validate,
shard.link,
)
playerRouter.post(
'/shard/account',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
body('password').isString().isLength({ min: 8, max: 64 }),
validate,
shard.createGameAccount,
)
playerRouter.get(
'/shard/accounts',
// #swagger.tags = ['Player · Shard']
@@ -203,5 +222,14 @@ playerRouter.get(
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
shard.getSales,
)
playerRouter.get(
'/shard/houses',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'The callers own houses (home status)'
// #swagger.description = 'Houses owned by the callers linked accounts, with decay/IDOC status. Only the callers own houses — never anyone elses.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
module.exports = playerRouter

View File

@@ -9,13 +9,33 @@
const uoLinkClient = require('../../../utils/uoLinkClient')
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
const shardState = require('../../../model/shardState/shardState.model')
const settings = require('../../../model/settings/settings.model')
const { salesForAccounts } = require('../../../utils/shardSales')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('player-shard')
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
// Decorate a char.profile with cross-links from our own board data: the guild the
// character leads and any city governorship on its account. Best-effort — a
// failure here never fails the profile (it's a nicety, not the sheet).
async function enrichCharProfile(profile) {
if (!profile) return profile
try {
const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct })
if (guild) profile.guild = guild
if (profile.acct) {
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
if (govs.length) profile.governorOf = govs.map((g) => g.city)
}
} catch (err) {
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
}
return profile
}
// POST /player/shard/link — confirm an in-game link code.
async function link(req, res) {
const { code } = req.body
@@ -102,7 +122,7 @@ async function getChar(req, res) {
const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
}
return res.json(result.data)
return res.json(await enrichCharProfile(result.data))
}
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
if (result.status === 503 || result.status === 0) {
@@ -120,25 +140,80 @@ async function getChar(req, res) {
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)
const accounts = links.map((l) => l.account)
return res.json(await salesForAccounts(accounts))
} 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 }
// GET /player/shard/houses — the caller's OWN houses (home status), scoped to
// their linked accounts. A player sees their own decay/IDOC standing; never
// anyone else's. Full detail is fine here — it's their property.
async function getHouses(req, res) {
try {
const links = await shardLinks.listForUser(req.user.id)
const accounts = links.map((l) => l.account)
return res.json(await shardState.listHousesForAccounts(accounts))
} catch (err) {
log.error('player.shard.getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
// The password is never echoed anywhere; only the mapped reason is returned.
function mapCreateAccountError(res, result) {
const reason = (result.data && result.data.reason) || ''
switch (result.status) {
case 409:
return res.status(409).json({ message: 'That account name is already taken.' })
case 429:
return res.status(429).json({ message: 'The account limit for your network has been reached.' })
case 403:
return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' })
case 400:
return res.status(400).json({ message: reason || 'The account name or password was not accepted.' })
case 503:
case 0:
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
default:
return res.status(502).json({ message: 'Could not reach the shard to create the account.' })
}
}
// POST /player/shard/account — provision a GAME account for the signed-in website
// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the
// invite-accept "create game account" step alike (both act as the signed-in user).
// actor + websiteUserId are stamped from the session; the browser IP (req.ip,
// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is
// never logged. Gated by the game_account_signup setting AND the shard's own mode.
async function createGameAccount(req, res) {
const { account, password } = req.body
try {
if (!(await settings.isGameAccountSignupEnabled())) {
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
}
const result = await uoLinkClient.createAccount({
actor: req.user.username,
account,
password,
websiteUserId: req.user.id,
ip: req.ip,
})
if (result.ok) {
// Mirror the link locally so the portal lists the account immediately.
await shardLinks.link({ account, userId: req.user.id })
await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } })
log.info('game account created', { account, userId: req.user.id, ip: req.ip })
return res.status(201).json({ account, linked: true })
}
return mapCreateAccountError(res, result)
} catch (err) {
log.error('player.shard.createGameAccount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount }

View File

@@ -176,6 +176,58 @@ publicRouter.get(
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getIdoc,
)
publicRouter.get(
'/shard/champs',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current champion-spawn board (all categories)'
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
/* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getChamps,
)
publicRouter.get(
'/shard/guilds',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGuilds,
)
publicRouter.get(
'/shard/governors',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Current town-governor board (City Loyalty)'
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
/* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGovernors,
)
publicRouter.get(
'/shard/governors/:city/history',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Governor term history for a city'
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' }
/* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
param('city').isString().isLength({ min: 1, max: 40 }),
query('limit').optional().isInt({ min: 1, max: 500 }),
validate,
shard.getGovernorHistory,
)
publicRouter.get(
'/shard/presence',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
/* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
shard.getPresence,
)
publicRouter.get(
'/shard/houses',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
publicRouter.get(
'/shard/stream',
// #swagger.tags = ['Public · Shard']

View File

@@ -92,9 +92,102 @@ async function getIdoc(req, res) {
}
}
// GET /public/shard/champs — the current champion-spawn board (all categories).
// Served from our own store; live deltas (champ.update / champ.remove) arrive on
// the public SSE stream so the page can update in place.
async function getChamps(req, res) {
try {
return res.json(await shardState.listChamps())
} catch (err) {
log.error('shard.getChamps', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/guilds — the current guild board. Served from our store;
// live via guild.update / guild.remove / guild.join on the public SSE stream.
async function getGuilds(req, res) {
try {
return res.json(await shardState.listGuilds())
} catch (err) {
log.error('shard.getGuilds', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream.
async function getGovernors(req, res) {
try {
return res.json(await shardState.listGovernors())
} catch (err) {
log.error('shard.getGovernors', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/governors/:city/history — the term ledger for one city
// (look-back: "who were all the governors of Britain?"), newest first.
async function getGovernorHistory(req, res) {
try {
return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit))
} catch (err) {
log.error('shard.getGovernorHistory', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/presence — the online-population aggregate (count + per-facet
// + per-region). Live via presence.online on the public SSE stream.
async function getPresence(req, res) {
try {
return res.json(await shardState.latestPresence())
} catch (err) {
log.error('shard.getPresence', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only
// their location (name + region + map/coords). Owner, price, co-owners and decay
// detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay
// on the public SSE stream. This is the "where are the falling houses" board.
async function getHouses(req, res) {
try {
const idoc = await shardState.listIdoc()
const publicHouses = idoc.map((h) => ({
serial: h.serial,
name: h.name,
region: h.region,
map: h.map,
x: h.x,
y: h.y,
z: h.z,
isIdoc: true,
}))
return res.json(publicHouses)
} catch (err) {
log.error('shard.getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
function stream(req, res) {
broadcast.subscribe(req, res, 'public')
}
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, stream }
module.exports = {
getStatus,
getFeed,
getEconomy,
getOnline,
getIdoc,
getChamps,
getGuilds,
getGovernors,
getGovernorHistory,
getPresence,
getHouses,
stream,
}

View File

@@ -122,4 +122,36 @@ async function sendTest(to) {
}
}
module.exports = { isConfigured, sendContactMessage, sendTest }
/**
* Send an account invite. `to` is the invitee's email, `acceptUrl` the tokened
* accept link, `role` their assigned access level, `invitedByName` optional. If
* email is not configured, returns { sent: false, reason: 'NOT_CONFIGURED' } so
* the caller can surface the accept link for the admin to share manually rather
* than throwing. Throws only on an actual send failure.
*/
async function sendInvite({ to, acceptUrl, role, invitedByName }) {
const built = await buildTransport()
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
const roleLabel = role && role !== 'player' ? ` as ${role}` : ''
const by = invitedByName ? ` by ${invitedByName}` : ''
try {
await transport.sendMail({
from: fromHeader(config),
to,
subject: 'Your UOMysticmoon invitation',
text:
`You have been invited${by} to join UOMysticmoon${roleLabel}.\n\n` +
`Accept your invitation and set up your account here:\n${acceptUrl}\n\n` +
`This link is single-use and will expire. If you weren't expecting this, you can ignore it.`,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() })
return { sent: true }
} catch (err) {
log.error('invite send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
throw err
}
}
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite }

View File

@@ -0,0 +1,124 @@
// ── Town Cryer News gump sync (Protocol 2.1) ───────────────────────────────
//
// Keeps the in-game Town Cryer *News* gump in sync with the site's published
// news posts. Distinct from the scrolling town-crier lines (that's a one-shot
// announce leg in announceWorker); this is a STATE SYNC — an article stays in the
// gump while its post is published news, and is pulled when the post is
// unpublished/deleted/re-categorised.
//
// The website is the source of truth. POST /news is idempotent (re-post replaces),
// so a refresh or a reconnect re-assert is safe. Every call is best-effort and
// never throws — a sidecar/shard hiccup must never break saving or deleting a
// post. Reliability comes from reassertAll() on every WS (re)connect
// (uoLinkSocket.backfill), which re-pushes the current published set silently and
// closes the gap if an earlier live push failed.
const posts = require('../model/posts/posts.model')
const uoLinkClient = require('./uoLinkClient')
const settings = require('../model/settings/settings.model')
const { deriveExcerpt } = require('./sanitizeHtml')
const log = require('./logger')('news-gump')
const MAX_TITLE = 120
const MAX_BODY = 900
function baseUrl() {
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
}
function clamp(value, max) {
const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}`
}
// A post belongs in the gump exactly when it is published AND in the news category.
function inGump(post) {
return Boolean(post && post.published && post.category === 'news')
}
// Optional UO gump image id for news articles (a shard art id), from the
// `news_gump_image` setting. Omitted → the sidecar uses a neutral scroll.
async function gumpImage() {
try {
const raw = await settings.get('news_gump_image')
const n = Number(raw)
return Number.isInteger(n) && n > 0 ? n : undefined
} catch {
return undefined
}
}
// Build the in-game News article from a post. Body is a compact gump-HTML block
// (title centred + a plain-text excerpt) rather than the post's full rich HTML —
// the UO gump only supports a small HTML subset, so we keep it predictable. The
// "more info" URL is the public news list (news posts have no per-post route).
async function buildArticle(post, { announce = true } = {}) {
const title = clamp(post.title, MAX_TITLE)
const excerpt = clamp(post.excerpt || deriveExcerpt(post.body, MAX_BODY) || '', MAX_BODY)
const body = excerpt ? `<CENTER>${title}</CENTER><BR><BR>${excerpt}` : `<CENTER>${title}</CENTER>`
return {
id: String(post.id),
title,
body,
image: await gumpImage(),
url: `${baseUrl()}/site/news`,
announce,
}
}
// Push a post to the gump (only if it belongs there). announce=true has the criers
// proclaim the title; false is a silent refresh/re-assert.
async function pushPost(post, { announce = true } = {}) {
if (!inGump(post)) return { ok: false, skipped: true }
const res = await uoLinkClient.postNews(await buildArticle(post, { announce }))
if (!res.ok) log.warn('news gump push failed', { id: post.id, status: res.status, error: res.error })
return res
}
// Remove a post from the gump. A 404 (not present) is not an error worth noting.
async function removePost(id) {
const res = await uoLinkClient.deleteNews(String(id))
if (!res.ok && res.status !== 404) {
log.warn('news gump remove failed', { id, status: res.status, error: res.error })
}
return res
}
// Reconcile the gump after a post create/update/publish. `transition`
// ({ wasPublished, wasNews }) tells a fresh publish (announce) from an in-place
// edit (silent refresh) and catches a post leaving published-news (pull it).
async function syncPost(post, transition = {}) {
try {
if (inGump(post)) {
const wasInGump = Boolean(transition.wasPublished && transition.wasNews)
await pushPost(post, { announce: !wasInGump })
} else if (transition.wasPublished && transition.wasNews) {
await removePost(post.id)
}
} catch (err) {
log.warn('news gump sync failed', { id: post && post.id, message: err.message })
}
}
// Re-push every currently-published news post, silently — run on each WS
// (re)connect to reconcile the gump to our source of truth (also recovers any
// article whose original live push failed). Best-effort; never throws.
async function reassertAll() {
try {
const list = await posts.listAll('news')
const published = (list || []).filter((p) => p.published)
let pushed = 0
for (const p of published) {
const full = await posts.getById(p.id) // list projection may omit the body
if (full) {
await pushPost(full, { announce: false })
pushed += 1
}
}
if (pushed) log.info('re-asserted news gump articles', { count: pushed })
} catch (err) {
log.warn('news gump reassert failed', { message: err.message })
}
}
module.exports = { inGump, buildArticle, pushPost, removePost, syncPost, reassertAll }

View File

@@ -33,6 +33,20 @@ const PUBLIC_KINDS = new Set([
'server.hello',
'server.shutdown',
'server.crashed',
// Champion-spawn board deltas — the public Champions page renders these live.
'champ.update',
'champ.remove',
// Protocol 2.0 boards — public, rendered live on their respective pages.
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
// NOTE: house.update / house.remove (the full registry — owner, price, co-owners)
// are deliberately NOT public. The public Houses page shows only IDOC houses (via
// house.decay, which is public above) with location only; the full registry is
// staff-only and rides the admin SSE channel. See public/shard.controller getHouses.
])
// Open response streams per channel.

View File

@@ -14,6 +14,7 @@
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const broadcaster = require('./shardBroadcast')
const defaultLog = require('./logger')('shard-ingest')
@@ -33,11 +34,17 @@ const LOGGED_KINDS = new Set([
'karma.change',
'audit.set',
'audit.command',
'admin.audit',
'cheat.fastwalk',
'link.request',
'server.hello',
'server.shutdown',
'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit',
'account.unlinked',
])
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
@@ -132,6 +139,45 @@ async function applyStateChange(event, deps) {
lastRefreshed: event.lastRefreshed,
})
return
case 'champ.update':
await shardState.upsertChamp(event)
return
case 'champ.remove':
await shardState.removeChamp(event.serial)
return
case 'page.new':
case 'page.updated':
await shardState.upsertPage(event)
return
case 'page.closed':
await shardState.removePage(event.pageId)
return
// ── Protocol 2.0 boards ──────────────────────────────────────────────
case 'guild.update':
await shardState.upsertGuild(event)
return
case 'guild.remove':
await shardState.removeGuild(event.id)
return
case 'city.update':
// Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event)
return
case 'presence.online':
await shardState.setPresence(event)
return
case 'house.update':
await shardState.upsertHouseRegistry(event)
return
case 'house.remove':
await shardState.removeHouse(event.serial)
return
case 'account.unlinked':
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
// our local link mirror so attribution stops immediately.
if (event.account) await deps.shardLinks.removeByAccount(event.account)
return
// guild.join / account.audit → logged; region.enter → broadcast-only.
default:
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
// broadcasting still happen in ingest().
@@ -145,6 +191,7 @@ async function ingest(event, deps = {}) {
const d = {
shardEvents: deps.shardEvents || shardEventsModel,
shardState: deps.shardState || shardStateModel,
shardLinks: deps.shardLinks || shardLinksModel,
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
broadcast: deps.broadcast || broadcaster.broadcast,
log: deps.log || defaultLog,

View File

@@ -0,0 +1,26 @@
// Recent player-vendor sales for a set of game accounts. Shared by the player
// self endpoint (the caller's linked accounts) and the admin user-detail
// endpoint (a target user's linked accounts). Reads the site's own ingested
// event log — no sidecar round-trip — and filters to sales whose owning account
// is in the set. Newest 50, already newest-first from shardEvents.list.
const shardEvents = require('../model/shardEvents/shardEvents.model')
async function salesForAccounts(accounts) {
const set = accounts instanceof Set ? accounts : new Set(accounts)
if (set.size === 0) return []
const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 })
return events
.filter((e) => e.payload && set.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,
}))
}
module.exports = { salesForAccounts }

View File

@@ -100,15 +100,63 @@ function getHistory({ kind, limit = 100 } = {}) {
return call(`/history${qs ? `?${qs}` : ''}`)
}
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
// Live board / queue projections — snapshotted on WS (re)connect and served from
// our own store thereafter.
const getChamps = () => call('/champs')
const getPages = () => call('/pages')
// Protocol 2.0 board projections — same snapshot-on-connect pattern.
const getGuilds = () => call('/guilds')
const getGovernors = () => call('/governors')
const getHouses = () => call('/houses')
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
// ── Commands ──────────────────────────────────────────────────────────────
const confirmLink = (code, websiteUserId) =>
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
// Account provisioning (Protocol 2.0). createAccount provisions a game account and
// auto-links it to the website user in one step; `ip` is the END USER's browser IP
// (read from the request), which the shard needs for its per-IP account cap — the
// sidecar only sees our server. The password is hashed on the shard and never
// appears in any reply/event/log. unlinkAccount severs a game account's tie from
// the site side. `actor` is the staff/website id, recorded in the shard audit.
const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
call('/accounts/create', {
method: 'POST',
body: { actor, account, password, websiteUserId: websiteUserId == null ? undefined : String(websiteUserId), ip },
})
const unlinkAccount = ({ actor, account }) =>
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
const postTownCrier = ({ id, lines, durationSec }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
// in the in-game News window; re-posting the same id REPLACES it. `announce`
// (default true on the sidecar) controls whether the criers proclaim the title.
const postNews = ({ id, title, body, image, url, announce }) =>
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
// ── Staff write plane (§6) ─────────────────────────────────────────────────
// Every call carries `actor` — the website username of the staff member — set by
// the controller from the session, NEVER from the browser. The shard records it
// for attribution and echoes an admin.audit event back over the WS feed.
const adminKick = ({ actor, account, serial }) =>
call('/admin/kick', { method: 'POST', body: { actor, account, serial } })
const adminBan = ({ actor, account, serial, durationSec, reason }) =>
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
const adminUnban = ({ actor, account }) =>
call('/admin/unban', { method: 'POST', body: { actor, account } })
const adminBroadcast = ({ actor, text, hue }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
// ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
module.exports = {
invalidateConfig,
health,
@@ -118,8 +166,24 @@ module.exports = {
getVendors,
getHistory,
getEconomy,
getChamps,
getPages,
getGuilds,
getGovernors,
getHouses,
getPresence,
confirmLink,
linkLookup,
createAccount,
unlinkAccount,
postTownCrier,
deleteTownCrier,
postNews,
deleteNews,
adminKick,
adminBan,
adminUnban,
adminBroadcast,
respondPage,
closePage,
}

View File

@@ -16,6 +16,8 @@ const WebSocket = require('ws')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkClient = require('./uoLinkClient')
const shardIngest = require('./shardIngest')
const shardState = require('../model/shardState/shardState.model')
const newsGump = require('./newsGump')
const log = require('./logger')('uo-link-socket')
const BACKOFF_MIN_MS = 1000
@@ -59,6 +61,54 @@ async function backfill() {
const series = [...eco.data.series].reverse()
for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true })
}
// Champ board + help-page queue have no replay stream — snapshot the
// authoritative current state directly (the sidecar guide's advice for both),
// reconciling our tables to it so a stale row from before a disconnect can't
// linger. Live champ.*/page.* deltas keep them fresh thereafter.
const champs = await uoLinkClient.getChamps()
if (champs.ok && champs.data && Array.isArray(champs.data.spawns)) {
await shardState.replaceChamps(champs.data.spawns)
log.info('snapshotted champ board from /champs', { count: champs.data.spawns.length })
}
const pages = await uoLinkClient.getPages()
if (pages.ok && pages.data && Array.isArray(pages.data.pages)) {
await shardState.replacePages(pages.data.pages)
log.info('snapshotted help-page queue from /pages', { count: pages.data.pages.length })
}
// ── Protocol 2.0 boards ──────────────────────────────────────────────
// Same as champs/pages: snapshot the authoritative current state and
// reconcile our tables to it. Each call is independently guarded so a
// failed/absent board (e.g. no City Loyalty → empty /governors) never wipes
// another. Governors are NOT cleared before upsert (cities are fixed and the
// term-capture is idempotent, so a reconnect can't spawn spurious terms).
const guilds = await uoLinkClient.getGuilds()
if (guilds.ok && guilds.data && Array.isArray(guilds.data.guilds)) {
await shardState.replaceGuilds(guilds.data.guilds)
log.info('snapshotted guild board from /guilds', { count: guilds.data.guilds.length })
}
const governors = await uoLinkClient.getGovernors()
if (governors.ok && governors.data && Array.isArray(governors.data.cities)) {
await shardState.replaceGovernors(governors.data.cities)
log.info('snapshotted governor board from /governors', { count: governors.data.cities.length })
}
const houses = await uoLinkClient.getHouses()
if (houses.ok && houses.data && Array.isArray(houses.data.houses)) {
for (const ev of houses.data.houses) await shardIngest.ingest(ev, { fromBackfill: true })
log.info('snapshotted house registry from /houses', { count: houses.data.houses.length })
}
const presence = await uoLinkClient.getPresence()
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
await shardState.setPresence(presence.data)
log.info('snapshotted online population from /online', { count: presence.data.count })
}
// Re-assert our published news into the in-game Town Cryer News gump. The
// website is the source of truth; this reconciles the gump on every
// (re)connect (and recovers any article whose original live push failed).
// Silent (announce:false) so a reconnect never re-proclaims old news.
await newsGump.reassertAll()
} catch (err) {
log.warn('backfill failed (continuing on live feed)', { message: err.message })
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
// so any stray query fails fast instead of hanging the runner. These tests stub
// every model method the controller touches, so the DB is never actually hit.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const ctrl = require('../src/router/v1/admin/usersShard.controller')
const users = require('../src/model/users/users.model')
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
const shardState = require('../src/model/shardState/shardState.model')
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
const { salesForAccounts } = require('../src/utils/shardSales')
const db = require('../src/utils/db')
after(() => db.close())
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
// Save/restore the originals so each test's monkeypatches don't leak.
const originals = {
getById: users.getById,
listForUser: shardLinks.listForUser,
listHousesForAccounts: shardState.listHousesForAccounts,
listOnlineForAccounts: shardState.listOnlineForAccounts,
eventsList: shardEvents.list,
}
afterEach(() => {
users.getById = originals.getById
shardLinks.listForUser = originals.listForUser
shardState.listHousesForAccounts = originals.listHousesForAccounts
shardState.listOnlineForAccounts = originals.listOnlineForAccounts
shardEvents.list = originals.eventsList
})
// ── salesForAccounts util ──────────────────────────────────────────────────
test('salesForAccounts returns [] for an empty account set without hitting the log', async () => {
let called = false
shardEvents.list = async () => {
called = true
return []
}
assert.deepEqual(await salesForAccounts([]), [])
assert.equal(called, false)
})
test('salesForAccounts keeps only sales owned by the given accounts, newest 50', async () => {
const events = []
// 60 sales owned by "mine", plus some owned by "other".
for (let i = 0; i < 60; i++) {
events.push({ t: i, payload: { ownerAcct: 'mine', itemType: 'sword', amount: 1, price: 10, commission: 1 } })
}
events.push({ t: 999, payload: { ownerAcct: 'other', itemType: 'shield', amount: 1, price: 5 } })
shardEvents.list = async () => events
const rows = await salesForAccounts(['mine'])
assert.equal(rows.length, 50) // capped
assert.ok(rows.every((r) => r.ownerAcct === 'mine')) // never leaks "other"
assert.deepEqual(Object.keys(rows[0]).sort(), ['amount', 'commission', 'itemType', 'ownerAcct', 'price', 't'])
})
// ── Controller: unknown user → 404 ─────────────────────────────────────────
for (const handler of ['getUser', 'listAccounts', 'getSales', 'getHouses', 'getOnline']) {
test(`${handler} returns 404 when the user does not exist`, async () => {
users.getById = async () => null
const res = mockRes()
await ctrl[handler]({ params: { id: '404' } }, res)
assert.equal(res.statusCode, 404)
})
}
// ── Controller: scoping to the user's accounts ─────────────────────────────
test('listAccounts returns the users linked accounts', async () => {
users.getById = async () => ({ id: 7, username: 'bob', role: 'player' })
shardLinks.listForUser = async (id) => {
assert.equal(id, 7)
return [{ account: 'acctA' }, { account: 'acctB' }]
}
const res = mockRes()
await ctrl.listAccounts({ params: { id: '7' } }, res)
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, [{ account: 'acctA' }, { account: 'acctB' }])
})
test('getHouses passes exactly the users accounts to the model', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => [{ account: 'acctA' }, { account: 'acctB' }]
let received = null
shardState.listHousesForAccounts = async (accounts) => {
received = accounts
return [{ serial: '0x1', isIdoc: true }]
}
const res = mockRes()
await ctrl.getHouses({ params: { id: '7' } }, res)
assert.deepEqual(received, ['acctA', 'acctB'])
assert.deepEqual(res.body, [{ serial: '0x1', isIdoc: true }])
})
test('getOnline passes exactly the users accounts to the model', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => [{ account: 'acctA' }]
let received = null
shardState.listOnlineForAccounts = async (accounts) => {
received = accounts
return [{ serial: '0x2', name: 'Zoe' }]
}
const res = mockRes()
await ctrl.getOnline({ params: { id: '7' } }, res)
assert.deepEqual(received, ['acctA'])
assert.deepEqual(res.body, [{ serial: '0x2', name: 'Zoe' }])
})
test('a user with no linked accounts yields empty sales/houses/online', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => []
shardState.listHousesForAccounts = async (a) => (a.length ? [{}] : [])
shardState.listOnlineForAccounts = async (a) => (a.length ? [{}] : [])
shardEvents.list = async () => [{ payload: { ownerAcct: 'someoneElse' } }]
const sales = mockRes()
const houses = mockRes()
const online = mockRes()
await ctrl.getSales({ params: { id: '7' } }, sales)
await ctrl.getHouses({ params: { id: '7' } }, houses)
await ctrl.getOnline({ params: { id: '7' } }, online)
assert.deepEqual(sales.body, [])
assert.deepEqual(houses.body, [])
assert.deepEqual(online.body, [])
})
test('getUser returns the sanitized user row', async () => {
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
const res = mockRes()
await ctrl.getUser({ params: { id: '7' } }, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.username, 'bob')
})

View File

@@ -0,0 +1,78 @@
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// Exercise invite create/lookup/single-use accept against an in-memory fake by
// monkeypatching the shared db module the model require()s. No DB.
const db = require('../src/model/invites/invites.db')
const invites = require('../src/model/invites/invites.model')
let rows
let nextId
const saved = {}
beforeEach(() => {
rows = []
nextId = 1
for (const k of ['insert', 'getById', 'findByTokenHash', 'markAccepted', 'revoke']) saved[k] = db[k]
db.insert = async ({ tokenHash, email, role, invitedBy, expiresAt }) => {
const id = nextId++
rows.push({ id, token_hash: tokenHash, email, role, status: 'pending', invited_by: invitedBy ?? null, accepted_user_id: null, expires_at: expiresAt, created_at: new Date(), accepted_at: null })
return id
}
db.getById = async (id) => rows.find((r) => r.id === id) || null
db.findByTokenHash = async (h) => rows.find((r) => r.token_hash === h) || null
db.markAccepted = async (id, userId) => {
const row = rows.find((r) => r.id === id && r.status === 'pending')
if (!row) return 0
row.status = 'accepted'
row.accepted_user_id = userId
return 1
}
db.revoke = async (id) => {
const row = rows.find((r) => r.id === id && r.status === 'pending')
if (!row) return 0
row.status = 'revoked'
return 1
}
})
afterEach(() => {
for (const k of Object.keys(saved)) db[k] = saved[k]
})
test('create stores only the token hash, never the plaintext token', async () => {
const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
assert.ok(token && token.length >= 20)
assert.equal(rows[0].token_hash, invites.hashToken(token))
assert.notEqual(rows[0].token_hash, token) // hash, not the raw token
assert.equal(invite.email, 'a@b.com')
assert.equal(invite.role, 'player')
assert.equal(invite.status, 'pending')
})
test('findValidByToken resolves a pending token and rejects a wrong/used one', async () => {
const { token } = await invites.create({ email: 'a@b.com', role: 'moderator', invitedBy: 1 })
assert.ok(await invites.findValidByToken(token))
assert.equal(await invites.findValidByToken('not-a-real-token'), null)
})
test('accept is single-use — the second accept loses the race', async () => {
const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
const row = await invites.findValidByToken(token)
assert.equal(await invites.accept(row.id, 55), true)
assert.equal(await invites.accept(row.id, 66), false) // already consumed
assert.equal(await invites.findValidByToken(token), null) // no longer pending
})
test('an expired invite is not valid (exercises the expiry branch, not a bad token)', async () => {
const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1, ttlDays: -1 })
// The token itself is correct and the row is pending — only expires_at rejects it.
assert.ok(rows[0] && rows[0].status === 'pending')
assert.equal(await invites.findValidByToken(token), null)
})
test('revoke makes a pending invite unusable', async () => {
const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
assert.equal(await invites.revoke(invite.id), 1)
assert.equal(await invites.findValidByToken(token), null)
})

View File

@@ -0,0 +1,69 @@
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// Exercise the News-gump sync decisions against a fake sidecar client by
// monkeypatching the shared modules newsGump require()s (same instance) — no DB,
// no network.
const uoLinkClient = require('../src/utils/uoLinkClient')
const settings = require('../src/model/settings/settings.model')
const newsGump = require('../src/utils/newsGump')
let calls
const saved = {}
beforeEach(() => {
calls = { post: [], del: [] }
saved.postNews = uoLinkClient.postNews
saved.deleteNews = uoLinkClient.deleteNews
saved.get = settings.get
uoLinkClient.postNews = async (article) => { calls.post.push(article); return { ok: true, status: 200 } }
uoLinkClient.deleteNews = async (id) => { calls.del.push(id); return { ok: true, status: 200 } }
settings.get = async () => null // no gump image configured
})
afterEach(() => {
uoLinkClient.postNews = saved.postNews
uoLinkClient.deleteNews = saved.deleteNews
settings.get = saved.get
})
const newsPost = (over = {}) => ({ id: 42, category: 'news', published: true, title: 'Double XP Weekend', excerpt: 'Starts Friday.', body: null, ...over })
test('buildArticle centres the title, links the news list, and respects announce', async () => {
const a = await newsGump.buildArticle(newsPost(), { announce: false })
assert.equal(a.id, '42')
assert.match(a.body, /<CENTER>Double XP Weekend<\/CENTER>/)
assert.match(a.body, /Starts Friday\./)
assert.match(a.url, /\/site\/news$/)
assert.equal(a.announce, false)
})
test('a fresh publish into news pushes with announce=true', async () => {
await newsGump.syncPost(newsPost(), { wasPublished: false, wasNews: false })
assert.equal(calls.post.length, 1)
assert.equal(calls.post[0].announce, true)
assert.equal(calls.del.length, 0)
})
test('an edit of already-published news refreshes silently (announce=false)', async () => {
await newsGump.syncPost(newsPost({ title: 'Edited' }), { wasPublished: true, wasNews: true })
assert.equal(calls.post.length, 1)
assert.equal(calls.post[0].announce, false)
})
test('unpublishing published news pulls the article from the gump', async () => {
await newsGump.syncPost(newsPost({ published: false }), { wasPublished: true, wasNews: true })
assert.equal(calls.post.length, 0)
assert.deepEqual(calls.del, ['42'])
})
test('a draft never-published news post does nothing', async () => {
await newsGump.syncPost(newsPost({ published: false }), { wasPublished: false, wasNews: false })
assert.equal(calls.post.length, 0)
assert.equal(calls.del.length, 0)
})
test('a non-news post (e.g. screenshot) is never pushed', async () => {
await newsGump.syncPost(newsPost({ category: 'screenshot' }), { wasPublished: false, wasNews: false })
assert.equal(calls.post.length, 0)
})

View File

@@ -0,0 +1,69 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../src/utils/shardIngest')
// Build a set of stub deps that record the champ/page/state calls the dispatcher
// makes, plus a spy shardEvents.append and broadcast. Only the methods the tested
// kinds touch need to be real; the rest are no-op async so ingest() never throws.
function makeDeps() {
const calls = { champUpsert: [], champRemove: [], pageUpsert: [], pageRemove: [], appended: [], broadcast: [] }
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertChamp: async (ev) => { calls.champUpsert.push(ev) },
removeChamp: async (serial) => { calls.champRemove.push(serial) },
upsertPage: async (ev) => { calls.pageUpsert.push(ev) },
removePage: async (id) => { calls.pageRemove.push(id) },
// Unused by these kinds but present so any stray routing is a no-op.
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, addEconomySample: noop,
},
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
log: { warn() {}, info() {}, error() {} },
}
}
beforeEach(() => shardIngest.reset())
test('champ.update routes to shardState.upsertChamp and is not written to the event log', async () => {
const deps = makeDeps()
const ev = { kind: 'champ.update', serial: '0x1', category: 'champion', name: 'Abyss', status: 'active', t: 1 }
const r = await shardIngest.ingest(ev, deps)
assert.equal(deps.calls.champUpsert.length, 1)
assert.equal(deps.calls.champUpsert[0].serial, '0x1')
assert.equal(r.logged, false) // champ.* is state-only, not appended to shard_events
assert.equal(deps.calls.appended.length, 0)
assert.equal(deps.calls.broadcast.length, 1) // still broadcast live
})
test('champ.remove routes to shardState.removeChamp', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'champ.remove', serial: '0x2', t: 2 }, deps)
assert.deepEqual(deps.calls.champRemove, ['0x2'])
})
test('page.new and page.updated upsert the page; page.closed removes it', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'page.new', pageId: '0x24C', type: 'Bug', sender: { name: 'Al' }, t: 3 }, deps)
await shardIngest.ingest({ kind: 'page.updated', pageId: '0x24C', handled: true, t: 4 }, deps)
await shardIngest.ingest({ kind: 'page.closed', pageId: '0x24C', t: 5 }, deps)
assert.equal(deps.calls.pageUpsert.length, 2)
assert.deepEqual(deps.calls.pageRemove, ['0x24C'])
})
test('admin.audit is appended to the event log (moderation history)', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest({ kind: 'admin.audit', action: 'ban', actor: 'web:jane', target: 'griefer', t: 6 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.appended.length, 1)
assert.equal(deps.calls.appended[0].kind, 'admin.audit')
})
test('champ.remove without a serial is a harmless no-op', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'champ.remove', t: 7 }, deps)
assert.deepEqual(deps.calls.champRemove, [undefined])
})

View File

@@ -0,0 +1,119 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../src/utils/shardIngest')
// Stub deps recording the Protocol 2.0 board calls the dispatcher makes. Only the
// methods the tested kinds touch need to be real; the rest are no-op async so
// ingest() never throws on an unrelated kind.
function makeDeps() {
const calls = {
guildUpsert: [], guildRemove: [],
governorUpsert: [],
presenceSet: [],
houseRegistry: [], houseRemove: [],
linkRemove: [],
appended: [], broadcast: [],
}
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertGuild: async (ev) => { calls.guildUpsert.push(ev) },
removeGuild: async (id) => { calls.guildRemove.push(id) },
upsertGovernor: async (ev) => { calls.governorUpsert.push(ev) },
setPresence: async (ev) => { calls.presenceSet.push(ev) },
upsertHouseRegistry: async (ev) => { calls.houseRegistry.push(ev) },
removeHouse: async (serial) => { calls.houseRemove.push(serial) },
// Present so any stray routing is a harmless no-op.
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
addEconomySample: noop,
},
shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
log: { warn() {}, info() {}, error() {} },
}
}
beforeEach(() => shardIngest.reset())
test('guild.update routes to upsertGuild and is not logged; guild.remove routes to removeGuild', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest({ kind: 'guild.update', id: 1042, name: 'TSH', t: 1 }, deps)
assert.equal(deps.calls.guildUpsert.length, 1)
assert.equal(deps.calls.guildUpsert[0].id, 1042)
assert.equal(r.logged, false) // board state, not appended to shard_events
await shardIngest.ingest({ kind: 'guild.remove', id: 1042, t: 2 }, deps)
assert.deepEqual(deps.calls.guildRemove, [1042])
})
test('guild.join is appended to the event log (real-time joins feed) and broadcast', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.join', id: 1042, who: { name: 'Bran' }, t: 3 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.appended.length, 1)
assert.equal(deps.calls.appended[0].kind, 'guild.join')
assert.equal(deps.calls.broadcast.length, 1)
})
test('city.update routes to upsertGovernor (which also captures term history)', async () => {
const deps = makeDeps()
await shardIngest.ingest(
{ kind: 'city.update', city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 4 }, deps)
assert.equal(deps.calls.governorUpsert.length, 1)
assert.equal(deps.calls.governorUpsert[0].city, 'Britain')
})
test('presence.online routes to setPresence and is not logged', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'presence.online', count: 42, byRegion: { Britain: 18 }, t: 5 }, deps)
assert.equal(deps.calls.presenceSet.length, 1)
assert.equal(deps.calls.presenceSet[0].count, 42)
assert.equal(r.logged, false)
})
test('house.update routes to upsertHouseRegistry; house.remove routes to removeHouse', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'house.update', serial: '0x40001234', name: 'Anvil', t: 6 }, deps)
assert.equal(deps.calls.houseRegistry.length, 1)
assert.equal(deps.calls.houseRegistry[0].serial, '0x40001234')
await shardIngest.ingest({ kind: 'house.remove', serial: '0x40001234', t: 7 }, deps)
assert.deepEqual(deps.calls.houseRemove, ['0x40001234'])
})
test('region.enter is broadcast-only — not logged, no state side effect', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'region.enter', from: 'Britain', to: 'Despise', who: { name: 'Darrow' }, t: 8 }, deps)
assert.equal(r.logged, false)
assert.equal(deps.calls.appended.length, 0)
assert.equal(deps.calls.broadcast.length, 1) // still surfaced live
})
test('account.unlinked reconciles the local link mirror and is logged', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'account.unlinked', origin: 'in-game', account: 'bob', websiteUserId: '9931', t: 9 }, deps)
assert.deepEqual(deps.calls.linkRemove, ['bob']) // mirror dropped
assert.equal(r.logged, true) // provisioning audit trail
assert.equal(deps.calls.appended[0].kind, 'account.unlinked')
})
test('account.audit is logged (provisioning history) but has no state side effect', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'account.audit', origin: 'web', action: 'create', actor: 'web:jane', target: 'bob', t: 10 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.linkRemove.length, 0)
assert.equal(deps.calls.appended[0].kind, 'account.audit')
})
test('account.audit / account.unlinked are NOT on the public SSE allowlist', () => {
const broadcast = require('../src/utils/shardBroadcast')
assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false)
assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false)
})

View File

@@ -0,0 +1,77 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
// Term capture lives in the model (shardState.model.upsertGovernor →
// recordGovernorTransition) and talks to the db module. We exercise the real
// logic against an in-memory fake by monkeypatching the shared db module object
// (same instance the model require()s) — no DB, no mocking library.
const db = require('../src/model/shardState/shardState.db')
const model = require('../src/model/shardState/shardState.model')
let terms // in-memory shard_governor_terms
let nextId
const saved = {}
beforeEach(() => {
terms = []
nextId = 1
for (const k of ['currentGovernorTerm', 'closeGovernorTerm', 'openGovernorTerm', 'upsertGovernor']) {
saved[k] = db[k]
}
db.currentGovernorTerm = async (city) =>
terms.find((t) => t.city === city && t.ended_at === null) || null
db.closeGovernorTerm = async (id, endedAt) => {
const row = terms.find((t) => t.id === id)
if (row) row.ended_at = endedAt
}
db.openGovernorTerm = async ({ city, serial, name, acct, webId, startedAt }) => {
terms.push({ id: nextId++, city, governor_serial: serial, governor_name: name,
governor_acct: acct, governor_web_id: webId, started_at: startedAt, ended_at: null })
}
db.upsertGovernor = async () => {} // snapshot write — irrelevant to term capture
})
function restore() {
for (const k of Object.keys(saved)) db[k] = saved[k]
}
test('a repeated city.update with the same governor does NOT open a second term', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 200 })
const open = terms.filter((t) => t.ended_at === null)
assert.equal(terms.length, 1)
assert.equal(open.length, 1)
assert.equal(open[0].governor_serial, '0x1')
assert.equal(open[0].started_at, 100)
restore()
})
test('a governor change closes the old term and opens a new one', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x2', name: 'Mira' }, t: 300 })
assert.equal(terms.length, 2)
const [first, second] = terms
assert.equal(first.governor_serial, '0x1')
assert.equal(first.ended_at, 300) // closed at the transition time
assert.equal(second.governor_serial, '0x2')
assert.equal(second.ended_at, null) // now current
assert.equal(second.started_at, 300)
restore()
})
test('a seat going vacant closes the term without opening a new one', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: null, t: 400 })
assert.equal(terms.length, 1)
assert.equal(terms[0].ended_at, 400)
restore()
})
test('terms are tracked independently per city', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 100 })
await model.upsertGovernor({ city: 'Minoc', governor: { serial: '0x9' }, t: 120 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 200 }) // dup, no-op
assert.equal(terms.length, 2)
assert.equal(terms.filter((t) => t.ended_at === null).length, 2)
restore()
})