Commit Graph

28 Commits

Author SHA1 Message Date
0c4eacfa4a refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 31s
Phase 3's acceptance criterion 1, made real. Three things, one review:

**The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO
namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/
userShard`, the uo-link and town-crier calls, `player.shard` — with zero core
consumers since slice 3 deleted the views. module-uo vendors its own bindings.
The five assertions core's `apiClient.test.js` made about those URLs moved with
them (Module-uo#5); the encoding test that used `governorHistory` now uses a
core route.

**The copy.** Core is the platform, not one game's site, so its words are
game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was
never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`,
the default hero, `brand.js`'s tagline and description, the seeded wiki
categories, and two user-visible NavEditor strings that named a module's admin
screen by its proper name. Which game an instance is for is the operator's to
say — BRAND_* vars, the hero editor, CMS pages — and every real instance already
does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing
live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts
what is absent, so renaming one adds a duplicate page to every install.

Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas
tables slice 1 took away, and the two settings rows core seeded for a module
(`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live
defect — see Module-uo#5, which takes ownership of both and repairs the
one-shot migration core's ordering had disabled.

**The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`,
first step of the server-tests job because it needs no dependencies. It reads
CODE, not prose — file names, import specifiers, route path literals, declared
identifiers and property names — per §5.2, so core's English may still say
"shard" where saying it is worth more than the word costs.

Two things it gets right only because getting them wrong was tried first: it
matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains
"ultIma", four times in this repo), and it strips comments and string bodies in
one character walk (a comment contains quotes, a string contains `//`) — the
`checkImports.js` lesson. It has its own 17-test suite, because a boundary check
that silently stops checking is worse than none. The three §6.5 grandfathering
allowlists are exempt by name, and an exemption that stops matching fails the
build rather than lingering.

BREAKING CHANGE: core no longer seeds `game_account_signup` or
`uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install
running core without module-uo keeps whatever rows it already has and gains no
new ones — nothing in core reads either key.

Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a
`## Shard integration (uo-link)` section and the architecture diagram. That is
documentation, which §5.2 does not cover, and it belongs with the phase-closing
docs pass rather than half-done here.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:41:18 -05:00
f7d27f7a06 refactor(client): delete the UO client half (phase 3, slice 3)
35 files and 5,332 lines out — twelve public pages, seven admin views, two
player views, eight components, the two `data/` leaves and the three `lib/`
ones, plus the two tests that came with them. §2.7.1's estimate of 51 files /
~3,700 lines was measured differently and is corrected in the docs PR.

The seams core keeps, each smaller than what it replaced:

Nine rows leave the public header and six leave the admin sidebar, and both
lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already
handled a module page's heading, so the six TITLES entries and the
`/admin/characters` branch of `sectionTitle` simply go.

`/player` had `PlayerCharacters` as its index — a UO page — and rather than name
a replacement or invent a landing screen it now resolves to the first row of the
portal nav this viewer can reach (`firstDestinationFor`, beside
`allowedPathsFor` and reading the BASE nav for the same reason: an override is
presentation and where everybody lands is behaviour). With the module installed
that is still Characters, so a player's first screen after signing in does not
change. Deliberately generic and deliberately not in the portal layout — the
admin index is the same question with a hardcoded answer, and if the two
logged-in areas ever become one this is what serves both.

`game_account_signup` goes with the rest of core's UO prose: the mode list, the
derived public flag, the validation and a Site Settings field whose help text
named Bridge.cfg. The row itself is untouched and module-uo reads it through
ctx.settings — the data stays, the semantics move.

