Compare commits
90 Commits
70d49b7792
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e7dea29885 | |||
| b150e354a8 | |||
| e86b04567f | |||
|
|
3aed6bca17 | ||
| 518f1e0449 | |||
| 2e955f1e9c | |||
| 2784cad6e4 | |||
| d7dd0e5078 | |||
| 5c24ff1378 | |||
| 2a8b9d5748 | |||
| 97cb8be2d5 | |||
| ce6f5b8788 | |||
| e669243aca | |||
| 09e6ffd67c | |||
| 252733644b | |||
| 6398285a13 | |||
| d33064e8d7 | |||
| 6d25279b32 | |||
| 5197c2c281 | |||
| c73db117f5 | |||
|
|
22cf093f97 | ||
| 4a35e86bc8 | |||
| 9889eefb0f | |||
| bc97e5d221 | |||
|
|
5c9aa2891c | ||
|
|
c54dcb47f5 | ||
| 18dfceae73 | |||
| 7c1a88febb | |||
| ddcfb5de29 | |||
| 00d476c00b | |||
| ad7defd471 | |||
| e18eec8957 | |||
| 38f83ad2e0 | |||
| 3eb9fa8653 | |||
| 3a6b9196fa | |||
| c47e0fb303 | |||
| 33fadbd254 | |||
| 1dc6084bf7 | |||
| ecef87f120 | |||
| 706b450828 | |||
| 42e6f3a0cb | |||
| 5ae53d287f | |||
| 3f12e5f49c | |||
| ce02abbc11 | |||
| 4bdc764742 | |||
| a98fceb4bb | |||
| 5a6cb58a33 | |||
| d5838f7d4c | |||
| 90459f2c49 | |||
| a2e859bf9a | |||
| 2dbe4d8388 | |||
| e85ca632ce | |||
| 83bd4ec2d4 | |||
| 5c8585fe75 | |||
| fc79bb6ed0 | |||
|
|
a6b20cb273 | ||
| af6036b9c7 | |||
| e0245a209c | |||
| 10ebce706f | |||
| d1cb3e9511 | |||
| c1957f0bfb | |||
|
|
b6343a0937 | ||
| e1608bb280 | |||
| f0975df598 | |||
| 8322e8318c | |||
| 25a5734107 | |||
| c583ddb77f | |||
| 29056ba996 | |||
| 186f057bc0 | |||
|
|
33a013ca98 | ||
| 5e2bc22a94 | |||
| 04cd64b838 | |||
| 916c11ee92 | |||
|
|
ea3755760d | ||
|
|
54b3002701 | ||
| 9d98109628 | |||
| d0808b766b | |||
| eab0a83f26 | |||
| 1444c77413 | |||
| 2e24427032 | |||
| afcdb373ec | |||
| 45fb4a3f15 | |||
| 5a091157d6 | |||
| 3a1bbdd165 | |||
| 32def88c4e | |||
| 71207cef16 | |||
| cdea1aa7cd | |||
| 6ce60a82c3 | |||
| 06b4a06baa | |||
|
|
f2fa6abff7 |
24
README.md
24
README.md
@@ -7,19 +7,29 @@ so they live in one place, independent of either codebase.
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
|
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
|
||||||
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||||
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
||||||
ci/ cross-cutting CI/quality notes
|
installer/ docs for the installer that deploys a shard's bridge components
|
||||||
|
ci/ cross-cutting CI/quality notes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Setting up a shard?** [`installer/INSTALL.md`](installer/INSTALL.md) is the operator guide, and
|
||||||
|
the installer is the supported path: one binary deploys the plugin overlay, installs the uo-link
|
||||||
|
sidecar as a service, and hands you the values the website needs.
|
||||||
|
|
||||||
### `website/`
|
### `website/`
|
||||||
| Doc | What it covers |
|
| Doc | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
|
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
|
||||||
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
|
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
|
||||||
|
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
|
||||||
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
|
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
|
||||||
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
|
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
|
||||||
|
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
|
||||||
|
| [CLILOCS.md](website/CLILOCS.md) | UO's id → name table: converting one from your client so items have names |
|
||||||
|
| [UOFIDDLER.md](website/UOFIDDLER.md) | **Operator runbook** — step-by-step extraction from your own UO client (cliloc table, creature art) |
|
||||||
|
| [MARKETPLACE.md](website/MARKETPLACE.md) | The player-vendor index: how it is gathered, what it costs, how to tune it |
|
||||||
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
|
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
|
||||||
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
||||||
|
|
||||||
@@ -46,6 +56,12 @@ ci/ cross-cutting CI/quality notes
|
|||||||
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes |
|
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes |
|
||||||
| [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
| [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
||||||
|
|
||||||
|
### `installer/`
|
||||||
|
| Doc | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| [INSTALL.md](installer/INSTALL.md) | **Start here to set up a shard** — the installer deploys the plugin overlay and the uo-link sidecar, registers the service, and connects it to the website. Appendix A is the same thing by hand, still supported |
|
||||||
|
| [PLAN.md](installer/PLAN.md) | Installer design of record — phases, locked decisions, the bundle/compat-matrix model |
|
||||||
|
|
||||||
## Provenance
|
## Provenance
|
||||||
|
|
||||||
- `website/*` was extracted from `RunicGateway/website` via `git filter-repo`.
|
- `website/*` was extracted from `RunicGateway/website` via `git filter-repo`.
|
||||||
|
|||||||
168
android/PLAN.md
168
android/PLAN.md
@@ -1,6 +1,6 @@
|
|||||||
# Android App — Plan
|
# Android App — Plan
|
||||||
|
|
||||||
Status: **M0–M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)).** This document is the
|
Status: **M0–M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)). **M11 (Protocol 3.0 shard parity)** is scoped and next: the app sees none of the four shard features v3 added (`ruleset`, `leaderboards`, `market`, `atlas`) and does not consult `GET /public/shard/features`, so it gates shard nav on session role alone while an admin can switch any of those surfaces off or raise its audience — the v3 `edge` → `main` cutover is held until it lands (§9 M11).** This document is the
|
||||||
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
|
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
|
||||||
API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
|
API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
|
||||||
reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
|
reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
|
||||||
@@ -631,6 +631,8 @@ not rank).
|
|||||||
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
|
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
|
||||||
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
|
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
|
||||||
| Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) |
|
| Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) |
|
||||||
|
| **Rules / Leaderboards / Market** | everyone, *if the shard publishes them* | `/public/shard/{ruleset,points,market}` (M11) |
|
||||||
|
| **Atlas** (bestiary) | everyone, *if the shard publishes it* | `/public/atlas/*` (M11) |
|
||||||
| Contact | everyone | `/public/contact` |
|
| Contact | everyone | `/public/contact` |
|
||||||
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
|
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
|
||||||
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
|
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
|
||||||
@@ -639,6 +641,12 @@ not rank).
|
|||||||
Guidelines:
|
Guidelines:
|
||||||
- The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries
|
- The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries
|
||||||
with a `minAccess`/`requiredCapability` field, filtered by the session.
|
with a `minAccess`/`requiredCapability` field, filtered by the session.
|
||||||
|
- **Session role is not the only gate on shard surfaces (M11).** Every shard-derived feature is
|
||||||
|
*admin-configurable* — it can be switched off or raised to a higher audience rung — so a shard entry
|
||||||
|
is filtered by the session role **and** by `GET /public/shard/features`, which reports the features
|
||||||
|
the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app
|
||||||
|
shows everything: the server gates regardless, and a nav that flickers in on every load is worse than
|
||||||
|
a link that briefly `403`s.
|
||||||
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
|
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
|
||||||
groups and a "Sign in" affordance.
|
groups and a "Sign in" affordance.
|
||||||
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
|
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
|
||||||
@@ -663,9 +671,15 @@ Guidelines:
|
|||||||
### 6.2 Public shard (live)
|
### 6.2 Public shard (live)
|
||||||
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
|
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
|
||||||
`/public/shard/*` GETs.
|
`/public/shard/*` GETs.
|
||||||
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE, safe kinds only) and patch the
|
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE) and patch the in-memory boards in
|
||||||
in-memory boards in place (champ/guild/city/house/presence update+remove frames). Reconnect with
|
place (champ/guild/city/house/presence update+remove frames). Reconnect with backoff; fall back to
|
||||||
backoff; fall back to poll if SSE drops.
|
poll if SSE drops. What arrives on the stream is **resolved from the caller's audience rung at
|
||||||
|
subscribe time**, not from a fixed allowlist (Protocol 3.0 §3.6) — the stream request carries the
|
||||||
|
bearer like every other call, so a signed-in app session sees exactly what the same account sees on
|
||||||
|
the web.
|
||||||
|
- **Visibility + the Protocol 3.0 surfaces (M11)** — `GET /public/shard/features` drives which of these
|
||||||
|
the menu offers; `GET /public/shard/{ruleset,points,points/:system,market,market/meta,market/vendors/:serial}`
|
||||||
|
and `GET /public/atlas/*` are the new reads. Full contract and traps in §9 M11.
|
||||||
|
|
||||||
### 6.3 Player self-service & game data (bearer)
|
### 6.3 Player self-service & game data (bearer)
|
||||||
- **Account** — `GET /player/account`; `PATCH /player/account/username`;
|
- **Account** — `GET /player/account`; `PATCH /player/account/username`;
|
||||||
@@ -675,6 +689,11 @@ Guidelines:
|
|||||||
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
|
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
|
||||||
`/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
|
`/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
|
||||||
"offline, retry" state (see §7).
|
"offline, retry" state (see §7).
|
||||||
|
- **The character sheet carries two things the app does not yet read (M11):** the `points` block
|
||||||
|
(per-character loyalty/points standings, Protocol 3.0 §7.3) and the server-resolved cliloc names on
|
||||||
|
`equipment[].clilocName` / `titles.rewardResolved` (§8.6). Both are served **ungated** on this route —
|
||||||
|
a character's own standings are self-service data and do not depend on the public `leaderboards`
|
||||||
|
feature being visible, which is the behavior the app must mirror rather than re-gate.
|
||||||
- **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no
|
- **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no
|
||||||
item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the
|
item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the
|
||||||
art/asset work on the platform side) and is explicitly out of the first release.
|
art/asset work on the platform side) and is explicitly out of the first release.
|
||||||
@@ -883,10 +902,147 @@ push, and Play (M6–M8) follow the designed app.
|
|||||||
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
|
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
|
||||||
editor, Discord-bot config, uo-link config, OAuth-provider setup.
|
editor, Discord-bot config, uo-link config, OAuth-provider setup.
|
||||||
|
|
||||||
|
12. **M11 — Protocol 3.0 shard parity** (post-v1; scoped 2026-07-30). The website's Protocol 3.0 work
|
||||||
|
added four shard features and, with them, an **admin-configurable visibility framework** the app
|
||||||
|
knows nothing about. `link/v3.md` §10 deferred the app side as a follow-up; it is now scoped
|
||||||
|
deliberately, and **the v3 `edge` → `main` cutover is held until both parts land** so web and app
|
||||||
|
surface the same shard on the same day (decided 2026-07-30).
|
||||||
|
|
||||||
|
Neither part is coupled to the cutover *merge order*, which is what makes holding it a schedule
|
||||||
|
decision rather than a technical one: against a pre-v3 website every new route and
|
||||||
|
`/public/shard/features` simply `404`s, and each consumer below falls back to exactly today's
|
||||||
|
behavior. The app declares no protocol version and never talks to the sidecar.
|
||||||
|
|
||||||
|
- **Part 1 — the visibility rules + the read-model adds.** The security-shaped half, reviewed on
|
||||||
|
its own:
|
||||||
|
- `GET /public/shard/features` → `{ level, features[] }`: the features **this caller** may reach.
|
||||||
|
A new singleton cache mirrors the web client's (`lib/useShardFeatures.js`): per-viewer but
|
||||||
|
stable for a session, invalidated on sign-in/out and on a server switch.
|
||||||
|
- `MenuEntry` gains `feature: String?` beside its existing `access`, so the one declarative menu
|
||||||
|
(§5) filters on the session role **and** the shard's live feature config. While the lookup is
|
||||||
|
in flight or has failed, **show everything** — the same deliberate fail-open the web client
|
||||||
|
takes, because the server gates regardless and a nav that flickers in on every load is worse
|
||||||
|
than a link that briefly `403`s. The gate is server-side; hiding is presentation.
|
||||||
|
- **`404` and `403` mean different things here** and neither is a generic error:
|
||||||
|
`requireFeature` `404`s a *disabled* feature (deliberately not disclosing that it exists) and
|
||||||
|
`403`s a viewer *below its audience*. Both render "not available on this shard", alongside the
|
||||||
|
existing `503` = shard offline (§7).
|
||||||
|
- **The `level` from `/features` is authoritative — do not re-derive the rung from the role.**
|
||||||
|
The server's ladder is `anonymous → logged_in → player → staff → admin`, where `player` means
|
||||||
|
*a linked game account* and staff always satisfy `player` (the same superset rule `Menu.kt`
|
||||||
|
already encodes as `isPlayer || isStaff`).
|
||||||
|
- **`char.profile.points`** → the "Loyalty & Points" block the web character sheet gained:
|
||||||
|
`CharProfileDto.points[{system, nameString, points, maxPoints, rank?}]`. Three traps, all of
|
||||||
|
them things a real shard does and a fake one does not (`v3.md` §7.5): `maxPoints == 0` means
|
||||||
|
**uncapped** and is the *common* case, so nothing may divide by it; `nameString` is usually
|
||||||
|
`null` because most systems name themselves with a cliloc, making the humanise-the-`system`-key
|
||||||
|
path the **primary** one rather than a fallback; and `rank` is absent unless the shard runs
|
||||||
|
`PointsProfileRank=true` — absent and "unranked" are different answers.
|
||||||
|
- **Cliloc-resolved names** (`v3.md` §8.6, already live on the website): `EquipmentDto` gains
|
||||||
|
`name` + `clilocName` and `TitlesDto` gains `rewardResolved`, so equipment stops rendering as a
|
||||||
|
layer or a bare id. Precedence is `name → clilocName → layer`: a player-given name outranks the
|
||||||
|
resolved type name, and the server applies the same order. A shard with no cliloc table
|
||||||
|
configured sends neither field and the sheet renders exactly as it does today.
|
||||||
|
- `ActorDto` keeps its `acct` / `webId` fields (nullable, so nothing breaks) but its KDoc stops
|
||||||
|
describing them as available: they are **locked to the admin rung**, always, and stripped from
|
||||||
|
every response below it.
|
||||||
|
- **Part 2 — the four new screens**, each hidden by its feature name in the menu:
|
||||||
|
- **Rules** — `GET /public/shard/ruleset` (`ruleset`). A `null` body means "the shard has not
|
||||||
|
published its ruleset yet", which is a different state from the feature being disabled. Every
|
||||||
|
block is optional and omitted when its system is off. **`caps.skill` / `caps.totalSkill` are in
|
||||||
|
tenths** (1000 = 100.0) and must be converted — the raw number is actively misleading, not
|
||||||
|
merely unhelpful. Live via the `world.ruleset` frame, which is on the public stream by default.
|
||||||
|
- **Leaderboards** — `GET /public/shard/points`, `/points/:system` (`leaderboards`). The same
|
||||||
|
`maxPoints`/`nameString` traps as the profile block. Live via `points.board`.
|
||||||
|
- **Market** — `GET /public/shard/market` (`q`, `minPrice`, `maxPrice`, `itemId`, `map`, `region`,
|
||||||
|
`sort`, `limit`, `offset`), `/market/meta` for the filter options + staleness, and
|
||||||
|
`/market/vendors/:serial` (`market`). Four things this screen must get right: it is the site's
|
||||||
|
first **rate-limited** public endpoint, so handle `429` the way the contact form does; the
|
||||||
|
*"prices last refreshed N minutes ago"* banner is **required, not decoration** — the shard
|
||||||
|
sweeps vendors round-robin, so a listing can legitimately be a full cycle stale and a page
|
||||||
|
implying live prices sends people to an item that sold twenty minutes ago; a `truncated` shop
|
||||||
|
must say so; and `location` is a **nested object** that an admin may gate away entirely, which
|
||||||
|
the vendor screen renders as "hidden by the shard" (a real answer) rather than as blank
|
||||||
|
coordinates — same for `ownerName` / `ownerSerial`. **The `market` SSE fan-out is off by
|
||||||
|
default** (a live firehose of vendor inventories would be the site's biggest bandwidth
|
||||||
|
consumer), so the screen is a plain paginated read and must never depend on live frames.
|
||||||
|
- **Atlas** — `GET /public/atlas/{creatures,creatures/:slug,regions,landmarks,champions,meta}`
|
||||||
|
(`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard
|
||||||
|
*content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like
|
||||||
|
`/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar.
|
||||||
|
Three traps. Two are units/naming, from `v3.md` §6.3: respawn delays are **seconds**
|
||||||
|
throughout, and `points` is a *count* on the search route while `spawners` is the *list* on
|
||||||
|
the detail route. The third is a **shape**: `places` is a list of
|
||||||
|
`{facet, label, spawners, maxAlive}` **objects**, not of place-name strings — it is the
|
||||||
|
aggregate the screen exists to show ("Shrines, Isamu-Jima, Yew"), it arrives only on the
|
||||||
|
detail route, and typing it `List<String>` makes that whole route fail to decode while the
|
||||||
|
request itself returns `200`.
|
||||||
|
- **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`)
|
||||||
|
against a local website on the cutover branch, per
|
||||||
|
[`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with
|
||||||
|
**every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface
|
||||||
|
instead of erroring on it. Unit tests cover the menu filter (role × feature set), the
|
||||||
|
`404`/`403`/`503` mapping, and DTO decode for each new shape. **Decode tests must feed real
|
||||||
|
captured JSON**, not DTOs built in Kotlin: the fakes under `data/api/fake/` construct objects
|
||||||
|
directly, so they can never catch a wire/type mismatch — which is how the `places` shape above
|
||||||
|
shipped past a green suite.
|
||||||
|
- **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard
|
||||||
|
Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot
|
||||||
|
config, uo-link config and OAuth-provider setup.
|
||||||
|
|
||||||
|
13. **M12 — Admin theming & navigation parity** (post-v1; scoped 2026-08-08). The website merged
|
||||||
|
runtime admin theming, brand assets and nav overrides to `main` (website#126 / docs#109). The app
|
||||||
|
reads exactly one field of it — `brand.accent` — and renders a hardcoded `APP_MENU`, so an admin
|
||||||
|
who re-skins the site and restructures the header sees none of it on the phone. This milestone
|
||||||
|
makes the app a full consumer of that contract.
|
||||||
|
|
||||||
|
**Design of record: [`THEMING_AND_NAV.md`](./THEMING_AND_NAV.md)** — the token map, the phase
|
||||||
|
list and the locked decisions live there rather than here, mirroring how the website side kept
|
||||||
|
[`../website/THEMING_AND_NAV.md`](../website/THEMING_AND_NAV.md) separate from its own plan.
|
||||||
|
|
||||||
|
**No backend work.** Everything consumed is already live on `website/main`:
|
||||||
|
`GET /public/settings` gained `theme` (the full resolved token map) and `nav_public`, its `brand`
|
||||||
|
block now returns *effective* values, and `GET /api/v1/settings/nav` serves the admin/player
|
||||||
|
overrides to any authenticated account.
|
||||||
|
|
||||||
|
The points that shaped the plan, and that a reader of this file should know without opening it:
|
||||||
|
|
||||||
|
- **The app's palette is already the `runic-gateway` preset**, value for value — M5 was drawn
|
||||||
|
from the same `theme.css` the preset was later extracted from. So the website's governing
|
||||||
|
invariant (an untouched instance renders byte-for-byte as before) carries over as a *testable
|
||||||
|
equality assertion* on the resolved `ColorScheme`, not an approximation.
|
||||||
|
- **Radii apply as a ratio, not as literal dp.** The app's `Shapes` came from the M5 mockup and
|
||||||
|
genuinely differ from the web tokens (`medium` 12dp vs `--radius-card` 10px); a literal mapping
|
||||||
|
would restyle the untouched app the day this ships. A ratio against the `runic-gateway`
|
||||||
|
baseline makes an untouched instance a provable no-op while still tracking the admin's intent.
|
||||||
|
- **Fonts are bundled, not downloadable.** Seven families join the already-bundled Cinzel
|
||||||
|
(~1.5–2.5 MB, against a 4.2 MB signed release). Downloadable fonts were rejected: they need the
|
||||||
|
Play Store provider, so a de-Googled device silently falls back.
|
||||||
|
- **Nav overrides are keyed by *website* paths**, so the app needs a path → route table — the one
|
||||||
|
new cross-repo coupling here. Two asymmetries are decided rather than papered over: an override
|
||||||
|
for a path the app does not surface in its menu (champs / guilds / governors / houses, which
|
||||||
|
live behind the Shard hub) is **ignored**, because a nav override may never *introduce*
|
||||||
|
navigation; and the app's own entries with no web counterpart keep their coded order.
|
||||||
|
- **The gates are untouched.** `MenuAccess` and `MenuEntry.feature` still run *after* the merge,
|
||||||
|
so `hidden: false` cannot un-hide what a role or the shard's visibility config withholds — the
|
||||||
|
same boundary the website's §7 draws.
|
||||||
|
- **The authenticated navs are thinner than they look.** `nav_player` reaches two app rows and
|
||||||
|
`nav_admin` two (`/player`, `/account`, `/admin`, `/admin/moderation`); the sidebar's other
|
||||||
|
~18 rows are admin *configuration* the app excludes, and two of the app's four staff entries
|
||||||
|
are aggregates with no single web row. That is why they honor `label` and `hidden` only — and
|
||||||
|
why that phase is scheduled **last and marked optional**, so it can be dropped on its merits
|
||||||
|
once the rest is working.
|
||||||
|
- **Excluded**, in the same class as M10's and M11's exclusions: the admin *configuration* panels
|
||||||
|
themselves. The app does not gain Appearance or Navigation editors; it is a consumer.
|
||||||
|
|
||||||
|
Nine phases into a fresh `edge` in both repos, reaching `main` as one `edge` → `main` merge —
|
||||||
|
the same shape the website side used. Phase 0 (the contract and the appearance store) carries a
|
||||||
|
hard rule: it must change nothing on screen.
|
||||||
|
|
||||||
### Deferred (not a milestone)
|
### Deferred (not a milestone)
|
||||||
|
|
||||||
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now
|
- **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone
|
||||||
**deferred with no app work scheduled**. The website's router refactor is being done in place with
|
(2026-07-22), now **deferred with no app work scheduled**. The website's router refactor is being done in place with
|
||||||
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
|
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
|
||||||
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
|
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
|
||||||
to diverge from web, the migration comes back — starting from a one-line alias mount on the server,
|
to diverge from web, the migration comes back — starting from a one-line alias mount on the server,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ android-app/
|
|||||||
│ │ │ │ │ │ │ ├── PlayerShardDto.kt
|
│ │ │ │ │ │ │ ├── PlayerShardDto.kt
|
||||||
│ │ │ │ │ │ │ ├── PostDto.kt
|
│ │ │ │ │ │ │ ├── PostDto.kt
|
||||||
│ │ │ │ │ │ │ ├── PublicDto.kt
|
│ │ │ │ │ │ │ ├── PublicDto.kt
|
||||||
|
│ │ │ │ │ │ │ ├── ShardContentDto.kt
|
||||||
│ │ │ │ │ │ │ ├── ShardDto.kt
|
│ │ │ │ │ │ │ ├── ShardDto.kt
|
||||||
│ │ │ │ │ │ │ ├── SsoDto.kt
|
│ │ │ │ │ │ │ ├── SsoDto.kt
|
||||||
│ │ │ │ │ │ │ └── WikiDto.kt
|
│ │ │ │ │ │ │ └── WikiDto.kt
|
||||||
@@ -106,6 +107,7 @@ android-app/
|
|||||||
│ │ │ │ │ ├── NotificationsRepository.kt
|
│ │ │ │ │ ├── NotificationsRepository.kt
|
||||||
│ │ │ │ │ ├── PlayerShardRepository.kt
|
│ │ │ │ │ ├── PlayerShardRepository.kt
|
||||||
│ │ │ │ │ ├── SettingsRepository.kt
|
│ │ │ │ │ ├── SettingsRepository.kt
|
||||||
|
│ │ │ │ │ ├── ShardFeaturesRepository.kt
|
||||||
│ │ │ │ │ ├── ShardRepository.kt
|
│ │ │ │ │ ├── ShardRepository.kt
|
||||||
│ │ │ │ │ └── WikiRepository.kt
|
│ │ │ │ │ └── WikiRepository.kt
|
||||||
│ │ │ │ ├── di/
|
│ │ │ │ ├── di/
|
||||||
@@ -171,6 +173,8 @@ android-app/
|
|||||||
│ │ │ │ │ ├── session/
|
│ │ │ │ │ ├── session/
|
||||||
│ │ │ │ │ │ └── SessionViewModel.kt
|
│ │ │ │ │ │ └── SessionViewModel.kt
|
||||||
│ │ │ │ │ ├── shard/
|
│ │ │ │ │ ├── shard/
|
||||||
|
│ │ │ │ │ │ ├── AtlasScreen.kt
|
||||||
|
│ │ │ │ │ │ ├── AtlasViewModel.kt
|
||||||
│ │ │ │ │ │ ├── ChampsScreen.kt
|
│ │ │ │ │ │ ├── ChampsScreen.kt
|
||||||
│ │ │ │ │ │ ├── ChampsViewModel.kt
|
│ │ │ │ │ │ ├── ChampsViewModel.kt
|
||||||
│ │ │ │ │ │ ├── FrameFields.kt
|
│ │ │ │ │ │ ├── FrameFields.kt
|
||||||
@@ -180,7 +184,13 @@ android-app/
|
|||||||
│ │ │ │ │ │ ├── GuildsViewModel.kt
|
│ │ │ │ │ │ ├── GuildsViewModel.kt
|
||||||
│ │ │ │ │ │ ├── HousesScreen.kt
|
│ │ │ │ │ │ ├── HousesScreen.kt
|
||||||
│ │ │ │ │ │ ├── HousesViewModel.kt
|
│ │ │ │ │ │ ├── HousesViewModel.kt
|
||||||
|
│ │ │ │ │ │ ├── LeaderboardsScreen.kt
|
||||||
|
│ │ │ │ │ │ ├── LeaderboardsViewModel.kt
|
||||||
│ │ │ │ │ │ ├── LiveBoard.kt
|
│ │ │ │ │ │ ├── LiveBoard.kt
|
||||||
|
│ │ │ │ │ │ ├── MarketScreen.kt
|
||||||
|
│ │ │ │ │ │ ├── MarketViewModel.kt
|
||||||
|
│ │ │ │ │ │ ├── RulesScreen.kt
|
||||||
|
│ │ │ │ │ │ ├── RulesViewModel.kt
|
||||||
│ │ │ │ │ │ ├── ShardComponents.kt
|
│ │ │ │ │ │ ├── ShardComponents.kt
|
||||||
│ │ │ │ │ │ ├── ShardEventText.kt
|
│ │ │ │ │ │ ├── ShardEventText.kt
|
||||||
│ │ │ │ │ │ ├── ShardScreen.kt
|
│ │ │ │ │ │ ├── ShardScreen.kt
|
||||||
@@ -284,6 +294,7 @@ android-app/
|
|||||||
│ │ │ │ │ ├── PlayerShardDtoTest.kt
|
│ │ │ │ │ ├── PlayerShardDtoTest.kt
|
||||||
│ │ │ │ │ ├── PublicDtoTest.kt
|
│ │ │ │ │ ├── PublicDtoTest.kt
|
||||||
│ │ │ │ │ ├── ShardBoardDtoTest.kt
|
│ │ │ │ │ ├── ShardBoardDtoTest.kt
|
||||||
|
│ │ │ │ │ ├── ShardContentDtoTest.kt
|
||||||
│ │ │ │ │ ├── ShardDtoTest.kt
|
│ │ │ │ │ ├── ShardDtoTest.kt
|
||||||
│ │ │ │ │ ├── SsoDtoTest.kt
|
│ │ │ │ │ ├── SsoDtoTest.kt
|
||||||
│ │ │ │ │ └── WikiDtoTest.kt
|
│ │ │ │ │ └── WikiDtoTest.kt
|
||||||
@@ -294,7 +305,8 @@ android-app/
|
|||||||
│ │ │ │ └── FakeShardStream.kt
|
│ │ │ │ └── FakeShardStream.kt
|
||||||
│ │ │ └── repository/
|
│ │ │ └── repository/
|
||||||
│ │ │ ├── AccountTrustedDevicesTest.kt
|
│ │ │ ├── AccountTrustedDevicesTest.kt
|
||||||
│ │ │ └── ConnectionVersionGuardTest.kt
|
│ │ │ ├── ConnectionVersionGuardTest.kt
|
||||||
|
│ │ │ └── ShardFeaturesRepositoryTest.kt
|
||||||
│ │ ├── ui/
|
│ │ ├── ui/
|
||||||
│ │ │ ├── admin/
|
│ │ │ ├── admin/
|
||||||
│ │ │ │ ├── AdminContentViewModelTest.kt
|
│ │ │ │ ├── AdminContentViewModelTest.kt
|
||||||
@@ -304,7 +316,8 @@ android-app/
|
|||||||
│ │ │ ├── contact/
|
│ │ │ ├── contact/
|
||||||
│ │ │ │ └── ContactViewModelTest.kt
|
│ │ │ │ └── ContactViewModelTest.kt
|
||||||
│ │ │ ├── navigation/
|
│ │ │ ├── navigation/
|
||||||
│ │ │ │ └── MenuAccessTest.kt
|
│ │ │ │ ├── MenuAccessTest.kt
|
||||||
|
│ │ │ │ └── MenuFeatureGatingTest.kt
|
||||||
│ │ │ ├── notifications/
|
│ │ │ ├── notifications/
|
||||||
│ │ │ │ └── NotificationRoutingTest.kt
|
│ │ │ │ └── NotificationRoutingTest.kt
|
||||||
│ │ │ ├── player/
|
│ │ │ ├── player/
|
||||||
@@ -315,6 +328,8 @@ android-app/
|
|||||||
│ │ │ │ ├── FrameFieldsTest.kt
|
│ │ │ │ ├── FrameFieldsTest.kt
|
||||||
│ │ │ │ ├── LiveBoardTest.kt
|
│ │ │ │ ├── LiveBoardTest.kt
|
||||||
│ │ │ │ ├── ShardBoardViewModelTest.kt
|
│ │ │ │ ├── ShardBoardViewModelTest.kt
|
||||||
|
│ │ │ │ ├── ShardContentHelpersTest.kt
|
||||||
|
│ │ │ │ ├── ShardContentViewModelTest.kt
|
||||||
│ │ │ │ └── ShardEventTextTest.kt
|
│ │ │ │ └── ShardEventTextTest.kt
|
||||||
│ │ │ ├── theme/
|
│ │ │ ├── theme/
|
||||||
│ │ │ │ └── BrandColorTest.kt
|
│ │ │ │ └── BrandColorTest.kt
|
||||||
|
|||||||
502
android/THEMING_AND_NAV.md
Normal file
502
android/THEMING_AND_NAV.md
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
# Android: honoring admin-configurable theming & navigation
|
||||||
|
|
||||||
|
> Build contract for the Android client's half of the feature shipped in
|
||||||
|
> [`docs/website/THEMING_AND_NAV.md`](../website/THEMING_AND_NAV.md).
|
||||||
|
> Same workflow as the website side: design → phased build → verify.
|
||||||
|
> Milestone **M12**; see [`PLAN.md`](./PLAN.md) §9.
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
The website merged runtime admin theming, brand assets and navigation overrides
|
||||||
|
to `main` (website#126 / docs#109). An admin who re-skins the site from
|
||||||
|
Admin → Appearance and restructures the header from Admin → Navigation currently
|
||||||
|
sees **none of it on the phone**: the app reads exactly one field, `brand.accent`,
|
||||||
|
and renders a hardcoded `APP_MENU`.
|
||||||
|
|
||||||
|
This milestone makes the app a full consumer of that contract:
|
||||||
|
|
||||||
|
1. **Theme** — the whole resolved color palette, the corner-radius scale, the
|
||||||
|
shadow depth, and the font choice.
|
||||||
|
2. **Brand assets** — the uploaded logo and hero, which the app has modeled in
|
||||||
|
`BrandDto` since M1 and has never rendered.
|
||||||
|
3. **Navigation** — the public header's labels, order, hidden entries, dropdown
|
||||||
|
sections and admin-added links, plus the label/hidden overrides for the
|
||||||
|
player and staff surfaces.
|
||||||
|
|
||||||
|
## 2. Core principle: the shipped app is the default, always
|
||||||
|
|
||||||
|
The website's governing invariant is that an instance with no settings rows
|
||||||
|
renders byte-for-byte as it did before the feature existed. **The app inherits
|
||||||
|
that invariant unchanged**, and it is unusually cheap to honor here because of a
|
||||||
|
fact worth stating plainly:
|
||||||
|
|
||||||
|
> **The app's `ui/theme/Color.kt` palette is already, value for value, the
|
||||||
|
> `runic-gateway` preset.** All fifteen themable tokens match. The M5 design pass
|
||||||
|
> was drawn from the same `theme.css` the preset was later extracted from.
|
||||||
|
|
||||||
|
So the fallback for every color is not a "close enough" approximation — it is the
|
||||||
|
identical value. A shard with no `theme_visual` row must produce a `ColorScheme`
|
||||||
|
that is `==` to today's `ShardColorScheme`, and that is a testable claim, not an
|
||||||
|
aspiration. It is locked by a test (§7, AC-1).
|
||||||
|
|
||||||
|
The same asymmetry the server uses applies on the client: **forgiving on read.**
|
||||||
|
A token that is missing, malformed, or unknown falls back field-by-field to the
|
||||||
|
shipped value. A bad `--accent` must not discard a good `--bg` beside it, and a
|
||||||
|
settings call that fails is the same state as "no overrides" — never an error
|
||||||
|
screen, never a half-painted theme.
|
||||||
|
|
||||||
|
## 3. What the server already publishes
|
||||||
|
|
||||||
|
No backend work. Everything below is live on `website/main` today.
|
||||||
|
|
||||||
|
| Source | Field | Shape |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /public/settings` | `theme` | `Record<cssVar, string>` — 15 colors, 4 radii, `--shadow-card`, 3 font stacks. **Absent** when no row exists |
|
||||||
|
| `GET /public/settings` | `brand.accent` / `.logo` / `.hero` / `.favicon` | Already **effective** values (override → env). The app reads `accent` today |
|
||||||
|
| `GET /public/settings` | `nav_public` | Raw JSON **string**: a bare items map, or `{items, sections, links}` |
|
||||||
|
| `GET /api/v1/settings/nav` | `nav_admin`, `nav_player` | Raw JSON strings. Gate is `requireAuth`, **no role check** — a player may read it |
|
||||||
|
|
||||||
|
Two shapes to get right on the wire:
|
||||||
|
|
||||||
|
- `nav_public` is a **JSON string inside a JSON object**, because `settings.value`
|
||||||
|
is `TEXT`. It is parsed a second time, exactly as the web client's
|
||||||
|
`parseJsonSetting` does.
|
||||||
|
- `theme` being **absent** and `theme` being `{}` are the same thing to the app,
|
||||||
|
and both mean "shipped defaults". The server never emits an empty map
|
||||||
|
(`resolveThemeTokens` returns `null` instead), but the app must not depend on
|
||||||
|
that.
|
||||||
|
|
||||||
|
**The trap in this payload: read the resolved fields, never the raw rows.**
|
||||||
|
`theme_visual` and `brand_assets` are in `PUBLIC_KEYS`, so their raw JSON strings
|
||||||
|
ride along in the same response as `theme` and `brand`. They are *inputs* — a
|
||||||
|
preset id and a sparse custom overlay — and re-deriving a palette from them would
|
||||||
|
be a second implementation of `resolveThemeTokens`, in Kotlin, guaranteed to
|
||||||
|
drift the first time a preset changes. The app consumes `theme` and `brand`,
|
||||||
|
which the server has already layered `:root ← preset ← custom` for it, and models
|
||||||
|
neither raw key. `nav_public` is the one raw row the app does read, because there
|
||||||
|
is no resolved counterpart — the merge is the *client's* job on the web too.
|
||||||
|
|
||||||
|
## 4. Locked decisions
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Theme depth | **Colors, radii, shadow and fonts** — the full token set, not accent-only |
|
||||||
|
| Fonts | **Bundle the families**, do not use downloadable fonts — see §5.3 |
|
||||||
|
| Radii | Applied as a **ratio against the `runic-gateway` baseline**, not as literal dp — see §5.2 |
|
||||||
|
| Semantic color | `--mode-live` / `--mode-maint` and the app's success/warning/danger pills stay **fixed**, never themed. Mirrors the server's `FIXED_TOKENS` |
|
||||||
|
| Light mode | Still **out of scope**. Every v1 preset is dark; the website's Parchment preset was cancelled (website §8 phase 9). The app stays dark-only, and `Theme.kt` keeps its single `darkColorScheme` |
|
||||||
|
| Favicon | **No app surface.** Ignored, and not modeled |
|
||||||
|
| Added links | A path matching a known app route opens the **native screen**; anything else hands off to a **Custom Tab** — see §6.3 |
|
||||||
|
| `nav_admin` / `nav_player` | **`label` and `hidden` only.** No order, no group — see §6.4 |
|
||||||
|
| Refresh | On connect, on **process start**, and on **resume** alongside the existing role re-validation — see §5.5 |
|
||||||
|
| Failure posture | Forgiving on read, field by field. A failed settings call renders the shipped app, never an error |
|
||||||
|
|
||||||
|
## 5. Theme
|
||||||
|
|
||||||
|
### 5.1 Colors — the 15-token map
|
||||||
|
|
||||||
|
Every themable token has exactly one home in the app palette. This table is the
|
||||||
|
contract; `ui/theme/Color.kt`'s current constants are its right-hand column.
|
||||||
|
|
||||||
|
| CSS token | App constant | Shipped value | Material role(s) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--bg` | `ShardSurface` | `#0E1318` | `surface`, `surfaceContainerLow` |
|
||||||
|
| `--bg-deep` | `ShardPage` | `#0B0F14` | `background` |
|
||||||
|
| `--panel-a` | `ShardCardTop` | `#192231` | feature-card gradient top |
|
||||||
|
| `--panel-b` | `ShardCardBottom` | `#141A21` | feature-card gradient bottom |
|
||||||
|
| `--panel-flat` | `ShardElevated` | `#11161D` | `surfaceVariant`, `surfaceContainer`, `surfaceContainerHigh` |
|
||||||
|
| `--line` | `ShardOutline` | `#2A3544` | `outline` |
|
||||||
|
| `--line-soft` | `ShardDivider` | `#1D2733` | `outlineVariant` |
|
||||||
|
| `--accent` | `ShardAccent` | `#7F99BD` | `secondary`, `tertiary` |
|
||||||
|
| `--accent-bright` | `ShardCta` | `#CDD9E8` | `primary`, `onSecondaryContainer` |
|
||||||
|
| `--ink` | `ShardHeading` | `#EEF3F8` | brightest headings |
|
||||||
|
| `--head` | `ShardHeadingDim` | `#E6EDF6` | heading on surface |
|
||||||
|
| `--text` | `ShardBody` | `#C4CDD8` | `onBackground`, `onSurface` |
|
||||||
|
| `--muted` | `ShardMuted` | `#AEB8C4` | `onSurfaceVariant` |
|
||||||
|
| `--dim` | `ShardFaint` | `#6F7D8E` | meta / faint labels |
|
||||||
|
| `--blue` | `ShardPillBg` | `#13243C` | `secondaryContainer` |
|
||||||
|
|
||||||
|
`ShardOnCta` (`#0B0F14`) is **derived**, not themed: it is text drawn on the
|
||||||
|
`--accent-bright` fill, and it tracks `--bg-deep`. This mirrors the server's
|
||||||
|
derived-token rule for `--panel-grad` — a value expressed in terms of another
|
||||||
|
token must never be frozen as a literal, or a future light preset inherits a dark
|
||||||
|
one and looks broken.
|
||||||
|
|
||||||
|
**Two consumers, one resolution.** Ten of these fifteen have a Material role;
|
||||||
|
five do not — `ShardCardTop`, `ShardCardBottom`, `ShardHeading`, `ShardHeadingDim`
|
||||||
|
and `ShardFaint`. So the resolved palette is a single `ShardPalette` data class,
|
||||||
|
provided two ways:
|
||||||
|
|
||||||
|
- fed into `darkColorScheme(...)` for the Material roles;
|
||||||
|
- exposed as a `LocalShardPalette` CompositionLocal for the rest.
|
||||||
|
|
||||||
|
Today those non-Material colors are imported as top-level `val`s straight from
|
||||||
|
`Color.kt`. That surface was measured before scoping the phase, and it is
|
||||||
|
**smaller than it looks** — two files, sixteen imports:
|
||||||
|
|
||||||
|
| file | imports | themable | semantic (stay fixed) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ui/components/ThemeComponents.kt` | 14 | `ShardCardTop`, `ShardCardBottom`, `ShardElevated`, `ShardFaint`, `ShardOutline`, `ShardPillBg`, `ShardPillFg` | the 7 success/warning/danger constants |
|
||||||
|
| `ui/shard/ShardComponents.kt` | 2 | — | `ShardSuccess`, `ShardSuccessDot` |
|
||||||
|
|
||||||
|
So the migration is **one file and seven constants**; nothing else in the app
|
||||||
|
reaches past `MaterialTheme.colorScheme`. That is the M5 design's premise paying
|
||||||
|
off — the ~20 screens take the new palette through the `ColorScheme` swap with no
|
||||||
|
per-screen work, which is exactly why this milestone is affordable.
|
||||||
|
|
||||||
|
It is still the phase's correctness risk rather than its bulk: leaving a direct
|
||||||
|
`ShardCardTop` import behind is a card that stays blue on a Fantasy shard, and
|
||||||
|
nothing fails to compile. A grep for `ui.theme.Shard` imports outside
|
||||||
|
`ui/theme/` — expected to return only the semantic constants once phase 1
|
||||||
|
lands — is the cheap check, and belongs in the phase's PR description.
|
||||||
|
|
||||||
|
**Accent handling changes.** `RunicGatewayTheme(accent: Color?)` currently copies
|
||||||
|
one color onto `primary`/`secondary`/`tertiary`. That was a reasonable stand-in
|
||||||
|
for a one-field contract and is now wrong twice over: it puts `--accent` on
|
||||||
|
`primary`, which the table above assigns to `--accent-bright`, and it ignores the
|
||||||
|
other fourteen. It is replaced by `RunicGatewayTheme(appearance: SiteAppearance)`.
|
||||||
|
`brand.accent` remains the fallback for `--accent` when `theme` is absent but the
|
||||||
|
env accent is set — which is exactly the pre-feature branding path, and must keep
|
||||||
|
working.
|
||||||
|
|
||||||
|
### 5.2 Radii — a ratio, not a literal
|
||||||
|
|
||||||
|
The app's `Shapes` came from the M5 mockup, not from `theme.css`, and the two
|
||||||
|
scales genuinely differ:
|
||||||
|
|
||||||
|
| | web | app |
|
||||||
|
|---|---|---|
|
||||||
|
| input / chip | `--radius-input` 8px | `extraSmall`/`small` 8dp |
|
||||||
|
| card | `--radius-card` 10px | `medium` **12**dp |
|
||||||
|
| panel | `--radius-panel` 12px | `large` **16**dp |
|
||||||
|
| — | — | `extraLarge` 24dp |
|
||||||
|
| pill | `--radius-pill` 999px | `CircleShape` at call sites |
|
||||||
|
|
||||||
|
A literal mapping would restyle the untouched app the moment this milestone
|
||||||
|
ships — `medium` 12→10, `large` 16→12 — which §2 forbids. Copying the app's
|
||||||
|
scale into the server is worse: a second source of truth.
|
||||||
|
|
||||||
|
**Decision: apply the radii as a ratio.** For each of the four fields compute
|
||||||
|
`resolved ÷ runic-gateway baseline`, then scale the app's own shipped dp value by
|
||||||
|
it. Consequences, all of them wanted:
|
||||||
|
|
||||||
|
- an untouched instance, or one that explicitly picks `runic-gateway`, gives
|
||||||
|
ratio `1.0` for all four and is a **provable no-op**;
|
||||||
|
- Fantasy (`--radius-panel: 3px`) → ratio `0.25` → `large` 16dp → 4dp: sharp
|
||||||
|
corners, at the app's own scale;
|
||||||
|
- Modern (8px) → `0.667` → 12dp;
|
||||||
|
- `extraLarge` has no web counterpart and follows `--radius-panel`'s ratio, since
|
||||||
|
it is the panel family;
|
||||||
|
- `--radius-pill` at 999 keeps `CircleShape`; below ~50% of baseline it resolves
|
||||||
|
to a rounded rect, so an admin who squares the site off squares off the app's
|
||||||
|
chips too.
|
||||||
|
|
||||||
|
Round to whole dp and clamp at 0.
|
||||||
|
|
||||||
|
### 5.3 Fonts — bundled, mapped by first family
|
||||||
|
|
||||||
|
The shortlist is 12 options across three roles, spanning **eight** families:
|
||||||
|
|
||||||
|
- **serif** — EB Garamond, Merriweather, Playfair Display, IM Fell English, Georgia*
|
||||||
|
- **display** — Cinzel, Playfair Display, EB Garamond, IM Fell English
|
||||||
|
- **sans** — Inter, Work Sans, Source Sans 3, Helvetica Neue / Arial*
|
||||||
|
|
||||||
|
\* system stacks with no webfont; on Android these resolve to the platform
|
||||||
|
`FontFamily.Serif` / `FontFamily.SansSerif`, which is what the app uses today.
|
||||||
|
|
||||||
|
**Cinzel is already bundled** (`res/font/cinzel_variable.ttf`, M5). Seven more
|
||||||
|
are added: EB Garamond, Merriweather, Playfair Display, IM Fell English, Inter,
|
||||||
|
Work Sans, Source Sans 3. All SIL OFL; each needs its license file under
|
||||||
|
`app/licenses/`, **not** under `res/font/` (aapt rejects a `.txt` there — the M5
|
||||||
|
gotcha).
|
||||||
|
|
||||||
|
Downloadable fonts were rejected: they need the Play Store font provider, so a
|
||||||
|
de-Googled device silently falls back, and every text style gains an async
|
||||||
|
loading state.
|
||||||
|
|
||||||
|
Resolution is by **the first family name in the stack**, which is how the value
|
||||||
|
is constructed server-side and the only part that carries the choice:
|
||||||
|
|
||||||
|
```
|
||||||
|
"'EB Garamond', Georgia, serif" → EBGaramond
|
||||||
|
"Cinzel, Georgia, serif" → Cinzel
|
||||||
|
"Georgia, \"Times New Roman\", serif" → FontFamily.Serif (system)
|
||||||
|
"\"Helvetica Neue\", Arial, sans-serif" → FontFamily.SansSerif (system)
|
||||||
|
<anything unrecognized> → the role's shipped family
|
||||||
|
```
|
||||||
|
|
||||||
|
The three roles map onto `Type.kt`'s existing three groups verbatim:
|
||||||
|
`--display` → the Cinzel display/headline/title block, `--serif` → the `AppSerif`
|
||||||
|
body block, `--sans` → the `AppSans` label block. Sizes, weights and tracking do
|
||||||
|
not move — only the family.
|
||||||
|
|
||||||
|
**IM Fell English has no bold weight** (the website doc records the same). A
|
||||||
|
`FontWeight.Bold` request against it must resolve to its single weight rather
|
||||||
|
than synthesize; check what Compose does here on device and pin the behavior in
|
||||||
|
the phase's notes.
|
||||||
|
|
||||||
|
APK cost: roughly **1.5–2.5 MB** across seven families, variable-axis where Google
|
||||||
|
Fonts publishes one (Cinzel, EB Garamond, Merriweather, Playfair Display, Inter,
|
||||||
|
Work Sans, Source Sans 3) and single-weight for IM Fell English. Measure the
|
||||||
|
release APK before and after, and record both numbers in the PR — R8 does not
|
||||||
|
shrink `res/font/`.
|
||||||
|
|
||||||
|
### 5.4 Shadow depth
|
||||||
|
|
||||||
|
`--shadow-card` is one of four closed values. Compose has no CSS box-shadow, so
|
||||||
|
it maps to card elevation:
|
||||||
|
|
||||||
|
| stored value | elevation |
|
||||||
|
|---|---|
|
||||||
|
| `none` | 0dp |
|
||||||
|
| `0 8px 20px rgba(0,0,0,0.25)` (Soft) | 2dp |
|
||||||
|
| `0 14px 34px rgba(0,0,0,0.3)` (Default) | 4dp |
|
||||||
|
| `0 18px 44px rgba(0,0,0,0.45)` (Deep) | 8dp |
|
||||||
|
|
||||||
|
Matched by exact string against the server's `SHADOW_OPTIONS`; anything else is
|
||||||
|
the shipped default. Applied to `FeatureCard` and the Material `Card` defaults.
|
||||||
|
|
||||||
|
### 5.5 When the appearance is (re-)read
|
||||||
|
|
||||||
|
Today `AppViewModel.loadBrand()` calls `GET /public/settings` **once**, on
|
||||||
|
process start or on connect, and holds a `BrandDto`. That becomes a
|
||||||
|
`SiteAppearance` — `{brand, theme, navPublic}` — held in the same place and
|
||||||
|
refreshed:
|
||||||
|
|
||||||
|
- **on connect** and **on process start** (as today);
|
||||||
|
- **on resume**, beside the existing `sessionViewModel.revalidate()`. An admin
|
||||||
|
changing the theme on a laptop and picking the phone up should see it, and the
|
||||||
|
app already pays for a resume round-trip.
|
||||||
|
|
||||||
|
The authenticated `GET /api/v1/settings/nav` is fetched only when the session is
|
||||||
|
signed in, and re-fetched when the session changes — the same lifecycle
|
||||||
|
`ShardFeaturesRepository` already uses (M11). Signing out drops the cached admin
|
||||||
|
and player overrides.
|
||||||
|
|
||||||
|
Every one of these is best-effort. A failed refresh keeps the last good
|
||||||
|
appearance; there is no loading state and no error surface.
|
||||||
|
|
||||||
|
### 5.6 Brand assets — the logo and the hero
|
||||||
|
|
||||||
|
`brand.logo` and `brand.hero` have been in `BrandDto` since M1 and have **never
|
||||||
|
been rendered**; the app draws `brand.name` as text everywhere the website draws
|
||||||
|
a logo. Nothing new is needed to fetch them — they already arrive resolved, and
|
||||||
|
`AppViewModel.resolveAsset` / `LocalAssetResolver` already turn a site-relative
|
||||||
|
`/uploads/…` path into an absolute URL. Coil is already a dependency.
|
||||||
|
|
||||||
|
Two surfaces, chosen to mirror the website's without inventing new layout:
|
||||||
|
|
||||||
|
- **the drawer header**, above the instance name that sits there today;
|
||||||
|
- **the top bar**, replacing the uppercased name when a logo exists.
|
||||||
|
|
||||||
|
And the hero on **Home**, above the title block, which is the one screen with a
|
||||||
|
hero-shaped space.
|
||||||
|
|
||||||
|
The M5 `BrandLogo` rule carries over: **render nothing when the slot is empty.**
|
||||||
|
Not a placeholder, not a reserved gap — an instance with no uploaded logo must
|
||||||
|
lay out exactly as it does today, which is §2 applied to assets. On the centered
|
||||||
|
surfaces the website stacks the logo *above* rather than beside, for the same
|
||||||
|
reason: a row would change the block's height on instances that have no logo.
|
||||||
|
|
||||||
|
An asset that fails to load is the same as no asset. No broken-image icon, no
|
||||||
|
retry.
|
||||||
|
|
||||||
|
## 6. Navigation
|
||||||
|
|
||||||
|
### 6.1 The hard constraint carries over
|
||||||
|
|
||||||
|
The website's §7 constraint is a security boundary and it survives verbatim here,
|
||||||
|
with one clarification the app makes concrete: **an override is presentation.**
|
||||||
|
|
||||||
|
The app's two gates — `MenuAccess` against the session, and `MenuEntry.feature`
|
||||||
|
against `GET /public/shard/features` (M11) — run **after** the override merge and
|
||||||
|
are unchanged by it. An override cannot introduce an app route, cannot touch
|
||||||
|
`access` or `feature`, and `hidden: false` never un-hides an entry the caller's
|
||||||
|
role or the shard's visibility config would otherwise withhold. Hiding is
|
||||||
|
subtractive, exactly as `applyNavOverrides` has it.
|
||||||
|
|
||||||
|
### 6.2 Path → app route
|
||||||
|
|
||||||
|
The public nav is keyed by **website** paths. The app needs a mapping table, and
|
||||||
|
it is the one new piece of cross-repo coupling this milestone introduces — so it
|
||||||
|
lives in one file with the website's `NAV` array quoted beside it.
|
||||||
|
|
||||||
|
| website `to` | app route | note |
|
||||||
|
|---|---|---|
|
||||||
|
| `/` | `Routes.HOME` | |
|
||||||
|
| `/site/news` | `Routes.NEWS` | |
|
||||||
|
| `/site/five-on-friday` | `Routes.news(FIVE_ON_FRIDAY)` | the app's News screen already has all four categories as tabs — these three select one |
|
||||||
|
| `/site/newsletter` | `Routes.news(NEWSLETTER)` | |
|
||||||
|
| `/site/screenshots` | `Routes.news(SCREENSHOTS)` | |
|
||||||
|
| `/wiki` | `Routes.WIKI` | |
|
||||||
|
| `/site/shard` | `Routes.SHARD` | `feature: status` |
|
||||||
|
| `/site/champs` | `Routes.SHARD_CHAMPS` | **not in `APP_MENU` today** — reached via the Shard hub |
|
||||||
|
| `/site/guilds` | `Routes.SHARD_GUILDS` | as above |
|
||||||
|
| `/site/governors` | `Routes.SHARD_GOVERNORS` | as above |
|
||||||
|
| `/site/houses` | `Routes.SHARD_HOUSES` | as above |
|
||||||
|
| `/site/rules` | `Routes.SHARD_RULES` | |
|
||||||
|
| `/site/atlas` | `Routes.ATLAS` | |
|
||||||
|
| `/site/leaderboards` | `Routes.SHARD_LEADERBOARDS` | |
|
||||||
|
| `/site/market` | `Routes.SHARD_MARKET` | |
|
||||||
|
| `/site/about` | `Routes.page("about")` | |
|
||||||
|
|
||||||
|
Three asymmetries to resolve rather than paper over:
|
||||||
|
|
||||||
|
- **`Routes.NEWS` takes no category argument today.** It gains an optional one so
|
||||||
|
the three category entries can land on the right tab. This is a small route
|
||||||
|
change with its own test, not a nav concern.
|
||||||
|
- **Four web entries have no `APP_MENU` row** (champs / guilds / governors /
|
||||||
|
houses — the app puts them behind the Shard hub, which is the better phone
|
||||||
|
shape and stays). An override for one of them therefore has a mapped route but
|
||||||
|
no menu entry. **Rule: an override for a path the app does not surface in its
|
||||||
|
menu is ignored**, exactly as the web drops an override for an unknown `to`.
|
||||||
|
It is *not* an invitation to add the entry — the hub is a deliberate design
|
||||||
|
choice, and a nav override may not introduce navigation.
|
||||||
|
- **Ten app entries have no `nav_public` counterpart** — Contact, Account and
|
||||||
|
Notifications, the three player groups, and the four staff rows. They are
|
||||||
|
unaffected by `nav_public` and keep their coded order, appended after the
|
||||||
|
overridden public block in the drawer. (They already sit below the public
|
||||||
|
entries today, so this is the current layout, not a new one.) A few of them are
|
||||||
|
instead reachable through `nav_admin` / `nav_player` — but fewer than you would
|
||||||
|
expect, which is §6.4's subject.
|
||||||
|
|
||||||
|
### 6.3 Sections and added links
|
||||||
|
|
||||||
|
`nav_public` may carry `sections` and `links` (website phase 10). Both land in
|
||||||
|
the drawer:
|
||||||
|
|
||||||
|
- **A section** renders as a drawer group with its label as a header and its
|
||||||
|
members indented beneath — the drawer's natural idiom. The website's
|
||||||
|
click-to-open dropdown does not translate and is not copied; a drawer is
|
||||||
|
already a vertical list.
|
||||||
|
- **`pruneNav`'s rule is ported and is load-bearing**: a section whose every
|
||||||
|
member is hidden by the role or feature gate must not render as an empty
|
||||||
|
header. The app's port drops it.
|
||||||
|
- **An added link** carries no gate and always shows, matching the web. Its `to`
|
||||||
|
is validated the same way the web validates it on read — must start with a
|
||||||
|
single `/`, no `//`, no whitespace or quote characters — and a value failing
|
||||||
|
that is dropped rather than rendered.
|
||||||
|
|
||||||
|
An added link **opens natively when its path maps to an app route**, and hands
|
||||||
|
off to a Custom Tab otherwise. The patterns the app can resolve:
|
||||||
|
|
||||||
|
```
|
||||||
|
/ → HOME
|
||||||
|
/site/news|five-on-friday|newsletter|screenshots → NEWS (category)
|
||||||
|
/site/news/<idOrSlug> → POST
|
||||||
|
/wiki → WIKI
|
||||||
|
/wiki/<slug> → WIKI_PAGE
|
||||||
|
/site/<shard surface> → the mapped shard route (per §6.2)
|
||||||
|
/site/about, /page/<slug> → PAGE
|
||||||
|
/contact → CONTACT
|
||||||
|
anything else → WebHandoff (Custom Tab), M3's existing hand-off
|
||||||
|
```
|
||||||
|
|
||||||
|
A native match still passes through the app's own gates: an added link to
|
||||||
|
`/site/market` on a shard that does not publish the market lands on the Market
|
||||||
|
screen's honest "not published here" state (M11's `FEATURE_UNAVAILABLE`), which
|
||||||
|
is what typing the URL on the web does too. The link itself is not gated — that
|
||||||
|
is the website's decision and the app does not second-guess it.
|
||||||
|
|
||||||
|
### 6.4 `nav_admin` and `nav_player` — label and hidden only
|
||||||
|
|
||||||
|
Both are bare maps and neither carries sections or links. The app honors
|
||||||
|
**`label` and `hidden`, and ignores `order` and `group`.**
|
||||||
|
|
||||||
|
The reason is that the app's rows are a small and *differently shaped* subset —
|
||||||
|
and the actual overlap was measured before scoping the phase, because it turned
|
||||||
|
out to be thinner than the milestone assumed:
|
||||||
|
|
||||||
|
| website `to` | app route | |
|
||||||
|
|---|---|---|
|
||||||
|
| `/player` | `PLAYER_CHARACTERS` | ✅ |
|
||||||
|
| `/account` | `ACCOUNT` | ✅ |
|
||||||
|
| `/account/appeals` | — | the app has no appeals screen at all |
|
||||||
|
| `/admin` | `ADMIN_DASHBOARD` | ✅ |
|
||||||
|
| `/admin/moderation` | `ADMIN_MODERATION` | ✅ |
|
||||||
|
| — | `ADMIN_CONTENT` | an app-side aggregate of the website's separate Posts / Pages / Wiki / Activity rows |
|
||||||
|
| — | `ADMIN_SUPPORT` | likewise; the nearest web row is `/admin/moderation/appeals`, which is not the same screen |
|
||||||
|
| — | `PLAYER_VENDORS`, `PLAYER_HOUSES` | no player-portal row on the web |
|
||||||
|
| — | `NOTIFICATIONS`, `CONTACT` | app-only surfaces |
|
||||||
|
|
||||||
|
**So `nav_player` reaches two app rows and `nav_admin` reaches two.** The website
|
||||||
|
sidebar's other ~18 rows are admin *configuration* the app deliberately excludes
|
||||||
|
(M10/M11), and two of the app's four staff entries are aggregates with no single
|
||||||
|
web row to be renamed from.
|
||||||
|
|
||||||
|
That is the whole case for label-and-hidden-only. Reordering two rows against a
|
||||||
|
foreign order of twenty-two is noise, and `group` names sections the app does not
|
||||||
|
render. Renaming "Characters" or hiding it is still a real intent that should
|
||||||
|
reach the phone, and four rows' worth of it is worth one cached call.
|
||||||
|
|
||||||
|
**It is also the case for questioning whether phase 7 is worth building at all.**
|
||||||
|
Four rows is a thin return for a new authenticated fetch, a session-keyed cache
|
||||||
|
and its teardown. It is scheduled last precisely so that decision can be taken
|
||||||
|
with the rest of the milestone already working — dropping it costs nothing that
|
||||||
|
phases 0–6 depend on. Unmapped keys are ignored either way.
|
||||||
|
|
||||||
|
**A label override replaces a `@StringRes`.** `MenuEntry.labelRes` is an int; the
|
||||||
|
resolved entry carries `label: String?` beside it and the drawer prefers it. That
|
||||||
|
means an admin's label is **not localized** — it is one string for every locale,
|
||||||
|
which is what an admin typing a label means, and matches the website.
|
||||||
|
|
||||||
|
## 7. Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-1 — the no-op proof.** With `theme` absent, `nav_public` absent and no
|
||||||
|
`brand_assets`, the resolved `ColorScheme`, `Shapes`, `Typography` and drawer
|
||||||
|
entry list are **equal** to today's shipped values. A unit test asserts the
|
||||||
|
full `ColorScheme` equality, not a spot check.
|
||||||
|
- **AC-2 — per-field fallback.** A `theme` map carrying one valid token and four
|
||||||
|
malformed ones applies the one and falls back on the four.
|
||||||
|
- **AC-3 — the gates still hold.** An override marking a feature-gated or
|
||||||
|
role-gated entry `hidden: false` shows nothing to a caller who fails that gate.
|
||||||
|
A section whose members are all gated out does not render.
|
||||||
|
- **AC-4 — degradation.** With the settings call failing, the app renders the
|
||||||
|
shipped theme and the coded menu, with no error surface.
|
||||||
|
- **AC-5 — on-device.** Two passes on the AVD against a local website:
|
||||||
|
- one against an instance themed **Fantasy**, with a reordered and sectioned
|
||||||
|
nav, one added link of each kind (native-mapped and Custom-Tab), and an
|
||||||
|
uploaded logo and hero;
|
||||||
|
- one against an **untouched** instance, confirming AC-1 by eye as well as by
|
||||||
|
test — this is the pass that catches a token a screen never read.
|
||||||
|
|
||||||
|
The role dimension reuses the existing five-rung walk (`anonymous`,
|
||||||
|
`logged_in`, `player`, `staff`, `admin`) from [`../link/v3.md`](../link/v3.md)
|
||||||
|
§11, since §6.1's whole claim is that the override merge does not disturb the
|
||||||
|
gates.
|
||||||
|
|
||||||
|
## 8. Build phases
|
||||||
|
|
||||||
|
Every phase targets **`edge`** in `Android-app/` and `docs/`, cut fresh from
|
||||||
|
`main` in both. The feature reaches `main` as **one `edge` → `main` merge** when
|
||||||
|
all phases are done — the same shape the website side used. Do not open a phase
|
||||||
|
PR against `main`.
|
||||||
|
|
||||||
|
| # | Phase | Ships |
|
||||||
|
|---|---|---|
|
||||||
|
| **0** | **Contract & appearance store** | `SettingsDto` gains `theme: Map<String,String>?` and `nav_public: String?`; `SiteAppearance` replaces the bare `BrandDto` in `AppViewModel`; second-stage JSON parse; resume refresh (§5.5). **No visual change** — this phase must be invisible |
|
||||||
|
| **1** | **Colors** | `ShardPalette` + `LocalShardPalette`; all direct `Color.kt` imports migrated; `RunicGatewayTheme(appearance)`; AC-1 + AC-2 tests |
|
||||||
|
| **2** | **Radii & shadow** | Ratio-scaled `Shapes` (§5.2), elevation map (§5.4) |
|
||||||
|
| **3** | **Fonts** | Seven bundled families + licenses; stack → `FontFamily` resolution; `Type.kt` takes its three families from the resolved theme. APK size recorded |
|
||||||
|
| **4** | **Brand assets** | Logo in the drawer header and top bar, hero on Home (§5.6). Coil + `LocalAssetResolver` already exist; renders nothing when unset |
|
||||||
|
| **5** | **Public nav: label / order / hidden** | The path→route table (§6.2), `Routes.news(category)`, the merge, drawer wiring. AC-3 |
|
||||||
|
| **6** | **Public nav: sections & added links** | Drawer groups, `pruneNav` port, link path validation, native-route resolution + Custom Tab fallback (§6.3) |
|
||||||
|
| **7** | **Authenticated navs** | `GET /api/v1/settings/nav` behind a session-keyed repository; label/hidden for the four mapped rows (§6.4). **Optional — reconsider before starting it** |
|
||||||
|
| **8** | **Docs, coverage & cutover** | This doc's "as landed" notes and any amendments the build forces, the `PLAN.md` §9 M12 entry refreshed, Sonar coverage for the new modules, AC-5 on-device walk, then `edge` → `main` |
|
||||||
|
|
||||||
|
Phase 0 is the one with a hard rule attached: **it must change nothing on
|
||||||
|
screen.** Everything after it is additive on top of a store that is already
|
||||||
|
proven not to have moved anything.
|
||||||
|
|
||||||
|
## 9. Out of scope
|
||||||
|
|
||||||
|
- **Light mode / a light preset.** The website cancelled its Parchment phase; the
|
||||||
|
app stays dark-only.
|
||||||
|
- **Favicon.** No app surface.
|
||||||
|
- **Editing any of this from the app.** The M10 staff surface does not include
|
||||||
|
Appearance or Navigation, and this milestone does not add them. The app is a
|
||||||
|
consumer.
|
||||||
|
- **A theme preview.** Out of scope on the web too.
|
||||||
|
- **Per-screen restyling.** If a screen looks wrong under a warm preset, that is
|
||||||
|
a token the screen should have been reading and did not — fix the call site,
|
||||||
|
do not add a special case.
|
||||||
904
installer/INSTALL.md
Normal file
904
installer/INSTALL.md
Normal file
@@ -0,0 +1,904 @@
|
|||||||
|
# Installing Runic Gateway on your shard
|
||||||
|
|
||||||
|
Operator guide for the **Runic Gateway installer** — the tool that takes a working ServUO
|
||||||
|
installation and connects it to a Runic Gateway website.
|
||||||
|
|
||||||
|
> **The installer is the supported way to set this up.** Download one binary, run `install`, paste
|
||||||
|
> four values into your website. It deploys the plugin overlay, installs the uo-link sidecar and
|
||||||
|
> registers it as a service, and gives you [`doctor`, `update` and `uninstall`](#7-day-two)
|
||||||
|
> afterwards. Start at [§1](#1-download-and-verify).
|
||||||
|
>
|
||||||
|
> [Appendix A](#appendix-a--installing-by-hand) is the same deployment done by hand. It is
|
||||||
|
> **supported, not deprecated** — use it on a host that cannot run the binary, when you want to
|
||||||
|
> place things yourself, or when you are developing on the bridge and installing from a working
|
||||||
|
> tree rather than a release. It is also the reference for what the installer does under the hood.
|
||||||
|
>
|
||||||
|
> Design of record: [PLAN.md](PLAN.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this installs
|
||||||
|
|
||||||
|
Three things, on the machine that runs your shard:
|
||||||
|
|
||||||
|
| # | Component | Where it comes from |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **The plugin overlay** — C# source that ServUO compiles at boot, copied into your server tree | [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) release tarball |
|
||||||
|
| 2 | **The uo-link sidecar** — a small Rust service that the shard dials out to, and that your website reads from | [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) release binary |
|
||||||
|
| 3 | **A record of what it did** — `install.json`, plus cached copies of the patches and of every file the patch tier edited | Written by the installer |
|
||||||
|
|
||||||
|
```
|
||||||
|
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
|
||||||
|
(1) overlay (2) binary + service (yours)
|
||||||
|
```
|
||||||
|
|
||||||
|
The shard **dials out**; it never listens for the website and is never reachable from the internet.
|
||||||
|
Only the sidecar is exposed, and only to your website.
|
||||||
|
|
||||||
|
### What it deliberately does not do
|
||||||
|
|
||||||
|
- **It never restarts or manages ServUO.** Your shard keeps starting the way it always has. The
|
||||||
|
installer refuses to run while ServUO is up, and tells you when a restart is required.
|
||||||
|
- **It never deletes anything from your server tree.** The overlay sync only adds and overwrites.
|
||||||
|
- **It never contacts your website.** It prints four values for you to paste into Admin → Shard.
|
||||||
|
- **It never edits stock ServUO files without asking.** That is the opt-in
|
||||||
|
[patch tier](#4-the-patch-tier-optional), and skipping it still leaves you with a working bridge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before you begin
|
||||||
|
|
||||||
|
| Requirement | Detail |
|
||||||
|
|---|---|
|
||||||
|
| A working ServUO install | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
|
||||||
|
| ServUO **57.4** *(patch tier only)* | **57.4 is the only supported version.** The base install works on any reasonably current ServUO. The patch tier is written and tested against stock 57.4; on any other version it is **unsupported and untested** — you can still choose to run it, behind an explicit opt-in, and it applies only where the exact lines it patches are unchanged. See [§4](#4-the-patch-tier-optional). |
|
||||||
|
| ServUO **stopped** | `ServUO.exe` holds a lock on `Scripts.dll` and writes `Saves/` on exit. The installer refuses to deploy under a running shard. |
|
||||||
|
| Administrator / root | It writes into system directories and registers a service. |
|
||||||
|
| Outbound HTTPS | To `gitea.whitlocktech.com`, to fetch the bundle and the two artifacts. Nothing inbound is needed, and no Gitea account or git client is required. |
|
||||||
|
| The sidecar on the **same host** as the shard | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — the loopback socket *is* the trust boundary for inbound commands. |
|
||||||
|
| Admin access to your Runic Gateway site | The last step is pasting four values into Admin → Shard. |
|
||||||
|
|
||||||
|
**Back up first.** The overlay overwrites `Scripts/Scripts.csproj` (a stock file), and the patch
|
||||||
|
tier edits stock sources. A copy of `Scripts/` and `Config/` before you start costs nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Download and verify
|
||||||
|
|
||||||
|
Releases are **unsigned**. There is no code-signing certificate and no notarization, so the
|
||||||
|
`SHA256SUMS` file published beside every artifact is the whole trust anchor — check it.
|
||||||
|
|
||||||
|
Download the installer for your OS, plus `SHA256SUMS`, from the
|
||||||
|
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases):
|
||||||
|
|
||||||
|
```
|
||||||
|
runicgateway-installer-linux-x86_64
|
||||||
|
runicgateway-installer-linux-aarch64
|
||||||
|
runicgateway-installer-windows-x86_64.exe
|
||||||
|
SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
`linux-aarch64` is for arm64 hosts — Ampere/Graviton instances, Pi-class boxes. `uname -m` says
|
||||||
|
`aarch64` on those and `x86_64` otherwise. There is no macOS build and no Windows-on-arm build: the
|
||||||
|
shard dials the sidecar out on loopback, so the two have to share a host, and no ServUO host is
|
||||||
|
either of those.
|
||||||
|
|
||||||
|
**Linux**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c SHA256SUMS --ignore-missing
|
||||||
|
chmod +x runicgateway-installer-linux-x86_64
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows** (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash
|
||||||
|
Get-Content .\SHA256SUMS # compare the line for this file, case-insensitively
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows will show a **SmartScreen "Windows protected your PC"** prompt on first run, because the
|
||||||
|
binary is unsigned and unknown. Once you have verified the checksum above: *More info* →
|
||||||
|
*Run anyway*. If you would rather not, Appendix A's manual path uses no unsigned binary except the
|
||||||
|
sidecar itself, which you verify the same way.
|
||||||
|
|
||||||
|
The installer applies the same standard to everything **it** downloads: each artifact's SHA256 is
|
||||||
|
checked against the value recorded in the bundle manifest — which CI computed after verifying it
|
||||||
|
against the publishing repo's own `SHA256SUMS` — and a mismatch aborts the run.
|
||||||
|
|
||||||
|
### What it installs is a bundle, not "latest"
|
||||||
|
|
||||||
|
The three components version independently but must agree on one wire protocol, so CI publishes a
|
||||||
|
[**bundle**](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/README.md):
|
||||||
|
one exact, protocol-checked pair of sidecar + overlay versions. The installer resolves that at run
|
||||||
|
time rather than hardcoding versions or blindly taking each repo's newest release.
|
||||||
|
|
||||||
|
Consequences worth knowing:
|
||||||
|
|
||||||
|
- A sidecar patch release does **not** mean re-downloading the installer. The bundle is data.
|
||||||
|
- `--bundle <tag>` (e.g. `--bundle 2026.08.04`) pins an exact past combination, so a reinstall six
|
||||||
|
months from now reproduces today's install rather than tomorrow's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./runicgateway-installer-linux-x86_64 install
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows: from an elevated PowerShell
|
||||||
|
.\runicgateway-installer-windows-x86_64.exe install
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `install --verify` first if you want to see exactly what would change and write nothing — the
|
||||||
|
same idea as `deploy.ps1 -Verify`, which developers of the plugin use.
|
||||||
|
|
||||||
|
The installer **does not install itself.** Keep the binary somewhere sensible on the host (it is
|
||||||
|
one file); `doctor`, `update` and `uninstall` are run from it later. Examples below shorten it to
|
||||||
|
`runicgateway`.
|
||||||
|
|
||||||
|
### What it asks
|
||||||
|
|
||||||
|
1. **Your ServUO root** — detected if the installer is run from inside it or from an obvious
|
||||||
|
sibling, otherwise prompted. A directory qualifies only if it contains `ServUO.exe`, `Scripts/`
|
||||||
|
and `Config/`.
|
||||||
|
2. **Whether to apply the patch tier** — off unless you say yes. On a ServUO that is not 57.4 the
|
||||||
|
prompt defaults to **no** and carries an unsupported-version warning you have to answer past.
|
||||||
|
See [§4](#4-the-patch-tier-optional).
|
||||||
|
3. **The hostname your website should use to reach this machine** — used only to compose the two
|
||||||
|
URLs it prints at the end. The sidecar's bind address is frequently `127.0.0.1` or `0.0.0.0`,
|
||||||
|
neither of which is something to hand to a website.
|
||||||
|
4. **Your site's URL** — used only to print a clickable link to its Admin → Shard page. The
|
||||||
|
installer never contacts your website.
|
||||||
|
|
||||||
|
### An illustrative run
|
||||||
|
|
||||||
|
```
|
||||||
|
Runic Gateway installer — bundle 2026.08.04 (protocol 3)
|
||||||
|
|
||||||
|
ServUO /opt/ServUO (57.4)
|
||||||
|
Shard process not running
|
||||||
|
Overlay servuo-plugins v0.1.1 protocol 3
|
||||||
|
Sidecar uo-link v1.1.0 protocol 3
|
||||||
|
|
||||||
|
✓ overlay tarball verified sha256 75dc6d6c…
|
||||||
|
✓ sidecar binary verified sha256 27d491ef…
|
||||||
|
|
||||||
|
Overlay sync
|
||||||
|
ADD Config/Bridge.cfg
|
||||||
|
ADD Scripts/Custom/Bridge/*.cs (22 files)
|
||||||
|
CHANGE Scripts/Scripts.csproj
|
||||||
|
deployed. add=23 change=1 unchanged=0 kept=0
|
||||||
|
|
||||||
|
Patch tier not selected
|
||||||
|
Without it: no vendor.sale events, no in-game moderation audit forwarding.
|
||||||
|
|
||||||
|
uo-link sidecar
|
||||||
|
binary /usr/bin/runicgateway-link install
|
||||||
|
✓ sidecar binary verified sha256 27d491ef…
|
||||||
|
config /etc/runicgateway/sidecar.toml created
|
||||||
|
database /var/lib/runicgateway/uo-link.db
|
||||||
|
listening on shard 127.0.0.1:7788 website 127.0.0.1:8080
|
||||||
|
service runicgateway-link.service active, enabled
|
||||||
|
running as runicgateway
|
||||||
|
|
||||||
|
Recorded /etc/runicgateway/install.json
|
||||||
|
|
||||||
|
Scripts.csproj changed — ServUO rebuilds Scripts.dll on next boot.
|
||||||
|
Start your shard when ready; the installer does not start it for you.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the [token handoff](#5-connect-the-website).
|
||||||
|
|
||||||
|
### Commands and flags
|
||||||
|
|
||||||
|
The surface this guide specifies. Each command is idempotent: a second run with nothing new to do
|
||||||
|
reports "unchanged" and writes nothing.
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `install` | The full run above. |
|
||||||
|
| `doctor` | Diagnoses an existing deployment end to end — see [§7](#7-day-two). |
|
||||||
|
| `update` | Re-resolves the bundle; updates the sidecar (replace + restart) and the overlay (re-sync + tell you to restart ServUO). |
|
||||||
|
| `uninstall` | Removes only what the installer exclusively owns; prints — never performs — anything inside your ServUO tree. |
|
||||||
|
|
||||||
|
| Flag | Applies to | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `--verify` | `install`, `update` | Dry run. Report every change that would be made; write nothing. |
|
||||||
|
| `--servuo <path>` | `install`, `doctor`, `update` | Name the ServUO root instead of detecting or prompting. |
|
||||||
|
| `--bundle <tag>` | `install`, `update` | Pin an exact published bundle instead of the current one. |
|
||||||
|
| `--patches` / `--no-patches` | `install`, `update` | Decide the patch tier non-interactively. `--patches` never loosens the region check: patches whose target lines are not stock are reported for you to apply by hand, not forced. On `update` it is what takes up a feature the shard does not already have. |
|
||||||
|
| `--patches-unsupported-servuo` | `install` | Required *in addition to* `--patches` to run the patch tier on a ServUO that is not 57.4. Unsupported and untested — see [§4](#4-the-patch-tier-optional). Ignored on 57.4. |
|
||||||
|
| `--host <name>` | `install` | The hostname to print in the website URLs. |
|
||||||
|
| `--site-url <url>` | `install` | Your site's base URL, for the Admin → Shard link. |
|
||||||
|
| `--yes` | all | Assume the default answer to every prompt. Combine with the flags above for an unattended run. **On `uninstall` it means yes** — that prompt defaults to no, and typing `uninstall --yes` is not an accident. |
|
||||||
|
| `--no-backup` | `install`, `update` | Do not copy the files this run is about to overwrite. They are otherwise saved under the state directory — see [§7](#7-day-two). |
|
||||||
|
| `--purge` | `uninstall` | Also delete `sidecar.toml`, `uo-link.db`, the cached patch set and every backup, all of which are otherwise kept. |
|
||||||
|
|
||||||
|
Exit codes are `0` success, `1` the run failed, `2` the arguments were unusable. Two commands also
|
||||||
|
use `1` for a run that *completed* and found something wrong, so they can be read from a script:
|
||||||
|
`doctor` when any check failed, and `uninstall` when a step could not be carried out (everything
|
||||||
|
else still was).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Where everything lands
|
||||||
|
|
||||||
|
**Linux**
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|---|---|
|
||||||
|
| `/usr/bin/runicgateway-link` | The sidecar binary |
|
||||||
|
| `/etc/runicgateway/sidecar.toml` | Sidecar config, including the auth token |
|
||||||
|
| `/etc/runicgateway/install.json` | What the installer deployed: versions, commit, per-file hashes, applied patches, timestamps |
|
||||||
|
| `/etc/runicgateway/patches/` | Copies of the patches the tier evaluated, so `uninstall` can print the exact hunks long after the release tarball is gone, and a refused one is still on hand to apply yourself |
|
||||||
|
| `/etc/runicgateway/patches/originals/` | Each file the patch tier edited, exactly as it was beforehand — a revert you can verify rather than reconstruct |
|
||||||
|
| `/etc/runicgateway/backups/<timestamp>/` | Copies of the files a run replaced, with a `manifest.json` naming each. Newest three kept; skip with `--no-backup` |
|
||||||
|
| `/var/lib/runicgateway/uo-link.db` | The sidecar's SQLite store (event history, cached profiles, link map) |
|
||||||
|
| `/etc/systemd/system/runicgateway-link.service` | The service unit, running as a dedicated user |
|
||||||
|
|
||||||
|
**Windows**
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|---|---|
|
||||||
|
| `%ProgramFiles%\RunicGateway\uo-link-sidecar.exe` | The sidecar binary |
|
||||||
|
| `%ProgramData%\RunicGateway\sidecar.toml` | Sidecar config, including the auth token |
|
||||||
|
| `%ProgramData%\RunicGateway\install.json` | As above |
|
||||||
|
| `%ProgramData%\RunicGateway\patches\` | As above |
|
||||||
|
| `%ProgramData%\RunicGateway\patches\originals\` | As above |
|
||||||
|
| `%ProgramData%\RunicGateway\backups\<timestamp>\` | As above |
|
||||||
|
| `%ProgramData%\RunicGateway\uo-link.db` | The sidecar's SQLite store |
|
||||||
|
| `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log` | The service's log. A Windows service has no console to write to, so it logs here instead; rolled daily, seven kept. A foreground run still logs to stdout as usual |
|
||||||
|
| Service `RunicGatewayLink` | Automatic start, restart on failure, running as `NT SERVICE\RunicGatewayLink`. Needs a sidecar **v1.2.0 or newer** — see [Troubleshooting](#troubleshooting) on error 1053 |
|
||||||
|
|
||||||
|
**Inside your ServUO tree** (added by the overlay sync — 24 files):
|
||||||
|
|
||||||
|
```
|
||||||
|
Config/Bridge.cfg every bridge setting, heavily commented
|
||||||
|
Scripts/Custom/Bridge/*.cs 22 files: the plugin itself
|
||||||
|
Scripts/Scripts.csproj OVERWRITES a stock file (see below)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Both service definitions pin the config path**, because the sidecar's own default is relative to
|
||||||
|
its working directory — and a service manager's working directory is not somewhere you want a
|
||||||
|
database or a config file. On Windows it can be `%SystemRoot%\System32` or, under
|
||||||
|
`C:\Program Files\`, a silently redirected VirtualStore copy.
|
||||||
|
|
||||||
|
How the *database* path is pinned differs by platform, and that is deliberate:
|
||||||
|
|
||||||
|
| | Config | Database |
|
||||||
|
|---|---|---|
|
||||||
|
| **Linux** | `Environment=UOLINK_CONFIG=` in the unit | `Environment=UOLINK_DB_PATH=` in the unit — `/etc` and `/var/lib` are different directories, so both need naming |
|
||||||
|
| **Windows** | `--config` inside the service's own `binPath` | nothing to set: a relative `[store] path` resolves against the config's directory, which *is* `%ProgramData%\RunicGateway` |
|
||||||
|
|
||||||
|
The Windows service would otherwise need a **machine-wide** environment variable — `sc.exe` has no
|
||||||
|
per-service one — which every process on the host inherits and which outlives an uninstall.
|
||||||
|
|
||||||
|
**Both run as a dedicated, unprivileged account.** Linux gets a `runicgateway` system user; Windows
|
||||||
|
gets a virtual service account, `NT SERVICE\RunicGatewayLink`, which Windows creates as part of
|
||||||
|
registering the service and which has no password. Neither runs as root or `LocalSystem`.
|
||||||
|
|
||||||
|
**`sidecar.toml` is locked down, because it holds your auth token.** Neither default location
|
||||||
|
protects it on its own — `/etc` is world-readable, and `%ProgramData%` grants `Users` read access by
|
||||||
|
inheritance — so the installer sets the permissions itself: `chmod 600` plus `chown` to the service
|
||||||
|
user on Linux, and an explicit ACL of SYSTEM, Administrators and the service account on Windows.
|
||||||
|
|
||||||
|
> **`Scripts.csproj` is overwritten deliberately.** The stock file omits `Scripts/Custom/`, so the
|
||||||
|
> plugin would sit in the tree and never compile — and ServUO would not tell you, because it
|
||||||
|
> ignores the script build's exit code and silently reloads the previous `Scripts.dll`. That
|
||||||
|
> failure mode is the reason [§6](#6-start-servuo-and-verify) exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The patch tier (optional)
|
||||||
|
|
||||||
|
Most of the plugin ships as **added** files, which is why the base install is a safe file copy. Two
|
||||||
|
features cannot: they need edits to stock ServUO sources, because the events they depend on do not
|
||||||
|
exist.
|
||||||
|
|
||||||
|
| Patch | Edits | Gives you | Rebuild needed |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `playervendor-sale-eventsink.patch` + `playervendor-sale-gump.patch` | `Server/EventSink.cs`, `Scripts/Gumps/PlayerVendorGumps.cs` | `vendor.sale` events — player-vendor purchases with buyer, owner, price and commission, which is what cheat detection needs | **Core solution rebuild** (`dotnet build ServUO.sln`) — the dynamic script build is not enough |
|
||||||
|
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | In-game moderation actions (`[ban`, `[kick`, `[bcast`) forwarded to the website's moderation log as `admin.audit` | Script build only — a shard restart is enough |
|
||||||
|
|
||||||
|
Each patch has a companion `.cs` file that is copied **only after** its patch applies, because it
|
||||||
|
references symbols the patch introduces. That is why they are not in the base overlay: shipping them
|
||||||
|
unconditionally would break the build on every unpatched install.
|
||||||
|
|
||||||
|
How the installer handles it:
|
||||||
|
|
||||||
|
- **Opt-in.** The base install completes without it, and declining is a supported outcome, not a
|
||||||
|
degraded one.
|
||||||
|
- **Dry-run first, always.** Every patch is checked before anything is applied, and reported per
|
||||||
|
patch. Most real shards are hand-modified; a patch that does not apply is expected, not alarming.
|
||||||
|
- **A modified file is not automatically a refusal.** These patches touch three small regions of
|
||||||
|
three large files. If you have edited `Logging.cs` somewhere else entirely, the installer says so
|
||||||
|
and still applies the patch — it checks whether *the lines the patch edits* are still stock, not
|
||||||
|
whether the whole file is. It applies only where the surrounding lines match the patch exactly and
|
||||||
|
appear exactly once; anything less and it stops and hands you the hunk to apply by hand. It never
|
||||||
|
force-fits a patch by loosening the match.
|
||||||
|
- **All or nothing per feature.** The two vendor-sale patches are one unit and are applied together
|
||||||
|
or not at all — and within a patch, if one hunk cannot be placed safely, none are. A patch that
|
||||||
|
could have been placed but was held back by its sibling says exactly that; it is never reported as
|
||||||
|
applied.
|
||||||
|
- **It does not need `git`, and does not use it.** The matching and the writing are the installer's
|
||||||
|
own, which is why it can place a patch on a shard where `git apply` refuses — the shipped patches
|
||||||
|
and their target files do not all use the same line endings, and that alone defeats `git apply`.
|
||||||
|
Nothing outside a patched region is touched, down to the byte, and inserted lines take your file's
|
||||||
|
own line ending.
|
||||||
|
- **Your ServUO version is reported, not decisive** — but see the warning below before running this
|
||||||
|
on anything other than 57.4.
|
||||||
|
- **Recorded, and the `.patch` files cached**, so re-runs stay idempotent and `uninstall` can print
|
||||||
|
the exact hunks to revert — along with how each was applied, since a patch placed into a file you
|
||||||
|
had already modified is one to look at more carefully when reverting. Patches that were *not*
|
||||||
|
applied are cached too, because that is the copy the run tells you to apply by hand.
|
||||||
|
- **A copy of every file it edits is kept, exactly as it was beforehand**, under
|
||||||
|
`patches/originals/` in the installer's own directory — not in your ServUO tree. It is written
|
||||||
|
before the first edit and never overwritten, so however many times you re-run `install`, it stays
|
||||||
|
the version from before the tier ever touched the file. That is what lets you verify a revert
|
||||||
|
rather than reconstruct one.
|
||||||
|
- **Re-running is safe.** A patch already in place is recognised and left alone, and the record
|
||||||
|
keeps the way it originally landed rather than relabelling it.
|
||||||
|
|
||||||
|
### ⚠ On any ServUO that is not 57.4: unsupported, untested, no guarantees
|
||||||
|
|
||||||
|
> **Runic Gateway is designed, built and tested against stock ServUO 57.4.** That is the only
|
||||||
|
> supported version.
|
||||||
|
>
|
||||||
|
> On any other version — a newer release, an older one, or a fork — the patch tier is
|
||||||
|
> **UNSUPPORTED, UNTESTED, and NOT GUARANTEED TO WORK.** You may run it. If you do, you are on your
|
||||||
|
> own: it is not covered by support, and a bad outcome may not show up until your shard is live,
|
||||||
|
> because ServUO's script build reports success even when it failed and quietly keeps running the
|
||||||
|
> previous `Scripts.dll`.
|
||||||
|
>
|
||||||
|
> The installer will still refuse to place a patch anywhere the exact lines it edits have changed —
|
||||||
|
> but matching text is not the same as matching behaviour. A hunk can land correctly and still be
|
||||||
|
> wrong for a tree that has diverged around it.
|
||||||
|
>
|
||||||
|
> **Back up your ServUO tree first, and verify your shard boots and compiles afterwards.**
|
||||||
|
|
||||||
|
Because of that, on a non-57.4 tree the tier is off by default and takes a deliberate yes:
|
||||||
|
|
||||||
|
- the interactive prompt defaults to **no** and prints the warning above;
|
||||||
|
- `--patches` on its own is **not** enough — an unattended run must also pass
|
||||||
|
`--patches-unsupported-servuo`;
|
||||||
|
- the choice is recorded, and `doctor` keeps showing an unsupported-version row for the life of the
|
||||||
|
install — so whoever looks after this shard next can see it without being told.
|
||||||
|
|
||||||
|
A run where the tier is selected on a shard that has been worked on looks like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
Patch tier 2 of 3 applied
|
||||||
|
✓ playervendor-sale-eventsink Server/EventSink.cs
|
||||||
|
stock file — applied at line 171, 1521, 1771, 2416
|
||||||
|
✓ playervendor-sale-gump Scripts/Gumps/PlayerVendorGumps.cs
|
||||||
|
file modified, patched region stock — applied at line 95
|
||||||
|
✗ commandlogging-event Scripts/Commands/Logging.cs
|
||||||
|
patched region has been modified (hunk 1) — not applied
|
||||||
|
apply this by hand, then re-run install to record it:
|
||||||
|
/etc/runicgateway/patches/commandlogging-event.patch
|
||||||
|
|
||||||
|
⚠ Server/EventSink.cs — a CORE ServUO file was patched. Rebuild the solution:
|
||||||
|
dotnet build ServUO.sln
|
||||||
|
A shard restart is not enough; ServUO's dynamic script build does not rebuild the core, and it
|
||||||
|
will not tell you so.
|
||||||
|
|
||||||
|
Not applied, so you do not get: no in-game moderation audit forwarding.
|
||||||
|
Everything else works. Apply the hunks by hand if you want them, then re-run install to record it.
|
||||||
|
```
|
||||||
|
|
||||||
|
The line numbers are where each hunk was actually found in *your* file, not where it sits in stock
|
||||||
|
ServUO — they differ as soon as anything above the region has been edited, and yours is the one to
|
||||||
|
go to.
|
||||||
|
|
||||||
|
If it is skipped or fails, you lose exactly two things — **`vendor.sale` events** and **in-game
|
||||||
|
moderation audit forwarding**. Everything else works. You can apply the patches later by hand (see
|
||||||
|
`patches/README.md` in the tarball) and re-run `install` to record it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Connect the website
|
||||||
|
|
||||||
|
The installer ends a successful run by printing the one manual step it cannot do for you:
|
||||||
|
|
||||||
|
```
|
||||||
|
Runic Gateway is installed.
|
||||||
|
|
||||||
|
One manual step remains — connect the website to this sidecar:
|
||||||
|
|
||||||
|
Base URL http://shard.example.com:8080
|
||||||
|
WebSocket URL ws://shard.example.com:8080/ws
|
||||||
|
Protocol version 3
|
||||||
|
Auth token 4f9c… (also in /etc/runicgateway/sidecar.toml)
|
||||||
|
|
||||||
|
Paste these into Admin → Shard on your Runic Gateway site:
|
||||||
|
https://your-site.example/admin/shard
|
||||||
|
|
||||||
|
The token is write-only once saved — the site will never show it back to you.
|
||||||
|
```
|
||||||
|
|
||||||
|
Every value there comes from asking the installed sidecar itself (`--print-config`), not from a
|
||||||
|
log file or a guess, so it cannot drift from what the service actually runs.
|
||||||
|
|
||||||
|
On your site, sign in as an administrator and open **Admin → Shard (uo-link)**:
|
||||||
|
|
||||||
|
| Field on the page | Paste |
|
||||||
|
|---|---|
|
||||||
|
| Enable the shard integration | ✔ on |
|
||||||
|
| Base URL (REST) | the **Base URL** line |
|
||||||
|
| WebSocket URL (feed) | the **WebSocket URL** line |
|
||||||
|
| Auth token | the **Auth token** line |
|
||||||
|
| Protocol | the **Protocol version** line (`3`) |
|
||||||
|
|
||||||
|
Saving restarts the site's ingest client, so the change takes effect immediately. The token is
|
||||||
|
AES-GCM encrypted at rest and **never returned to any client** — losing it means reading it back
|
||||||
|
from `sidecar.toml` on the shard host, not from the website.
|
||||||
|
|
||||||
|
### If your website is on a different machine
|
||||||
|
|
||||||
|
The sidecar binds `127.0.0.1:8080` by default, which is reachable only from the shard host. If your
|
||||||
|
website runs elsewhere, you must widen the bind — and then narrow the access:
|
||||||
|
|
||||||
|
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` (or a specific LAN address) and restart the
|
||||||
|
service.
|
||||||
|
2. **Firewall port 8080 to your website's address only.** The auth token is always on, but it
|
||||||
|
travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
|
||||||
|
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse proxy or a
|
||||||
|
VPN/WireGuard link, and give the website the proxied `https://` / `wss://` URLs.
|
||||||
|
|
||||||
|
The `[shard] bind` line is a different matter: leave it on `127.0.0.1:7788`. That socket accepts
|
||||||
|
*inbound commands* to the game, and being loopback-only is what makes that safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Start ServUO and verify
|
||||||
|
|
||||||
|
Start your shard the way you always do. Then confirm the bridge is actually live — not merely
|
||||||
|
installed. **A successful file copy is not a working bridge**: ServUO shells out to `dotnet build`,
|
||||||
|
prints the output, ignores the exit code, and reloads the existing `Scripts.dll`, so a broken script
|
||||||
|
build looks exactly like a clean boot.
|
||||||
|
|
||||||
|
**a. Watch the boot output.** You want to see the build succeed *and* the bridge announce itself:
|
||||||
|
|
||||||
|
```
|
||||||
|
Core: Compiling scripts...
|
||||||
|
Build succeeded.
|
||||||
|
[Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 sweeps(stat=30s decay=60s …
|
||||||
|
```
|
||||||
|
|
||||||
|
If you scrolled past it, force the question:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <servuo root>
|
||||||
|
dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors
|
||||||
|
```
|
||||||
|
|
||||||
|
**b. Ask the shard, in game.** As an Administrator:
|
||||||
|
|
||||||
|
```
|
||||||
|
[bridge status
|
||||||
|
```
|
||||||
|
|
||||||
|
It reports the config plus `connected=True depth=0 sent=… dropped=0 …`. `connected=False` means the
|
||||||
|
shard cannot reach the sidecar; `dropped` climbing means the sidecar is wedged and the shard is
|
||||||
|
shedding events rather than stalling — which it is designed to do. `[bridge reload` re-reads
|
||||||
|
`Bridge.cfg` without a restart; `[bridge sweepnow` forces one pass of every stream.
|
||||||
|
|
||||||
|
**c. Ask the sidecar.** `/health` needs no auth, so it is safe to curl from a terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:8080/health
|
||||||
|
{"status":"ok","protocol":3,"plugin_connected":true,"database":"ok","uptime":"2m","last_event":"2026-08-04T18:22:10.412Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`plugin_connected: true` is the one that matters — it is the only value in this whole guide that
|
||||||
|
distinguishes "files copied" from "the bridge works".
|
||||||
|
|
||||||
|
**d. Ask the website.** The public site should stop showing the shard as offline, and live events
|
||||||
|
should appear on the admin dashboard within seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Day two
|
||||||
|
|
||||||
|
### `runicgateway doctor`
|
||||||
|
|
||||||
|
The command that makes this supportable. Run it before asking anyone for help — its output is the
|
||||||
|
first thing a maintainer will want.
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Install record /etc/runicgateway/install.json (bundle 2026.08.04, installer 1.0.0, …)
|
||||||
|
✓ ServUO found /opt/ServUO (57.4)
|
||||||
|
✓ Overlay in sync 24 files, all hashes match install.json
|
||||||
|
⚠ Patch tier 1 applied — moderation-audit (region-match)
|
||||||
|
✓ uo-link installed uo-link-sidecar 1.1.0 (protocol 3)
|
||||||
|
config /etc/runicgateway/sidecar.toml database /var/lib/runicgateway/uo-link.db
|
||||||
|
✓ Service runicgateway-link.service active, enabled as runicgateway
|
||||||
|
✓ Sidecar reachable 127.0.0.1:8080 /health ok, up 6h, database ok
|
||||||
|
✓ Protocol sidecar 3 = overlay manifest 3
|
||||||
|
✗ Shard connected no — the shard is running (pid 8123) but has not dialed in
|
||||||
|
✓ Bundle 2026.08.04 — up to date
|
||||||
|
✓ Backups 2026-08-04T09:12:44Z — 3 file(s) replaced by update to bundle 2026.08.04
|
||||||
|
3 kept in /etc/runicgateway/backups
|
||||||
|
```
|
||||||
|
|
||||||
|
Rows come from asking the installed sidecar (`--version`, `--print-config`) rather than from reading
|
||||||
|
`install.json`, so `doctor` reports what the binary would actually do — including which config and
|
||||||
|
database file the *service* resolves — rather than what the installer believes it was told. The
|
||||||
|
overlay row compares live file hashes against `install.json`, which is how it tells "you edited a
|
||||||
|
deployed file" from "the file is gone"; the bundle row is what tells you the overlay upstream has
|
||||||
|
moved on. Each patched file is re-checked against the cached copy of its patch, so a core upgrade or
|
||||||
|
a restored backup that quietly removed the tier's edits is caught here — nothing else would notice.
|
||||||
|
|
||||||
|
It writes nothing at all, and it is safe to run while the shard is up; that is in fact the only
|
||||||
|
state in which the last row can be `✓`.
|
||||||
|
|
||||||
|
**Reading the marks:**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `✓` | as it should be |
|
||||||
|
| `⚠` | worth knowing, not broken — a stopped shard, a service you never registered, an unpatched tier, or no route to Gitea to check for a newer bundle |
|
||||||
|
| `✗` | broken. `doctor` exits `1` if any row is `✗`, so it can be run from a monitoring script; a `⚠` never causes that |
|
||||||
|
|
||||||
|
The distinction on the last row is worth spelling out: **shard not running** is a `⚠` (start it),
|
||||||
|
while **shard running and not dialed in** is a `✗` — that is the silent failure this whole guide
|
||||||
|
warns about, where ServUO reports a clean boot over a script build that failed.
|
||||||
|
|
||||||
|
### `runicgateway update`
|
||||||
|
|
||||||
|
Re-resolves the bundle and moves both halves to a combination whose protocol versions were checked
|
||||||
|
together — never to two independently-latest artifacts that may disagree.
|
||||||
|
|
||||||
|
- **Sidecar**: download → verify → replace binary → restart service. No shard downtime.
|
||||||
|
- **Overlay**: download → verify → re-sync → record the new commit → **tell you to restart ServUO.**
|
||||||
|
It does not restart your shard.
|
||||||
|
|
||||||
|
Your `sidecar.toml`, your `Bridge.cfg` edits and your database are not touched. `Bridge.cfg` is
|
||||||
|
overwritten only if you have not changed it; a modified copy is reported, not clobbered.
|
||||||
|
|
||||||
|
**Anything it does overwrite is copied first.** Every `.cs` file the overlay owns is replaced
|
||||||
|
unconditionally — that is deliberate, they are code — so if you have edited one, the run saves your
|
||||||
|
copy under `backups/<timestamp>/` in the state directory before writing, alongside `sidecar.toml`
|
||||||
|
and any stock ServUO file the patch tier is about to touch. Each backup carries a `manifest.json`
|
||||||
|
saying where every file came from. The newest three are kept; `--no-backup` skips taking one.
|
||||||
|
|
||||||
|
Putting a file back is yours to do — the installer will not restore an old file over a newer
|
||||||
|
release, because it cannot know what has changed since. A run that overwrites nothing takes no
|
||||||
|
backup, so a no-op `update` leaves nothing behind.
|
||||||
|
|
||||||
|
It updates the ServUO tree `install.json` names — not a tree it detects — and it needs the shard
|
||||||
|
stopped, exactly as `install` does. There is nothing to update on a host that was never installed;
|
||||||
|
it says so rather than performing a first install under a verb that promises to preserve.
|
||||||
|
|
||||||
|
**Your auth token is not reprinted.** It has not changed and your website already has it. The one
|
||||||
|
thing an update can change that the site must be told about is the **protocol version**, and it says
|
||||||
|
so plainly when that happens — a stale number in Admin → Shard is answered with `409` and looks
|
||||||
|
exactly like your shard going offline.
|
||||||
|
|
||||||
|
**The patch tier under `update`:** features you already have are re-checked against the new release
|
||||||
|
(normally nothing to do), without asking you again — you consented when they were installed, and
|
||||||
|
that includes a shard where the tier ran unsupported. Features you never took are **named, not
|
||||||
|
applied**; run `update --patches` (or `install --patches`) to take one up. A shard that declined the
|
||||||
|
tier stays unpatched through every update.
|
||||||
|
|
||||||
|
### `runicgateway uninstall`
|
||||||
|
|
||||||
|
Removes what it exclusively owns, and **prints** everything else. The installer cannot know what you
|
||||||
|
have changed in your own server tree since deployment, so an automatic revert risks silently eating
|
||||||
|
your work.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Removed** | The sidecar binary, its service entry, `install.json` |
|
||||||
|
| **Kept** | `sidecar.toml`, `uo-link.db`, the cached patch set with its pre-patch originals, and every backup an upgrade took (`--purge` drops all of them) |
|
||||||
|
| **Printed, not done** | Every overlay file deployed into your ServUO tree, by path, for you to delete — with any file you have edited since deployment flagged, so you do not delete your own work by mistake |
|
||||||
|
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert — with how each landed, since one placed into a file you had already modified is worth a closer look. The pre-patch copy kept under `patches/originals/` is there to diff against. |
|
||||||
|
|
||||||
|
It lists all of that **before** asking, and the prompt defaults to **no**. `--yes` proceeds, which is
|
||||||
|
what an unattended uninstall needs; nothing else about the command is destructive to your shard,
|
||||||
|
which is neither stopped nor started.
|
||||||
|
|
||||||
|
The report is also written to a file — `runicgateway-uninstall-<timestamp>.txt` in the directory you
|
||||||
|
ran the command from — so it survives the scrollback. That is why the cached patches and the
|
||||||
|
originals stay behind by default: they are the only offline record of what the tier changed once the
|
||||||
|
release tarball is gone, and the report tells you to diff against them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause and fix |
|
||||||
|
|---|---|
|
||||||
|
| **Windows asks for Administrator as soon as you launch it** | Expected, and it needs Administrator anyway. Windows applies *installer detection* to unsigned executables whose file name contains `install` and elevates them before the program starts. Run it from an already-elevated PowerShell and you will not see the prompt. |
|
||||||
|
| **"ServUO is running — stop it before installing"** | Correct, and not overridable. `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit; deploying underneath it corrupts one or both. Stop the shard, install, start it again. |
|
||||||
|
| Shard boots clean but nothing reaches the site | The classic silent failure: ServUO ignores the script build's exit code and reloaded a **stale `Scripts.dll`**. Run `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` and read the errors it prints. |
|
||||||
|
| `[bridge status` says `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service is running, and that `[shard] bind` in `sidecar.toml` matches `Host`/`Port` in `Bridge.cfg`. |
|
||||||
|
| `[bridge` is not a command | The plugin did not compile, or `Bridge.cfg` has the bridge disabled. See the row above. |
|
||||||
|
| Website says the shard is offline; `/health` is fine locally | The website cannot reach port 8080 — bind address or firewall. See [§5](#if-your-website-is-on-a-different-machine). Note that the site is *designed* to render normally with the shard offline, so this fails quietly by design. |
|
||||||
|
| Website logs `409` from the sidecar | Protocol mismatch: the number in Admin → Shard does not match the sidecar's. The sidecar rejects rather than mis-parsing. Set the field to what `/health` reports (`protocol`). If the *sidecar* and *overlay* disagree, you have a hand-assembled pair — reinstall from a bundle. |
|
||||||
|
| `401` from the sidecar | Wrong or missing auth token. Read the live one back with `uo-link-sidecar --print-config --config <path>`; do not retype it from a screenshot. |
|
||||||
|
| **"service NOT REGISTERED" at the end of an otherwise successful run** | The host has no service manager the installer can drive — most often no systemd (a container, or a distro that never had it), or the `runicgateway` user could not be created. The binary and config *are* installed; the run prints the exact unit and commands to finish by hand. It never falls back to running the service as root or `LocalSystem`. |
|
||||||
|
| **Windows: `sc start` fails with 1053, "the service did not respond in a timely fashion"** | Almost always a **sidecar older than v1.2.0**, which cannot start as a service no matter how correct its config. 1053 is a handshake failure, not a crash: Windows waited 30 seconds for the process to identify itself to the service control manager, and a sidecar built before service support was added never does. Check with `"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --version`. Tell-tale signs: `sc query` shows `SERVICE_EXIT_CODE : 0` (nothing crashed), and running the same binary in the foreground with the same `--config` works perfectly. |
|
||||||
|
| Service registered but stops immediately | Distinct from 1053 above — here the process really did exit. On Windows read `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log`, which is where a service logs since it has no stdout, and check that `sc qc RunicGatewayLink` shows `--config` in `BINARY_PATH_NAME` and that `NT SERVICE\RunicGatewayLink` has read access to `sidecar.toml`; on Linux check the `runicgateway` user can read `/etc/runicgateway/sidecar.toml` and write `/var/lib/runicgateway/`, and read `journalctl -u runicgateway-link`. |
|
||||||
|
| A patch will not apply | Expected on a hand-modified shard. The base install is unaffected; you lose only the two features in [§4](#4-the-patch-tier-optional). Apply the hunks by hand if you want them. |
|
||||||
|
| `vendor.sale` events never arrive despite patching | The `EventSink.cs` patch is a **core** change. A shard restart is not enough — rebuild the solution (`dotnet build ServUO.sln`). |
|
||||||
|
| Sidecar writes its database somewhere unexpected | A relative `[store] path` resolves against the directory holding `sidecar.toml` — not the working directory. Run `--print-config` to see the absolute path it will actually use. |
|
||||||
|
| Token leaked into a log or a screenshot | Clear `[web] auth_token` in `sidecar.toml`, restart the service (a new token is generated and saved), read it back with `--print-config`, and re-save it in Admin → Shard. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — installing by hand
|
||||||
|
|
||||||
|
This is what the installer automates, done by hand. It is a **supported path**, not a deprecated
|
||||||
|
one — reach for it when the host cannot run the binary, when you would rather not run an unsigned
|
||||||
|
one, when you want to place every file yourself, or when you are developing on the bridge and
|
||||||
|
installing from a working tree instead of a release. It is also the reference for what
|
||||||
|
[§2](#2-run-it) does under the hood.
|
||||||
|
|
||||||
|
For a normal shard, [the installer](#1-download-and-verify) is fewer steps and checks more.
|
||||||
|
|
||||||
|
Throughout: `<servuo>` is your ServUO root, and **the shard is stopped**.
|
||||||
|
|
||||||
|
### A1. Fetch the bundle (so you install a checked pair)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
|
||||||
|
```
|
||||||
|
|
||||||
|
It names the sidecar tag, the overlay tag, their agreed `protocol`, and the SHA256 of every asset.
|
||||||
|
Use those versions together; that pairing is the only thing CI has verified.
|
||||||
|
|
||||||
|
### A2. Deploy the plugin overlay
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -LO https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/runicgateway-overlay-0.1.1.tar.gz
|
||||||
|
curl -LO https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/SHA256SUMS
|
||||||
|
sha256sum -c SHA256SUMS --ignore-missing # must say: OK
|
||||||
|
|
||||||
|
tar xzf runicgateway-overlay-0.1.1.tar.gz # → runicgateway-overlay/
|
||||||
|
cd runicgateway-overlay
|
||||||
|
cat manifest.json # version, commit, protocol, per-file hashes
|
||||||
|
|
||||||
|
cp -r overlay/. <servuo>/ # adds files; overwrites Scripts/Scripts.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, `Expand-Archive` does not read `.tar.gz`; use `tar.exe` (shipped with Windows 10+) and
|
||||||
|
`Copy-Item -Recurse -Force`. Plugin developers have `deploy.ps1` in the source repo, which does the
|
||||||
|
same copy with a hash diff and a `-Verify` dry run — it is not shipped in the tarball.
|
||||||
|
|
||||||
|
The overlay only ever **adds or overwrites**. Nothing in your tree is deleted.
|
||||||
|
|
||||||
|
*Optional — the patch tier* (stock ServUO 57.4 only; see `patches/README.md` in the tarball for the
|
||||||
|
full explanation):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <servuo>
|
||||||
|
git apply --check patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch
|
||||||
|
git apply patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch
|
||||||
|
cp patches/BridgeVendorSale.cs Scripts/Custom/Bridge/
|
||||||
|
dotnet build ServUO.sln # REQUIRED — EventSink.cs is a core file
|
||||||
|
|
||||||
|
git apply --check patches/commandlogging-event.patch
|
||||||
|
git apply patches/commandlogging-event.patch
|
||||||
|
cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/
|
||||||
|
```
|
||||||
|
|
||||||
|
`git apply` works in a plain directory — the shard does not need to be a git repo. If you use
|
||||||
|
`patch` instead, note that some core files are CRLF while others are LF: use `patch --binary`.
|
||||||
|
|
||||||
|
**If `git apply` refuses a patch whose target region is visibly untouched, line endings are the
|
||||||
|
usual cause** — the `.patch` files and their targets do not all use the same ones, and `git apply`
|
||||||
|
compares them literally. The installer's own tier normalizes line endings and trailing whitespace
|
||||||
|
for the *comparison* while writing back your file's own endings, which is why it can place patches
|
||||||
|
`git apply` rejects. Running the installer is the easier route here.
|
||||||
|
|
||||||
|
### A3. Install the sidecar
|
||||||
|
|
||||||
|
On an arm64 host substitute `uo-link-sidecar-linux-aarch64` for the asset name below (`uname -m`
|
||||||
|
says `aarch64`); releases from v1.2.0 carry both. Take the version from the bundle you fetched in
|
||||||
|
A1 rather than the one written here.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64
|
||||||
|
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/SHA256SUMS
|
||||||
|
sha256sum -c SHA256SUMS --ignore-missing
|
||||||
|
|
||||||
|
sudo install -m 0755 uo-link-sidecar-linux-x86_64 /usr/bin/runicgateway-link
|
||||||
|
sudo mkdir -p /etc/runicgateway /var/lib/runicgateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Provision the config and read back the token in one step. `--print-config` writes the file if it is
|
||||||
|
missing, generates the auth token if there is none, and prints the resolved settings as JSON — it is
|
||||||
|
the supported alternative to scraping the startup log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db \
|
||||||
|
/usr/bin/runicgateway-link --print-config --config /etc/runicgateway/sidecar.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"component": "uo-link-sidecar",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"protocol": 3,
|
||||||
|
"config_path": "/etc/runicgateway/sidecar.toml",
|
||||||
|
"config_created": true,
|
||||||
|
"token_generated": true,
|
||||||
|
"shard": { "bind": "127.0.0.1:7788" },
|
||||||
|
"web": {
|
||||||
|
"bind": "127.0.0.1:8080",
|
||||||
|
"ws_path": "/ws",
|
||||||
|
"auth_required": true,
|
||||||
|
"auth_token": "4f9c…"
|
||||||
|
},
|
||||||
|
"store": { "path": "/var/lib/runicgateway/uo-link.db" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`config_created` and `token_generated` tell you whether *this* run provisioned anything — the values
|
||||||
|
alone cannot distinguish a fresh install from a re-read. **The output contains the auth token in
|
||||||
|
clear text**: keep it out of shell transcripts, logs and support bundles.
|
||||||
|
|
||||||
|
### A4. Register the service
|
||||||
|
|
||||||
|
**Linux** — `/etc/systemd/system/runicgateway-link.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Runic Gateway uo-link sidecar
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=runicgateway
|
||||||
|
Environment=UOLINK_CONFIG=/etc/runicgateway/sidecar.toml
|
||||||
|
Environment=UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db
|
||||||
|
ExecStart=/usr/bin/runicgateway-link
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --no-create-home runicgateway
|
||||||
|
sudo chown -R runicgateway /var/lib/runicgateway /etc/runicgateway
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now runicgateway-link
|
||||||
|
systemctl status runicgateway-link
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows** (elevated PowerShell) — binary under `%ProgramFiles%`, data under `%ProgramData%`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-Item -ItemType Directory -Force "$env:ProgramFiles\RunicGateway", "$env:ProgramData\RunicGateway" | Out-Null
|
||||||
|
Copy-Item .\uo-link-sidecar-windows-x86_64.exe "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe"
|
||||||
|
|
||||||
|
& "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe" --print-config --config "$env:ProgramData\RunicGateway\sidecar.toml"
|
||||||
|
|
||||||
|
# The config file now holds your auth token. Lock it down before anything else can read it:
|
||||||
|
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r '*S-1-5-32-544:(F)'
|
||||||
|
|
||||||
|
# binPath carries the config path. The single quotes matter: the value itself contains the double
|
||||||
|
# quotes the service manager needs around a path with spaces in it.
|
||||||
|
sc.exe create RunicGatewayLink `
|
||||||
|
binPath= '"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --config "C:\ProgramData\RunicGateway\sidecar.toml"' `
|
||||||
|
obj= 'NT SERVICE\RunicGatewayLink' start= auto
|
||||||
|
sc.exe failure RunicGatewayLink reset= 86400 actions= restart/5000
|
||||||
|
|
||||||
|
# The service account exists only once sc create has created it, so its grants come after:
|
||||||
|
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /grant 'NT SERVICE\RunicGatewayLink:(R)'
|
||||||
|
icacls "$env:ProgramData\RunicGateway" /grant 'NT SERVICE\RunicGatewayLink:(OI)(CI)M'
|
||||||
|
|
||||||
|
sc.exe start RunicGatewayLink
|
||||||
|
```
|
||||||
|
|
||||||
|
Four things there are easy to get wrong:
|
||||||
|
|
||||||
|
- **The sidecar must be v1.2.0 or newer.** Earlier builds are plain console programs, and the
|
||||||
|
Windows service control manager cannot supervise one: it waits 30 seconds for the process to
|
||||||
|
identify itself, then fails the start with **1053** even though the process is running and healthy.
|
||||||
|
From v1.2.0 the same binary does both — started by the SCM it runs as a service, started from a
|
||||||
|
shell it runs in the foreground, with no flag to choose between them.
|
||||||
|
- **The config path goes in `binPath`, not in a machine environment variable.** `sc.exe` has no
|
||||||
|
per-service environment, and a machine-wide `UOLINK_CONFIG` would be inherited by every process on
|
||||||
|
the host and survive an uninstall. Never leave the config path to the default — it is relative to
|
||||||
|
the service's working directory, which for a service is `%SystemRoot%\System32`.
|
||||||
|
- **The database needs no pinning here.** A relative `[store] path` resolves against the directory
|
||||||
|
holding `sidecar.toml`, which is already `%ProgramData%\RunicGateway`.
|
||||||
|
- **`obj=` is what keeps this off `LocalSystem`.** `NT SERVICE\RunicGatewayLink` is a virtual
|
||||||
|
service account: Windows creates it with the service, it has no password, and it exists only for
|
||||||
|
this service. Omit `obj=` and you get the most privileged local identity there is, for a process
|
||||||
|
listening on two TCP ports.
|
||||||
|
|
||||||
|
Once it is running, `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log` is where it logs — a
|
||||||
|
service has no console to write to. Seven days are kept.
|
||||||
|
|
||||||
|
### A5. Connect the website, start the shard, verify
|
||||||
|
|
||||||
|
Exactly as in [§5](#5-connect-the-website) and [§6](#6-start-servuo-and-verify): paste the four
|
||||||
|
values into Admin → Shard, start ServUO, then check `[bridge status` in game and `/health` on the
|
||||||
|
sidecar.
|
||||||
|
|
||||||
|
### A6. Updating by hand
|
||||||
|
|
||||||
|
Re-read `current.json`, and if either version moved: replace the sidecar binary and restart its
|
||||||
|
service; re-extract the overlay tarball over your tree and restart ServUO. Keep the two in step —
|
||||||
|
`current.json` is the only statement that a given pair speaks the same protocol.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B — `sidecar.toml` reference
|
||||||
|
|
||||||
|
Written on first run with a generated token. Environment variables override the file; the file
|
||||||
|
overrides these defaults.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[shard]
|
||||||
|
bind = "127.0.0.1:7788" # where the SHARD dials in. Keep this on loopback.
|
||||||
|
|
||||||
|
[web]
|
||||||
|
bind = "127.0.0.1:8080" # where the WEBSITE connects. Widen only with a firewall in front.
|
||||||
|
auth_token = "…" # generated if blank; the website's Admin → Shard "Auth token"
|
||||||
|
|
||||||
|
[store]
|
||||||
|
path = "uo-link.db" # relative paths resolve against this file's directory, not the CWD
|
||||||
|
```
|
||||||
|
|
||||||
|
| Environment variable | Overrides |
|
||||||
|
|---|---|
|
||||||
|
| `UOLINK_CONFIG` | Which config file to read (`--config <PATH>` outranks it) |
|
||||||
|
| `UOLINK_SHARD_BIND` | `[shard] bind` |
|
||||||
|
| `UOLINK_WEB_BIND` | `[web] bind` |
|
||||||
|
| `UOLINK_WEB_TOKEN` | `[web] auth_token` |
|
||||||
|
| `UOLINK_DB_PATH` | `[store] path` |
|
||||||
|
|
||||||
|
| Sidecar command | Output |
|
||||||
|
|---|---|
|
||||||
|
| `uo-link-sidecar --version` | `uo-link-sidecar 1.1.0 (protocol 3)` |
|
||||||
|
| `uo-link-sidecar --print-config [--config PATH]` | The JSON in [A3](#a3-install-the-sidecar). Provisions on first run. **Contains the token.** |
|
||||||
|
| `uo-link-sidecar --help` | Usage. An unrecognized argument exits `2` rather than starting a sidecar you did not ask for. |
|
||||||
|
|
||||||
|
Authentication is **always on**: a blank token is generated and written back, so the web surface is
|
||||||
|
never unauthenticated. `/health` is the one unauthenticated route, so monitoring can reach it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix C — `Config/Bridge.cfg` settings worth reviewing
|
||||||
|
|
||||||
|
The file is deployed heavily commented and every setting has a working default — you can leave it
|
||||||
|
entirely alone. These are the ones most shards want to look at once. Run `[bridge reload` after
|
||||||
|
editing; endpoint changes take effect on the next reconnect.
|
||||||
|
|
||||||
|
| Setting | Default | Why you might change it |
|
||||||
|
|---|---|---|
|
||||||
|
| `LinkUrl` | `https://yoursite/link` | Shown in game when a player runs `[link` to connect their account. **Set this to your site.** |
|
||||||
|
| `PublicConnectAddress` | *(blank)* | The one connection detail the bridge will publish, e.g. `play.myshard.com,2593`. Blank omits it; `Server.cfg`'s address is **never** published automatically. |
|
||||||
|
| `AdminWriteEnabled` | `false` | Opt-in staff write plane: kick/ban/broadcast from the website. Authorization is enforced on the website; `AdminAccessFloor` is the shard-side floor that even a compromised sidecar cannot cross. |
|
||||||
|
| `MarketEnabled`, `MarketSweepSeconds`, `MarketSweepBatch` | `true`, `60`, `25` | The player-vendor index. Coverage takes `ceil(vendors / batch) × seconds` — 500 vendors is one full pass every 20 minutes at the defaults. |
|
||||||
|
| `PointsLeaderboardEnabled`, `PointsTopN`, `PointsSystems` | `true`, `10`, *(all shown on the loyalty gump)* | Standings boards. One frame **per system**, and ServUO carries ~25 of them, so a large `TopN` multiplies. |
|
||||||
|
| `RulesetEnabled`, `RulesetIncludeSchedule` | `true`, `true` | Publishes your ruleset (expansion, caps, systems on/off) to the site's rules page. Turn the schedule off if you would rather not advertise a predictable restart window. |
|
||||||
|
| `SignupMode` | `hybrid` | Which side may mint accounts — `website`, `game`, or `hybrid`. Pair `website` with `Accounts.AutoCreateAccounts=false`, or an in-game login still creates accounts. |
|
||||||
|
| `QueueCap` | `10000` | Outbound queue cap. On overflow the plugin **drops oldest** and counts drops, because a stalled sidecar must never take the shard down with it. |
|
||||||
|
|
||||||
|
Sweep intervals (`StatSweepSeconds`, `DecaySweepSeconds`, `EconomySweepSeconds`, and the rest) trade
|
||||||
|
freshness against Core-thread time. The measured cost is small — a vitals sweep is 0.0015 ms per
|
||||||
|
character, so 1000 online players is ~1.5 ms per pass — but there is rarely anything to gain by
|
||||||
|
hurrying them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
| Doc | What |
|
||||||
|
|---|---|
|
||||||
|
| [PLAN.md](PLAN.md) | The installer's design of record — phases, locked decisions, the bundle model |
|
||||||
|
| [`bundles/README.md`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/README.md) | The compat matrix: what a bundle is and how it is composed |
|
||||||
|
| [link/INTEGRATION.md](../link/INTEGRATION.md) | The sidecar's HTTP/WS API — for anyone integrating something other than the website |
|
||||||
|
| [link/ADMIN_CONTROLS.md](../link/ADMIN_CONTROLS.md) | The staff write plane in detail, before you turn `AdminWriteEnabled` on |
|
||||||
|
| [website/SHARD_VISIBILITY.md](../website/SHARD_VISIBILITY.md) | Which shard data each audience sees, configured on the website |
|
||||||
|
| [link/SHARD_PREREQS.md](../link/SHARD_PREREQS.md) | A worked example of diagnosing a shard whose scripts silently stopped compiling |
|
||||||
1383
installer/PLAN.md
Normal file
1383
installer/PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
67
installer/PROJECT_TREE.md
Normal file
67
installer/PROJECT_TREE.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Runic Gateway installer — Project Tree
|
||||||
|
|
||||||
|
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
|
||||||
|
> the [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) repository, which
|
||||||
|
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
|
||||||
|
> by hand — changes will be overwritten by the next sync.
|
||||||
|
|
||||||
|
A snapshot of the tracked files in the repository (build output, dependencies, and other
|
||||||
|
git-ignored paths are excluded).
|
||||||
|
|
||||||
|
```text
|
||||||
|
installer/
|
||||||
|
├── .gitea/
|
||||||
|
│ ├── ISSUE_TEMPLATE/
|
||||||
|
│ │ ├── bug_report.md
|
||||||
|
│ │ ├── config.yaml
|
||||||
|
│ │ └── feature_request.md
|
||||||
|
│ ├── scripts/
|
||||||
|
│ │ └── gen_tree.py
|
||||||
|
│ ├── workflows/
|
||||||
|
│ │ ├── bundle.yml
|
||||||
|
│ │ ├── pr-checks.yml
|
||||||
|
│ │ ├── release.yml
|
||||||
|
│ │ └── sync-project-tree.yml
|
||||||
|
│ └── PULL_REQUEST_TEMPLATE.md
|
||||||
|
├── bundles/
|
||||||
|
│ └── README.md
|
||||||
|
├── src/
|
||||||
|
│ ├── backup.rs
|
||||||
|
│ ├── bundle.rs
|
||||||
|
│ ├── cli.rs
|
||||||
|
│ ├── diff.rs
|
||||||
|
│ ├── doctor.rs
|
||||||
|
│ ├── install.rs
|
||||||
|
│ ├── lib.rs
|
||||||
|
│ ├── main.rs
|
||||||
|
│ ├── net.rs
|
||||||
|
│ ├── overlay.rs
|
||||||
|
│ ├── patch.rs
|
||||||
|
│ ├── paths.rs
|
||||||
|
│ ├── record.rs
|
||||||
|
│ ├── service.rs
|
||||||
|
│ ├── servuo.rs
|
||||||
|
│ ├── sidecar.rs
|
||||||
|
│ ├── tier.rs
|
||||||
|
│ ├── ui.rs
|
||||||
|
│ ├── uninstall.rs
|
||||||
|
│ ├── update.rs
|
||||||
|
│ └── util.rs
|
||||||
|
├── tests/
|
||||||
|
│ ├── fixtures/
|
||||||
|
│ │ ├── commandlogging-event.patch
|
||||||
|
│ │ ├── patch_tier.json
|
||||||
|
│ │ ├── playervendor-sale-eventsink.patch
|
||||||
|
│ │ ├── playervendor-sale-gump.patch
|
||||||
|
│ │ └── published-bundle.json
|
||||||
|
│ └── real_patches.rs
|
||||||
|
├── .gitignore
|
||||||
|
├── Cargo.lock
|
||||||
|
├── Cargo.toml
|
||||||
|
├── CODE_OF_CONDUCT.md
|
||||||
|
├── CONTRIBUTING.md
|
||||||
|
├── CONTRIBUTORS.md
|
||||||
|
├── LICENSE.md
|
||||||
|
├── README.md
|
||||||
|
└── SECURITY.md
|
||||||
|
```
|
||||||
@@ -23,7 +23,31 @@ Every route **except `GET /health`** requires the shared token from `sidecar.tom
|
|||||||
| REST | `X-Api-Key: <token>` |
|
| REST | `X-Api-Key: <token>` |
|
||||||
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
|
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
|
||||||
|
|
||||||
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
|
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run; rotate by editing `sidecar.toml` and restarting.
|
||||||
|
|
||||||
|
To read it back afterwards, ask the sidecar rather than hunting through the startup log or the TOML:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
|
||||||
|
{
|
||||||
|
"component": "uo-link-sidecar",
|
||||||
|
"config_created": false,
|
||||||
|
"config_path": "/etc/runicgateway/sidecar.toml",
|
||||||
|
"protocol": 3,
|
||||||
|
"shard": { "bind": "127.0.0.1:7788" },
|
||||||
|
"store": { "path": "/var/lib/runicgateway/uo-link.db" },
|
||||||
|
"token_generated": false,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"web": {
|
||||||
|
"auth_required": true,
|
||||||
|
"auth_token": "c0f04ace…",
|
||||||
|
"bind": "127.0.0.1:8080",
|
||||||
|
"ws_path": "/ws"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the same set of values Admin → Shard asks for — base URL and WS URL are `web.bind` (substituting a reachable host if it is `0.0.0.0`) plus `web.ws_path`. The output **contains the token in clear text**, so treat it as a secret: it belongs in a terminal, not in a log or a CI artifact. `--print-config` also performs first-run setup, writing the config file and generating a token if there is none, and reports whether it did via `config_created` / `token_generated`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,31 +55,31 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`.
|
|||||||
|
|
||||||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
||||||
|
|
||||||
- Every response carries an **`X-UOLink-Version: 2`** header.
|
- Every response carries an **`X-UOLink-Version: 3`** header.
|
||||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
|
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`.
|
||||||
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
- **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
|
{ "error": "protocol version mismatch", "sidecar_protocol": 3, "client_protocol": "2" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
||||||
|
|
||||||
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
|
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
|
||||||
|
|
||||||
**v3 (Protocol 3.0) is being built and the version has not been bumped yet.** It is defined as *adds
|
**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
|
||||||
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to
|
`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
|
||||||
`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main`
|
the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
|
||||||
cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and
|
ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3
|
||||||
the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site
|
sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the
|
||||||
repeatedly.
|
version number alone tells you what is available.
|
||||||
|
|
||||||
Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints.
|
**Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a
|
||||||
That is safe in the direction that matters: event kinds are additive, and a client that ignores
|
client still declaring `2` gets a 409 on every protected route and, on the WebSocket, a closed
|
||||||
unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not**
|
connection on the `ws.hello` mismatch. So update the pinned version at the same time you deploy the
|
||||||
do is infer feature availability from the version number during this window — probe the endpoint, or
|
v3 sidecar. Nothing that existed in v2 changed shape, so that is the whole migration — the website
|
||||||
treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no
|
does it with a one-shot boot migration of its `uo_link_config.protocol` row ([`v3.md`](v3.md) §4.1);
|
||||||
feature-negotiation array**: v3 implies all three kinds.
|
a third-party client changes the constant it sends.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,7 +92,7 @@ GET /health (no auth)
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
|
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
|
||||||
"protocol": 1,
|
"protocol": 3,
|
||||||
"plugin_connected": true, // is the shard link up right now?
|
"plugin_connected": true, // is the shard link up right now?
|
||||||
"database": "ok", // "ok" | "error"
|
"database": "ok", // "ok" | "error"
|
||||||
"uptime": "3d 12h",
|
"uptime": "3d 12h",
|
||||||
@@ -91,7 +115,7 @@ A push-only stream of game events as they happen. You do **not** send commands o
|
|||||||
**On connect**, the first frame is:
|
**On connect**, the first frame is:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "kind": "ws.hello", "protocol": 1 }
|
{ "kind": "ws.hello", "protocol": 3 }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
|
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
|
||||||
@@ -449,6 +473,78 @@ would carry ~25 zeroes. `maxPoints` follows the same `0 == uncapped` rule as the
|
|||||||
a points lookup stops at the character's own row, but a rank must count every row that beats them, in
|
a points lookup stops at the character's own row, but a rank must count every row that beats them, in
|
||||||
every system, on every profile build. Derive rank from `points.board` instead for anyone in the top N.
|
every system, on every profile build. Derive rank from `points.board` instead for anyone in the top N.
|
||||||
|
|
||||||
|
#### Player-vendor marketplace (Protocol 3.0)
|
||||||
|
|
||||||
|
The shard-wide shop index: every player vendor's shop name, owner, location and priced inventory —
|
||||||
|
the same set the in-game **Vendor Search** gump reads, published so a site can offer the same search
|
||||||
|
from outside the game.
|
||||||
|
|
||||||
|
An **amortized round-robin diff sweep**, not a snapshot RPC, and the distinction is load-bearing:
|
||||||
|
`rpc.rs::try_route` correlates a reply on the FIRST frame carrying a matching `reqId`, so a chunked
|
||||||
|
reply sharing one `reqId` would deliver chunk 1 to the HTTP caller and leak chunks 2..N onto the
|
||||||
|
broadcast feed. A whole-world snapshot could not fit in one frame inside the 10 s reply timeout
|
||||||
|
either. The per-account `vendor.snapshot` RPC (§5) is unaffected and still serves the player portal.
|
||||||
|
|
||||||
|
Each tick inventories at most `Bridge.MarketSweepBatch` vendors (default 25) starting from a
|
||||||
|
persistent cursor, so **per-tick cost is bounded independently of world size**; full coverage takes
|
||||||
|
`ceil(vendors / batch) × MarketSweepSeconds`. A vendor is emitted only when its contents, prices,
|
||||||
|
shop name or location actually change.
|
||||||
|
|
||||||
|
| kind | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `location{}`, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. |
|
||||||
|
| `vendor.listing.remove` | `serial` | The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag. |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"kind":"vendor.listing","serial":"0x40001234",
|
||||||
|
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
||||||
|
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
||||||
|
"region":"Britain","house":"Darrow's Villa"},
|
||||||
|
"count":2,"total":2,"truncated":false,
|
||||||
|
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
|
||||||
|
"price":25000,"name":null,"cliloc":1023721},
|
||||||
|
{"serial":"0x40012ABD","itemId":7026,"hue":1157,"amount":3,
|
||||||
|
"price":500,"name":"a shard sigil","cliloc":1041243}],
|
||||||
|
"t":1752489280000}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Six things consumers get wrong.**
|
||||||
|
|
||||||
|
1. **`name` is `null` for nearly every item; `cliloc` is the real label.** Items carry a
|
||||||
|
`LabelNumber`, not a name. The plugin deliberately never calls `VendorSearch.GetItemName`, which
|
||||||
|
builds an `ObjectPropertyList`, serialises it and byte-parses the packet **per item** — a
|
||||||
|
multi-hundred-millisecond stall across a full pass. (It would not work anyway: every current
|
||||||
|
client ships its cliloc files compressed and ServUO's bundled `Ultima.StringList` cannot read
|
||||||
|
them, so the in-game gump has the same gap.) Resolve clilocs consumer-side; a non-null `name` is a
|
||||||
|
player-set literal and is strictly more specific, so **prefer it over the cliloc**.
|
||||||
|
2. **`location` is one nested object, and it may be absent entirely.** It is nested so that a
|
||||||
|
consumer gating vendor whereabouts gates one field rather than five that can drift apart — the
|
||||||
|
website's `market.location` rule removes the whole object. Treat a missing `location` as "not
|
||||||
|
published", not as an error.
|
||||||
|
3. **`truncated` means the shop holds more than the frame carries.** `count` is what was published,
|
||||||
|
`total` is what the shop actually holds, capped by `Bridge.MarketMaxListings` (default 250). A
|
||||||
|
commodity reseller with thousands of stacked resources is real and an uncapped frame for one is
|
||||||
|
measured in megabytes. Say "showing 250 of 3,104" rather than presenting a partial shop as
|
||||||
|
complete.
|
||||||
|
4. **`child: true` means the price buys the ENCLOSING CONTAINER.** ServUO prices a container as a
|
||||||
|
unit and everything inside inherits that price with no `VendorItem` of its own; `DoSearch`
|
||||||
|
surfaces the same flag. A UI that prints the container's price against each item inside it is
|
||||||
|
lying about the shard.
|
||||||
|
5. **Opted-out vendors are absent, and that is a privacy control.** `pv.VendorSearch` is the player's
|
||||||
|
own in-game toggle and the sweep honours it — hide your vendor in game and it is hidden here too.
|
||||||
|
The same goes for `Map.Internal` and a null backpack, matching `DoSearch`. Process
|
||||||
|
`vendor.listing.remove` promptly: it is how a player *revoking* that consent reaches you.
|
||||||
|
6. **Prices are inherently stale, by design.** The round-robin sweep means a shop can be a full cycle
|
||||||
|
behind. Any UI over this must say how old the data may be — the website derives it from the oldest
|
||||||
|
vendor row.
|
||||||
|
|
||||||
|
Entries carry `ownerSerial`/`ownerName` and **never `acct` or `webId`**, the same rule `points.board`
|
||||||
|
follows. Absent entirely if the shard runs `Bridge.MarketEnabled=false` or an older plugin. Render
|
||||||
|
from `GET /market` (§6) on connect, then keep live with these events — though note that a live
|
||||||
|
firehose of whole vendor inventories is the largest stream the bridge produces, and a consumer that
|
||||||
|
only needs a browsable index (as the website does) is better served by the REST read plus the
|
||||||
|
periodic re-sweep.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. REST — read queries
|
## 5. REST — read queries
|
||||||
@@ -829,6 +925,35 @@ standings built over months and blanking them during a restart reads as data los
|
|||||||
one excluded by `Bridge.PointsSystems`). That is distinct from a published board nobody has scored in
|
one excluded by `Bridge.PointsSystems`). That is distinct from a published board nobody has scored in
|
||||||
yet, which is **200** with an empty `top[]` — and the two are worth rendering differently.
|
yet, which is **200** with an empty `top[]` — and the two are worth rendering differently.
|
||||||
|
|
||||||
|
### Player-vendor marketplace (Protocol 3.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /market?limit=200&offset=0
|
||||||
|
→ { "vendors": [ {"kind":"vendor.listing","serial":"0x40001234",
|
||||||
|
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
||||||
|
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
||||||
|
"region":"Britain","house":"Darrow's Villa"},
|
||||||
|
"count":2,"total":2,"truncated":false,"items":[ ... ],"t":...}, ... ],
|
||||||
|
"total": 137, "limit": 200, "offset": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Every vendor's latest shop, exactly as `vendor.listing` published it (§4 for the frame and its six
|
||||||
|
gotchas). Served from the sidecar's projection, so it answers while the shard is down.
|
||||||
|
|
||||||
|
**This is the only PAGED read the sidecar serves**, because it is the only board that can be a whole
|
||||||
|
world's inventory. `limit` is clamped to 1..1000 (default 200); `total` is returned so a caller knows
|
||||||
|
when to stop rather than paging until it sees a short page, which would race a concurrent sweep.
|
||||||
|
Ordering is by **serial**, not by shop name — a serial is stable while a shop name is renameable, so
|
||||||
|
a rename mid-walk cannot make a vendor skip or repeat a page.
|
||||||
|
|
||||||
|
The route is `/market` and deliberately **not** `/vendors`: `/vendors/{account}` next door is the
|
||||||
|
per-account RPC (§5), and two routes a prefix apart meaning "this player's shops" and "every shop on
|
||||||
|
the shard" is a trap nobody wins.
|
||||||
|
|
||||||
|
Frames are served **verbatim**, owner names and coordinates included. That is not an oversight: the
|
||||||
|
sidecar defines no audiences. Deciding who may see what is the consuming site's job — see
|
||||||
|
[`v3.md`](v3.md) §3 for how the website does it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Status codes
|
## 7. Status codes
|
||||||
@@ -854,7 +979,7 @@ yet, which is **200** with an empty `top[]` — and the two are worth rendering
|
|||||||
A typical character page:
|
A typical character page:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
|
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "3" };
|
||||||
|
|
||||||
// 1. render the roster
|
// 1. render the roster
|
||||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||||
|
|||||||
29
link/PLAN.md
29
link/PLAN.md
@@ -284,6 +284,15 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
|
|||||||
|
|
||||||
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
|
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
|
||||||
|
|
||||||
|
**Update (3.0).** That recommendation held, and the reason it had to hold turned out to be stronger
|
||||||
|
than "avoids a dependency": **ServUO cannot resolve clilocs either.** Every current client ships its
|
||||||
|
`Cliloc.*` files compressed, and the bundled `Ultima.StringList` reads only the older plain layout —
|
||||||
|
so `VendorSearch.StringList` is null and `VendorSearch.GetItemName` returns `item.Name` on any modern
|
||||||
|
shard. The in-game Vendor Search gump has the same gap, which is why `vendor.listing` never calls it.
|
||||||
|
Pushing name resolution to the plugin was never an option. See [`v3.md`](v3.md) §8.6 and
|
||||||
|
`docs/website/CLILOCS.md` for how the site gets a table instead (the operator converts one from their
|
||||||
|
own client, once).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Corrections to `BRIDGE_FINDINGS.md`
|
## 8. Corrections to `BRIDGE_FINDINGS.md`
|
||||||
@@ -327,6 +336,23 @@ leaderboards. `BridgePoints` is the widest read the bridge performs: ten of Serv
|
|||||||
keep a row for every character ever created, so it selects the top N in a single bounded pass rather
|
keep a row for every character ever created, so it selects the top N in a single bounded pass rather
|
||||||
than sorting, and runs on a deliberately slow 300 s interval.
|
than sorting, and runs on a deliberately slow 300 s interval.
|
||||||
|
|
||||||
|
Also shipped: **`vendor.listing`** ([`v3.md`](v3.md) §8), `BridgeMarket.cs`, the shard-wide
|
||||||
|
player-vendor index. It introduces the one sweep pattern the bridge did not previously have — an
|
||||||
|
**amortized round-robin**. Every other sweep walks its whole collection per tick, which is fine for
|
||||||
|
tens of houses or a fixed set of point systems and is not fine for a world of shops whose inventories
|
||||||
|
recurse into containers. `BridgeMarket` inventories at most `MarketSweepBatch` vendors per tick from
|
||||||
|
a persistent cursor, so the per-tick cost is bounded by the batch rather than by world size, and full
|
||||||
|
coverage takes `ceil(vendors / batch) x MarketSweepSeconds`. Measured at **15.4 ms** for a cold tick
|
||||||
|
of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff), on a shard of 209k
|
||||||
|
items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own
|
||||||
|
`PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site.
|
||||||
|
|
||||||
|
That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 →
|
||||||
|
3** and the coordinated `edge` → `main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1).
|
||||||
|
The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints
|
||||||
|
but changes nothing that already existed in v2 — so the operator-visible break is limited to
|
||||||
|
re-pinning the version, which the website does for itself in a one-shot boot migration.
|
||||||
|
|
||||||
### Config keys (`Config/Bridge.cfg`)
|
### Config keys (`Config/Bridge.cfg`)
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
@@ -342,7 +368,8 @@ Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is
|
|||||||
|
|
||||||
The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals
|
The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals
|
||||||
for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's
|
for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's
|
||||||
`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` block).
|
`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` and `Market*`
|
||||||
|
blocks).
|
||||||
**`servuo-plugins/overlay/Config/Bridge.cfg`
|
**`servuo-plugins/overlay/Config/Bridge.cfg`
|
||||||
is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults.
|
is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults.
|
||||||
|
|
||||||
|
|||||||
@@ -18,18 +18,23 @@ link/
|
|||||||
│ ├── scripts/
|
│ ├── scripts/
|
||||||
│ │ └── gen_tree.py
|
│ │ └── gen_tree.py
|
||||||
│ ├── workflows/
|
│ ├── workflows/
|
||||||
|
│ │ ├── pr-checks.yml
|
||||||
│ │ ├── release.yml
|
│ │ ├── release.yml
|
||||||
│ │ ├── sonarqube.yml
|
│ │ ├── sonarqube.yml
|
||||||
│ │ └── sync-project-tree.yml
|
│ │ └── sync-project-tree.yml
|
||||||
│ └── PULL_REQUEST_TEMPLATE.md
|
│ └── PULL_REQUEST_TEMPLATE.md
|
||||||
├── sidecar/
|
├── sidecar/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
|
│ │ ├── app.rs
|
||||||
|
│ │ ├── cli.rs
|
||||||
│ │ ├── config.rs
|
│ │ ├── config.rs
|
||||||
│ │ ├── main.rs
|
│ │ ├── main.rs
|
||||||
│ │ ├── rpc.rs
|
│ │ ├── rpc.rs
|
||||||
│ │ ├── shard.rs
|
│ │ ├── shard.rs
|
||||||
│ │ ├── store.rs
|
│ │ ├── store.rs
|
||||||
│ │ └── web.rs
|
│ │ ├── unix.rs
|
||||||
|
│ │ ├── web.rs
|
||||||
|
│ │ └── windows.rs
|
||||||
│ ├── .gitignore
|
│ ├── .gitignore
|
||||||
│ ├── Cargo.lock
|
│ ├── Cargo.lock
|
||||||
│ ├── Cargo.toml
|
│ ├── Cargo.toml
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# uo-link
|
# uo-link
|
||||||
|
|
||||||
|
> **Historical snapshot**, from before the bridge was split into
|
||||||
|
> [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) (sidecar) and
|
||||||
|
> [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins)
|
||||||
|
> (plugin). Kept for the architecture notes below. **To set a shard up, use
|
||||||
|
> [installer/INSTALL.md](../installer/INSTALL.md)** — `deploy.ps1` as described here is a developer
|
||||||
|
> tool, not the operator path.
|
||||||
|
|
||||||
ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
|
ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
170
link/v3.md
170
link/v3.md
@@ -1,6 +1,6 @@
|
|||||||
# Protocol 3.0 — Shard content, standings & the visibility framework
|
# Protocol 3.0 — Shard content, standings & the visibility framework
|
||||||
|
|
||||||
**Status:** In progress. All work lands on an `edge` branch in each repo; `edge` → `main` is the v3 cutover.
|
**Status:** Feature-complete on `edge`; the cutover (order 6) is in review. All work lands on an `edge` branch in each repo; `edge` → `main` is the v3 cutover.
|
||||||
**Date:** 2026-07-28
|
**Date:** 2026-07-28
|
||||||
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
|
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
|
||||||
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
||||||
@@ -15,14 +15,19 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
|
|||||||
| 2 | **B/1** — `world.ruleset` (§5) | ✅ **Done** | servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3), link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17), website [#111](https://gitea.whitlocktech.com/RunicGateway/website/pulls/111), docs [#66](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/66) |
|
| 2 | **B/1** — `world.ruleset` (§5) | ✅ **Done** | servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3), link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17), website [#111](https://gitea.whitlocktech.com/RunicGateway/website/pulls/111), docs [#66](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/66) |
|
||||||
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
|
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
|
||||||
| 4 | **B/2** — `points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
|
| 4 | **B/2** — `points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
|
||||||
| 5a | **B/3 dependency** — cliloc table (§8.6) | 🟨 In review | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) |
|
| 5a | **B/3 dependency** — cliloc table (§8.6) | ✅ **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) |
|
||||||
| 5b | **B/3** — `vendor.listing` (§8) | ⬜ Not started | — |
|
| 5b | **B/3** — `vendor.listing` (§8) | ✅ **Done** | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) |
|
||||||
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — |
|
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3 (§4) | 🟨 In review | the bump: link [#20](https://gitea.whitlocktech.com/RunicGateway/link/pulls/20), website [#117](https://gitea.whitlocktech.com/RunicGateway/website/pulls/117), docs [#72](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/72) — then `edge` → `main`: servuo-plugins [#6](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/6), link [#21](https://gitea.whitlocktech.com/RunicGateway/link/pulls/21), website [#118](https://gitea.whitlocktech.com/RunicGateway/website/pulls/118), docs [#73](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/73) |
|
||||||
|
|
||||||
Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather
|
Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather
|
||||||
than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item
|
than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item
|
||||||
names; 5b is the four-repo wire change.
|
names; 5b is the four-repo wire change.
|
||||||
|
|
||||||
|
**The `edge` → `main` half of order 6 is held for Android parity** (decided 2026-07-30, see §10): the
|
||||||
|
app sees none of the four new features and gates shard nav on session role alone, so merging the
|
||||||
|
cutover first would ship a shard whose app client silently disagrees with the web client about what is
|
||||||
|
public. The **bump** PRs into `edge` are unaffected and merge normally.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Why 3.0
|
## 1. Why 3.0
|
||||||
@@ -242,6 +247,39 @@ admin-set `uo_link_config.protocol` column — so it happens **exactly once**, a
|
|||||||
from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides.
|
from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides.
|
||||||
- No feature-negotiation array anywhere — v3 implies all three kinds.
|
- No feature-negotiation array anywhere — v3 implies all three kinds.
|
||||||
|
|
||||||
|
### 4.1 What the bump actually touches
|
||||||
|
|
||||||
|
The version lives in five places, and all five move together:
|
||||||
|
|
||||||
|
| Where | Change |
|
||||||
|
|---|---|
|
||||||
|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION` 2 → 3 (with the v3 note beside the v2 one), plus the sidecar README's worked example |
|
||||||
|
| `website/server/db/schema.sql` | `uo_link_config.protocol` column default 1 → 3, plus the boot migration below |
|
||||||
|
| `website/server/src/model/uoLinkConfig/uoLinkConfig.model.js` | `DEFAULT_PROTOCOL` — what a site with nothing saved yet declares |
|
||||||
|
| `website/server/src/utils/uoLinkClient.js` + `uoLinkSocket.js` | the `config.protocol || …` fallbacks, so an unset value can never quietly send `1` and 409 with a confusing message |
|
||||||
|
| `website/client/.../ShardAdmin.jsx`, `website/.env.example` | the admin form's initial value and the documented env default |
|
||||||
|
|
||||||
|
**The migration has to be one-shot, and that is the only subtle part.** `schema.sql` is re-run on
|
||||||
|
*every* boot (`utils/db.js::ensureSchema`), and every other statement in its migration block is an
|
||||||
|
idempotent `ADD COLUMN IF NOT EXISTS` / `MODIFY`. A bare `UPDATE uo_link_config SET protocol = 3`
|
||||||
|
would not be idempotent in the sense that matters: `protocol` is **admin-editable**, so an operator
|
||||||
|
who deliberately pins an older sidecar in Admin → Shard would silently be un-pinned on the next
|
||||||
|
restart. It is therefore gated on a marker row in `settings`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
|
||||||
|
UPDATE uo_link_config SET protocol = 3
|
||||||
|
WHERE id = 1 AND protocol < 3
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
|
||||||
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
||||||
|
```
|
||||||
|
|
||||||
|
The marker is written *after* the `UPDATE`, so the first boot on the new build migrates and every
|
||||||
|
later boot is a no-op. A fresh install has no `uo_link_config` row to update and simply gets the
|
||||||
|
marker plus the new column default. `protocol < 3` rather than `= 2` so an install that never left
|
||||||
|
the old default of `1` is carried across too — it could not have been talking to a v2 sidecar
|
||||||
|
anyway.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Part B/1 — `world.ruleset` ✅ Done
|
## 5. Part B/1 — `world.ruleset` ✅ Done
|
||||||
@@ -312,8 +350,11 @@ Sidecar — `store.rs`: singleton `ruleset(id CHECK(id=1), rev, json, updated_t)
|
|||||||
`main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so
|
`main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so
|
||||||
it answers during a shard outage (`PROTOCOL_2.md` §12.2).
|
it answers during a shard outage (`PROTOCOL_2.md` §12.2).
|
||||||
|
|
||||||
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so follow the
|
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so it cannot use the
|
||||||
`getPresence()` block's explicit form, not the array-only `snapshot()` helper); `shardIngest.js` →
|
array-only `snapshot()` helper — but it **must still go through `shardIngest.ingest()`**, as
|
||||||
|
`ingestEach` does, rather than calling `shardState.setRuleset` directly: the two arrival orders have
|
||||||
|
to produce the same stored frame, and a direct call quietly made backfill a second writer that
|
||||||
|
skipped the normalization below); `shardIngest.js` →
|
||||||
`shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello`
|
`shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello`
|
||||||
already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table
|
already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table
|
||||||
(`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind
|
(`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind
|
||||||
@@ -322,6 +363,17 @@ already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_rulese
|
|||||||
Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside
|
Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside
|
||||||
`/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`.
|
`/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`.
|
||||||
|
|
||||||
|
**The `shard` field falls back to the instance's own name.** ServUO ships `Server.cfg` with
|
||||||
|
`Name=My Shard`, so an operator who never edited it publishes that verbatim — which is the shard
|
||||||
|
saying *unnamed*, not naming anything, and the rules page then reads "My Shard" under a header
|
||||||
|
carrying the real one. `shardIngest` substitutes `settings.getInstanceName()` (the admin-editable
|
||||||
|
site title, else `BRAND_NAME` — the same resolution `getPublic().brand.name` uses, so one install
|
||||||
|
never shows two names) when `shard` is absent, blank, or exactly the stock default, matched
|
||||||
|
case-insensitively and trim-tolerantly but only as a **whole** value: a shard genuinely called
|
||||||
|
*"My Shard Reborn"* has named itself and keeps it. Applied at **ingest**, not on read, because the
|
||||||
|
ruleset is also broadcast live — the same object goes to the SSE fan-out, so a read-time
|
||||||
|
substitution would be undone by the next reconnect's frame.
|
||||||
|
|
||||||
### 5.4 Risk
|
### 5.4 Risk
|
||||||
|
|
||||||
Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit
|
Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit
|
||||||
@@ -600,6 +652,15 @@ Client — NEW `routes/public/Leaderboards.jsx` at `/site/leaderboards`; a "Loya
|
|||||||
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
|
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
|
||||||
`AdminCharacter.jsx`.
|
`AdminCharacter.jsx`.
|
||||||
|
|
||||||
|
**An unscored board still renders a row.** Most systems on a young shard have `top: []`, and a page
|
||||||
|
of blank cards reads as broken rather than as new — so a board with no entries shows a single
|
||||||
|
placeholder bearing the **instance's own name** with an em dash where a score goes, above the
|
||||||
|
existing "nobody has earned points here yet" line. It is deliberately **not** shaped like an entry —
|
||||||
|
no rank, no medal, no bar, muted — because a placeholder that looked like a real standing would be a
|
||||||
|
fabricated one; the first real entry replaces it outright. Purely presentational: the API keeps
|
||||||
|
sending an empty `top`, so no consumer ever receives an invented row. Web and app render it the same
|
||||||
|
way (`Leaderboards.jsx`, `LeaderboardsScreen.kt`).
|
||||||
|
|
||||||
### 7.5 What the run against a real shard changed
|
### 7.5 What the run against a real shard changed
|
||||||
|
|
||||||
The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a
|
The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a
|
||||||
@@ -639,7 +700,7 @@ if it is renamed back.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Part B/3 — `vendor.listing`
|
## 8. Part B/3 — `vendor.listing` 🟨 In review
|
||||||
|
|
||||||
### 8.1 It cannot be an RPC, and this is load-bearing
|
### 8.1 It cannot be an RPC, and this is load-bearing
|
||||||
|
|
||||||
@@ -807,6 +868,75 @@ and text paths converge on identical content.
|
|||||||
driven by `staleAt` (the oldest `shard_vendors.updated_at`). The round-robin sweep means data is
|
driven by `staleAt` (the oldest `shard_vendors.updated_at`). The round-robin sweep means data is
|
||||||
inherently up to one full cycle old, and the UI must say so.
|
inherently up to one full cycle old, and the UI must say so.
|
||||||
|
|
||||||
|
Shipped with a second page, `routes/public/MarketVendor.jsx` at `/site/market/vendors/:serial` —
|
||||||
|
where a search result points. It is the only surface that can render the two states the result list
|
||||||
|
cannot: a `truncated` shop (*"showing 250 of 3,104 — this shop holds more than the shard
|
||||||
|
publishes"*) and a `location` an admin has gated away, which is a real answer rather than an empty
|
||||||
|
coordinate.
|
||||||
|
|
||||||
|
### 8.8 What the build changed
|
||||||
|
|
||||||
|
Four things the implementation settled differently from §8 as written, all of them found by building
|
||||||
|
against the live shard.
|
||||||
|
|
||||||
|
**1. `location` is a nested object, not flat `map`/`x`/`y`/`region`.** §8.1's payload sketch had them
|
||||||
|
flat, and it would have made `market.location` — a rule Part A pre-wired — **inert**, exactly like
|
||||||
|
the `characterName` miss §7.5 records: `projectValue` matches literal JSON keys, so there is no
|
||||||
|
`location` key for the rule to match. Flat keys would have needed five rules that could drift apart.
|
||||||
|
Nesting makes one rule hide the facet, the coordinates, the region and the house together, on the
|
||||||
|
live frame and the stored read model alike, because both now spell it the same way.
|
||||||
|
|
||||||
|
The other pre-wired rule, `market.ownerName`, checked out — it is a real key on the frame. Owner is
|
||||||
|
written as flat `ownerSerial`/`ownerName` rather than through `BridgeJson.Actor`, which would add
|
||||||
|
`acct` and `webId`; same argument `points.board` makes. `ownerSerial` was **added** to the
|
||||||
|
configurable fields alongside `ownerName`, because an admin who hides the owner's name and leaves a
|
||||||
|
serial every other board resolves back to that name has not hidden anything.
|
||||||
|
|
||||||
|
**2. The per-vendor diff signature is the full listing set, not §8.3's `count | Σ(serial ^ price)`.**
|
||||||
|
That hash collides on the single most common change a shop makes: two items swapping prices, which
|
||||||
|
is what re-pricing looks like. The signature is built over the same buffer the frame is written
|
||||||
|
from, in the same order, so a match really does mean an identical frame.
|
||||||
|
|
||||||
|
**3. There is no `payload` column on `shard_vendors`.** §8.5 implied the board pattern (whole frame
|
||||||
|
in JSON, columns hoisted for display). It does not apply here: the items ARE the searchable rows, so
|
||||||
|
they are normalized into `shard_vendor_items` and there is nothing left worth duplicating. The
|
||||||
|
sidecar keeps the whole blob, because outage resilience is its job and search is not.
|
||||||
|
|
||||||
|
**4. Sweep cost is reported, and a slow tick warns.** The batch cap is a *claim* about per-tick cost,
|
||||||
|
and an operator tuning `MarketSweepBatch` was otherwise tuning blind. `[bridge status` now carries
|
||||||
|
`lastMs`/`maxMs`, and a tick over 50 ms prints a rate-limited warning naming the knob.
|
||||||
|
|
||||||
|
Measured on the live shard (27 vendors × 40 listings, 209k items / 43k mobiles):
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| First tick — 25 vendors emitted cold | **15.4 ms** |
|
||||||
|
| Second tick — the remaining 2 | **3.4 ms** |
|
||||||
|
| Steady state — nothing changed | **0.3 ms** |
|
||||||
|
| Website `/market` search over 1,040 listings | 1,040 total, names resolved |
|
||||||
|
| Cliloc re-resolution pass over 1,040 rows | **50 ms** |
|
||||||
|
|
||||||
|
The diff is what makes the steady state ~free; the batch cap is what bounds the cold case. Note the
|
||||||
|
arithmetic the warning exists for: at the default cap of 250 listings, a batch of 25 **full** shops
|
||||||
|
is 6,250 items ≈ 95 ms — over budget. Real shops hold tens, which is why 25 is the default, but a
|
||||||
|
shard of commodity resellers should lower the batch, and now it will be told to.
|
||||||
|
|
||||||
|
Two smaller things worth not rediscovering:
|
||||||
|
|
||||||
|
- **`BridgeJson.Escape` takes a NON-NULL string** — it dereferences `value.Length` immediately — and
|
||||||
|
`BridgeJson.Str` writes its own `,"key":` prefix, so neither serves a value inside a hand-built
|
||||||
|
object. Nearly everything this frame writes is legitimately null (an item's plain `Name` is null
|
||||||
|
for almost every item; a vendor in the street has no house), so that is the common path, not an
|
||||||
|
edge case. `BridgeMarket.Text()` is the two-line writer that was missing.
|
||||||
|
- **The ServUO console writes in the OS code page**, so an em dash in a `Console.WriteLine` renders
|
||||||
|
as `???` in the log an operator would paste into an issue. Bridge console output is ASCII.
|
||||||
|
|
||||||
|
Search-side, one thing the site had to fix rather than inherit: `%` and `_` in a user's query are
|
||||||
|
**LIKE** metacharacters, not SQL ones, so parameterization does not neutralize them — a search for
|
||||||
|
`%` would otherwise match every listing on the shard. `shardMarket.db.js` escapes them. (The atlas's
|
||||||
|
`LIKE` searches predate this and have the same shape over a much smaller table; worth a follow-up,
|
||||||
|
not a blocker here.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Sequencing
|
## 9. Sequencing
|
||||||
@@ -817,9 +947,9 @@ inherently up to one full cycle old, and the UI must say so.
|
|||||||
| 2 | **B/1** — `world.ruleset` (§5) | all four | new kind | ✅ Done |
|
| 2 | **B/1** — `world.ruleset` (§5) | all four | new kind | ✅ Done |
|
||||||
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
|
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
|
||||||
| 4 | **B/2** — `points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
|
| 4 | **B/2** — `points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
|
||||||
| 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | 🟨 In review |
|
| 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done |
|
||||||
| 5b | **B/3** — `vendor.listing` (§8) | all four | new kinds | ⬜ |
|
| 5b | **B/3** — `vendor.listing` (§8) | all four | new kinds | ✅ Done |
|
||||||
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | ⬜ |
|
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | 🟨 In review — `edge` → `main` held for Android parity (§10) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -841,8 +971,24 @@ inherently up to one full cycle old, and the UI must say so.
|
|||||||
- `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed
|
- `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed
|
||||||
artifacts, and `test/routeManifest.test.js` fails on drift.
|
artifacts, and `test/routeManifest.test.js` fails on drift.
|
||||||
|
|
||||||
**Follow-up, not scoped for 3.0:** the Android app consumes the same public/player shard API and will
|
**Android parity — now scoped, and it gates the cutover (decided 2026-07-30).** This was written as a
|
||||||
need `/public/shard/features` to hide its own nav. Track separately against `android-app/`.
|
"track separately" follow-up. It was re-examined before the cutover and the gap is wider than nav
|
||||||
|
hiding: the app consumes the same public/player shard API but has **no consumer for any of the four new
|
||||||
|
features** (`ruleset`, `leaderboards`, `market`, `atlas`), no `points` block on its character sheet, no
|
||||||
|
cliloc-resolved item names (§8.6), and — the part that matters for §3 — **it gates shard navigation on
|
||||||
|
session role alone**, so an admin who disables a feature or raises its audience leaves the app
|
||||||
|
rendering entries that `404`/`403` into a generic error where the web client hides them.
|
||||||
|
|
||||||
|
Two things were verified as already correct and are recorded so they are not re-derived: the app's SSE
|
||||||
|
request rides the same authenticated OkHttp client as every other call, so an app session resolves to
|
||||||
|
the same audience rung as the same account on the web; and every shard DTO in the app is
|
||||||
|
nullable-with-defaults, so field projection strips fields without a deserialization failure.
|
||||||
|
|
||||||
|
Scoped as **M11 in [`../android/PLAN.md`](../android/PLAN.md) §9**, two PRs (the visibility rules +
|
||||||
|
read-model adds, then the four screens). `edge` → `main` is held until both land, so web and app
|
||||||
|
surface the same shard on the same day. Neither PR is coupled to the merge order — on a pre-v3 website
|
||||||
|
every new route and `/public/shard/features` `404`s and the app falls back to today's behavior — so
|
||||||
|
holding the cutover is a schedule decision, not a technical dependency.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,20 @@ server/
|
|||||||
vendors, chars, sales, houses
|
vendors, chars, sales, houses
|
||||||
appeals.router.js (4) /player/appeals
|
appeals.router.js (4) /player/appeals
|
||||||
shard.controller.js + appeals.controller.js
|
shard.controller.js + appeals.controller.js
|
||||||
|
settings/ index.js owns the shared `noindex, requireAuth` gate
|
||||||
|
(authenticated, ANY role) and the mount table.
|
||||||
|
A fifth group, for site-wide settings that
|
||||||
|
need a login but no particular role — /public
|
||||||
|
is anonymous, /admin/settings is adminOnly
|
||||||
|
while AdminLayout renders for editors and
|
||||||
|
moderators, and /player is self-scoped data
|
||||||
|
nav.router.js (1) /settings/nav — the nav_admin and
|
||||||
|
nav_player overrides, read by the
|
||||||
|
layouts that render them
|
||||||
|
theme.router.js (1) /settings/theme/options — the closed
|
||||||
|
sets the admin appearance form is
|
||||||
|
built from. Static; no DB read
|
||||||
|
nav.controller.js + theme.controller.js
|
||||||
admin/ index.js mounts the capability routers below at their
|
admin/ index.js mounts the capability routers below at their
|
||||||
own prefixes; owns the shared
|
own prefixes; owns the shared
|
||||||
`noindex, isLoggedIn, staffOnly` gate and
|
`noindex, isLoggedIn, staffOnly` gate and
|
||||||
@@ -151,7 +165,15 @@ server/
|
|||||||
email.router.js (6) /admin/email — Gmail OAuth2
|
email.router.js (6) /admin/email — Gmail OAuth2
|
||||||
delivery — adminOnly
|
delivery — adminOnly
|
||||||
discordBot.router.js (2) /admin/discord-bot — adminOnly
|
discordBot.router.js (2) /admin/discord-bot — adminOnly
|
||||||
settings.router.js (2) /admin/settings — adminOnly
|
settings.router.js (4) /admin/settings — adminOnly. The
|
||||||
|
DELETE /:key is "reset to default"
|
||||||
|
and carries its own key allowlist
|
||||||
|
(theming/nav keys + the hero draft)
|
||||||
|
so it can never drop site_mode or
|
||||||
|
the uo-link config; POST
|
||||||
|
/brand-asset/:slot uploads a
|
||||||
|
logo/hero/favicon and writes the
|
||||||
|
brand_assets row in the same call
|
||||||
dashboard.router.js (2) GET /dashboard (staff-wide) and
|
dashboard.router.js (2) GET /dashboard (staff-wide) and
|
||||||
PUT /site-mode (adminOnly) — the
|
PUT /site-mode (adminOnly) — the
|
||||||
two singletons owning no path
|
two singletons owning no path
|
||||||
@@ -248,6 +270,61 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
|
|||||||
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
|
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
|
||||||
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
|
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
|
||||||
|
|
||||||
|
**Deliberately unseeded keys** — the theming & navigation overrides
|
||||||
|
(`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`). All
|
||||||
|
five are JSON strings, and **the absence of the row is the "use the default"
|
||||||
|
state**: colors/fonts/radii fall back to `theme.css`, assets to `BRAND_*`, navs
|
||||||
|
to the hardcoded `NAV` arrays. No migration writes defaults into them, because a
|
||||||
|
stored copy of a default would stop tracking the default. Resetting one is
|
||||||
|
therefore a `DELETE`, not a write — see `DELETABLE_KEYS` in `settings.model.js`
|
||||||
|
and [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §2.
|
||||||
|
|
||||||
|
Values are `TEXT`, so a JSON-valued key arrives as a **string** and every
|
||||||
|
consumer parses it. Server side that is `utils/settingsJson.js`
|
||||||
|
(`parseJsonSetting`), client side `client/src/lib/settingsJson.js` and
|
||||||
|
`parseLayout`; both treat a malformed or wrong-shaped value as **absent** rather
|
||||||
|
than as an error, so a hand-edited row degrades to the default instead of
|
||||||
|
rendering something broken.
|
||||||
|
|
||||||
|
**The three `nav_*` rows are presentation, never authorization.** An entry is
|
||||||
|
keyed by an item's existing `to` and may carry only `label`, `order`, `hidden`
|
||||||
|
and — admin nav only — `group`; `utils/navOverrides.js` rejects anything else on
|
||||||
|
write, naming the key. It deliberately does **not** check that a `to` exists: the
|
||||||
|
base `NAV` arrays are client constants, and duplicating them server-side would
|
||||||
|
create a second source of truth for navigation that drifts the first time a route
|
||||||
|
is added. `client/src/lib/navOverrides.js` drops an unknown `to` at merge time
|
||||||
|
instead, which is also what makes deleting a route in code safe. The merge runs
|
||||||
|
*before* the role and shard-feature filters in `SiteHeader.jsx` /
|
||||||
|
`AdminLayout.jsx`, which are unchanged and remain the boundary — a stored
|
||||||
|
`hidden: false` on a gated item shows nobody anything. `hidden: false` is
|
||||||
|
accepted (the editor sends it mid-edit) but never stored, so hiding stays
|
||||||
|
subtractive. `hidden` on `/admin/navigation` is dropped for `nav_admin`, because
|
||||||
|
that screen is the only UI that can un-hide anything.
|
||||||
|
|
||||||
|
**`nav_public` may also carry dropdown sections and admin-authored links**, as
|
||||||
|
`{ items, sections, links }` — a bare map still reads as `items`, and a nav with
|
||||||
|
no sections still stores one. A **section** has a label and a position and no
|
||||||
|
route at all: it only opens, so it adds no reachable surface. A **link** is the
|
||||||
|
one place a path may be named that the code does not declare, and is therefore
|
||||||
|
the one place the path rule applies: same-origin only, no scheme and no
|
||||||
|
protocol-relative `//host`. A link carries no gate of its own and needs none —
|
||||||
|
the page behind it enforces its own access, so an added link advertises a route
|
||||||
|
and never grants one. Coded entries stay in `items`, keyed by a route the base
|
||||||
|
array must declare, which is what keeps "an override cannot introduce a route"
|
||||||
|
structurally true. Sections and links are dropped for `nav_admin` / `nav_player`,
|
||||||
|
whose layouts cannot render them.
|
||||||
|
|
||||||
|
**`theme_visual` is resolved server-side, not shipped raw to the browser.**
|
||||||
|
`utils/themeResolve.js` layers `:root` ← preset ← custom, field by field, into
|
||||||
|
the CSS custom properties `getPublic()` returns as `theme`; the SPA's only job
|
||||||
|
is to write them onto `<html>` and take back what it wrote last time
|
||||||
|
(`client/src/lib/themeVars.js`). One authority for the merge means the effective
|
||||||
|
accent in `brand.accent` — the cross-repo contract the Android app and the
|
||||||
|
Discord bot theme themselves from — always agrees with what the website paints.
|
||||||
|
Values reaching a CSS variable are checked against closed sets on both paths:
|
||||||
|
strictly on write (400, naming the field) and forgivingly on read (drop the bad
|
||||||
|
field, keep its neighbours).
|
||||||
|
|
||||||
### activity_log — append-only
|
### activity_log — append-only
|
||||||
| col | type | notes |
|
| col | type | notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -407,6 +484,50 @@ Two values carry non-obvious meanings, both set by the plugin and both documente
|
|||||||
cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards
|
cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards
|
||||||
awaiting cliloc resolution sort by their own key rather than clumping together under NULL.
|
awaiting cliloc resolution sort by their own key rather than clumping together under NULL.
|
||||||
|
|
||||||
|
### shard_vendors / shard_vendor_items — the player-vendor marketplace (Protocol 3.0)
|
||||||
|
|
||||||
|
The shard-wide shop index, fed by `vendor.listing` / `vendor.listing.remove`. One row per player
|
||||||
|
vendor and one per priced listing. Full operator detail in [`MARKETPLACE.md`](MARKETPLACE.md); the
|
||||||
|
design is `docs/link/v3.md` §8.
|
||||||
|
|
||||||
|
| Table | Shape |
|
||||||
|
|---|---|
|
||||||
|
| `shard_vendors` | `serial` (PK), `shop_name`, `owner_serial`, `owner_name`, `map`/`x`/`y`/`z`, `region`, `house`, `item_count`, `item_total`, `truncated`, `t`, `updated_at`. Indexes on owner, map, region and `updated_at`. |
|
||||||
|
| `shard_vendor_items` | `id` (PK), `vendor_serial`, `serial`, `item_id`, `hue`, `amount`, `price`, `name`, `cliloc`, `display_name`, `child`. Indexes on `vendor_serial`, `price`, `item_id`, `display_name`, and `(display_name, price)`. |
|
||||||
|
|
||||||
|
**Ingest is per-vendor and authoritative**: the frame is the whole shop, so ingest is
|
||||||
|
delete-then-insert of that vendor's listings inside one transaction. All-or-nothing matters
|
||||||
|
specifically because the two writes are "the shop" and "what is in it" — a failure between them
|
||||||
|
leaves a shop advertising an inventory it no longer has, which is visibly wrong and indistinguishable
|
||||||
|
from a genuinely empty shop. No foreign keys, consistent with every other `shard_*` table.
|
||||||
|
|
||||||
|
**There is deliberately no `payload` column**, unlike `shard_points_boards` directly above. The
|
||||||
|
board's top-N is a fixed-size list read whole, so it lives in JSON; here the items *are* the
|
||||||
|
searchable rows, so they are normalized and nothing is left worth duplicating. The sidecar keeps the
|
||||||
|
whole blob — outage resilience is its job, search is ours.
|
||||||
|
|
||||||
|
Market state, not events: neither kind is in `LOGGED_KINDS`, and this is the strongest case of the
|
||||||
|
three v3 kinds. One frame carries up to 250 listings and the sweep re-emits a shop on any price
|
||||||
|
change, so logging would turn `shard_events` into a price history nobody reads.
|
||||||
|
|
||||||
|
Two columns carry non-obvious meanings:
|
||||||
|
|
||||||
|
- **`item_count` vs `item_total`.** `item_count` is what the frame published; `item_total` is what
|
||||||
|
the shop actually holds. They differ when `truncated` — the shard caps listings per frame
|
||||||
|
(`Bridge.MarketMaxListings`, 250 by default), and a commodity reseller with thousands of stacks
|
||||||
|
genuinely exceeds it. Any UI must show both or it presents a partial shop as complete.
|
||||||
|
- **`display_name` is denormalized at ingest**, resolved from the item's literal `name` (preferred —
|
||||||
|
a player set it, so it is more specific) else its `cliloc` against `shard_clilocs`. 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 triggers a bulk re-resolution** of this column (after a boot
|
||||||
|
import and after an admin import; ~50 ms per thousand rows, never throws).
|
||||||
|
|
||||||
|
`updated_at` is written explicitly on every upsert rather than left to `ON UPDATE CURRENT_TIMESTAMP`,
|
||||||
|
which MariaDB does not fire when every column is written back unchanged. A shop re-published
|
||||||
|
identically is still *freshly confirmed*, and without this the staleness banner would age a perfectly
|
||||||
|
current shop forever.
|
||||||
|
|
||||||
### shard_feature_visibility — per-feature audience config (Protocol 3.0)
|
### shard_feature_visibility — per-feature audience config (Protocol 3.0)
|
||||||
|
|
||||||
One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5),
|
One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5),
|
||||||
@@ -558,7 +679,7 @@ are authoritative, and they answer different questions:
|
|||||||
|
|
||||||
| Artifact | Source of truth for | Generated by |
|
| Artifact | Source of truth for | Generated by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 215 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 226 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||||
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
||||||
|
|
||||||
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
|
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
|
||||||
@@ -734,7 +855,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
|||||||
|
|
||||||
| Method | Path | Notes |
|
| Method | Path | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
|
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL); these are **effective** values, so an admin theme (`theme_visual`) beats `BRAND_ACCENT_COLOR` and an uploaded `brand_assets` asset beats its `BRAND_*` path — an optional **`theme`** block, the resolved CSS custom properties for that admin theme (absent when the instance was never themed, which is what makes it render from the shipped stylesheet unchanged) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
|
||||||
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
|
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
|
||||||
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
|
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
|
||||||
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
|
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
|
||||||
@@ -745,6 +866,9 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
|||||||
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
|
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
|
||||||
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
|
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
|
||||||
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
|
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
|
||||||
|
| GET | `/shard/market?q=&minPrice=&maxPrice=&itemId=&map=®ion=&sort=&limit=&offset=` | search the player-vendor marketplace (Protocol 3.0 `vendor.listing`). Returns **listings**, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from `shard_vendors` + `shard_vendor_items`, so it renders while the shard is down. Behind `requireFeature('market')` **and rate-limited** — the first genuinely expensive public read on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). `sort ∈ {price_asc, price_desc, recent}`. `q` matches the resolved display name **or** the item's literal name, with `%`/`_` escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let `?q=%` match every listing on the shard. Every response repeats `staleAt` (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. |
|
||||||
|
| GET | `/shard/market/meta` | index size, staleness (`staleAt`/`freshAt`) and which facets and regions actually hold vendors, so a client builds its filters without running a search it will discard. |
|
||||||
|
| GET | `/shard/market/vendors/:serial` | one shop and its listings; `:serial` must match `/^0x[0-9A-Fa-f]{1,16}$/` or **400** before any query runs. **404** = a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer, and distinguishing them would leak that a hidden vendor exists. `truncated` (with `total` exceeding `count`) means the shop holds more than the shard publishes per frame. |
|
||||||
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
|
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
|
||||||
| GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. |
|
| GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. |
|
||||||
| GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |
|
| GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |
|
||||||
@@ -755,6 +879,18 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
|||||||
|
|
||||||
Public content GETs pass through the **siteMode** gate (§5).
|
Public content GETs pass through the **siteMode** gate (§5).
|
||||||
|
|
||||||
|
### /settings (settings/index.js → §2) — behind `requireAuth` + `noindex`, no role gate
|
||||||
|
|
||||||
|
Site-wide settings that need a login but no particular role. It exists because the
|
||||||
|
other four groups each answer a different question: `/public` is anonymous,
|
||||||
|
`/admin/settings` is `adminOnly`, and `/player` is data scoped to `req.user.id`.
|
||||||
|
These rows are configuration that happens to need a login.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/settings/nav` | `{ nav_admin, nav_player }` — the stored nav overrides as raw JSON strings (or `null`), for the two authenticated layouts that render them. Deliberately not public: an anonymous visitor has no use for either, and the admin nav's labels describe the shape of the admin surface. Open to **any** role because `AdminLayout` renders for editors and moderators and `PlayerPortalLayout` for players, none of whom can read `GET /admin/settings`. Presentation-only — the role/feature filters in those layouts still decide what is shown, and an override can never un-hide a gated item (see [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §7) |
|
||||||
|
| GET | `/settings/theme/options` | The closed sets an admin may pick from when theming the site: the presets (each with its **full token map**, so a form can show what an unset field currently resolves to), the curated Google Fonts shortlist per role, the shadow depths, the editable color/radius field names paired with the CSS variable each drives, and `shippedTokens` (what `theme.css`'s `:root` declares). Static — derived from `config/themePresets.js`, no DB read. Served rather than duplicated in client code so the options the form **offers** can never drift from the ones `PUT /admin/settings` **accepts** |
|
||||||
|
|
||||||
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
|
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
|
||||||
|
|
||||||
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
|
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
|
||||||
@@ -789,7 +925,9 @@ file a route sits in — that is the property the route manifest freezes.
|
|||||||
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
|
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
|
||||||
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
||||||
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
|
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
|
||||||
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}`. Enum-constrained keys are validated on the way in; `theme_visual` additionally has every value checked against the closed sets in `config/themePresets.js` (hex color, shortlisted font stack, bounded px radius, listed shadow) and is stored stringified, and `brand_assets` has every slot checked against `utils/brandAssets.js` — a same-origin path under `/uploads/`, `/brand/` or `/assets/`, never an off-origin or protocol-relative URL, since these values are written straight into the page as an `<img src>` / `<link rel=icon>` / `og:image`. Cleared slots are dropped rather than stored as `null`. The three `nav_*` keys go through `utils/navOverrides.js` on the same path — shape only (`label`/`order`/`hidden`/`group` keyed by an app path), since whether a key names a route the nav declares is settled client-side at merge time; without this they would reach the store as `"[object Object]"` and read as absent for ever. A write to `brand_assets` or `theme_visual` invalidates the cached HTML shell (a nav write does not — nav is not in the shell). The read path drops bad fields anyway, so the `400` is about **feedback** — a save that appears to succeed and then does nothing is worse than a rejection |
|
||||||
|
| DELETE | `/settings/:key` | reset one setting to its default by deleting the row. Allowlisted to the keys whose default lives outside the store (`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`, `hero_layout_draft`) — anything else is `400`. Idempotent: resetting a key that was never set succeeds |
|
||||||
|
| POST | `/settings/brand-asset/:slot` | upload one brand asset (`logo` · `hero` · `favicon`) **and** point `brand_assets` at it, in one call → `{ url, brand_assets }`. One call rather than "upload, then PUT" so a half-completed save never leaves an unreferenced file in `/uploads`. Uses the shared `imageUpload.js` multer config — the mimetype allowlist is never widened, only tightened per slot: favicons are **PNG only** (§4.10 of [THEMING_AND_NAV.md](THEMING_AND_NAV.md)) and capped at 512 KB, logos at 1 MB, heroes at the shared 8 MB. A refused file is unlinked before the response. Merges into the existing overrides, so uploading a logo never clears a hero. `adminOnly` — tighter than the generic `POST /admin/uploads`, which editors may reach |
|
||||||
| GET | `/activity?limit=&offset=` | paginated activity log |
|
| GET | `/activity?limit=&offset=` | paginated activity log |
|
||||||
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
||||||
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
|
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
|
||||||
@@ -805,6 +943,42 @@ file a route sits in — that is the property the route manifest freezes.
|
|||||||
|
|
||||||
Every admin write logs to `activity_log`.
|
Every admin write logs to `activity_log`.
|
||||||
|
|
||||||
|
### The SPA HTML shell (`app.js` → `utils/htmlShell.js`)
|
||||||
|
|
||||||
|
The SPA catch-all serves `client/dist/index.html` with this instance's branding templated into the
|
||||||
|
`<head>` — title, meta description, Open Graph / Twitter tags, `<link rel="icon">` — so one prebuilt
|
||||||
|
image serves per-instance metadata to a crawler that never runs the JavaScript.
|
||||||
|
|
||||||
|
That used to be a single render at module load, from `BRAND_*` env only. It cannot be, now that the
|
||||||
|
favicon and OG image can come from the admin's `brand_assets` row: the shell depends on state that
|
||||||
|
changes while the process runs. `utils/htmlShell.js` owns the lifecycle, and three properties are
|
||||||
|
deliberate:
|
||||||
|
|
||||||
|
- **A cached string in the steady state.** The shell is rendered lazily on first request and reused;
|
||||||
|
a settings read per page view would put the database on the critical path of every SPA route,
|
||||||
|
including during an outage where the API is already degraded. Concurrent first requests share one
|
||||||
|
render.
|
||||||
|
- **A DB fault never fails the page.** A failed read renders the env-only shell — exactly the
|
||||||
|
pre-feature behavior — and that result is cached like any other, so an outage does not become a
|
||||||
|
failing query per page view.
|
||||||
|
- **Byte-identical with no rows.** An instance that has never been themed and has uploaded nothing
|
||||||
|
gets the same bytes it got before the feature existed. Locked by `test/htmlShell.test.js`, which
|
||||||
|
keeps a verbatim copy of the old renderer as its reference.
|
||||||
|
|
||||||
|
Invalidation is explicit — the settings controller calls `htmlShell.invalidate()` after a successful
|
||||||
|
write to `brand_assets` or `theme_visual` — with a **5-minute TTL as a safety net**, because the cache
|
||||||
|
is per process: in a scaled deployment the worker that handled the write is the only one that learns
|
||||||
|
of it, and without the TTL every other worker would serve the old favicon until the next restart.
|
||||||
|
|
||||||
|
The shell also carries the resolved theme as a `<style id="theme-boot">:root{…}</style>` block, last
|
||||||
|
in `<head>` so it follows the built stylesheet and wins the equal-specificity tie. It exists only to
|
||||||
|
stop a themed instance painting the shipped palette for one frame; `SiteContext` removes it once the
|
||||||
|
`/public/settings` payload has arrived and applied — gated on a **successful** fetch, since dropping
|
||||||
|
it after a failed one would strip a themed instance back to the shipped colors. Token names and
|
||||||
|
values are re-checked against conservative patterns on the way into the block: everything there comes
|
||||||
|
from a closed set already, and this keeps that a property of the HTML writer rather than of a
|
||||||
|
validator three modules away.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Site mode (LIVE / MAINTENANCE)
|
## 5. Site mode (LIVE / MAINTENANCE)
|
||||||
@@ -835,7 +1009,11 @@ who"; `activity_log` provides the history feed.
|
|||||||
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`).
|
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`).
|
||||||
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
||||||
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
||||||
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
- **Rate limiting** (`express-rate-limit`) on `/auth/login`, `/public/contact`, and — the only limited
|
||||||
|
*read* — `/public/shard/market` and `/public/shard/market/vendors/:serial` (60/min/IP). Every other
|
||||||
|
public read is an indexed lookup of bounded size; the marketplace search is a `LIKE` scan plus a
|
||||||
|
`COUNT` over the largest `shard_*` table, anonymous by default, so it is the one public GET that is
|
||||||
|
worth money to serve.
|
||||||
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
||||||
- **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in
|
- **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in
|
||||||
**`server/src/config/csp.js`** (`app.js` only wires them up):
|
**`server/src/config/csp.js`** (`app.js` only wires them up):
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ they did before the table existed.
|
|||||||
|
|
||||||
## Converting
|
## Converting
|
||||||
|
|
||||||
|
> **Step-by-step operator instructions — where to get UOFiddler, where your
|
||||||
|
> client files are, and how to verify the import — are in
|
||||||
|
> [`UOFIDDLER.md`](UOFIDDLER.md).** This section covers the formats and the
|
||||||
|
> reasoning behind them.
|
||||||
|
|
||||||
Either format below is accepted; the site sniffs which one it was handed.
|
Either format below is accepted; the site sniffs which one it was handed.
|
||||||
|
|
||||||
| Format | Fidelity | Notes |
|
| Format | Fidelity | Notes |
|
||||||
@@ -77,8 +82,16 @@ dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/cli
|
|||||||
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
|
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
|
||||||
```
|
```
|
||||||
|
|
||||||
A UOFiddler GUI export works equally well — anything producing one of the two
|
A UOFiddler GUI export works too, but **not unmodified**: its Cliloc tab writes
|
||||||
shapes above is fine.
|
`Number;Text;Flag` — three columns, the flag *last* — and the parser reads
|
||||||
|
`number<separator>text`, so the trailing field is absorbed into the name and
|
||||||
|
every item renders as `quarter staff;0`. Stripping it is one `sed`, given in
|
||||||
|
[`UOFIDDLER.md`](UOFIDDLER.md) §Route B.
|
||||||
|
|
||||||
|
The parser already tolerates `number,flag,text`, with the flag in the *middle*.
|
||||||
|
It is not extended to cover the trailing form because a final `;0` is
|
||||||
|
indistinguishable from a name that genuinely ends that way — a heuristic there
|
||||||
|
would corrupt real names to save the operator one command.
|
||||||
|
|
||||||
## Shard-added and shard-edited items
|
## Shard-added and shard-edited items
|
||||||
|
|
||||||
|
|||||||
167
website/MARKETPLACE.md
Normal file
167
website/MARKETPLACE.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Marketplace — the player-vendor index
|
||||||
|
|
||||||
|
**Status:** On `edge` — servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116).
|
||||||
|
**Design:** [`docs/link/v3.md` §8](../link/v3.md) — Protocol 3.0 Part B/3.
|
||||||
|
**Depends on:** [`CLILOCS.md`](CLILOCS.md) — without a cliloc table, listings render as item ids.
|
||||||
|
|
||||||
|
The marketplace is a searchable index of every player vendor on the shard: what
|
||||||
|
each shop is selling, for how much, and where it is standing. It is the same set
|
||||||
|
the in-game **Vendor Search** gump reads, offered from outside the game — so a
|
||||||
|
player can find the vanquishing kryss they want before logging in, and someone
|
||||||
|
who does not play at all can see that the economy exists.
|
||||||
|
|
||||||
|
Page: `/site/market`, plus `/site/market/vendors/:serial` for one shop.
|
||||||
|
|
||||||
|
## Three things the pages must say out loud
|
||||||
|
|
||||||
|
Everything below follows from how the data is gathered, and each has a visible
|
||||||
|
consequence the UI is required to surface.
|
||||||
|
|
||||||
|
**1. The prices are not live.** The shard sweeps vendors **round-robin** — at
|
||||||
|
most `Bridge.MarketSweepBatch` shops per tick — so a given shop can be a full
|
||||||
|
cycle behind. The page carries a *"prices last refreshed N minutes ago"* banner
|
||||||
|
driven by the **oldest** vendor row, not the newest: the one stale shop is the
|
||||||
|
one that wastes somebody's trip.
|
||||||
|
|
||||||
|
**2. A shop can be truncated.** `Bridge.MarketMaxListings` (250 by default) caps
|
||||||
|
how many listings one frame carries. A commodity reseller with thousands of
|
||||||
|
stacked resources is a real thing, and an uncapped frame for one is measured in
|
||||||
|
megabytes. Over the cap the shop reports `truncated`, and the vendor page says
|
||||||
|
*"showing 250 of 3,104 — this shop holds more than the shard publishes"* rather
|
||||||
|
than presenting a partial shop as complete.
|
||||||
|
|
||||||
|
**3. An item may have no name.** Items on the wire carry a cliloc id, not a name.
|
||||||
|
On a shard whose operator has not converted a cliloc table
|
||||||
|
([`CLILOCS.md`](CLILOCS.md)) the honest render is the item id — never an invented
|
||||||
|
label, which would be indistinguishable from a real one.
|
||||||
|
|
||||||
|
## Privacy: the player's own toggle wins
|
||||||
|
|
||||||
|
Only vendors whose owner left the in-game **Vendor Search** flag ON are ever sent
|
||||||
|
to the site. A player who hides their shop in game is hidden here too, and no
|
||||||
|
admin setting overrides that. When they hide one that was already indexed, the
|
||||||
|
shard emits `vendor.listing.remove` and the row is deleted — so revoking consent
|
||||||
|
takes effect, it does not merely stop refreshing.
|
||||||
|
|
||||||
|
Shop name, owner character name and location default to **Everyone**, because the
|
||||||
|
stock Vendor Search gump already shows exactly that set to any player in game.
|
||||||
|
They remain admin-configurable; see [`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md).
|
||||||
|
Account names and website user ids never cross the wire at all.
|
||||||
|
|
||||||
|
## How it is put together
|
||||||
|
|
||||||
|
```
|
||||||
|
ServUO uo-link sidecar website
|
||||||
|
────── ─────────────── ───────
|
||||||
|
BridgeMarket.cs vendors table shard_vendors
|
||||||
|
round-robin sweep ──────► (whole frame blob) ──────► shard_vendor_items
|
||||||
|
per-vendor diff GET /market (paged) + display_name
|
||||||
|
vendor.listing resolved at ingest
|
||||||
|
vendor.listing.remove
|
||||||
|
```
|
||||||
|
|
||||||
|
**The shard side** walks at most `MarketSweepBatch` vendors per tick from a
|
||||||
|
persistent cursor, diffs each against what it last published, and emits a whole
|
||||||
|
frame for any shop that moved. Per-tick cost is therefore bounded by the batch,
|
||||||
|
not by how many vendors the world holds — full coverage takes
|
||||||
|
`ceil(vendors / batch) × MarketSweepSeconds`.
|
||||||
|
|
||||||
|
**The sidecar** stores each frame whole and serves `GET /market`, its only paged
|
||||||
|
read. It normalizes nothing and defines no audiences: it is a dumb forwarder, and
|
||||||
|
search is the website's job.
|
||||||
|
|
||||||
|
**The website** splits each frame into a vendor row and its listings, replacing
|
||||||
|
that vendor's whole listing set inside one transaction (the frame is
|
||||||
|
authoritative for that vendor, never a delta). Item names are resolved against
|
||||||
|
the cliloc table **on the way in** and stored denormalized, which is what makes
|
||||||
|
search-by-name possible and keeps the cliloc table off the hot path.
|
||||||
|
|
||||||
|
## Operating it
|
||||||
|
|
||||||
|
Everything is in `Config/Bridge.cfg` on the shard. There is nothing to configure
|
||||||
|
on the website.
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `MarketEnabled` | `true` | Master switch. Off publishes nothing; the page shows an empty index. |
|
||||||
|
| `MarketSweepSeconds` | `60` | Tick interval. |
|
||||||
|
| `MarketSweepBatch` | `25` | Vendors inventoried per tick. Clamped 1..500. |
|
||||||
|
| `MarketMaxListings` | `250` | Per-shop listing cap, after which `truncated`. Clamped 1..5000. |
|
||||||
|
|
||||||
|
**Faster coverage vs. per-tick cost.** Lowering `MarketSweepSeconds` or raising
|
||||||
|
`MarketSweepBatch` both refresh the index sooner and both cost more per tick.
|
||||||
|
The expensive part is the item walk, which recurses into every container a vendor
|
||||||
|
is selling — so a shard of big shops should raise the interval rather than the
|
||||||
|
batch.
|
||||||
|
|
||||||
|
`[bridge status` reports the sweep, including `lastMs` and `maxMs`:
|
||||||
|
|
||||||
|
```
|
||||||
|
market(enabled=True sweeps=42 scanned=108 emitted=27 removed=0 skipped=0
|
||||||
|
truncated=0 tracked=27 vendors=27 cursor=2 batch=25 lastMs=0.31 maxMs=15.40)
|
||||||
|
```
|
||||||
|
|
||||||
|
A tick over **50 ms** prints a rate-limited warning naming the knob:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Bridge] market sweep took 82.4 ms (budget 50 ms) - lower Bridge.MarketSweepBatch (now 25) if this persists
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured on a shard with 27 vendors × 40 listings (209k items, 43k mobiles):
|
||||||
|
**15.4 ms** for the first cold tick of 25 vendors, **0.3 ms** in steady state —
|
||||||
|
the diff is what makes an unchanged world nearly free. Note the arithmetic: 25
|
||||||
|
*full* shops at the 250-listing cap is 6,250 items ≈ 95 ms, over budget. Real
|
||||||
|
shops hold tens, which is why 25 is the default and why the warning exists.
|
||||||
|
|
||||||
|
`[bridge sweepnow` runs one tick immediately; `[bridge reload` re-reads the
|
||||||
|
settings above without a restart.
|
||||||
|
|
||||||
|
## Names arriving late
|
||||||
|
|
||||||
|
Item names come from the cliloc table, and the market sweep will **not** re-send
|
||||||
|
an unchanged shop just because the site learned what its items are called. So a
|
||||||
|
cliloc import triggers a bulk re-resolution of every stored listing — otherwise
|
||||||
|
an operator who configures clilocs after the first sweep would see item ids until
|
||||||
|
every shop happened to change on its own. It runs after a boot import and after
|
||||||
|
an admin import, takes ~50 ms per thousand listings, and never throws: a failure
|
||||||
|
leaves names exactly as they were.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All under `/api/v1/public/shard`, gated by the `market` feature and
|
||||||
|
**rate-limited** — these are the first genuinely expensive public reads on the
|
||||||
|
site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*`
|
||||||
|
table, reachable with no session).
|
||||||
|
|
||||||
|
| Route | What |
|
||||||
|
|---|---|
|
||||||
|
| `GET /market` | Search. Returns **listings**, not vendors — "who sells X and for how much" is the question. `?q=&minPrice=&maxPrice=&itemId=&map=®ion=&sort=&limit=&offset=`, `sort ∈ {price_asc, price_desc, recent}`. |
|
||||||
|
| `GET /market/meta` | Index size, staleness, and which facets and regions actually hold vendors — so a client builds its filters without running a search it will discard. |
|
||||||
|
| `GET /market/vendors/:serial` | One shop and its listings. **404** for a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer. |
|
||||||
|
|
||||||
|
`q` matches the resolved display name **or** the item's own literal name, because
|
||||||
|
an item with a player-set name (most of what is worth searching for on a
|
||||||
|
player-run shard) may carry a generic cliloc. `%` and `_` in a query are escaped:
|
||||||
|
they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let
|
||||||
|
a search for `%` match every listing on the shard.
|
||||||
|
|
||||||
|
Full schemas are in the OpenAPI spec (`ShardMarketPage`, `ShardMarketVendor`,
|
||||||
|
`ShardMarketMeta`, `ShardMarketListing`, `ShardMarketLocation`).
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
`shard_vendors` (one row per shop) and `shard_vendor_items` (one row per priced
|
||||||
|
listing). Both are ingest-owned; nothing else writes to them. No foreign keys,
|
||||||
|
in keeping with every other `shard_*` table — the ingest transaction is what
|
||||||
|
keeps them consistent, and an FK would turn a malformed frame into a failed write
|
||||||
|
rather than a dropped row.
|
||||||
|
|
||||||
|
There is deliberately **no `payload` column** on `shard_vendors`, unlike the
|
||||||
|
points board next door. The board's top-N is a fixed-size list read whole, so it
|
||||||
|
lives in JSON; here the items *are* the searchable rows, so they are normalized
|
||||||
|
and there is nothing left worth duplicating. The sidecar keeps the whole blob,
|
||||||
|
because outage resilience is its job.
|
||||||
|
|
||||||
|
`shard_vendor_items.display_name` is denormalized and indexed (alone, and
|
||||||
|
composite with `price` for "cheapest matching X"). See "Names arriving late"
|
||||||
|
above for how it is kept current.
|
||||||
@@ -132,6 +132,7 @@ website/
|
|||||||
│ │ │ │ ├── RecoveryCodesPanel.jsx
|
│ │ │ │ ├── RecoveryCodesPanel.jsx
|
||||||
│ │ │ │ ├── TrustedDevicesPanel.jsx
|
│ │ │ │ ├── TrustedDevicesPanel.jsx
|
||||||
│ │ │ │ └── TrustLimitModal.jsx
|
│ │ │ │ └── TrustLimitModal.jsx
|
||||||
|
│ │ │ ├── BrandLogo.jsx
|
||||||
│ │ │ ├── CharacterSheet.jsx
|
│ │ │ ├── CharacterSheet.jsx
|
||||||
│ │ │ ├── CharacterStats.jsx
|
│ │ │ ├── CharacterStats.jsx
|
||||||
│ │ │ ├── CreateGameAccountForm.jsx
|
│ │ │ ├── CreateGameAccountForm.jsx
|
||||||
@@ -140,6 +141,7 @@ website/
|
|||||||
│ │ │ ├── MaintenanceGate.jsx
|
│ │ │ ├── MaintenanceGate.jsx
|
||||||
│ │ │ ├── Modal.jsx
|
│ │ │ ├── Modal.jsx
|
||||||
│ │ │ ├── MoonDot.jsx
|
│ │ │ ├── MoonDot.jsx
|
||||||
|
│ │ │ ├── NavDropdown.jsx
|
||||||
│ │ │ ├── PageHeader.jsx
|
│ │ │ ├── PageHeader.jsx
|
||||||
│ │ │ ├── PageState.jsx
|
│ │ │ ├── PageState.jsx
|
||||||
│ │ │ ├── PlayersOnline.jsx
|
│ │ │ ├── PlayersOnline.jsx
|
||||||
@@ -162,8 +164,13 @@ website/
|
|||||||
│ │ ├── lib/
|
│ │ ├── lib/
|
||||||
│ │ │ ├── format.js
|
│ │ │ ├── format.js
|
||||||
│ │ │ ├── heroLayout.js
|
│ │ │ ├── heroLayout.js
|
||||||
|
│ │ │ ├── navOverrides.js
|
||||||
|
│ │ │ ├── settingsJson.js
|
||||||
│ │ │ ├── shardEvents.js
|
│ │ │ ├── shardEvents.js
|
||||||
|
│ │ │ ├── themeVars.js
|
||||||
│ │ │ ├── useAsync.js
|
│ │ │ ├── useAsync.js
|
||||||
|
│ │ │ ├── useNavOverrides.js
|
||||||
|
│ │ │ ├── useShardFeatures.js
|
||||||
│ │ │ └── useShardFeed.js
|
│ │ │ └── useShardFeed.js
|
||||||
│ │ ├── routes/
|
│ │ ├── routes/
|
||||||
│ │ │ ├── admin/
|
│ │ │ ├── admin/
|
||||||
@@ -173,8 +180,10 @@ website/
|
|||||||
│ │ │ │ │ ├── AdminCharacter.jsx
|
│ │ │ │ │ ├── AdminCharacter.jsx
|
||||||
│ │ │ │ │ ├── AdminCharacters.jsx
|
│ │ │ │ │ ├── AdminCharacters.jsx
|
||||||
│ │ │ │ │ ├── Appeals.jsx
|
│ │ │ │ │ ├── Appeals.jsx
|
||||||
|
│ │ │ │ │ ├── AppearanceAdmin.jsx
|
||||||
│ │ │ │ │ ├── AuthProvidersAdmin.jsx
|
│ │ │ │ │ ├── AuthProvidersAdmin.jsx
|
||||||
│ │ │ │ │ ├── BotActivityAdmin.jsx
|
│ │ │ │ │ ├── BotActivityAdmin.jsx
|
||||||
|
│ │ │ │ │ ├── BrandAssetsPanel.jsx
|
||||||
│ │ │ │ │ ├── Dashboard.jsx
|
│ │ │ │ │ ├── Dashboard.jsx
|
||||||
│ │ │ │ │ ├── DiscordBotAdmin.jsx
|
│ │ │ │ │ ├── DiscordBotAdmin.jsx
|
||||||
│ │ │ │ │ ├── EmailDelivery.jsx
|
│ │ │ │ │ ├── EmailDelivery.jsx
|
||||||
@@ -183,13 +192,17 @@ website/
|
|||||||
│ │ │ │ │ ├── InvitesAdmin.jsx
|
│ │ │ │ │ ├── InvitesAdmin.jsx
|
||||||
│ │ │ │ │ ├── Moderation.jsx
|
│ │ │ │ │ ├── Moderation.jsx
|
||||||
│ │ │ │ │ ├── ModerationUser.jsx
|
│ │ │ │ │ ├── ModerationUser.jsx
|
||||||
|
│ │ │ │ │ ├── NavEditor.jsx
|
||||||
│ │ │ │ │ ├── PageBuilder.jsx
|
│ │ │ │ │ ├── PageBuilder.jsx
|
||||||
│ │ │ │ │ ├── PagesAdmin.jsx
|
│ │ │ │ │ ├── PagesAdmin.jsx
|
||||||
│ │ │ │ │ ├── PostEditor.jsx
|
│ │ │ │ │ ├── PostEditor.jsx
|
||||||
│ │ │ │ │ ├── PostsAdmin.jsx
|
│ │ │ │ │ ├── PostsAdmin.jsx
|
||||||
|
│ │ │ │ │ ├── PublicNavTree.jsx
|
||||||
│ │ │ │ │ ├── SettingsAdmin.jsx
|
│ │ │ │ │ ├── SettingsAdmin.jsx
|
||||||
│ │ │ │ │ ├── ShardAdmin.jsx
|
│ │ │ │ │ ├── ShardAdmin.jsx
|
||||||
│ │ │ │ │ ├── ShardOps.jsx
|
│ │ │ │ │ ├── ShardOps.jsx
|
||||||
|
│ │ │ │ │ ├── ShardVisibility.jsx
|
||||||
|
│ │ │ │ │ ├── SpawnAtlas.jsx
|
||||||
│ │ │ │ │ ├── UserDetail.jsx
|
│ │ │ │ │ ├── UserDetail.jsx
|
||||||
│ │ │ │ │ ├── UserEditor.jsx
|
│ │ │ │ │ ├── UserEditor.jsx
|
||||||
│ │ │ │ │ ├── UsersAdmin.jsx
|
│ │ │ │ │ ├── UsersAdmin.jsx
|
||||||
@@ -213,17 +226,23 @@ website/
|
|||||||
│ │ │ │ └── ResetPassword.jsx
|
│ │ │ │ └── ResetPassword.jsx
|
||||||
│ │ │ ├── public/
|
│ │ │ ├── public/
|
||||||
│ │ │ │ ├── About.jsx
|
│ │ │ │ ├── About.jsx
|
||||||
|
│ │ │ │ ├── Atlas.jsx
|
||||||
|
│ │ │ │ ├── AtlasCreature.jsx
|
||||||
│ │ │ │ ├── ChampSpawns.jsx
|
│ │ │ │ ├── ChampSpawns.jsx
|
||||||
│ │ │ │ ├── CmsPage.jsx
|
│ │ │ │ ├── CmsPage.jsx
|
||||||
│ │ │ │ ├── FiveOnFriday.jsx
|
│ │ │ │ ├── FiveOnFriday.jsx
|
||||||
│ │ │ │ ├── Governors.jsx
|
│ │ │ │ ├── Governors.jsx
|
||||||
│ │ │ │ ├── Guilds.jsx
|
│ │ │ │ ├── Guilds.jsx
|
||||||
│ │ │ │ ├── Houses.jsx
|
│ │ │ │ ├── Houses.jsx
|
||||||
|
│ │ │ │ ├── Leaderboards.jsx
|
||||||
│ │ │ │ ├── Maintenance.jsx
|
│ │ │ │ ├── Maintenance.jsx
|
||||||
|
│ │ │ │ ├── Market.jsx
|
||||||
|
│ │ │ │ ├── MarketVendor.jsx
|
||||||
│ │ │ │ ├── News.jsx
|
│ │ │ │ ├── News.jsx
|
||||||
│ │ │ │ ├── Newsletter.jsx
|
│ │ │ │ ├── Newsletter.jsx
|
||||||
│ │ │ │ ├── NewsletterIssue.jsx
|
│ │ │ │ ├── NewsletterIssue.jsx
|
||||||
│ │ │ │ ├── Portal.jsx
|
│ │ │ │ ├── Portal.jsx
|
||||||
|
│ │ │ │ ├── Rules.jsx
|
||||||
│ │ │ │ ├── Screenshots.jsx
|
│ │ │ │ ├── Screenshots.jsx
|
||||||
│ │ │ │ ├── Shard.jsx
|
│ │ │ │ ├── Shard.jsx
|
||||||
│ │ │ │ ├── ShardActivity.jsx
|
│ │ │ │ ├── ShardActivity.jsx
|
||||||
@@ -240,8 +259,11 @@ website/
|
|||||||
│ │ ├── apiClient.test.js
|
│ │ ├── apiClient.test.js
|
||||||
│ │ ├── format.test.js
|
│ │ ├── format.test.js
|
||||||
│ │ ├── heroLayout.test.js
|
│ │ ├── heroLayout.test.js
|
||||||
|
│ │ ├── navOverrides.test.js
|
||||||
│ │ ├── regionBuckets.test.js
|
│ │ ├── regionBuckets.test.js
|
||||||
│ │ └── shardEvents.test.js
|
│ │ ├── settingsJson.test.js
|
||||||
|
│ │ ├── shardEvents.test.js
|
||||||
|
│ │ └── themeVars.test.js
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ ├── package-lock.json
|
│ ├── package-lock.json
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
@@ -257,9 +279,12 @@ website/
|
|||||||
│ └── sonar-test-reporter.mjs
|
│ └── sonar-test-reporter.mjs
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── db/
|
│ ├── db/
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ │ └── spawnAtlas.art.example.json
|
||||||
│ │ ├── schema.sql
|
│ │ ├── schema.sql
|
||||||
│ │ └── seed.js
|
│ │ └── seed.js
|
||||||
│ ├── scripts/
|
│ ├── scripts/
|
||||||
|
│ │ ├── importSpawnAtlas.js
|
||||||
│ │ └── routeManifest.js
|
│ │ └── routeManifest.js
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── auth/
|
│ │ ├── auth/
|
||||||
@@ -294,6 +319,7 @@ website/
|
|||||||
│ │ │ ├── brand.js
|
│ │ │ ├── brand.js
|
||||||
│ │ │ ├── csp.js
|
│ │ │ ├── csp.js
|
||||||
│ │ │ ├── notificationStreams.js
|
│ │ │ ├── notificationStreams.js
|
||||||
|
│ │ │ ├── themePresets.js
|
||||||
│ │ │ └── version.js
|
│ │ │ └── version.js
|
||||||
│ │ ├── middleware/
|
│ │ ├── middleware/
|
||||||
│ │ │ ├── botScore.js
|
│ │ │ ├── botScore.js
|
||||||
@@ -365,15 +391,27 @@ website/
|
|||||||
│ │ │ ├── settings/
|
│ │ │ ├── settings/
|
||||||
│ │ │ │ ├── settings.db.js
|
│ │ │ │ ├── settings.db.js
|
||||||
│ │ │ │ └── settings.model.js
|
│ │ │ │ └── settings.model.js
|
||||||
|
│ │ │ ├── shardAtlas/
|
||||||
|
│ │ │ │ ├── shardAtlas.db.js
|
||||||
|
│ │ │ │ └── shardAtlas.model.js
|
||||||
|
│ │ │ ├── shardClilocs/
|
||||||
|
│ │ │ │ ├── shardClilocs.db.js
|
||||||
|
│ │ │ │ └── shardClilocs.model.js
|
||||||
│ │ │ ├── shardEvents/
|
│ │ │ ├── shardEvents/
|
||||||
│ │ │ │ ├── shardEvents.db.js
|
│ │ │ │ ├── shardEvents.db.js
|
||||||
│ │ │ │ └── shardEvents.model.js
|
│ │ │ │ └── shardEvents.model.js
|
||||||
│ │ │ ├── shardLinks/
|
│ │ │ ├── shardLinks/
|
||||||
│ │ │ │ ├── shardLinks.db.js
|
│ │ │ │ ├── shardLinks.db.js
|
||||||
│ │ │ │ └── shardLinks.model.js
|
│ │ │ │ └── shardLinks.model.js
|
||||||
|
│ │ │ ├── shardMarket/
|
||||||
|
│ │ │ │ ├── shardMarket.db.js
|
||||||
|
│ │ │ │ └── shardMarket.model.js
|
||||||
│ │ │ ├── shardState/
|
│ │ │ ├── shardState/
|
||||||
│ │ │ │ ├── shardState.db.js
|
│ │ │ │ ├── shardState.db.js
|
||||||
│ │ │ │ └── shardState.model.js
|
│ │ │ │ └── shardState.model.js
|
||||||
|
│ │ │ ├── shardVisibility/
|
||||||
|
│ │ │ │ ├── shardVisibility.db.js
|
||||||
|
│ │ │ │ └── shardVisibility.model.js
|
||||||
│ │ │ ├── trustedDevices/
|
│ │ │ ├── trustedDevices/
|
||||||
│ │ │ │ ├── trustedDevices.db.js
|
│ │ │ │ ├── trustedDevices.db.js
|
||||||
│ │ │ │ └── trustedDevices.model.js
|
│ │ │ │ └── trustedDevices.model.js
|
||||||
@@ -398,12 +436,14 @@ website/
|
|||||||
│ │ │ │ │ ├── account.router.js
|
│ │ │ │ │ ├── account.router.js
|
||||||
│ │ │ │ │ ├── activity.router.js
|
│ │ │ │ │ ├── activity.router.js
|
||||||
│ │ │ │ │ ├── admin.controller.js
|
│ │ │ │ │ ├── admin.controller.js
|
||||||
│ │ │ │ │ ├── admin.routes.js
|
|
||||||
│ │ │ │ │ ├── authProviders.controller.js
|
│ │ │ │ │ ├── authProviders.controller.js
|
||||||
│ │ │ │ │ ├── authProviders.router.js
|
│ │ │ │ │ ├── authProviders.router.js
|
||||||
│ │ │ │ │ ├── botActivity.controller.js
|
│ │ │ │ │ ├── botActivity.controller.js
|
||||||
│ │ │ │ │ ├── botActivity.router.js
|
│ │ │ │ │ ├── botActivity.router.js
|
||||||
|
│ │ │ │ │ ├── dashboard.router.js
|
||||||
│ │ │ │ │ ├── discordBot.controller.js
|
│ │ │ │ │ ├── discordBot.controller.js
|
||||||
|
│ │ │ │ │ ├── discordBot.router.js
|
||||||
|
│ │ │ │ │ ├── email.router.js
|
||||||
│ │ │ │ │ ├── emailConfig.controller.js
|
│ │ │ │ │ ├── emailConfig.controller.js
|
||||||
│ │ │ │ │ ├── imageUpload.js
|
│ │ │ │ │ ├── imageUpload.js
|
||||||
│ │ │ │ │ ├── index.js
|
│ │ │ │ │ ├── index.js
|
||||||
@@ -414,16 +454,25 @@ website/
|
|||||||
│ │ │ │ │ ├── pages.controller.js
|
│ │ │ │ │ ├── pages.controller.js
|
||||||
│ │ │ │ │ ├── pages.router.js
|
│ │ │ │ │ ├── pages.router.js
|
||||||
│ │ │ │ │ ├── posts.router.js
|
│ │ │ │ │ ├── posts.router.js
|
||||||
|
│ │ │ │ │ ├── settings.router.js
|
||||||
|
│ │ │ │ │ ├── shard.router.js
|
||||||
|
│ │ │ │ │ ├── shardAtlas.controller.js
|
||||||
|
│ │ │ │ │ ├── shardClilocs.controller.js
|
||||||
│ │ │ │ │ ├── shardOps.controller.js
|
│ │ │ │ │ ├── shardOps.controller.js
|
||||||
|
│ │ │ │ │ ├── shardVisibility.controller.js
|
||||||
│ │ │ │ │ ├── uoLink.controller.js
|
│ │ │ │ │ ├── uoLink.controller.js
|
||||||
|
│ │ │ │ │ ├── uoLink.router.js
|
||||||
│ │ │ │ │ ├── uploads.router.js
|
│ │ │ │ │ ├── uploads.router.js
|
||||||
│ │ │ │ │ ├── users.router.js
|
│ │ │ │ │ ├── users.router.js
|
||||||
│ │ │ │ │ ├── usersShard.controller.js
|
│ │ │ │ │ ├── usersShard.controller.js
|
||||||
│ │ │ │ │ └── wiki.router.js
|
│ │ │ │ │ └── wiki.router.js
|
||||||
│ │ │ │ ├── auth/
|
│ │ │ │ ├── auth/
|
||||||
│ │ │ │ │ ├── auth.controller.js
|
│ │ │ │ │ ├── auth.controller.js
|
||||||
│ │ │ │ │ ├── auth.routes.js
|
│ │ │ │ │ ├── index.js
|
||||||
│ │ │ │ │ ├── invite.controller.js
|
│ │ │ │ │ ├── invite.controller.js
|
||||||
|
│ │ │ │ │ ├── invite.router.js
|
||||||
|
│ │ │ │ │ ├── login.router.js
|
||||||
|
│ │ │ │ │ ├── loginGuards.js
|
||||||
│ │ │ │ │ ├── me.routes.js
|
│ │ │ │ │ ├── me.routes.js
|
||||||
│ │ │ │ │ ├── mobile.controller.js
|
│ │ │ │ │ ├── mobile.controller.js
|
||||||
│ │ │ │ │ ├── mobile.routes.js
|
│ │ │ │ │ ├── mobile.routes.js
|
||||||
@@ -431,7 +480,10 @@ website/
|
|||||||
│ │ │ │ │ ├── mobileSso.routes.js
|
│ │ │ │ │ ├── mobileSso.routes.js
|
||||||
│ │ │ │ │ ├── notifications.controller.js
|
│ │ │ │ │ ├── notifications.controller.js
|
||||||
│ │ │ │ │ ├── notifications.routes.js
|
│ │ │ │ │ ├── notifications.routes.js
|
||||||
|
│ │ │ │ │ ├── password.router.js
|
||||||
│ │ │ │ │ ├── passwordReset.controller.js
|
│ │ │ │ │ ├── passwordReset.controller.js
|
||||||
|
│ │ │ │ │ ├── register.router.js
|
||||||
|
│ │ │ │ │ ├── session.router.js
|
||||||
│ │ │ │ │ ├── sso.controller.js
|
│ │ │ │ │ ├── sso.controller.js
|
||||||
│ │ │ │ │ ├── sso.routes.js
|
│ │ │ │ │ ├── sso.routes.js
|
||||||
│ │ │ │ │ └── trustDevice.helper.js
|
│ │ │ │ │ └── trustDevice.helper.js
|
||||||
@@ -439,13 +491,29 @@ website/
|
|||||||
│ │ │ │ │ ├── internal.controller.js
|
│ │ │ │ │ ├── internal.controller.js
|
||||||
│ │ │ │ │ └── internal.routes.js
|
│ │ │ │ │ └── internal.routes.js
|
||||||
│ │ │ │ ├── player/
|
│ │ │ │ ├── player/
|
||||||
|
│ │ │ │ │ ├── account.router.js
|
||||||
│ │ │ │ │ ├── appeals.controller.js
|
│ │ │ │ │ ├── appeals.controller.js
|
||||||
│ │ │ │ │ ├── player.routes.js
|
│ │ │ │ │ ├── appeals.router.js
|
||||||
│ │ │ │ │ └── shard.controller.js
|
│ │ │ │ │ ├── index.js
|
||||||
|
│ │ │ │ │ ├── shard.controller.js
|
||||||
|
│ │ │ │ │ └── shard.router.js
|
||||||
│ │ │ │ ├── public/
|
│ │ │ │ ├── public/
|
||||||
|
│ │ │ │ │ ├── atlas.controller.js
|
||||||
|
│ │ │ │ │ ├── atlas.router.js
|
||||||
|
│ │ │ │ │ ├── index.js
|
||||||
|
│ │ │ │ │ ├── pages.router.js
|
||||||
|
│ │ │ │ │ ├── posts.router.js
|
||||||
│ │ │ │ │ ├── public.controller.js
|
│ │ │ │ │ ├── public.controller.js
|
||||||
│ │ │ │ │ ├── public.routes.js
|
│ │ │ │ │ ├── shard.controller.js
|
||||||
│ │ │ │ │ └── shard.controller.js
|
│ │ │ │ │ ├── shard.router.js
|
||||||
|
│ │ │ │ │ ├── site.router.js
|
||||||
|
│ │ │ │ │ └── wiki.router.js
|
||||||
|
│ │ │ │ ├── settings/
|
||||||
|
│ │ │ │ │ ├── index.js
|
||||||
|
│ │ │ │ │ ├── nav.controller.js
|
||||||
|
│ │ │ │ │ ├── nav.router.js
|
||||||
|
│ │ │ │ │ ├── theme.controller.js
|
||||||
|
│ │ │ │ │ └── theme.router.js
|
||||||
│ │ │ │ └── v1.router.js
|
│ │ │ │ └── v1.router.js
|
||||||
│ │ │ ├── api.router.js
|
│ │ │ ├── api.router.js
|
||||||
│ │ │ ├── cspReport.controller.js
|
│ │ │ ├── cspReport.controller.js
|
||||||
@@ -455,16 +523,26 @@ website/
|
|||||||
│ │ │ ├── auth.js
|
│ │ │ ├── auth.js
|
||||||
│ │ │ ├── botInternalClient.js
|
│ │ │ ├── botInternalClient.js
|
||||||
│ │ │ ├── botInternalKey.js
|
│ │ │ ├── botInternalKey.js
|
||||||
|
│ │ │ ├── brandAssets.js
|
||||||
|
│ │ │ ├── clilocParse.js
|
||||||
|
│ │ │ ├── clilocSource.js
|
||||||
│ │ │ ├── db.js
|
│ │ │ ├── db.js
|
||||||
|
│ │ │ ├── htmlShell.js
|
||||||
│ │ │ ├── logger.js
|
│ │ │ ├── logger.js
|
||||||
│ │ │ ├── mailer.js
|
│ │ │ ├── mailer.js
|
||||||
|
│ │ │ ├── navOverrides.js
|
||||||
│ │ │ ├── newsGump.js
|
│ │ │ ├── newsGump.js
|
||||||
│ │ │ ├── pushDispatch.js
|
│ │ │ ├── pushDispatch.js
|
||||||
│ │ │ ├── sanitizeHtml.js
|
│ │ │ ├── sanitizeHtml.js
|
||||||
│ │ │ ├── secretBox.js
|
│ │ │ ├── secretBox.js
|
||||||
|
│ │ │ ├── settingsJson.js
|
||||||
│ │ │ ├── shardBroadcast.js
|
│ │ │ ├── shardBroadcast.js
|
||||||
│ │ │ ├── shardIngest.js
|
│ │ │ ├── shardIngest.js
|
||||||
│ │ │ ├── shardSales.js
|
│ │ │ ├── shardSales.js
|
||||||
|
│ │ │ ├── shardVisibility.js
|
||||||
|
│ │ │ ├── spawnAtlasParse.js
|
||||||
|
│ │ │ ├── spawnAtlasSource.js
|
||||||
|
│ │ │ ├── themeResolve.js
|
||||||
│ │ │ ├── totp.js
|
│ │ │ ├── totp.js
|
||||||
│ │ │ ├── trustProxy.js
|
│ │ │ ├── trustProxy.js
|
||||||
│ │ │ ├── uoLinkClient.js
|
│ │ │ ├── uoLinkClient.js
|
||||||
@@ -483,14 +561,19 @@ website/
|
|||||||
│ │ ├── appeals.pure.test.js
|
│ │ ├── appeals.pure.test.js
|
||||||
│ │ ├── appeals.test.js
|
│ │ ├── appeals.test.js
|
||||||
│ │ ├── appLinks.test.js
|
│ │ ├── appLinks.test.js
|
||||||
|
│ │ ├── atlasController.test.js
|
||||||
│ │ ├── authController.test.js
|
│ │ ├── authController.test.js
|
||||||
│ │ ├── authMe.test.js
|
│ │ ├── authMe.test.js
|
||||||
│ │ ├── authTrustedDevice.test.js
|
│ │ ├── authTrustedDevice.test.js
|
||||||
│ │ ├── botInternalKey.test.js
|
│ │ ├── botInternalKey.test.js
|
||||||
│ │ ├── botScore.test.js
|
│ │ ├── botScore.test.js
|
||||||
|
│ │ ├── brandAssets.test.js
|
||||||
|
│ │ ├── clilocParse.test.js
|
||||||
|
│ │ ├── clilocSource.test.js
|
||||||
│ │ ├── csp.test.js
|
│ │ ├── csp.test.js
|
||||||
│ │ ├── emailConfig.model.test.js
|
│ │ ├── emailConfig.model.test.js
|
||||||
│ │ ├── honeypot.test.js
|
│ │ ├── honeypot.test.js
|
||||||
|
│ │ ├── htmlShell.test.js
|
||||||
│ │ ├── inviteController.test.js
|
│ │ ├── inviteController.test.js
|
||||||
│ │ ├── invites.test.js
|
│ │ ├── invites.test.js
|
||||||
│ │ ├── loginProtection.test.js
|
│ │ ├── loginProtection.test.js
|
||||||
@@ -501,6 +584,7 @@ website/
|
|||||||
│ │ ├── mobileSsoBridge.test.js
|
│ │ ├── mobileSsoBridge.test.js
|
||||||
│ │ ├── moderation.model.test.js
|
│ │ ├── moderation.model.test.js
|
||||||
│ │ ├── moderation.test.js
|
│ │ ├── moderation.test.js
|
||||||
|
│ │ ├── navOverrides.test.js
|
||||||
│ │ ├── newsGump.test.js
|
│ │ ├── newsGump.test.js
|
||||||
│ │ ├── notificationsRoutes.test.js
|
│ │ ├── notificationsRoutes.test.js
|
||||||
│ │ ├── pages.model.test.js
|
│ │ ├── pages.model.test.js
|
||||||
@@ -521,17 +605,34 @@ website/
|
|||||||
│ │ ├── secretBox.test.js
|
│ │ ├── secretBox.test.js
|
||||||
│ │ ├── selfTrustedDevices.test.js
|
│ │ ├── selfTrustedDevices.test.js
|
||||||
│ │ ├── session.test.js
|
│ │ ├── session.test.js
|
||||||
|
│ │ ├── settingsTheming.test.js
|
||||||
|
│ │ ├── shardBroadcast.visibility.test.js
|
||||||
│ │ ├── shardControllerPublic.test.js
|
│ │ ├── shardControllerPublic.test.js
|
||||||
│ │ ├── shardIngest.champsPages.test.js
|
│ │ ├── shardIngest.champsPages.test.js
|
||||||
|
│ │ ├── shardIngest.market.test.js
|
||||||
|
│ │ ├── shardIngest.points.test.js
|
||||||
│ │ ├── shardIngest.protocol2.test.js
|
│ │ ├── shardIngest.protocol2.test.js
|
||||||
|
│ │ ├── shardIngest.ruleset.test.js
|
||||||
|
│ │ ├── shardMarket.model.test.js
|
||||||
│ │ ├── shardState.governorTerms.test.js
|
│ │ ├── shardState.governorTerms.test.js
|
||||||
│ │ ├── shardState.model.test.js
|
│ │ ├── shardState.model.test.js
|
||||||
|
│ │ ├── shardVisibility.test.js
|
||||||
|
│ │ ├── spawnAtlas.parse.test.js
|
||||||
|
│ │ ├── spawnAtlas.source.test.js
|
||||||
│ │ ├── ssoCallback.test.js
|
│ │ ├── ssoCallback.test.js
|
||||||
│ │ ├── ssoState.test.js
|
│ │ ├── ssoState.test.js
|
||||||
|
│ │ ├── ssoTrustedDevice.test.js
|
||||||
|
│ │ ├── themeResolve.test.js
|
||||||
│ │ ├── totp.test.js
|
│ │ ├── totp.test.js
|
||||||
│ │ ├── trustedDevices.test.js
|
│ │ ├── trustedDevices.test.js
|
||||||
│ │ ├── trustProxy.test.js
|
│ │ ├── trustProxy.test.js
|
||||||
|
│ │ ├── uoLinkClient.test.js
|
||||||
│ │ └── usernamePolicy.test.js
|
│ │ └── usernamePolicy.test.js
|
||||||
|
│ ├── tools/
|
||||||
|
│ │ └── cliloc-export/
|
||||||
|
│ │ ├── clilocexport.csproj
|
||||||
|
│ │ ├── Program.cs
|
||||||
|
│ │ └── README.md
|
||||||
│ ├── .env.example
|
│ ├── .env.example
|
||||||
│ ├── package-lock.json
|
│ ├── package-lock.json
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
|
|||||||
@@ -60,12 +60,32 @@ board while holding back one column. See the table in §3.
|
|||||||
| **Shard rules** | Skill/stat caps, house limits, vet rewards, the ruleset | Everyone | Connect address → Everyone |
|
| **Shard rules** | Skill/stat caps, house limits, vet rewards, the ruleset | Everyone | Connect address → Everyone |
|
||||||
| **Spawn atlas** | Bestiary and spawn locations (static content) | Everyone | — |
|
| **Spawn atlas** | Bestiary and spawn locations (static content) | Everyone | — |
|
||||||
| **Leaderboards** | Point and loyalty standings | Everyone | Character names → Everyone |
|
| **Leaderboards** | Point and loyalty standings | Everyone | Character names → Everyone |
|
||||||
| **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Location → Everyone |
|
| **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Vendor owner character id → Everyone · In-game location → Everyone |
|
||||||
|
|
||||||
**Why the marketplace ships with live updates off.** A live feed of every vendor's full inventory
|
**Why the marketplace ships with live updates off.** A live feed of every vendor's full inventory
|
||||||
would be the single largest thing the site sends. No page needs it — the marketplace is a search over
|
would be the single largest thing the site sends. No page needs it — the marketplace is a search over
|
||||||
stored data with a “prices last refreshed N minutes ago” stamp. Turn it on only if you want it.
|
stored data with a “prices last refreshed N minutes ago” stamp. Turn it on only if you want it.
|
||||||
|
|
||||||
|
**Why the marketplace's fields default to Everyone.** A vendor's shop name, its owner's character
|
||||||
|
name and where it is standing are *already* visible to every player in game: the stock Vendor Search
|
||||||
|
gump surfaces exactly that set to anyone who opens it. Publishing them on the site is not a new
|
||||||
|
disclosure. They stay configurable because a shard may still prefer to keep its economy behind a
|
||||||
|
login — and because "already public in game" is a judgement about your shard, not ours.
|
||||||
|
|
||||||
|
**Location is one setting covering four things.** Hiding it removes the facet, the coordinates, the
|
||||||
|
region *and* the house name together. That is deliberate: those are four ways of saying the same
|
||||||
|
thing, and a setting that hid the coordinates while publishing the house name would not have hidden
|
||||||
|
anything.
|
||||||
|
|
||||||
|
**Hiding the owner name also hides the owner character id.** They are separate settings so you can
|
||||||
|
be explicit, but leaving the id published while hiding the name achieves nothing — the leaderboards
|
||||||
|
and guild boards resolve that same id back to a character name. Set both.
|
||||||
|
|
||||||
|
**What hiding a vendor cannot do.** Only vendors whose owner left the in-game *Vendor Search* flag ON
|
||||||
|
are ever sent to the site, so a player who hides their shop in game is hidden here too — and no
|
||||||
|
setting on this page can override that. It works the other way as well: these settings control who
|
||||||
|
sees the index, not whether players can find each other's shops in game.
|
||||||
|
|
||||||
**Why house owner/price default to Staff.** The public Houses page has always been a "where are the
|
**Why house owner/price default to Staff.** The public Houses page has always been a "where are the
|
||||||
falling houses" board — location only. Owner and price are the staff view. That split is preserved.
|
falling houses" board — location only. Owner and price are the staff view. That split is preserved.
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ The atlas is fully functional as text. `shard_spawn_creatures.art` is nullable
|
|||||||
and is NULL on every fresh import; pages render without images, which is the
|
and is NULL on every fresh import; pages render without images, which is the
|
||||||
normal and supported state, not a degraded one.
|
normal and supported state, not a degraded one.
|
||||||
|
|
||||||
An operator who wants art:
|
An operator who wants art — step-by-step, with the UOFiddler side spelled out, in
|
||||||
|
[`UOFIDDLER.md`](UOFIDDLER.md) §Part 2:
|
||||||
|
|
||||||
1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or
|
1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or
|
||||||
any art extractor).
|
any art extractor).
|
||||||
|
|||||||
981
website/THEMING_AND_NAV.md
Normal file
981
website/THEMING_AND_NAV.md
Normal file
@@ -0,0 +1,981 @@
|
|||||||
|
# Admin-Configurable Theming & Navigation
|
||||||
|
|
||||||
|
> Build contract for runtime-configurable theme, brand assets, and navigation.
|
||||||
|
> Derived from the design doc *Spec: Admin-Configurable Theming & Navigation*,
|
||||||
|
> **corrected to match the current codebase** and with the open questions resolved.
|
||||||
|
> Same workflow as the hero editor: design → phased build → verify.
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Let the site admin customize, at runtime with no rebuild or redeploy:
|
||||||
|
|
||||||
|
1. **Visual theme** — colors, fonts (from a curated Google Fonts shortlist), and
|
||||||
|
corner radius / shadow depth — via three presets or per-group custom overrides.
|
||||||
|
2. **Brand assets** — logo, hero image, favicon — uploaded to override the
|
||||||
|
`BRAND_*` env defaults.
|
||||||
|
3. **Navigation** — reorder, relabel, and show/hide items in the public site nav,
|
||||||
|
admin sidebar, and player portal nav, via drag-and-drop.
|
||||||
|
|
||||||
|
All three follow the `settings.model.js` pattern already used for `hero_layout`:
|
||||||
|
a JSON value stored under a settings key, exposed through `getPublic()` where
|
||||||
|
needed, edited from an admin view, applied at runtime.
|
||||||
|
|
||||||
|
## 2. Core principle: `BRAND_*` env stays the default, always
|
||||||
|
|
||||||
|
[`server/src/config/brand.js`](../../website/server/src/config/brand.js) is the
|
||||||
|
existing single source of instance identity, read once at startup from env with
|
||||||
|
baked-in Runic Gateway defaults. The app ships as one prebuilt image and each
|
||||||
|
instance re-skins itself via env. **This feature must not disturb that.**
|
||||||
|
|
||||||
|
Every new setting is an *override layer*, never a replacement:
|
||||||
|
|
||||||
|
- An instance where the admin has not touched these settings renders
|
||||||
|
**identically to today**, driven entirely by `BRAND_*` and the current
|
||||||
|
`theme.css` `:root`.
|
||||||
|
- Saving one setting makes that setting — and only that setting — take
|
||||||
|
precedence. Untouched settings keep following env.
|
||||||
|
- This holds **per field**, not per feature. A custom accent with untouched
|
||||||
|
fonts means the accent comes from the DB and the fonts still come from
|
||||||
|
`--serif`/`--display`/`--sans` as `theme.css` defines them.
|
||||||
|
- "Admin-set" means **a DB row exists for that key**. Absence of the row — not an
|
||||||
|
empty or false value — is what triggers the env/CSS fallback. An admin who
|
||||||
|
explicitly picks a preset that happens to equal the shipped default has still
|
||||||
|
set it, and it is stored and honored as explicit.
|
||||||
|
- **No migration writes defaults into the settings table.** New and existing
|
||||||
|
installs both start with zero rows for these keys; that absence *is* the
|
||||||
|
"use env default" state.
|
||||||
|
|
||||||
|
## 3. Locked decisions
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Brand contract | **`getPublic().brand` returns effective values** (override → env). The Android app and Discord embeds track admin theming for free — see §4.5 |
|
||||||
|
| Theme delivery | **The server resolves the whole effective token set** and the client writes it as CSS custom properties. No `[data-theme]` blocks — see §6.2 |
|
||||||
|
| Structural tokens | **Radius + shadow depth only.** `spacingUnit` and `borderWeight` are **cut**, not deferred — see §4.6 |
|
||||||
|
| Radius token values | **Seeded at today's real values** (four tokens, not three), so the promotion step is a true no-op — see §4.7 |
|
||||||
|
| Presets in v1 | **Three dark presets** — Runic Gateway, Modern, Fantasy. Parchment (light) is Phase 9 — see §4.8 |
|
||||||
|
| Fonts | **Curated shortlist, dropdown-only**, 4 options per role, 8 web families in **one** `css2?` request — see §5 |
|
||||||
|
| Raw custom CSS | **Out of scope entirely** — not deferred. Materially different risk profile (overlay/clickjacking tricks, tracking pixels via `background: url(...)`); would need its own feature and its own review |
|
||||||
|
| Live preview | Out of scope for v1 |
|
||||||
|
| Reduced-motion toggle | Out of scope for v1 |
|
||||||
|
| Nav override power | **`label`, `order`, `hidden`, and (admin nav only) `group`.** Never `to`, `roles`, or `feature` — see §7 |
|
||||||
|
| Reset to defaults | **Deletes the settings row.** Never writes a stored copy of the defaults |
|
||||||
|
| Favicon uploads | **PNG only.** No `.ico` — see §4.10 |
|
||||||
|
|
||||||
|
## 4. Corrections to the design doc (current-code reality)
|
||||||
|
|
||||||
|
The design doc is structurally sound; the token architecture, the
|
||||||
|
override-on-top-of-env principle, the nav-override security framing, and the
|
||||||
|
reuse of `imageUpload.js` all match reality. These are the points where it does
|
||||||
|
not, listed worst-first. §4.1–4.5 are blocking; §4.6–4.11 are scope corrections.
|
||||||
|
|
||||||
|
### 4.1 There is no way to delete a setting
|
||||||
|
|
||||||
|
The entire "Reset to defaults deletes the row" principle — which all five new
|
||||||
|
keys rely on, and which the doc lists as an acceptance criterion — has no
|
||||||
|
implementation.
|
||||||
|
[`settings.db.js`](../../website/server/src/model/settings/settings.db.js)
|
||||||
|
exposes `get` / `getAll` / `set` / `seedDefault` only, and the admin API is
|
||||||
|
`PUT /admin/settings` taking a key/value object
|
||||||
|
([`admin.controller.js:499`](../../website/server/src/router/v1/admin/admin.controller.js)).
|
||||||
|
|
||||||
|
**Fix:** add `settingsDb.remove(key)` and a `DELETE /api/v1/admin/settings/:key`
|
||||||
|
route with an explicit key allowlist (the five new keys plus `hero_layout_draft`).
|
||||||
|
Admin-only, same gate as the existing settings routes. Deleting a key that does
|
||||||
|
not exist is a success, not a 404 — "reset" is idempotent.
|
||||||
|
|
||||||
|
### 4.2 Non-admins cannot read their own nav overrides
|
||||||
|
|
||||||
|
The doc says `nav_admin` / `nav_player` are admin-only settings "fetched by the
|
||||||
|
authenticated `AdminLayout` / `PlayerPortalLayout`." But `GET /admin/settings` is
|
||||||
|
gated `requireRole('admin')`
|
||||||
|
([`settings.router.js:18,28`](../../website/server/src/router/v1/admin/settings.router.js)),
|
||||||
|
while `AdminLayout` renders for **editors and moderators** and
|
||||||
|
`PlayerPortalLayout` renders for **players**. Those users have no endpoint from
|
||||||
|
which to read the key, so their nav would silently never apply the override.
|
||||||
|
|
||||||
|
**Fix:** new `GET /api/v1/settings/nav`, `isLoggedIn` only, returning
|
||||||
|
`{ nav_admin, nav_player }`. Not in `PUBLIC_KEYS` — an anonymous visitor has no
|
||||||
|
use for either, and the admin nav's labels leak the shape of the admin surface.
|
||||||
|
|
||||||
|
### 4.3 `renderIndexHtml` runs once at boot, not per request
|
||||||
|
|
||||||
|
[`app.js:207`](../../website/server/src/app.js) reads and templates `index.html`
|
||||||
|
at module load and serves that one string for every SPA route forever. The doc
|
||||||
|
describes overriding `logo`/`favicon` as "an async settings read inside a
|
||||||
|
currently-synchronous-feeling builder" — it is actually a lifecycle change, not
|
||||||
|
just an `await`.
|
||||||
|
|
||||||
|
**Fix:** keep the rendered shell cached in a module-level variable, render it
|
||||||
|
lazily on first request, and invalidate on any successful write to
|
||||||
|
`brand_assets`. Two hard requirements:
|
||||||
|
|
||||||
|
- A DB fault must never fail the page — on a read error, fall back to the
|
||||||
|
env-only shell (the current behavior).
|
||||||
|
- The shell must stay a single cached string in the steady state. Do not do a
|
||||||
|
settings read per page view.
|
||||||
|
|
||||||
|
### 4.4 Settings values are strings, not objects
|
||||||
|
|
||||||
|
`settings.value` is `TEXT`
|
||||||
|
([`schema.sql:126`](../../website/server/db/schema.sql)) and JSON-valued keys are
|
||||||
|
stored `JSON.stringify`'d and parsed client-side — see `parseLayout` in
|
||||||
|
[`heroLayout.js:58`](../../website/client/src/lib/heroLayout.js). The doc's
|
||||||
|
`settings.brand_assets?.hero` and `settings.nav_public` read as if they arrive
|
||||||
|
parsed. They do not.
|
||||||
|
|
||||||
|
**Fix:** one shared `parseJsonSetting(str, validator)` helper, used by every
|
||||||
|
consumer. A malformed or wrong-shaped value is treated as **absent** (falls back
|
||||||
|
to env/code default), never as an error and never as a partial object. This is
|
||||||
|
the same fail-safe posture `parseLayout` already takes.
|
||||||
|
|
||||||
|
### 4.5 The Android app and Discord embeds are silently excluded
|
||||||
|
|
||||||
|
`getPublic().brand` is a **documented cross-repo contract**, not an internal
|
||||||
|
detail. [`publicBrand.test.js:30`](../../website/server/test/publicBrand.test.js)
|
||||||
|
locks its field list, and the Android app's `BrandDto` seeds the entire Material
|
||||||
|
theme from `brand.accent` (`MainActivity.kt:72` → `RunicGatewayTheme`), with
|
||||||
|
`logo` / `hero` / `favicon` fields alongside it. `brand.accentInt` — derived once
|
||||||
|
at boot — is what Discord embeds color themselves with.
|
||||||
|
|
||||||
|
If theme and asset overrides live only in the new keys, an admin changes the
|
||||||
|
accent on the website and **the phone app and the Discord bot keep the old one**.
|
||||||
|
|
||||||
|
**Fix (locked):** resolve the *effective* values server-side in
|
||||||
|
`getPublic()`'s brand block
|
||||||
|
([`settings.model.js:130-141`](../../website/server/src/model/settings/settings.model.js)):
|
||||||
|
|
||||||
|
```js
|
||||||
|
accent: themeVisual?.colors?.accent ?? brand.accent
|
||||||
|
logo: brandAssets?.logo ?? brand.logo
|
||||||
|
hero: brandAssets?.hero ?? brand.hero
|
||||||
|
favicon: brandAssets?.favicon ?? brand.favicon
|
||||||
|
```
|
||||||
|
|
||||||
|
The web client needs **no change** for this — its existing
|
||||||
|
`setProperty('--accent', brand.accent)` line
|
||||||
|
([`SiteContext.jsx:30-32`](../../website/client/src/contexts/SiteContext.jsx))
|
||||||
|
simply receives a better value. Consequences to handle:
|
||||||
|
|
||||||
|
- ~~`brand.accentInt` must be **recomputed from the effective accent** per
|
||||||
|
request rather than read from the boot-time constant, or Discord embeds
|
||||||
|
drift.~~ **Corrected in Phase 3 — this fix as written was a no-op.**
|
||||||
|
`getPublic().brand` never exposes `accentInt` (`publicBrand.test.js` asserts
|
||||||
|
it is `undefined`, deliberately: it is a Discord-only integer form), and the
|
||||||
|
server-side `brand.accentInt` has no consumer at all. Discord embeds are
|
||||||
|
colored by **`bot/src/brand.js`, in a separate process**, reading
|
||||||
|
`BRAND_ACCENT_COLOR` from env at boot — so there was nothing per-request to
|
||||||
|
recompute, and the drift the note describes was real but unfixable from the
|
||||||
|
server. What Phase 3 actually did: the bot now fetches
|
||||||
|
`GET /public/settings` → `brand.accent` (it already has a public-API client)
|
||||||
|
behind a 10-minute cached getter, keeping env as the fallback. See
|
||||||
|
"Phases 3–4 as landed" below.
|
||||||
|
- `publicBrand.test.js` gains cases: no rows → env values unchanged (the existing
|
||||||
|
assertions must still pass verbatim); `theme_visual` accent set → effective
|
||||||
|
accent returned; `brand_assets.favicon` set → favicon overridden while `logo`
|
||||||
|
and `hero` still come from env.
|
||||||
|
- The Android app needs **no change** to pick up accent/assets. Whether it should
|
||||||
|
also honor the full preset (radius, fonts) is a separate question for
|
||||||
|
`docs/android/PLAN.md`, out of scope here.
|
||||||
|
|
||||||
|
### 4.6 `spacingUnit` and `borderWeight` are not variable renames
|
||||||
|
|
||||||
|
The doc treats these as the same mechanism as color. They are not:
|
||||||
|
|
||||||
|
- **Spacing.** `theme.css` contains **zero** `calc()`-based spacings (the 5
|
||||||
|
`calc()` uses are all `width: min(…, calc(100% - 32px))` page shells). Every
|
||||||
|
padding is a hand-written non-multiple — `7px 14px`, `12px 26px`, `11px 14px`,
|
||||||
|
`13px 14px`. A density token that actually moves density means rewriting ~40
|
||||||
|
declarations into `calc(var(--space-unit) * n)`, and most of the app's real
|
||||||
|
spacing is inline JSX the token cannot reach anyway.
|
||||||
|
- **Border weight.** 39 hand-written `1px` borders, several of which are
|
||||||
|
*semantic* accents that must not scale with a density slider — `.note`'s 3px
|
||||||
|
left rule, `.page-quote`'s 3px, `.pb-tab`'s 2px active underline.
|
||||||
|
|
||||||
|
**Decision:** both are **cut from v1** and do not appear in the admin form.
|
||||||
|
Colors, fonts, radius and shadow depth cover "brand feel" cleanly; these two do
|
||||||
|
not, and shipping them as no-op fields would be worse than not shipping them.
|
||||||
|
|
||||||
|
### 4.7 Six radii cannot round-trip through three tokens
|
||||||
|
|
||||||
|
The doc's preset blocks set `--radius-card: 8px`, but the actual values in
|
||||||
|
`theme.css` are 14×`8px`, 4×`999px`, 4×`10px`, 1×`12px`, 1×`7px`, 1×`6px`. `.card`
|
||||||
|
and `.panel` are **10px** today and `.panel-flat` is **12px**. Adopting the doc's
|
||||||
|
three tokens verbatim would restyle every existing instance — including ones that
|
||||||
|
never touch the feature — which contradicts the acceptance criterion directly
|
||||||
|
above it.
|
||||||
|
|
||||||
|
**Fix (locked):** four tokens seeded at today's real values, so the promotion step
|
||||||
|
is genuinely a no-op:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--radius-pill: 999px; /* .btn, .pill, .badge, .wiki-tag */
|
||||||
|
--radius-panel: 12px; /* .panel-flat */
|
||||||
|
--radius-card: 10px; /* .card, .panel */
|
||||||
|
--radius-input: 8px; /* .input, .textarea, .select, .btn-sq, .note, .rte, .prose img */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The 7px (`.rte-btn`) and 6px (`.rte-linkmenu-item`) values stay literals — they are
|
||||||
|
interior editor chrome, not brand surface. The preset blocks in §6 carry corrected
|
||||||
|
`--radius-card` values accordingly.
|
||||||
|
|
||||||
|
### 4.8 Parchment is a light-mode port, not a preset
|
||||||
|
|
||||||
|
`theme.css` carries 28 `rgba()` literals that assume a dark background — `.pill`'s
|
||||||
|
`rgba(11,22,48,0.5)` fill, `.note`'s background, all seven `.badge-*` fills, the
|
||||||
|
diff add/del colors, `.moon`'s radial gradient, `#dbe2ea` prose strong — plus the
|
||||||
|
hero overlay stacks `rgba(11,15,20,…)` hardcoded in `heroLayout.js` and four route
|
||||||
|
files, plus `rgba(9,13,18,0.86)` inline in `SiteHeader.jsx:55`. None of that
|
||||||
|
responds to a `[data-theme]` variable block; Parchment would inherit dark chrome
|
||||||
|
on a light background and look broken.
|
||||||
|
|
||||||
|
**Decision:** three dark presets in v1. Parchment becomes **Phase 9**, scoped as a
|
||||||
|
light-mode port with its own contrast pass across every component.
|
||||||
|
|
||||||
|
### 4.9 The hero already has a third override layer
|
||||||
|
|
||||||
|
`hero_layout.background.image_url` **already** beats `brand.hero`
|
||||||
|
([`heroLayout.js:39-50`](../../website/client/src/lib/heroLayout.js)). The real
|
||||||
|
resolution order is:
|
||||||
|
|
||||||
|
```
|
||||||
|
hero_layout.background.image_url → brand_assets.hero → BRAND_HERO → /assets/img/runic-emblem.png
|
||||||
|
```
|
||||||
|
|
||||||
|
The doc's two-link chain omits the existing top link. The admin UI must say so
|
||||||
|
explicitly, or "I uploaded a hero and the portal ignored it" becomes a bug report
|
||||||
|
against a working system.
|
||||||
|
|
||||||
|
### 4.10 Favicon `.ico` is not possible without weakening the upload path
|
||||||
|
|
||||||
|
`MIME_EXT` in
|
||||||
|
[`imageUpload.js:24-30`](../../website/server/src/router/v1/admin/imageUpload.js)
|
||||||
|
has no `image/x-icon` or `image/vnd.microsoft.icon` entry, and the stored
|
||||||
|
extension is derived from that map — which is exactly the property that makes the
|
||||||
|
upload path safe. The doc floats "`.ico`/`.png` only" for favicons; the `.ico`
|
||||||
|
half would mean adding a new file type to `/uploads`.
|
||||||
|
|
||||||
|
**Decision:** **PNG only** for favicons. `<link rel="icon">` accepts PNG in every
|
||||||
|
browser this app supports, and the allowlist is left untouched. A tighter size cap
|
||||||
|
than the shared 8 MB limit is applied at the route, not in the shared multer
|
||||||
|
config.
|
||||||
|
|
||||||
|
### 4.11 Smaller notes
|
||||||
|
|
||||||
|
- **CSP is already fine.** [`config/csp.js:50-51`](../../website/server/src/config/csp.js)
|
||||||
|
already allows `https://fonts.googleapis.com` in `style-src` and
|
||||||
|
`https://fonts.gstatic.com` in `font-src`. The font shortlist needs no CSP
|
||||||
|
change — which is worth stating, because widening CSP for a cosmetic feature
|
||||||
|
would not be worth it.
|
||||||
|
- **Do not touch the footer badge.**
|
||||||
|
[`SiteFooter.jsx:19`](../../website/client/src/components/SiteFooter.jsx) is the
|
||||||
|
hardcoded "powered by Runic Gateway" emblem. It is deliberately not the instance
|
||||||
|
logo and must not follow `brand_assets.logo`.
|
||||||
|
- **Nav labels do not reach the portal hero.** `hero_layout`'s
|
||||||
|
`default-quick-links` element duplicates News / Screenshots / Five on Friday /
|
||||||
|
Newsletter / About as its own buttons. Renaming those in the nav editor will not
|
||||||
|
rename them on the portal; they are edited in the hero editor.
|
||||||
|
- **The nav editor must refuse to hide its own entry.** Not a lockout — hiding is
|
||||||
|
presentation-only and the URL still resolves — but recovering by typing a URL is
|
||||||
|
a bad enough experience to be worth one guard.
|
||||||
|
- **Process, per `CLAUDE.md`.** Every server-side phase requires
|
||||||
|
`npm run swagger`, `npm run routes:manifest` (`routeManifest.test.js` fails
|
||||||
|
otherwise), and a matching edit to
|
||||||
|
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md). None of this is in the design doc.
|
||||||
|
|
||||||
|
## 5. Fonts: curated Google Fonts, not free text
|
||||||
|
|
||||||
|
`index.html` already loads Cinzel from Google Fonts, so this extends an existing,
|
||||||
|
already-trusted pattern rather than introducing a new one.
|
||||||
|
|
||||||
|
**The dropdown's value — not free text — is what is stored.** Each option's value
|
||||||
|
*is* the full CSS `font-family` stack exactly as it will be applied, so the client
|
||||||
|
does zero string-building from admin input and `theme_visual` stays a closed set of
|
||||||
|
known-safe values.
|
||||||
|
|
||||||
|
### 5.1 The shortlist
|
||||||
|
|
||||||
|
| Role | Option | Stored stack |
|
||||||
|
|---|---|---|
|
||||||
|
| **Serif body** | EB Garamond — strongest fantasy/historic | `'EB Garamond', Georgia, serif` |
|
||||||
|
| | Merriweather — excellent readability | `Merriweather, Georgia, serif` |
|
||||||
|
| | Playfair Display — elegant/editorial | `'Playfair Display', Georgia, serif` |
|
||||||
|
| | IM Fell English — strongest old-world/UO flavor | `'IM Fell English', Georgia, serif` |
|
||||||
|
| **Display heading** | Cinzel — current Runic Gateway identity | `Cinzel, Georgia, serif` |
|
||||||
|
| | Playfair Display — elegant alternative | `'Playfair Display', Georgia, serif` |
|
||||||
|
| | EB Garamond — softer/classic | `'EB Garamond', Georgia, serif` |
|
||||||
|
| | IM Fell English — very strong fantasy | `'IM Fell English', Georgia, serif` |
|
||||||
|
| **Sans UI** | Inter — default modern UI choice | `Inter, Arial, sans-serif` |
|
||||||
|
| | Work Sans — slightly more character | `'Work Sans', Arial, sans-serif` |
|
||||||
|
| | Source Sans 3 — extremely readable | `'Source Sans 3', Arial, sans-serif` |
|
||||||
|
| | Arial — safe fallback/system option | `'Helvetica Neue', Arial, sans-serif` |
|
||||||
|
|
||||||
|
Two properties fall out of this list and are worth keeping:
|
||||||
|
|
||||||
|
- **Arial is the zero-cost option** — its stack is byte-identical to today's
|
||||||
|
`--sans`, so it needs no webfont at all and doubles as the current default.
|
||||||
|
- **Twelve slots, eight web families.** Playfair Display, EB Garamond and IM Fell
|
||||||
|
English each serve two roles.
|
||||||
|
|
||||||
|
### 5.2 Loading
|
||||||
|
|
||||||
|
One combined request, not eight — Google Fonts accepts multiple `family=`
|
||||||
|
parameters per URL, and the font *binaries* are only fetched when a family is
|
||||||
|
actually applied:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Static, in `index.html`, alongside the existing `preconnect` hints — a Google
|
||||||
|
Fonts URL is **never** built from admin input at runtime.
|
||||||
|
|
||||||
|
**Weight coverage gotcha:** IM Fell English ships **400 and italic only — no
|
||||||
|
bold.** `.display` and `.h1` use `font-weight: 600`, and `.btn` / `.eyebrow` /
|
||||||
|
`.badge` use 600–700, so choosing it yields browser-synthesized faux-bold. That is
|
||||||
|
acceptable for the display role (it is the authentic look) but is a reason not to
|
||||||
|
present it as a recommended body face.
|
||||||
|
|
||||||
|
## 6. Storage
|
||||||
|
|
||||||
|
Five new keys. `theme_visual`, `brand_assets` and `nav_public` join `PUBLIC_KEYS`;
|
||||||
|
`nav_admin` and `nav_player` are served by the authenticated endpoint from §4.2.
|
||||||
|
All are JSON strings, absent by default.
|
||||||
|
|
||||||
|
### 6.1 `theme_visual`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "preset": "runic-gateway", "custom": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
or, when the admin picks Custom:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preset": "custom",
|
||||||
|
"custom": {
|
||||||
|
"colors": { "bg": "#0e1318", "bgDeep": "#0b0f14", "panelA": "#192231", "panelB": "#141a21",
|
||||||
|
"accent": "#7f99bd", "accentBright": "#cdd9e8", "ink": "#eef3f8", "text": "#c4cdd8" },
|
||||||
|
"structure": { "radiusPill": "999px", "radiusPanel": "12px", "radiusCard": "10px",
|
||||||
|
"radiusInput": "8px", "shadowDepth": "0 14px 34px rgba(0,0,0,0.3)" },
|
||||||
|
"fonts": { "serif": "'EB Garamond', Georgia, serif",
|
||||||
|
"display": "Cinzel, Georgia, serif",
|
||||||
|
"sans": "Inter, Arial, sans-serif" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`colors` / `structure` / `fonts` are independently overridable groups — a custom
|
||||||
|
accent without touching radius or fonts is expected. A group or field the admin
|
||||||
|
never touched falls back to whatever preset or `:root` value is active. **Never
|
||||||
|
null a field out to "clear" it** — remove it from the object.
|
||||||
|
|
||||||
|
### 6.2 Preset blocks
|
||||||
|
|
||||||
|
> **Superseded in Phase 3.** The presets below are correct as *values* and were
|
||||||
|
> built as specified, but they do **not** live in `theme.css` as `[data-theme]`
|
||||||
|
> blocks. They live in `server/src/config/themePresets.js`, and the server
|
||||||
|
> resolves the effective token set into `getPublic().theme` for the client to
|
||||||
|
> write onto `<html>`. See "Phases 3–4 as landed" for why, and note two
|
||||||
|
> corrections the build made to the palettes: each preset carries the **full**
|
||||||
|
> color set (fifteen tokens, not the eight below), and `--shadow-card` is
|
||||||
|
> themed alongside the radii.
|
||||||
|
|
||||||
|
`:root` stays the **Runic Gateway** default — today's actual values — so an
|
||||||
|
instance with no `theme_visual` row renders exactly as it does now.
|
||||||
|
`runic-gateway` is *also* declared as a named preset so that switching back to
|
||||||
|
it after trying another is the same code path.
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="runic-gateway"] {
|
||||||
|
--bg: #0e1318; --bg-deep: #0b0f14; --panel-a: #192231; --panel-b: #141a21;
|
||||||
|
--accent: #7f99bd; --accent-bright: #cdd9e8; --ink: #eef3f8; --text: #c4cdd8;
|
||||||
|
--radius-pill: 999px; --radius-panel: 12px; --radius-card: 10px; --radius-input: 8px;
|
||||||
|
--serif: Georgia, "Times New Roman", serif;
|
||||||
|
--display: Cinzel, Georgia, serif;
|
||||||
|
--sans: "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern — flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. */
|
||||||
|
[data-theme="modern"] {
|
||||||
|
--bg: #101114; --bg-deep: #0a0a0c; --panel-a: #1c1d22; --panel-b: #17181c;
|
||||||
|
--accent: #4f8ef7; --accent-bright: #a8c8ff; --ink: #f2f3f5; --text: #b8bcc4;
|
||||||
|
--radius-pill: 8px; --radius-panel: 8px; --radius-card: 6px; --radius-input: 6px;
|
||||||
|
--serif: Inter, Arial, sans-serif;
|
||||||
|
--display: 'Work Sans', Arial, sans-serif;
|
||||||
|
--sans: Inter, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fantasy — warmer, higher contrast, carved corners; leans into UO harder. */
|
||||||
|
[data-theme="fantasy"] {
|
||||||
|
--bg: #1a120b; --bg-deep: #120c07; --panel-a: #2c1f14; --panel-b: #241a10;
|
||||||
|
--accent: #c9973f; --accent-bright: #e8c374; --ink: #f3e8d4; --text: #d3bfa0;
|
||||||
|
--radius-pill: 4px; --radius-panel: 3px; --radius-card: 2px; --radius-input: 2px;
|
||||||
|
--serif: 'EB Garamond', Georgia, serif;
|
||||||
|
--display: Cinzel, Georgia, serif;
|
||||||
|
--sans: 'EB Garamond', Georgia, serif;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Derived-token rule (do not break this):** `--panel-grad` and `--shadow-card` must
|
||||||
|
stay expressed *in terms of* the other variables, never written as a literal
|
||||||
|
gradient in a preset block. If `--panel-grad` is ever hardcoded, a future light
|
||||||
|
preset silently inherits a dark gradient and looks broken. Likewise `--mode-live`
|
||||||
|
and `--mode-maint` (the status dots) are **semantic** — green means live — and stay
|
||||||
|
fixed across all presets rather than being themed.
|
||||||
|
|
||||||
|
### 6.3 `brand_assets`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "logo": null, "hero": null, "favicon": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each field, once set, holds the stored upload URL (`/uploads/1234-abcd.png`) — the
|
||||||
|
same shape `POST /admin/uploads` already returns. A `null` or absent field falls
|
||||||
|
back to `brand.logo` / `brand.hero` / `brand.favicon`; uploading a logo does not
|
||||||
|
force the admin to also pick a hero.
|
||||||
|
|
||||||
|
### 6.4 `nav_public` / `nav_admin` / `nav_player`
|
||||||
|
|
||||||
|
Keyed by the item's existing `to`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"/admin/posts": { "label": "Blog Posts", "order": 10 },
|
||||||
|
"/admin/settings": { "hidden": true },
|
||||||
|
"/admin/moderation": { "order": 5, "group": "Content" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Any field absent for a given `to` falls back to the code default — label from
|
||||||
|
`NAV`, natural array order, `hidden: false`, original group. **Unknown `to` values
|
||||||
|
(not present in the current code's base array) are ignored, not stored and later
|
||||||
|
honored**, so removing a route in code can never leave a dangling override that
|
||||||
|
does something unexpected.
|
||||||
|
|
||||||
|
**`nav_public` may also be a wrapper** (Phase 10), because the public header is
|
||||||
|
the one nav an admin can restructure rather than only reorder:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": { "/site/champs": { "order": 0, "section": "sec_a1b2" } },
|
||||||
|
"sections": [ { "id": "sec_a1b2", "label": "The World", "order": 4 } ],
|
||||||
|
"links": [ { "id": "lnk_c3d4", "label": "Player Guide",
|
||||||
|
"to": "/wiki/new-player-guide", "order": 1, "section": "sec_a1b2" } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **A bare map is still read as the items map.** Every item key is a path
|
||||||
|
starting with `/`, so it can never collide with the literal key `items` — the
|
||||||
|
detection is unambiguous, and a nav with no sections still *stores* the bare
|
||||||
|
map, so this feature changed nothing for one that does not use it.
|
||||||
|
- `nav_admin` / `nav_player` keep the bare map; `sections` and `links` are
|
||||||
|
dropped for them, since neither layout can render an admin-created section.
|
||||||
|
- Top-level order is one number line shared by ungrouped entries **and
|
||||||
|
sections**; within a section, by its members. An admin-created entity with no
|
||||||
|
stored order appends after the coded ones rather than jumping to the front.
|
||||||
|
- **One level only.** No menu inside a menu.
|
||||||
|
- An `items[].section` or `links[].section` naming no declared section falls back
|
||||||
|
to the top level, mirroring the "group must name an existing title" rule.
|
||||||
|
|
||||||
|
## 7. Navigation: hard constraint
|
||||||
|
|
||||||
|
> **Amended in Phase 10.** This section originally said the override layer
|
||||||
|
> "cannot introduce a `to` that is not already in the corresponding hardcoded
|
||||||
|
> `NAV` array". That is still true of every **coded** entry, but the public
|
||||||
|
> header now also lets an admin add links of their own, so the constraint is
|
||||||
|
> restated below in the narrower form that survives. Nothing about the *gates*
|
||||||
|
> changed.
|
||||||
|
|
||||||
|
The override system can affect a **coded** entry's `label`, `order`, `hidden`,
|
||||||
|
and which container it sits in — `group` on the admin nav (an *existing* titled
|
||||||
|
section) or `section` on the public header (an admin-created dropdown).
|
||||||
|
|
||||||
|
It **cannot**:
|
||||||
|
|
||||||
|
- change a coded entry's `to`, or introduce a new one in its place;
|
||||||
|
- change or remove an entry's `roles` (admin nav) or `feature` (public nav) gate;
|
||||||
|
- un-hide an entry for a viewer whose role or feature check would otherwise fail.
|
||||||
|
|
||||||
|
**The public header may additionally carry admin-created `sections` and
|
||||||
|
admin-authored `links`** (§6.4, §7.2). This is a genuine widening and is worth
|
||||||
|
stating plainly:
|
||||||
|
|
||||||
|
- A **section** is a container with a label and a position. It has no `to` and is
|
||||||
|
never itself a link — it only opens — so it adds no reachable surface at all.
|
||||||
|
- A **link** is the one thing an admin may add to a nav, and the only place a path
|
||||||
|
is not required to already exist in code. It is restricted to a **same-origin
|
||||||
|
path**: no scheme, no protocol-relative `//host`, no whitespace or quotes. The
|
||||||
|
nav is not a place to send visitors to an origin the operator does not control.
|
||||||
|
- A link carries **no `roles` or `feature` of its own, and needs none**: the page
|
||||||
|
behind it enforces its own access, so a link to somewhere the viewer cannot
|
||||||
|
reach behaves exactly as typing that address would. Adding a link advertises a
|
||||||
|
route; it never grants one.
|
||||||
|
|
||||||
|
The property this rests on is structural rather than a check someone has to
|
||||||
|
remember: coded entries live in an `items` map whose keys **must** be routes the
|
||||||
|
base array declares, so that map can never introduce a route, while everything
|
||||||
|
that *can* name an arbitrary path lives in `links`, where the path rule is
|
||||||
|
applied on both the write and the read path.
|
||||||
|
|
||||||
|
The existing filters in
|
||||||
|
[`SiteHeader.jsx`](../../website/client/src/components/SiteHeader.jsx) and
|
||||||
|
[`AdminLayout.jsx`](../../website/client/src/routes/admin/AdminLayout.jsx)
|
||||||
|
run **after** the override merge, unchanged, and remain the actual security
|
||||||
|
boundary. The override layer is presentation-only. This is the same
|
||||||
|
"server-enforced gate, client-side is only about not advertising a dead end"
|
||||||
|
principle already documented in `SiteHeader.jsx`'s comments, and this feature must
|
||||||
|
not weaken it.
|
||||||
|
|
||||||
|
Three existing behaviors the merge must not disturb:
|
||||||
|
|
||||||
|
- **Empty dropdowns.** A section whose every entry is filtered out by a shard
|
||||||
|
feature must not render at all — a menu that opens onto nothing is worse than
|
||||||
|
no menu. `pruneNav` applies the gate inside a section and then drops one it
|
||||||
|
leaves empty.
|
||||||
|
|
||||||
|
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
|
||||||
|
redirects them out of anything else. Overrides apply before that filter, so a
|
||||||
|
moderator can still end up with a legitimately short sidebar — but the redirect
|
||||||
|
effect must keep working untouched.
|
||||||
|
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
|
||||||
|
override that hides every item in a group must produce no orphaned header.
|
||||||
|
|
||||||
|
### 7.1 Merge util
|
||||||
|
|
||||||
|
New shared pure module, `client/src/lib/navOverrides.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function applyNavOverrides(baseNav, overrides) {
|
||||||
|
// baseNav: the existing hardcoded array / grouped array — remains the source
|
||||||
|
// of truth for `to`, `roles`, `feature`, `icon`, `end`
|
||||||
|
// overrides: the parsed settings JSON, or null when the admin never touched it
|
||||||
|
// returns: a new array of the same shape with label/order/hidden/group applied
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`overrides` absent → return `baseNav` unchanged. This is the "respect defaults"
|
||||||
|
path and is the single most important case to test.
|
||||||
|
|
||||||
|
**As built (Phase 1).** Two shapes are handled by the one function — flat
|
||||||
|
(`SiteHeader`, `PlayerPortalLayout`) and grouped (`AdminLayout`) — detected by
|
||||||
|
whether every entry carries an `items` array. Three rules the doc left open,
|
||||||
|
settled by the implementation and locked by tests:
|
||||||
|
|
||||||
|
- **Ordering.** An item the admin never reordered keeps its index in the base
|
||||||
|
array as its sort key, so setting one `order` does not scramble the rest.
|
||||||
|
Explicit and implicit keys therefore share one number line and can collide;
|
||||||
|
ties break **explicit first** (an admin who said "0" means first, not
|
||||||
|
"wherever the untouched item at index 0 already sits"), and two explicit
|
||||||
|
equal orders keep code order via a stable sort. The editor writes an order for
|
||||||
|
every item in a list the way drag-and-drop does, so ties are the stale-row
|
||||||
|
case, not the normal one — they just have to resolve predictably.
|
||||||
|
- **`group`.** Accepted only when it names a title the base nav already
|
||||||
|
declares; anything else is dropped, so an item can never land under a header
|
||||||
|
that does not exist. Group *order* is not overridable — sections stay in code
|
||||||
|
order, only membership and within-group order move.
|
||||||
|
- **Field-by-field validation.** A bad `label` does not discard a good `order`
|
||||||
|
beside it, and `hidden` is honored only as the literal boolean `true`.
|
||||||
|
Everything unrecognized is ignored rather than rejected, so a hand-edited row
|
||||||
|
degrades to the code default instead of rendering a broken nav.
|
||||||
|
|
||||||
|
`hidden: false` cannot un-hide anything: hiding here is subtractive only, and
|
||||||
|
the role/feature filters still run afterward, unchanged.
|
||||||
|
|
||||||
|
## 8. Build phases
|
||||||
|
|
||||||
|
Each phase is independently shippable and leaves the site rendering identically to
|
||||||
|
today until the admin acts.
|
||||||
|
|
||||||
|
| Phase | Work |
|
||||||
|
|---|---|
|
||||||
|
| **0 — Settings-store groundwork** ✅ | `settingsDb.remove()`; `DELETE /admin/settings/:key` with key allowlist; `GET /settings/nav` (§4.2); `parseJsonSetting()` helper; register the five keys; three into `PUBLIC_KEYS`. Swagger + route-manifest regen |
|
||||||
|
| **1 — `navOverrides.js` + tests** ✅ | The pure merge util, unit-tested in isolation. **The one piece with real correctness risk** |
|
||||||
|
| **2 — Radius/shadow token groundwork** ✅ | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
|
||||||
|
| **3 — Theme engine** ✅ | Three presets, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
|
||||||
|
| **4 — Admin theme UI** ✅ | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
|
||||||
|
| **5 — Brand assets** ✅ | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the shells; `heroImage` chain extension |
|
||||||
|
| **6 — Public nav wiring** ✅ | `SiteHeader.jsx` → `nav_public`. Lowest risk of the three: no roles, no groups |
|
||||||
|
| **7 — Nav builder UI** ✅ | `NavEditor.jsx` with `@dnd-kit` (new dependency), **Public tab only** |
|
||||||
|
| **8 — Admin + Player nav** ✅ | Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use |
|
||||||
|
| **9 — Palette-following literals + Parchment** ❌ **cancelled** | Was: promote the hue-carrying `rgba()` literals of §4.8 so they follow the palette, then the light-mode port. Not scheduled — see "Phase 9, cancelled" below |
|
||||||
|
| **10 — Public nav sections + added links** ✅ | Admin-created dropdown sections in the public header, coded entries organised into them, and admin-authored same-origin links. `NavDropdown.jsx`, `buildPublicNav`/`pruneNav`, the `nav_public` wrapper of §6.4, and the Public tab's own tree editor. **Amends §7** |
|
||||||
|
|
||||||
|
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
|
||||||
|
fourth. **All four PR pairs target `edge`, not `main`** — the feature reaches
|
||||||
|
`main` as one `edge` → `main` merge once every phase is in, so no release ever
|
||||||
|
carries a half-wired theme engine. Phase 8 is the last one, so that merge is
|
||||||
|
what closes the feature.
|
||||||
|
|
||||||
|
### Phases 0–2 as landed
|
||||||
|
|
||||||
|
- **`/api/v1/settings` is a fifth router group**, not a route bolted onto an
|
||||||
|
existing one. §4.2 named the URL but not where it lives, and the domain split
|
||||||
|
leaves no group it fits: `/public` is anonymous, `/admin/settings` is
|
||||||
|
`adminOnly` while `AdminLayout` renders for editors and moderators, and
|
||||||
|
`/player` is data scoped to `req.user.id`. The group carries
|
||||||
|
`noindex, requireAuth` and no role gate. The route-manifest guard test that
|
||||||
|
asserts every `/admin/**` and `/player/**` route sits behind `requireAuth` now
|
||||||
|
covers `/settings/**` too.
|
||||||
|
- **Reset is `DELETE /api/v1/admin/settings/:key`** with the allowlist in
|
||||||
|
`settings.model.js` (`DELETABLE_KEYS`), which is what stops a stray request
|
||||||
|
from dropping `site_mode` or the uo-link config. It is admin-only and
|
||||||
|
idempotent, and a test asserts it never writes a row.
|
||||||
|
- **`parseJsonSetting` lives at `server/src/utils/settingsJson.js`.** Non-object
|
||||||
|
JSON (`4`, `"x"`, `null`, `[]`) is treated as absent alongside syntax errors,
|
||||||
|
and a validator rejection discards the whole object rather than half-applying
|
||||||
|
it. The client keeps `parseLayout`; a client-side counterpart arrives with its
|
||||||
|
first consumer in Phase 3.
|
||||||
|
- **Phase 2 was a 23-declaration promotion** — 14×`8px` → `--radius-input`,
|
||||||
|
4×`999px` → `--radius-pill`, 4×`10px` → `--radius-card`, 1×`12px` →
|
||||||
|
`--radius-panel` — matching the §4.7 census exactly. The `7px`/`6px` editor
|
||||||
|
chrome and the two `50%` circles stay literal. `--shadow-card` and
|
||||||
|
`--panel-grad` were **already** tokens and already derived, so the shadow half
|
||||||
|
of the phase was a no-op; the only two `box-shadow` declarations in
|
||||||
|
`theme.css` both already read `var(--shadow-card)`.
|
||||||
|
|
||||||
|
### Phases 3–4 as landed
|
||||||
|
|
||||||
|
Four things the design settled differently once it met the code.
|
||||||
|
|
||||||
|
**1. The server resolves the whole token set; there are no `[data-theme]`
|
||||||
|
blocks.** §6.2 put the presets in `theme.css` and had the client set a
|
||||||
|
`data-theme` attribute. That does not work as written: `SiteContext` writes
|
||||||
|
`--accent` as an **inline style on `<html>`** (`SiteContext.jsx:31`), and an
|
||||||
|
inline property beats any attribute-selector block. An admin who picked Fantasy
|
||||||
|
without also setting a custom accent would have had Fantasy's `#c9973f` painted
|
||||||
|
over by `brand.accent` from env — and §4.5's whole point is that
|
||||||
|
`getPublic().brand.accent` is what the phone app themes itself from, so the two
|
||||||
|
surfaces would have disagreed about the accent while both being "right".
|
||||||
|
|
||||||
|
The fix removes the conflict rather than sequencing around it. Presets live in
|
||||||
|
`server/src/config/themePresets.js`; `server/src/utils/themeResolve.js` layers
|
||||||
|
`:root` ← preset ← custom **per field** into a token map; `getPublic()` returns
|
||||||
|
it as `theme`; `client/src/lib/themeVars.js` writes it onto `<html>`. One
|
||||||
|
authority for the merge, `brand.accent` is by construction the accent the site
|
||||||
|
actually paints, and `theme.css`'s `:root` is untouched — an instance with no
|
||||||
|
row gets no `theme` block, the client writes nothing, and the page renders
|
||||||
|
byte-for-byte as today.
|
||||||
|
|
||||||
|
The client half's real logic is *removal*: inline properties are not cleared by
|
||||||
|
writing a smaller object over them, so `applyThemeTokens` tracks what it set
|
||||||
|
last time and `removeProperty`s whatever the new payload no longer mentions.
|
||||||
|
Without that, "Reset to defaults" would look broken until a reload.
|
||||||
|
|
||||||
|
**2. Presets carry the full fifteen-token palette, and theme `--shadow-card`.**
|
||||||
|
§6.2's blocks set eight colors. Applied literally, Fantasy's warm brown page
|
||||||
|
would have kept `--line: #2a3544` and `--blue: #13243c` — dark blue-grey borders
|
||||||
|
and a blue-grey active nav row — because those tokens are not in the list.
|
||||||
|
Every preset now sets `--panel-flat`, `--line`, `--line-soft`, `--head`,
|
||||||
|
`--muted`, `--dim` and `--blue` as well. The admin *form* still exposes only
|
||||||
|
§6.1's eight; the rest are supporting shades a preset gets right coherently but
|
||||||
|
that are not worth hand-picking. `--mode-live` / `--mode-maint` stay fixed
|
||||||
|
across every preset (green means live) and `--panel-grad` stays derived, both
|
||||||
|
locked by tests.
|
||||||
|
|
||||||
|
**3. The option catalog is served, not duplicated.**
|
||||||
|
`GET /api/v1/settings/theme/options` returns the presets (with their full token
|
||||||
|
maps, so a control can show what an unset field currently resolves to), the font
|
||||||
|
shortlist, the shadow depths, and the editable field names paired with the CSS
|
||||||
|
variable each drives. Duplicating those lists in client code would mean the form
|
||||||
|
could offer a font the server rejects, which surfaces as a save 400ing for no
|
||||||
|
visible reason. A test asserts every offered option validates.
|
||||||
|
|
||||||
|
Validation is deliberately asymmetric: **strict on write** (`PUT
|
||||||
|
/admin/settings` 400s and names the offending field) and **forgiving on read**
|
||||||
|
(a bad field is dropped, its neighbours keep applying). Strict-on-write gives
|
||||||
|
feedback; forgiving-on-read means a row hand-edited in the DB degrades to the
|
||||||
|
shipped default instead of rendering a broken site.
|
||||||
|
|
||||||
|
One addition to §5.1's twelve font options: **Georgia in the serif list.** The
|
||||||
|
shortlist gave the sans role a "today's default" option (Arial, byte-identical
|
||||||
|
to `--sans`) but left serif with no way back to `Georgia, "Times New Roman",
|
||||||
|
serif` short of resetting the whole theme. It pulls in no web family, so §5.2's
|
||||||
|
combined URL is unchanged.
|
||||||
|
|
||||||
|
**4. The Discord bot fetches the accent; §4.5's `accentInt` note was a no-op.**
|
||||||
|
See the correction in §4.5. `bot/src/brand.js` now reads
|
||||||
|
`GET /public/settings` → `brand.accent` through the public-API client it already
|
||||||
|
had, behind getters with a 10-minute TTL — so `brand.accentInt` stays a plain
|
||||||
|
property read at every existing call site, an embed never awaits a network call,
|
||||||
|
and any failure (site down, maintenance, malformed body) keeps the last known
|
||||||
|
good value with `BRAND_ACCENT_COLOR` as the floor.
|
||||||
|
|
||||||
|
**Found while smoke-testing: §4.8's rgba literals are not only a light-mode
|
||||||
|
problem.** The 28 dark-assuming `rgba()` literals were scoped to Phase 9 on the
|
||||||
|
reasoning that they break a *light* preset. Applying **Fantasy** on a live
|
||||||
|
instance shows they also carry a **hue**: `.btn-ghost`'s
|
||||||
|
`background: rgba(11, 22, 48, 0.45)` (essentially `--blue` at 45%) leaves the
|
||||||
|
portal's quick-link buttons reading blue on a warm brown page, and the hero
|
||||||
|
overlay stack in `heroLayout.js` is `rgba(11,15,20,…)` regardless of preset.
|
||||||
|
Nothing is broken or unreadable — it is a visible seam, not a bug — but Phase 9
|
||||||
|
should be re-scoped from "light-mode port" to "make the hue-carrying literals
|
||||||
|
follow the palette", which the dark presets need too. Not fixed here: it is the
|
||||||
|
23-declaration-style promotion Phase 2 was, and folding it into the phase that
|
||||||
|
introduced the presets would have hidden it inside an unrelated diff.
|
||||||
|
|
||||||
|
**Deferred to Phase 5, and done there:** the theme arrives with the
|
||||||
|
`/public/settings` fetch, so a themed instance painted the shipped palette for
|
||||||
|
one frame before repainting. Phase 5 had to rewrite `renderIndexHtml` into a
|
||||||
|
cached, invalidated shell anyway (§4.3), and injecting a `<style>` block with the
|
||||||
|
effective tokens there removed the flash for free rather than solving it twice.
|
||||||
|
|
||||||
|
**Also fixed in passing:** `settings/nav.controller.js` imported the logger
|
||||||
|
*factory* rather than calling it, so `log.error` was `undefined` and a DB fault
|
||||||
|
would have thrown a `TypeError` inside the catch — no response sent, request
|
||||||
|
left hanging — instead of returning a 500. Introduced in Phase 0.
|
||||||
|
|
||||||
|
### Phase 5 as landed
|
||||||
|
|
||||||
|
**The upload is one call, not two.** §8 said "upload endpoint on the existing
|
||||||
|
multer config", which reads as: reuse `POST /admin/uploads`, then `PUT` the
|
||||||
|
`brand_assets` row. Two problems with that. The generic upload is `staffOnly` —
|
||||||
|
editors can reach it — while the row it would write is `adminOnly`, and the
|
||||||
|
site's identity is not the editor tier's to change. And a run that uploaded and
|
||||||
|
then failed (or was abandoned) would leave a file in `/uploads` that nothing
|
||||||
|
references.
|
||||||
|
|
||||||
|
So: **`POST /api/v1/admin/settings/brand-asset/:slot`**, `adminOnly`, using the
|
||||||
|
shared `imageUpload.js` multer config and returning `{ url, brand_assets }`. It
|
||||||
|
read-modify-writes the row, so uploading a logo never clears a hero (§6.3). The
|
||||||
|
per-slot rules only ever *tighten* the shared allowlist, never widen it (§9):
|
||||||
|
|
||||||
|
| Slot | Types | Cap |
|
||||||
|
|---|---|---|
|
||||||
|
| `logo` | the shared image allowlist | 1 MB |
|
||||||
|
| `hero` | the shared image allowlist | 8 MB (the shared ceiling) |
|
||||||
|
| `favicon` | **PNG only** (§4.10) | 512 KB |
|
||||||
|
|
||||||
|
The cap is enforced after multer has written the file and the file is unlinked
|
||||||
|
before the response, rather than by a second multer instance with its own limits.
|
||||||
|
One upload config and one allowlist is the property worth keeping; a briefly
|
||||||
|
written file that is deleted before the request returns is not.
|
||||||
|
|
||||||
|
**There is no per-slot delete route.** Clearing one asset is a `PUT` of the
|
||||||
|
remaining ones, and clearing the last one is the existing reset-by-delete —
|
||||||
|
`{}` is never stored, because absence of the row is what selects the env
|
||||||
|
defaults (§2) and a stored empty object would be a second way to say the same
|
||||||
|
thing.
|
||||||
|
|
||||||
|
**`brand_assets` needed a validator of its own, which the design did not
|
||||||
|
anticipate.** These are the only settings values written straight into HTML as
|
||||||
|
URLs the browser then fetches — an `<img src>`, a `<link rel="icon">`, an
|
||||||
|
`og:image`. `utils/brandAssets.js` accepts a same-origin path under `/uploads/`,
|
||||||
|
`/brand/` or `/assets/` and nothing else: no scheme, no protocol-relative
|
||||||
|
`//host` (which looks like a path and loads off-origin), no `..`, no whitespace
|
||||||
|
or quotes. Same asymmetry as the theme — strict on write with the field named,
|
||||||
|
forgiving on read so one hand-edited slot does not cost the admin the other two.
|
||||||
|
|
||||||
|
**The shell cache carries a TTL as well as explicit invalidation.** §4.3 asked
|
||||||
|
for a module-level cache invalidated on write, and that is what the settings
|
||||||
|
controller does. But the cache is *per process*: in a scaled deployment the
|
||||||
|
worker that handled the write is the only one that learns of it, and every other
|
||||||
|
would serve the old favicon until the next restart. A 5-minute TTL makes the rest
|
||||||
|
converge on their own while keeping the steady state at one render per process
|
||||||
|
per five minutes — not one per page view. Concurrent first requests share a
|
||||||
|
single render, an invalidation that lands mid-render is not overwritten by the
|
||||||
|
in-flight result, and a failed settings read renders the env-only shell and
|
||||||
|
caches *that*, so an outage is not a failing query per page view.
|
||||||
|
|
||||||
|
**Theme flash: fixed here, with a handoff.** The shell now also carries the
|
||||||
|
resolved tokens as `<style id="theme-boot">:root{…}</style>`, injected last in
|
||||||
|
`<head>` so it follows the built stylesheet and wins the equal-specificity tie.
|
||||||
|
`SiteContext` removes that block once the `/public/settings` payload has arrived
|
||||||
|
and been applied — otherwise a later reset would remove the inline properties
|
||||||
|
only to reveal the stale block underneath. The removal is gated on a
|
||||||
|
**successful** fetch, not merely a finished one: a failed request leaves the app
|
||||||
|
with no theme at all, and dropping the block then would strip a themed instance
|
||||||
|
back to the shipped palette for no reason.
|
||||||
|
|
||||||
|
**The logo went into all six MoonDot surfaces, not three.** §8 named the three
|
||||||
|
persistent shells (site header, admin sidebar, portal sidebar); the admin login,
|
||||||
|
the player login/register card and the maintenance page carry the same mark and
|
||||||
|
an operator who uploads a logo means their instance, not three of its pages.
|
||||||
|
`components/BrandLogo.jsx` renders **nothing** when `brand.logo` is empty — which
|
||||||
|
is the shipped default — so every one of those surfaces is unchanged on an
|
||||||
|
untouched instance. On the three centered layouts the logo is stacked *above* the
|
||||||
|
moon rather than beside it, because turning that block into a flex row would have
|
||||||
|
changed its height on instances with no logo.
|
||||||
|
|
||||||
|
The footer's "powered by Runic Gateway" emblem is deliberately untouched (§4.11):
|
||||||
|
it is the project's badge, not the instance's.
|
||||||
|
|
||||||
|
**The hero chain needed no code.** §4.9's real order —
|
||||||
|
`hero_layout.background.image_url` → `brand_assets.hero` → `BRAND_HERO` →
|
||||||
|
`/assets/img/runic-emblem.png` — already holds, because Phase 3 resolved
|
||||||
|
`brand_assets` into `getPublic().brand.hero` and `SiteContext.heroImage` reads
|
||||||
|
that. What was missing was saying so: the hero row in the admin panel now states
|
||||||
|
that a hero-editor background wins over the uploaded one, so "I uploaded a hero
|
||||||
|
and the portal ignored it" does not become a bug report against a working system.
|
||||||
|
|
||||||
|
**Observed and left alone:** the shell's `<title>` and description still come
|
||||||
|
from `BRAND_NAME`/`BRAND_DESCRIPTION`, not from the admin-set `site_title` that
|
||||||
|
`getPublic().brand.name` prefers, so an instance that renamed itself through the
|
||||||
|
admin panel still has the env name in its tab and its link previews. Fixing it
|
||||||
|
would change the served shell for instances with no `brand_assets` row, which is
|
||||||
|
exactly what §9 says must not change in this phase. It wants its own change.
|
||||||
|
|
||||||
|
### Phases 6–8 as landed
|
||||||
|
|
||||||
|
The nav half, wired end to end: the public header, the admin sidebar and the
|
||||||
|
player portal all read their override row, and `/admin/navigation` writes them.
|
||||||
|
Five things the design did not settle.
|
||||||
|
|
||||||
|
**1. The server had no way to store a nav row, and would have stored garbage.**
|
||||||
|
§8 described phases 6–8 as client work, and for the *merge* that is right. But
|
||||||
|
`updateSettings` validates and stringifies `theme_visual` and `brand_assets` and
|
||||||
|
lets everything else through to `settingsDb.set` — so a `nav_public` object would
|
||||||
|
have been written as the string `"[object Object]"`, which `parseJsonSetting`
|
||||||
|
then reads as absent. The save would have returned 200 and done nothing, for
|
||||||
|
ever. `server/src/utils/navOverrides.js` mirrors `utils/brandAssets.js`:
|
||||||
|
`validateNavOverrides` is strict on write and names the offending key,
|
||||||
|
`resolveNavOverrides` is forgiving and drops fields that would do nothing.
|
||||||
|
|
||||||
|
**2. The server cannot check that a `to` exists, and should not try.** The three
|
||||||
|
base `NAV` arrays are client constants. Shipping a copy to the server would
|
||||||
|
create a second source of truth for navigation that drifts the first time a route
|
||||||
|
is added, and it would buy nothing: `applyNavOverrides` already drops an entry
|
||||||
|
whose `to` the base array does not declare, which is the right place for it — a
|
||||||
|
route deleted in code stops mattering immediately, with no migration. **The
|
||||||
|
server validates shape; the client owns membership.** So the write path accepts
|
||||||
|
any app-internal path as a key (absolute, no scheme, no `//host`, no whitespace)
|
||||||
|
and rejects everything else, and it rejects any field that is not one of the
|
||||||
|
four — a `roles` or `to` in the body is a 400, not something quietly stored.
|
||||||
|
|
||||||
|
**3. `hidden: false` is accepted and never stored.** The editor sends it while a
|
||||||
|
row is being edited, so rejecting it would be hostile; storing it would leave a
|
||||||
|
row that reads like an instruction to *force* something visible, which this layer
|
||||||
|
must never be able to express. It is dropped on the way in, and hiding stays
|
||||||
|
subtractive.
|
||||||
|
|
||||||
|
**4. The nav editor cannot be hidden, and that is enforced three times.** An
|
||||||
|
admin who hid `/admin/navigation` would lose the only screen that can un-hide it.
|
||||||
|
The row's eye toggle is disabled with a note saying why; `resolveNavOverrides`
|
||||||
|
drops `hidden` on that one `to` for `nav_admin`; and `AdminLayout` strips it
|
||||||
|
again before merging, which is what also covers a row edited straight in the
|
||||||
|
database. Typing the URL still works regardless — the guard is about not
|
||||||
|
stranding an admin who never learned it.
|
||||||
|
|
||||||
|
**5. Orders are written only when something actually moved.** §7.1 says the
|
||||||
|
editor writes an order for every item "the way drag-and-drop does", and it does —
|
||||||
|
but only for a nav whose sequence differs from the code's. An admin who renames
|
||||||
|
one item stores exactly one field, and a route added to `NAV` later still lands
|
||||||
|
where the code puts it. The comparison is against the base **restricted to the
|
||||||
|
rows that admin can see**, so a role- or feature-gated item missing from their
|
||||||
|
palette is not mistaken for a reorder. An override for such an item is carried
|
||||||
|
through their save untouched rather than quietly reset.
|
||||||
|
|
||||||
|
Two smaller notes. The section dropdown offers "(no section)" only to rows coded
|
||||||
|
into an untitled group (Dashboard, Account): for anything else it is a move an
|
||||||
|
override cannot express (§6.4 allows an existing titled section or nothing), so
|
||||||
|
offering it would silently do nothing. And `useNavOverrides` keeps one
|
||||||
|
module-level copy of the two authenticated rows, which is what lets a save in the
|
||||||
|
editor update the sidebar the admin is looking at without a reload — and stops
|
||||||
|
the second layout to mount from flashing the coded nav first.
|
||||||
|
|
||||||
|
### Phase 10 as landed
|
||||||
|
|
||||||
|
Asked for after phases 6–8 were built and before the `edge` → `main` cutover:
|
||||||
|
the public site should support dropdown sections with links organised inside
|
||||||
|
them. Scoped to the **public header only** — the admin sidebar keeps its four
|
||||||
|
coded sections and the player portal its three flat rows — and to **same-origin
|
||||||
|
links**, which is what makes §7's amendment a narrowing rather than an opening.
|
||||||
|
|
||||||
|
**The shape change was free because nothing had shipped.** `nav_public` grew a
|
||||||
|
`{items, sections, links}` wrapper. Had this landed after the cutover it would
|
||||||
|
have needed a migration or a version field; before it, a forgiving read of the
|
||||||
|
bare map is enough, and that read is kept anyway as insurance for a row written
|
||||||
|
during review.
|
||||||
|
|
||||||
|
**Sections are entries in the top-level order, which is why the Public tab has
|
||||||
|
its own editor.** The admin sidebar's groups are a fixed frame the code declares:
|
||||||
|
only membership moves. A public section is something the admin created and can
|
||||||
|
drag among the pills. That is a tree, not a list of groups, so
|
||||||
|
`PublicNavTree.jsx` renders it with a nested `SortableContext` per section, while
|
||||||
|
the other two tabs keep the phase-7 grouped editor. The shared `Row` was
|
||||||
|
generalised — its destination `<select>` takes a list of choices instead of
|
||||||
|
knowing about admin group titles.
|
||||||
|
|
||||||
|
**Moving between containers is still the dropdown, not a drag**, exactly as on
|
||||||
|
the Admin tab. Cross-container dragging is a lot of interaction surface for
|
||||||
|
something an admin does once, and keeping every drag a simple reorder is what
|
||||||
|
lets the nested contexts stay independent.
|
||||||
|
|
||||||
|
**Deleting a section does not delete what is inside it.** The entries move back
|
||||||
|
to the top level. It is the one destructive act this screen could commit — those
|
||||||
|
are coded pages and the admin's own links — so it is locked by a test.
|
||||||
|
|
||||||
|
**The dropdown opens on click, never hover, and the trigger is not a link.** A
|
||||||
|
hover menu is unusable on touch, and making the trigger navigate means tapping to
|
||||||
|
open takes you somewhere instead. A section is a container, not a destination.
|
||||||
|
`NavDropdown.jsx` carries the rest of the contract: Escape closes and returns
|
||||||
|
focus, an outside press closes, navigating closes, Arrow Up/Down walk the items,
|
||||||
|
and `aria-haspopup`/`aria-expanded` let it be announced as a menu.
|
||||||
|
|
||||||
|
**A bug the palette filter had, found by the test for it:** `buildNavOverrides`
|
||||||
|
judged "does this route still exist?" against the *palette* — the base array
|
||||||
|
already filtered to what the editing admin can see. For the admin nav that is
|
||||||
|
harmless (an admin sees every row), but on the public header a shard-feature-gated
|
||||||
|
row is filtered out, so the guard meant to carry its override through could never
|
||||||
|
fire, and their save would have quietly reset it. Membership is now judged against
|
||||||
|
the **full** coded nav while the rows still come from the palette: they are two
|
||||||
|
different questions.
|
||||||
|
|
||||||
|
### Phase 9, cancelled
|
||||||
|
|
||||||
|
The §4.8 `rgba()` literal promotion and the Parchment light-mode port are **not
|
||||||
|
scheduled**. The finding that motivated them stands and is worth keeping: those
|
||||||
|
literals carry a *hue*, not merely a light/dark assumption — `.btn-ghost` is
|
||||||
|
`rgba(11,22,48,0.45)`, so the portal quick-links read blue on Fantasy's warm
|
||||||
|
page. It is a real rough edge in the three dark presets, not only a blocker for a
|
||||||
|
hypothetical light one. It is simply not worth the contrast pass across every
|
||||||
|
component right now. Anyone picking it up should start from the census in §4.8
|
||||||
|
and the live observation in "Phases 3–4 as landed".
|
||||||
|
|
||||||
|
### 8.1 Admin builder UI notes
|
||||||
|
|
||||||
|
- Tabbed control for the three navs; drag-and-drop reorderable list.
|
||||||
|
- **The palette is filtered to the editing admin's own visible items** — the base
|
||||||
|
array run through *their* role/feature check — so an admin cannot drag in, and
|
||||||
|
therefore can never accidentally expose, an item they cannot already see
|
||||||
|
themselves. A deliberate UX guardrail on top of the merge-time enforcement.
|
||||||
|
- Per item: label input with a "reset to default" that clears the override, an eye
|
||||||
|
toggle for `hidden`, and on the Admin tab a group dropdown limited to the fixed
|
||||||
|
set of titles already in `NAV`.
|
||||||
|
- "Reset to defaults" per nav **deletes the row** (§4.1), never saves `{}`.
|
||||||
|
|
||||||
|
## 9. Acceptance criteria
|
||||||
|
|
||||||
|
- Fresh instance, no admin action: colors, fonts, radii, brand assets and all
|
||||||
|
three navs render byte-for-byte as today, driven by `BRAND_*` and the current
|
||||||
|
hardcoded `theme.css` / `NAV` arrays.
|
||||||
|
- After Phase 2 and before any admin UI exists, the rendered site is visually
|
||||||
|
identical — the token promotion is a true no-op.
|
||||||
|
- Setting `theme_visual.custom.colors` alone changes colors only; radius, fonts,
|
||||||
|
assets and nav are unaffected.
|
||||||
|
- Font dropdowns only ever produce values from the §5.1 shortlist. No admin input
|
||||||
|
is concatenated into a `font-family` string or a Google Fonts URL at runtime.
|
||||||
|
- Setting only `brand_assets.favicon` changes the served favicon only — the OG
|
||||||
|
image and hero backgrounds still resolve from `brand.js` env values.
|
||||||
|
- With no `brand_assets` row, the served HTML shell is **byte-identical** to
|
||||||
|
today's. Covered by a server-side test in `publicBrand.test.js`.
|
||||||
|
- Uploaded assets go through the existing `imageUpload.js` mimetype allowlist. No
|
||||||
|
second upload path with weaker validation.
|
||||||
|
- `getPublic().brand` with no new rows returns exactly what it returns today —
|
||||||
|
the existing `publicBrand.test.js` assertions pass verbatim.
|
||||||
|
- An admin cannot, through the nav builder, cause any user to see a nav item their
|
||||||
|
role/feature gate would otherwise hide. Verified by overriding `hidden: false`
|
||||||
|
on a role-gated item as a lower-privileged test admin and confirming the filter
|
||||||
|
still hides it.
|
||||||
|
- A dropdown section whose every entry is hidden by shard visibility **does not
|
||||||
|
render at all**, rather than opening onto an empty menu.
|
||||||
|
- An added link cannot leave the origin: a `to` carrying a scheme, a
|
||||||
|
protocol-relative `//host`, whitespace or quotes is refused on write and dropped
|
||||||
|
on read. An added link never grants access — the page behind it still gates
|
||||||
|
itself.
|
||||||
|
- Deleting a dropdown section returns its entries to the top level; it never
|
||||||
|
removes a coded page or an admin's own link.
|
||||||
|
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
|
||||||
|
a stored copy of the defaults.
|
||||||
282
website/UOFIDDLER.md
Normal file
282
website/UOFIDDLER.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# Extracting from your own UO client (UOFiddler)
|
||||||
|
|
||||||
|
**Audience:** the shard operator, once, at setup time.
|
||||||
|
**Related:** [`CLILOCS.md`](CLILOCS.md) (why the cliloc conversion is unavoidable),
|
||||||
|
[`SPAWN_ATLAS.md`](SPAWN_ATLAS.md) (where creature art fits).
|
||||||
|
|
||||||
|
Two features read data that **only exists inside a UO client**, and a UO client's
|
||||||
|
files are EA's, not ours to redistribute. So neither this repo nor any image we
|
||||||
|
publish can ship them — the operator extracts from **their own** client, once,
|
||||||
|
and points the site at the result.
|
||||||
|
|
||||||
|
| Feature | What it needs | Required? | Without it |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Item / title names** ([`CLILOCS.md`](CLILOCS.md)) | `Cliloc.enu`, converted | No | Names render as raw ids — `id 1023721` instead of *quarter staff* |
|
||||||
|
| **Creature art** ([`SPAWN_ATLAS.md`](SPAWN_ATLAS.md)) | Sprites from `.mul`/`.uop` | No | Atlas pages render as text, which is the normal state |
|
||||||
|
|
||||||
|
**Both are optional and neither is load-bearing.** A shard that never does any of
|
||||||
|
this is fully supported. Do part one and skip part two if art is not worth your
|
||||||
|
time — they share only the tool.
|
||||||
|
|
||||||
|
Everything you extract stays **outside the repository**: the converted cliloc
|
||||||
|
file lives at a path you choose, and `spawnAtlas.art.json` plus `server/uploads/`
|
||||||
|
are gitignored, so none of it can be committed by accident.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 0 — Get UOFiddler
|
||||||
|
|
||||||
|
[UOFiddler](https://github.com/polserver/UOFiddler) is the community client-file
|
||||||
|
editor. We use it because its `Ultima.dll` already contains the cliloc
|
||||||
|
decompressor, maintained by people who do this for a living.
|
||||||
|
|
||||||
|
1. Download the latest release zip from
|
||||||
|
<https://github.com/polserver/UOFiddler/releases/latest> — one asset, named
|
||||||
|
`UOFiddler-<version>.zip` (4.22.2 is ~2 MB).
|
||||||
|
2. Extract it. The zip contains a single top-level folder, and the two files that
|
||||||
|
matter are at **its root**:
|
||||||
|
|
||||||
|
```
|
||||||
|
UOFiddler-4.22.2/
|
||||||
|
Ultima.dll ← the decompressor (Part 1 needs this path)
|
||||||
|
UoFiddler.exe ← the GUI (Part 2 needs this)
|
||||||
|
plugins/
|
||||||
|
…
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Runtime:** UOFiddler 4.22.2 is built for **.NET 10**. Running `UoFiddler.exe`
|
||||||
|
needs the .NET 10 **Desktop** Runtime (Windows only); loading `Ultima.dll` from
|
||||||
|
the converter in Part 1 needs the .NET 10 runtime. Install from
|
||||||
|
<https://dotnet.microsoft.com/download/dotnet/10.0>.
|
||||||
|
|
||||||
|
### Finding your client files
|
||||||
|
|
||||||
|
The cliloc file is in your **UO client installation directory**, not in your
|
||||||
|
ServUO tree — the shard server has no copy of it. Look for `Cliloc.enu` (English;
|
||||||
|
the other seven are `chs`, `cht`, `deu`, `esp`, `fra`, `jpn`, `kor`) beside
|
||||||
|
`art.mul` / `artLegacyMUL.uop`. The EA Classic Client's default location is:
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\Program Files (x86)\Electronic Arts\Ultima Online Classic\
|
||||||
|
```
|
||||||
|
|
||||||
|
**If your shard distributes its own patched client to players, use that copy.**
|
||||||
|
Any cliloc edits you shipped to players are then already in the base table and
|
||||||
|
you need no overlay for them (see [`CLILOCS.md`](CLILOCS.md) §Shard-added and
|
||||||
|
shard-edited items).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Convert the cliloc table
|
||||||
|
|
||||||
|
**Goal:** turn the client's compressed `Cliloc.enu` into a file the site can
|
||||||
|
read, and point the site at it.
|
||||||
|
|
||||||
|
The site cannot read `Cliloc.enu` directly. Every modern client compresses it
|
||||||
|
(the "Mythic" container), and so does ServUO's own bundled `Ultima.StringList` —
|
||||||
|
which is why the shard cannot supply names on our behalf either. The full
|
||||||
|
reasoning is in [`CLILOCS.md`](CLILOCS.md) §Why the operator has to convert the
|
||||||
|
file; this section is just the procedure.
|
||||||
|
|
||||||
|
Two routes. **The bundled tool is the recommended one** — the GUI export needs a
|
||||||
|
fixup step, described below.
|
||||||
|
|
||||||
|
### Route A — the bundled converter (recommended)
|
||||||
|
|
||||||
|
Needs a .NET SDK (any version 8 or newer — the project targets `net8.0` and rolls
|
||||||
|
forward, so whatever you have works) **plus** the .NET 10 runtime from Part 0,
|
||||||
|
which is what actually loads `Ultima.dll`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd website/server/tools/cliloc-export
|
||||||
|
dotnet build -c Release
|
||||||
|
|
||||||
|
# plain binary — recommended, exact
|
||||||
|
dotnet run -c Release -- \
|
||||||
|
"/path/to/UOFiddler-4.22.2/Ultima.dll" \
|
||||||
|
"/path/to/UO client/Cliloc.enu" \
|
||||||
|
/srv/uo-data/clilocs.plain
|
||||||
|
|
||||||
|
# or tab-delimited text, if you want to eyeball or hand-edit it
|
||||||
|
dotnet run -c Release -- \
|
||||||
|
"/path/to/UOFiddler-4.22.2/Ultima.dll" \
|
||||||
|
"/path/to/UO client/Cliloc.enu" \
|
||||||
|
/srv/uo-data/clilocs.tsv --tsv
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output for a stock English client:
|
||||||
|
|
||||||
|
```
|
||||||
|
wrote 123490 entries to /srv/uo-data/clilocs.plain (maxTextBytes=12150, skippedOversize=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sanity-check that number.** A stock `Cliloc.enu` is ~123,000 entries. A few
|
||||||
|
hundred means it read something else and you should not ship the result. The
|
||||||
|
tool exits non-zero and says `no entries were written — is that a cliloc file?`
|
||||||
|
when it gets nothing at all.
|
||||||
|
|
||||||
|
The conversion runs on whatever machine has the client (usually Windows), and the
|
||||||
|
site reads the output wherever it runs — so **copy the output file to the server**
|
||||||
|
if those are different machines. It is a single self-contained file (~5 MB); the
|
||||||
|
`--tsv` form is larger but diff-able.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Errors you may hit</summary>
|
||||||
|
|
||||||
|
| Message | Cause |
|
||||||
|
|---|---|
|
||||||
|
| `Ultima.StringList not found — is that really UOFiddler's Ultima.dll?` | First argument points at some other `Ultima.dll` (ServUO ships one too — it is **not** the same assembly and cannot do this) |
|
||||||
|
| `You must install .NET to run this application` | Missing the .NET 10 runtime from Part 0 step 3 |
|
||||||
|
| `Unexpected Ultima.StringList API` | UOFiddler older than 4.21 |
|
||||||
|
| `usage: clilocexport …` | Fewer than three arguments |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Route B — the UOFiddler GUI
|
||||||
|
|
||||||
|
Use this if you would rather not install a .NET SDK. **It needs one extra step**,
|
||||||
|
so do not skip the fixup.
|
||||||
|
|
||||||
|
1. Launch `UoFiddler.exe` and point it at your client directory when it asks
|
||||||
|
(or **Options → Path Settings**).
|
||||||
|
2. Open the **Cliloc** tab and use its **export to CSV** action.
|
||||||
|
3. It writes `CliLoc.csv` to UOFiddler's configured output path, in **three**
|
||||||
|
columns with a header row:
|
||||||
|
|
||||||
|
```
|
||||||
|
Number;Text;Flag
|
||||||
|
1023721;quarter staff;0
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Strip the trailing flag column.** The site's text parser reads
|
||||||
|
`number<TAB|,|;>text`, so that third field is otherwise absorbed into the name
|
||||||
|
and every item on the site renders as `quarter staff;0`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -E 's/;[0-9]+$//' CliLoc.csv > clilocs.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Content CliLoc.csv |
|
||||||
|
ForEach-Object { $_ -replace ';\d+$','' } |
|
||||||
|
Set-Content -Encoding utf8 clilocs.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
The header row needs no removal — a line whose first field is not an integer
|
||||||
|
is skipped. Blank entries (`1005008;`) survive the fixup correctly and are
|
||||||
|
dropped at import, as intended.
|
||||||
|
|
||||||
|
5. Copy `clilocs.csv` to the server.
|
||||||
|
|
||||||
|
**Why the fixup is not just done for us:** the parser already handles
|
||||||
|
`number,flag,text` — the flag in the *middle*, which is what several exports
|
||||||
|
emit. UOFiddler puts it at the *end*, where it is indistinguishable from a name
|
||||||
|
that genuinely ends in `;0`. One `sed` on the operator's side beats a parser
|
||||||
|
heuristic that would corrupt real names.
|
||||||
|
|
||||||
|
### Point the site at it
|
||||||
|
|
||||||
|
Two ways, the setting winning over the environment:
|
||||||
|
|
||||||
|
| Where | How |
|
||||||
|
|---|---|
|
||||||
|
| **Admin → Shard → cliloc path** | Takes effect on the next refresh, no redeploy |
|
||||||
|
| `UO_CLIENT_PATH` env var | The deploy-time default |
|
||||||
|
|
||||||
|
The value may be **the file itself or a directory to search** — both are natural
|
||||||
|
answers to "where is it", and overlays are picked up either way.
|
||||||
|
|
||||||
|
Setting the path deliberately does **not** import as a side effect. Click
|
||||||
|
**Import** (or `POST /api/v1/admin/shard/clilocs/import`) to load it.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
`GET /api/v1/admin/shard/clilocs`, or the Admin → Shard panel, reports what each
|
||||||
|
source contributed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sources": [
|
||||||
|
{ "label": "clilocs.plain", "kind": "base", "entries": 123490, "added": 123490, "overrode": 0 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Roughly **67,500 rows stored** from a stock table is correct — about half a
|
||||||
|
cliloc table is empty strings for ids the client reserves and never uses.
|
||||||
|
|
||||||
|
Then load any character sheet with equipment: items should show names rather than
|
||||||
|
`id 1023721`.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>What a refusal means</summary>
|
||||||
|
|
||||||
|
A bad file answers `200` with a `status` and a named reason, not a `500` — you
|
||||||
|
need to be told *which file* to fix.
|
||||||
|
|
||||||
|
| `code` | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `COMPRESSED` | You pointed at the raw client `Cliloc.enu`. Convert it — this whole page. |
|
||||||
|
| `TRUNCATED` | Half-copied file. Re-copy; the loaded table is untouched. |
|
||||||
|
| `EMPTY` | A text source with no parseable rows — the file is named in the reason. |
|
||||||
|
| `status: needsReview` + `missingSources` | A previously-loaded source has vanished (unmounted volume? deliberate deletion?). Nothing changes until you re-import with `{ "approve": true }`. |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Custom items — do *not* re-export for these
|
||||||
|
|
||||||
|
Shard-added items carry ids no client table has. Drop a small delimited file in a
|
||||||
|
`custom/` directory beside the base file and re-import:
|
||||||
|
|
||||||
|
```
|
||||||
|
/srv/uo-data/
|
||||||
|
clilocs.plain ← base, from this guide
|
||||||
|
custom/
|
||||||
|
01-uomysticmoon.tsv ← your additions and overrides
|
||||||
|
```
|
||||||
|
|
||||||
|
Files are read in sorted order and **later sources win**, so an overlay both adds
|
||||||
|
new ids and overrides stock ones you have re-purposed. **Adding one item never
|
||||||
|
means re-exporting a 5 MB client file.** Details in [`CLILOCS.md`](CLILOCS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Creature art for the spawn atlas (optional)
|
||||||
|
|
||||||
|
**Goal:** put sprites on atlas pages. Purely cosmetic — the atlas is fully
|
||||||
|
functional as text, and `art` is NULL on every fresh import.
|
||||||
|
|
||||||
|
**This project ships no art and no art-extraction tooling, and never will.**
|
||||||
|
|
||||||
|
1. In `UoFiddler.exe` (paths configured as in Route B step 1), open the
|
||||||
|
**Animations** tab for creature sprites — or **Items** for object art — find
|
||||||
|
the creature, and export as PNG. Right-click an entry for its export options,
|
||||||
|
or use the tab's *Export All* action for a batch. (4.22.2 added an export
|
||||||
|
option to the Animation tab's thumbnail list, which is the convenient one
|
||||||
|
here.)
|
||||||
|
2. Put the images under `server/uploads/atlas/`.
|
||||||
|
3. Copy `server/db/data/spawnAtlas.art.example.json` to `spawnAtlas.art.json` in
|
||||||
|
the same directory and map creature slugs to file names:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lizardman": "lizardman.png",
|
||||||
|
"orc": "orc.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keys are the slugs the atlas API reports**, derived from the type names in
|
||||||
|
your own shard's `Spawns/*.xml` — read them off the atlas rather than guessing.
|
||||||
|
A creature with no entry renders without art, which is the default.
|
||||||
|
|
||||||
|
4. Restart, or `npm run atlas:import -- --force`.
|
||||||
|
|
||||||
|
The art map is re-read on every atlas refresh, so adding one image is an edit plus
|
||||||
|
a refresh. Both `spawnAtlas.art.json` and `server/uploads/` are gitignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licensing, briefly
|
||||||
|
|
||||||
|
UO's strings and sprites are EA's. Extracting from **your own** client for
|
||||||
|
**your own** shard is the arrangement here; redistributing the extracted files is
|
||||||
|
not something this project does or can advise on. That is the whole reason this
|
||||||
|
page exists instead of a download link.
|
||||||
@@ -249,6 +249,14 @@
|
|||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/settings"
|
"path": "/api/v1/admin/settings"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/settings/:key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/settings/brand-asset/:slot"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/shard/account"
|
"path": "/api/v1/admin/shard/account"
|
||||||
@@ -293,6 +301,18 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/char/:serial"
|
"path": "/api/v1/admin/shard/char/:serial"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs/import"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs/path"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/houses"
|
"path": "/api/v1/admin/shard/houses"
|
||||||
@@ -817,10 +837,30 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/idoc"
|
"path": "/api/v1/public/shard/idoc"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market/meta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market/vendors/:serial"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/online"
|
"path": "/api/v1/public/shard/online"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/points"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/points/:system"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/presence"
|
"path": "/api/v1/public/shard/presence"
|
||||||
@@ -860,6 +900,14 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/wiki/tags"
|
"path": "/api/v1/public/wiki/tags"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/settings/nav"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/settings/theme/options"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"internal": [
|
"internal": [
|
||||||
|
|||||||
Reference in New Issue
Block a user