KNOWN BREAK, accepted by the org lead: the shipped Android app reads
`gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a
`= false` default so nothing crashes; the app silently stops offering
game-account creation until it reads the module's `/public/shard/features`
instead. Out of scope here, recorded in the Android plan, and it lands well
before this workstream's cutover reaches `main`.

620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged;
routes.guards unchanged. The OpenAPI spec loses exactly one property, and only
because it was hand-written in swagger.js — regeneration alone would have left
the spec documenting a field core no longer returns.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:39:55 -05:00
847cfd2d2b feat(theming): brand-asset overrides and a cached, settings-aware HTML shell
Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon
overrides on top of the BRAND_* env defaults, delivered through an HTML
shell that is no longer built once at boot.

- utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per
  process, invalidated on a brand_assets/theme_visual write with a 5-minute
  TTL so other workers converge. A settings-read failure renders the
  env-only shell and caches that, so a DB outage is not a failing query per
  page view, and with no rows the output is byte-identical to what app.js
  served before.
- POST /admin/settings/brand-asset/:slot uploads one asset and writes the
  row in the same call, so an upload never leaves an unreferenced file. It
  reuses the shared multer allowlist and only tightens it per slot: favicons
  are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused
  files are unlinked before the response.
- utils/brandAssets.js constrains a stored asset to a same-origin path under
  /uploads, /brand or /assets — these are the only settings values written
  straight into the page as a URL. Strict on write, forgiving on read.
- The shell also carries the resolved theme as a <style id="theme-boot">
  block, removing the first-paint flash phases 3-4 deferred; SiteContext
  drops that block once a successful settings fetch has been applied.
- BrandLogo renders beside the MoonDot on all six shells and renders nothing
  when no logo is set, which is the shipped default.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 20:09:56 -05:00
01a559792c fix(shard): answer with the instance name when the shard is unnamed
ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
publishes that verbatim, so the rules page read "My Shard" under a header
carrying the real name. That value is the shard saying *unnamed* rather than
naming anything, so the site now answers with its own.

`settings.getInstanceName()` resolves `site_title || BRAND_NAME` — the same
resolution `getPublic().brand.name` already uses, so an install that set only
the site title can never show two different names on two pages. Bare
`brand.name` would have been wrong for exactly that case.

Substituted at INGEST rather than on read: world.ruleset is also broadcast
live, and the same object is handed to the SSE fan-out, so a read-time fix
would be undone by the next reconnect's frame. Matched case- and
padding-insensitively but only as a whole value, so a shard genuinely called
"My Shard Reborn" keeps its name.

Fixes a second ruleset writer found on the way: uoLinkSocket.backfill() called
shardState.setRuleset directly instead of going through the dispatcher as
ingestEach does, so the boot/reconnect snapshot silently skipped this
normalization. The two arrival orders have to produce the same stored frame.

Also renders a placeholder row on an unscored leaderboard — the instance name
with an em dash where a score goes, deliberately not shaped like an entry (no
medal, no bar) because a placeholder that looked like a real standing would be
a fabricated one. Presentation only; the API still sends an empty `top`.

Verified live against the shard + sidecar: rules page and leaderboards on web
and Android both correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:58:21 -05:00
8771a1cf6c feat(shard): the player-vendor marketplace
Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove
into shard_vendors + shard_vendor_items, serves a searchable public API over
them, and ships /site/market and /site/market/vendors/:serial.

Three things the pages have to say out loud, all consequences of how the data is
gathered:

- The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be
  a full cycle behind. The banner is driven by the OLDEST vendor row, not the
  newest — the one stale shop is the one that wastes somebody's trip.
- A shop can be truncated. `total` exceeding `count` means the shop holds more
  than the shard publishes per frame; the vendor page says "showing 250 of 3,104"
  rather than presenting a partial shop as complete.
- An item may have no name. On a shard with no cliloc table the honest render is
  the item id, never an invented label.

## The pre-wired visibility rules, re-checked

Part A pre-wired market.ownerName and market.location before the frame existed,
and the sibling rule it pre-wired for leaderboards (`characterName`) turned out
to be INERT because projectValue matches literal JSON keys. Both market rules
were checked against the real frame this time:

- `ownerName` is a real key. Kept.
- `location` is a real key ONLY because the frame nests it. Flat map/x/y/region
  would have made the rule match nothing — the same failure, one part later. It
  is nested on the wire and on the read model so one rule hides the facet, the
  coordinates, the region and the house together; five flat keys would be five
  rules that drift apart.
- `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a
  serial that the leaderboards and guild boards resolve back to that same name
  has not hidden anything.

Tests assert all three bite, on the stored read model AND on the raw frame —
the market's SSE stream is off by default but an admin can turn it on, and a rule
that worked on only one path is exactly the leak §3.6.1 records.

## Notable

- **No payload column on shard_vendors**, unlike shard_points_boards next door.
  The board's top-N is a fixed-size list read whole; here the items ARE the
  searchable rows, so they are normalized and nothing is left worth duplicating.
- **display_name is denormalized at ingest** (literal name preferred over the
  cliloc — a player set it, so it is more specific). Resolving at query time
  would put the cliloc table on the hot path and make search-by-name impossible.
  Because the shard's diff sweep will not re-send an unchanged shop just because
  the site learned what its items are called, a cliloc import now triggers a bulk
  re-resolution — 50 ms per thousand rows, never throws.
- **updated_at is written explicitly** on every upsert. MariaDB does not fire ON
  UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a
  shop re-published identically is still freshly confirmed — without this the
  staleness banner would age a perfectly current shop forever.
- **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not
  SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise
  match every listing on the shard.
- **Rate-limited** (60/min/IP), the only limited public read. Every other public
  GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over
  the largest shard_* table, anonymous by default.
- Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and
  stopping on a short page as well as on `total`, so a concurrent sweep shrinking
  the index cannot spin the walk.

## How it was tested

673 server tests pass (27 new). Client builds clean; swagger-output.json,
routes.manifest.json and routes.guards.json regenerated.

Verified full-stack against the live MariaDB and a real shard, not only units:

- 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust
  sidecar, into the site — names resolving through the cliloc table ("longsword",
  "katana"), real facets and regions in the filters.
- `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort
  filters, paging, and the vendor detail route.
- Visibility live: fields gated to staff vanish for an anonymous caller while
  shopName and price survive; audience=player 403s; enabled=0 404s; and
  /shard/features correctly drops `market` so the nav hides it.
- Re-publishing a shop smaller leaves no orphan items; an identical re-publish
  moves updated_at.
- The limiter fires (38x200 then 32x429 on a 70-request burst).

Not covered by an automated test: the two React pages are presentational and this
repo's client suite covers pure-logic modules only. They were driven against the
live API above, but not rendered in a DOM harness.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:50 -05:00
26094459ae feat(shard): ingest points.board and publish the leaderboards
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up
Britannia — and the site renders them, plus each character's own standings on
their sheet.

Server
  - shard_points_boards: one row per system, keyed by the shard's PointsType
    name. The top-N list stays inside `payload` — a fixed-size list read whole,
    exactly like shard_governors.candidates. Normalizing into an entries table
    buys nothing until something needs a per-character reverse lookup, and a
    character's own standings already ride inside char.profile.
  - shardIngest routes points.board to upsertPointsBoard and deliberately does
    NOT log it: this is board state like guild.update, and the shard emits a
    frame every time anyone's score moves a top ten.
  - uoLinkSocket backfills /points through snapshot() with ingestEach rather
    than a replace*: there is no points.remove and the system set is fixed, so
    upserting IS the reconciliation, and a system the operator later excludes
    keeps its last-known board rather than vanishing.
  - GET /public/shard/points and /points/:system behind
    requireFeature('leaderboards'), both projected per §3.6.1. :system is
    constrained to an identifier before any query runs; 404 for a system never
    published, distinct from a published board nobody has scored in (200, empty
    top).

The leaderboards field rule now keys on `name`, not `characterName`
  Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but
  projectValue matches on the LITERAL JSON key and the wire key is `name`. As
  written the rule was inert: an admin tightening character names would have got
  no enforcement and no error — precisely the failure §3.6.1 records for the
  flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed
  back, and the admin panel's FIELD_LABEL carries the meaning instead.

Client
  - routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame
    describes ONE system, so live frames merge over the fetched set by system
    key rather than replacing it wholesale the way the ruleset does. Filter
    matches board name, system key, or any ranked player — the last is what
    makes it useful ("where do I appear?").
  - A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both
    PlayerCharacter and AdminCharacter.
  - Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the
    system key when nameString is null. Neither is defensive padding: on a real
    shard uncapped and cliloc-only names are the majority case.

Verified end to end against the local MariaDB, the Rust sidecar, and the real
ServUO shard: backfill from /points, live SSE delivery (a board absent from the
initial fetch appearing without a reload, and an existing one updating in
place), REST reflecting the overwrite, and the gate at every rung — 200 by
default with names, names stripped but points kept at fieldRules name=staff, 403
plus dropped from /features at audience=staff, 404 when disabled. Page rendered
clean, no console errors beyond the pre-existing React Router v7 warnings.

605 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:44 -05:00
7c769ea8fd feat(atlas): serve the spawn atlas and give operators a panel for it
Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data
pipeline; this makes it reachable — six public routes, five admin ones, two
public pages and an admin panel. Still website-only: no plugin, no sidecar, no
new event kinds, no wire change.

The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here
touches the sidecar, so the pages stay complete while the shard is down, and a
/shard prefix would imply a dependency the atlas does not have. Unlike /shard/*
it IS site-mode gated, like /posts and /wiki: a bestiary is site content.

Every route carries requireFeature('atlas') and projects its response. The atlas
feature declares no sensitive fields, so the projection is a no-op today — the
call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate
should be covered by construction rather than by a retrofit.

Two bugs the UI surfaced, both fixed here:

Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes
MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not
divide into whole minutes, flagging it per record with DelayInSec. A `5` means
five minutes on one spawner and five seconds on the next, both plausible, and
the pipeline stored the raw number. 170 of 6,455 stock spawners are second
flagged. The parser normalises to seconds; the API and UI carry seconds.

That exposed the hash gate as a trap. "Has the tree changed?" is the wrong
question on its own: an install whose maps never change would have kept serving
the old readings forever, because the only thing compared was the tree.
PARSER_VERSION is now stored beside the source hashes and a mismatch counts as
drift, so any future parse correction lands on the next boot.

Also renamed the detail route's spawn-point array to `spawners` — it was
`points`, which is the COUNT on the search route, so one key meant a number in
one place and an array in the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 19:51:22 -05:00
61d6bfaca2 feat(shard): ingest world.ruleset and publish it at /site/rules
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.

Server
  - shard_ruleset: a singleton table (id = 1) holding the whole frame in
    `payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
    the frame is a flat description of config read as one page, and splitting it
    into columns would mean a schema change every time the shard grows a block.
  - shardIngest routes world.ruleset to setRuleset and deliberately does NOT
    log it — the shard re-emits the whole ruleset on every sidecar connect, so
    logging would append a duplicate row per reconnect, and server.hello already
    marks each of those.
  - uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
    which asserts an array; this covers the order where the sidecar was already
    up and holding the ruleset when we reconnected.
  - GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
    per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
    means the shard has never published one — a real answer, distinct from a
    published ruleset, and the page says so.

Client
  - routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
    complete ruleset, not a delta, so the newest one wins outright). Caps are
    rendered from tenths — 7000 is 700.0, and showing the raw number would
    mislead. A systems key this build doesn't know still renders, humanised, so
    a newer plugin can't go invisible against an older client.
  - Nav entry gated on the `ruleset` feature, so it hides rather than 403s.

Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.

497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 14:35:24 -05:00
12d50fd615 chore(quality): resolve SonarQube code smells across website
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 04:35:39 -05:00
aa2177715e fix(shard): restrict staff in-game location to admins/moderators
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m23s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m18s
The public "Staff online" list on the Shard page exposed each staff
member's in-game location (map + coordinates) to everyone, including
logged-in players and unauthenticated visitors.

Location is now privileged data:
- Server: getOnline inspects the caller's role via getUserFromRequest
  (the same non-rejecting helper siteMode uses on public routes) and
  only includes map/x/y/z for admin/moderator callers. For everyone
  else the fields are omitted from the JSON entirely, so they can't be
  read from the network tab. serial + name (online status) still shown.
- Client: Shard.jsx gates the location span on the viewer's role from
  useAuth() (same pattern as RoleGate); non-privileged viewers see who
  is online but no location field is rendered.

Tests: publicShardOnline.test.js covers admin + moderator (location
included), player + unauthenticated + editor (location omitted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 21:18:52 -05:00
526160721a feat(brand): emblem behind hero text + Powered By footer with logo
Address review: the SVG footer badge rendered too small, and the default
hero should feature the Runic Gateway emblem rather than the moon.

- Footer: drop powered-by.svg; render the emblem PNG beside a "Powered by
  Runic Gateway" label (left-justified, info text stays centered).
- Default hero: brand.hero (and the client fallbacks) now default to the
  emblem PNG. The untouched default hero centers the square emblem behind
  the text as a medallion with a symmetric legibility overlay (per-layer
  background-size so the overlay stays full-bleed). BRAND_HERO still
  overrides.
- Admin login / player shell / maintenance backgrounds center the emblem
  as a capped medallion instead of a cropped full-bleed cover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 16:56:22 -05:00
7a08546da6 feat(brand): BRAND_* env scheme — instance branding without a rebuild
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m20s
Replace baked-in UOM/MysticMoon/UOMysticmoon branding with a BRAND_* env
scheme so one prebuilt image runs as any shard; UOMysticmoon becomes the
first tenant that sets these vars rather than a special case in the code.

Architecture (chosen because the app ships as a prebuilt image):
- server/src/config/brand.js + bot/src/brand.js read BRAND_* once at boot,
  with Runic Gateway defaults.
- Text/colors reach the SPA at RUNTIME through the existing public settings
  API (settings.model.getPublic -> SiteContext), so no client rebuild. The
  admin-editable site title + contact email still override BRAND_NAME/email.
- SiteContext applies BRAND_ACCENT_COLOR to the --accent CSS var at runtime.
- Express templates the built index.html <title>/description/OG/favicon at
  serve time from BRAND_* (renderIndexHtml in app.js).
- Server-side consumers read brand directly: emails, TOTP issuer, API docs,
  boot logs, HTML error page. Bot uses it for embed color + logs.

Assets: logo/hero/favicon delivered from a ./brand:/app/brand bind-mount
(BRAND_LOGO/HERO/FAVICON), with neutral defaults baked in; hero falls back
to a built-in image when unset.

Scope: also genericized package.json names (uomysticmoon-* -> runic-gateway-*)
and the DB_NAME/DB_USER/COOKIE_NAME code defaults (runic_gateway/runic/
rg_token). Production keeps its real values by pinning them in .env — see
.env.uomysticmoon.example, which reproduces the exact UOMysticmoon identity
(proof the substitution works). Changing a deployed COOKIE_NAME invalidates
existing sessions, so UOMysticmoon pins uomm_token.

Verified: 193 server tests pass, client builds, app.js loads + templates the
built index.html, brand transform injects title/description/OG/favicon.
2026-07-18 02:20:04 -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
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
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
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
c4245e3f6a Restrict public presence to staff + let admins view any character
Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".

Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.

Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
2026-07-11 09:15:18 -05:00
49d0c1bd11 Add shard activity feed + admin live feed; fix public-feed leak
Front ends for the rest of the sidecar data, plus a security fix the live data
surfaced.

- lib/shardEvents.js: shared describe()/category/label for every event kind
  (sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds).
- Public /site/shard/activity (ShardActivity): the full event log with category
  filter tabs and a live tail (history + SSE merged, de-duped). Linked from the
  Shard page. Shard page now reuses the shared describe().
- Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to
  the admin SSE channel — shows every kind incl. audit/cheat/login attempts.
  useShardFeed generalized to take a stream url; api.adminShardStreamUrl added.

Security fix: GET /public/shard/feed now restricts to the public-safe kind
allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned
whatever was logged — including audit.* / cheat.* / link.request. Those are
still stored for the admin channel but never served publicly (verified: a
public request for audit.command returns 0 rows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 03:10:03 -05:00
49ce230c3a Full-site nav: one auth-aware nav bar on every page
- SiteHeader: a single consistent main nav (Home, News, Screenshots, Five on
  Friday, Newsletter, Wiki, Shard, About) with active-state highlighting, plus
  an auth-aware entry on the right — Sign in when logged out, My Account
  (player) or Admin (staff) when logged in.
- Portal (landing) now renders the site header too, so the nav is present
  across the entire site, not just interior pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:59:19 -05:00
fe6f93481b Add player portal + character-sheet front end (phase 4 follow-up)
Turns the raw shard endpoints into proper, navigable pages in the site's visual
language.

- components/CharacterSheet.jsx: reusable sheet — attribute tiles, vitals bars,
  resistances, skills (with bars), and equipment — styled with the shared
  panel/grid vocabulary.
- Player portal with a nav bar: PlayerPortalLayout (Characters / Account tabs +
  sign-out) wraps /player and /account. /player (PlayerCharacters) tells the
  logged-in player if they haven't linked a game account (with the [link code
  prompt) or, once linked, shows their characters grouped by account; each
  character opens its sheet at /player/char/:serial. Account security moved into
  the same shell (the buried "Game accounts" block was removed from it).
  Login/register now land on /player.
- Public: GET /public/shard/online (redacted name+serial+map) drives an
  "Online now" list on /site/shard that links to public character sheets at
  /site/shard/char/:serial (ShardChar). Swagger: ShardOnlinePlayer + regenerated.
- api.shard.online added.

Verified live against the running shard: Darrow's full sheet (STR 120, 58
skills, 3 equipment) renders through the browser-facing proxy; the online list
returns the live roster; player routes 401 without a session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:51:50 -05:00
1c9a9d26e1 Add public Shard page + player Game Accounts UI (phase 4)
Frontend for the uo-link integration, matching the existing site styling.

- api/client.js: api.shard.* (status/feed/economy/idoc/char), the
  shardStreamUrl SSE endpoint, and api.player.shard.* (link/accounts/roster/
  vendors).
- lib/useShardFeed.js: EventSource hook over /public/shard/stream with a
  rolling buffer and a connected flag (browser never touches the sidecar WS).
- routes/public/Shard.jsx: connection banner, stat tiles (online / gold supply
  / link), a gold-supply sparkline, "recent vendor sales" and "IDOC houses"
  lists, and a live event ticker — built from the shared panel/grid/format
  vocabulary. Registered at /site/shard under the maintenance gate and linked
  from the site header.
- routes/player/PlayerAccount.jsx: a "Game accounts" section — enter a [link
  code to link an account, then expand it to see characters and player vendors
  on demand (503 shows a retry banner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:17:45 -05:00
1dd7603f54 Add page builder admin UI + public page route (step 5 + step 6 client)
- PagesAdmin: list view of pages (title/slug/status/protected/updated) with
  new/edit navigation and a View link to the live page.
- PageBuilder: full-page block canvas — palette (adds any registered block),
  per-block editor cards with show/hide, up/down + native drag reorder, and
  remove; Content / Settings tabs; SEO metadata + layout/nav settings panels;
  publish/unpublish; protect (PATCH) and password-gated unprotect (modal);
  draft preview (mints a token, opens /preview/:id/:token); delete (blocked
  while protected). Surfaces server block-validation details on save.
- CmsPage: public renderer for /:slug (published; staff see drafts) and the
  token-gated /preview/:id/:token, rendering blocks via BlockList and
  reflecting the page title/meta.
- Routing: /:slug catch-all after all named routes + /preview/:id/:token
  outside the maintenance gate; admin /admin/pages, /pages/new, /pages/:id.
- api client: public page/pagePreview + admin pages CRUD/unprotect/preview.
- AdminLayout: "Pages" nav entry (Content group) with icon.
- theme.css: builder canvas + preview-banner + shell-wide styles.

Client builds clean (216 modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:54:36 -05:00
0318d6fe9f Make hero the full landing page; move quick links into hero editor
Remove the two-card destination row and the below-hero quick-links nav
from the portal so the hero fills the viewport with nothing rendered
after it. The 5 quick links (News, Screenshots, Five on Friday,
Monthly Newsletter, About) move into the hero editor as a third
buttons element in defaultLayout(), reusing the existing buttons
element type so they stay fully editable with no schema changes.

Also drop overflow:hidden on the hero section: on mobile, 100vh can
compute smaller than window.innerHeight, and with overflow hidden the
wrapped quick-links text was getting clipped at the bottom edge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 21:49:45 -05:00
438d252c05 Merge branch 'main' into rte-posts-upgrade 2026-06-30 18:33:16 +00:00
a5a8c1930c RTE Posts upgrade: TipTap editor + sanitization for posts
Extend the wiki's RichTextEditor to the Posts editor and close the
stored-XSS gap on public post bodies.

- RichTextEditor: add `variant` prop — `full` (wiki), `post` (no
  internal wiki-page link picker), `minimal` (image-only, for
  Screenshots captions). Toolbar sections rendered conditionally.
- PostEditor: replace the body textarea with a lazy-loaded
  RichTextEditor in Suspense; variant chosen by category
  (minimal for screenshots, post otherwise).
- posts.model: sanitize body via shared cleanBody on create/update,
  treat an empty TipTap `<p></p>` as null, and auto-derive the
  excerpt from the body (max 280 chars) when left blank.
- sanitizeHtml util: add deriveExcerpt() helper.
- FiveOnFriday / NewsletterIssue: wrap dangerouslySetInnerHTML with
  DOMPurify.sanitize() as defense-in-depth on render.

No schema or dependency changes. Verified end-to-end against the
local stack: 24/24 API assertions and a full UI round-trip across
all four post categories.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:14:54 -05:00
ccad727ec3 Hero Phase 2: editor shell + background/overlay + draft/preview/publish
Second phase of the hero canvas editor (see HERO_EDITOR.md).

- new lib/heroLayout.js: shared defaultLayout/buildOverlay/heroBackground/
  parseLayout used by both the portal and the editor (Portal refactored onto it)
- new admin view HeroEditor.jsx at /admin/hero (+ sidebar nav + route):
  - live canvas preview (16:9) rendering the draft via HeroElement
  - background panel: image upload (/admin/uploads, >1MB warning), 3x3 position
    grid, overlay opacity slider — all update the canvas in real time
  - debounced (800ms) auto-save to hero_layout_draft
  - Publish (writes hero_layout + draft), Preview (opens /?preview=1), Revert
- Portal: ?preview=1 renders the draft via the admin settings endpoint, with a
  "showing unpublished draft" banner; normal load renders the published layout

No schema/dep changes. Verified end to end: overlay/position update the canvas,
auto-save writes the draft, publish updates the live portal, preview shows the
draft while the public page shows live. Element drag/properties land in Phase 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:32:09 -05:00
578bffc51f Hero Phase 1: layout data path + portal renderer
First phase of the hero canvas editor (see HERO_EDITOR.md).

- settings.model: add hero_layout to PUBLIC_KEYS so the portal receives it
  (corrects the design doc — public settings is a whitelist, not getAll();
  hero_layout_draft stays admin-only)
- new HeroElement.jsx: renders one layout element by type (text_block,
  buttons, moon, badge, image); absolute % positioning with anchor; shared
  by the portal now and the editor canvas later
- MoonDot: optional color override for the hero moon element
- Portal.jsx: parse hero_layout (version-checked, try/catch), render elements
  sorted by z; fall back to a DEFAULT_LAYOUT built from the current hero so the
  page is byte-for-byte unchanged until staff publish their own

No schema change. Verified: default render matches the old hero; publishing a
hero_layout re-renders the portal; the draft key is not exposed publicly; client
builds; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:18:09 -05:00
6dba6a017c Frontend update 2026-06-26 21:51:27 -05:00