Compare commits
36 Commits
cdea1aa7cd
...
docs/insta
| Author | SHA1 | Date | |
|---|---|---|---|
| d0363cd62d | |||
| 83bd4ec2d4 | |||
| 5c8585fe75 | |||
| fc79bb6ed0 | |||
| 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 | |||
| 06b4a06baa | |||
|
|
f2fa6abff7 |
16
README.md
16
README.md
@@ -7,10 +7,11 @@ so they live in one place, independent of either codebase.
|
||||
## Layout
|
||||
|
||||
```
|
||||
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
|
||||
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
||||
ci/ cross-cutting CI/quality notes
|
||||
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
|
||||
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
||||
installer/ docs for the installer that deploys a shard's bridge components
|
||||
ci/ cross-cutting CI/quality notes
|
||||
```
|
||||
|
||||
### `website/`
|
||||
@@ -22,6 +23,7 @@ ci/ cross-cutting CI/quality notes
|
||||
| [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) |
|
||||
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
||||
@@ -49,6 +51,12 @@ ci/ cross-cutting CI/quality 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 |
|
||||
|
||||
### `installer/`
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [INSTALL.md](installer/INSTALL.md) | **Operator guide** — installing Runic Gateway on a ServUO shard, connecting it to the website, and diagnosing it. Includes the by-hand path, which works today |
|
||||
| [PLAN.md](installer/PLAN.md) | Installer design of record — phases, locked decisions, the bundle/compat-matrix model |
|
||||
|
||||
## Provenance
|
||||
|
||||
- `website/*` was extracted from `RunicGateway/website` via `git filter-repo`.
|
||||
|
||||
119
android/PLAN.md
119
android/PLAN.md
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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
|
||||
@@ -631,6 +631,8 @@ not rank).
|
||||
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
|
||||
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
|
||||
| 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` |
|
||||
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
|
||||
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
|
||||
@@ -639,6 +641,12 @@ not rank).
|
||||
Guidelines:
|
||||
- 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.
|
||||
- **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
|
||||
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
|
||||
@@ -663,9 +671,15 @@ Guidelines:
|
||||
### 6.2 Public shard (live)
|
||||
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
|
||||
`/public/shard/*` GETs.
|
||||
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE, safe kinds only) and patch the
|
||||
in-memory boards in place (champ/guild/city/house/presence update+remove frames). Reconnect with
|
||||
backoff; fall back to poll if SSE drops.
|
||||
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE) and patch the in-memory boards in
|
||||
place (champ/guild/city/house/presence update+remove frames). Reconnect with backoff; fall back to
|
||||
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)
|
||||
- **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`,
|
||||
`/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
|
||||
"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
|
||||
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.
|
||||
@@ -883,10 +902,98 @@ push, and Play (M6–M8) follow the designed app.
|
||||
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
|
||||
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.
|
||||
|
||||
### Deferred (not a milestone)
|
||||
|
||||
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now
|
||||
**deferred with no app work scheduled**. The website's router refactor is being done in place with
|
||||
- **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone
|
||||
(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/…`
|
||||
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,
|
||||
|
||||
@@ -85,6 +85,7 @@ android-app/
|
||||
│ │ │ │ │ │ │ ├── PlayerShardDto.kt
|
||||
│ │ │ │ │ │ │ ├── PostDto.kt
|
||||
│ │ │ │ │ │ │ ├── PublicDto.kt
|
||||
│ │ │ │ │ │ │ ├── ShardContentDto.kt
|
||||
│ │ │ │ │ │ │ ├── ShardDto.kt
|
||||
│ │ │ │ │ │ │ ├── SsoDto.kt
|
||||
│ │ │ │ │ │ │ └── WikiDto.kt
|
||||
@@ -106,6 +107,7 @@ android-app/
|
||||
│ │ │ │ │ ├── NotificationsRepository.kt
|
||||
│ │ │ │ │ ├── PlayerShardRepository.kt
|
||||
│ │ │ │ │ ├── SettingsRepository.kt
|
||||
│ │ │ │ │ ├── ShardFeaturesRepository.kt
|
||||
│ │ │ │ │ ├── ShardRepository.kt
|
||||
│ │ │ │ │ └── WikiRepository.kt
|
||||
│ │ │ │ ├── di/
|
||||
@@ -171,6 +173,8 @@ android-app/
|
||||
│ │ │ │ │ ├── session/
|
||||
│ │ │ │ │ │ └── SessionViewModel.kt
|
||||
│ │ │ │ │ ├── shard/
|
||||
│ │ │ │ │ │ ├── AtlasScreen.kt
|
||||
│ │ │ │ │ │ ├── AtlasViewModel.kt
|
||||
│ │ │ │ │ │ ├── ChampsScreen.kt
|
||||
│ │ │ │ │ │ ├── ChampsViewModel.kt
|
||||
│ │ │ │ │ │ ├── FrameFields.kt
|
||||
@@ -180,7 +184,13 @@ android-app/
|
||||
│ │ │ │ │ │ ├── GuildsViewModel.kt
|
||||
│ │ │ │ │ │ ├── HousesScreen.kt
|
||||
│ │ │ │ │ │ ├── HousesViewModel.kt
|
||||
│ │ │ │ │ │ ├── LeaderboardsScreen.kt
|
||||
│ │ │ │ │ │ ├── LeaderboardsViewModel.kt
|
||||
│ │ │ │ │ │ ├── LiveBoard.kt
|
||||
│ │ │ │ │ │ ├── MarketScreen.kt
|
||||
│ │ │ │ │ │ ├── MarketViewModel.kt
|
||||
│ │ │ │ │ │ ├── RulesScreen.kt
|
||||
│ │ │ │ │ │ ├── RulesViewModel.kt
|
||||
│ │ │ │ │ │ ├── ShardComponents.kt
|
||||
│ │ │ │ │ │ ├── ShardEventText.kt
|
||||
│ │ │ │ │ │ ├── ShardScreen.kt
|
||||
@@ -284,6 +294,7 @@ android-app/
|
||||
│ │ │ │ │ ├── PlayerShardDtoTest.kt
|
||||
│ │ │ │ │ ├── PublicDtoTest.kt
|
||||
│ │ │ │ │ ├── ShardBoardDtoTest.kt
|
||||
│ │ │ │ │ ├── ShardContentDtoTest.kt
|
||||
│ │ │ │ │ ├── ShardDtoTest.kt
|
||||
│ │ │ │ │ ├── SsoDtoTest.kt
|
||||
│ │ │ │ │ └── WikiDtoTest.kt
|
||||
@@ -294,7 +305,8 @@ android-app/
|
||||
│ │ │ │ └── FakeShardStream.kt
|
||||
│ │ │ └── repository/
|
||||
│ │ │ ├── AccountTrustedDevicesTest.kt
|
||||
│ │ │ └── ConnectionVersionGuardTest.kt
|
||||
│ │ │ ├── ConnectionVersionGuardTest.kt
|
||||
│ │ │ └── ShardFeaturesRepositoryTest.kt
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── admin/
|
||||
│ │ │ │ ├── AdminContentViewModelTest.kt
|
||||
@@ -304,7 +316,8 @@ android-app/
|
||||
│ │ │ ├── contact/
|
||||
│ │ │ │ └── ContactViewModelTest.kt
|
||||
│ │ │ ├── navigation/
|
||||
│ │ │ │ └── MenuAccessTest.kt
|
||||
│ │ │ │ ├── MenuAccessTest.kt
|
||||
│ │ │ │ └── MenuFeatureGatingTest.kt
|
||||
│ │ │ ├── notifications/
|
||||
│ │ │ │ └── NotificationRoutingTest.kt
|
||||
│ │ │ ├── player/
|
||||
@@ -315,6 +328,8 @@ android-app/
|
||||
│ │ │ │ ├── FrameFieldsTest.kt
|
||||
│ │ │ │ ├── LiveBoardTest.kt
|
||||
│ │ │ │ ├── ShardBoardViewModelTest.kt
|
||||
│ │ │ │ ├── ShardContentHelpersTest.kt
|
||||
│ │ │ │ ├── ShardContentViewModelTest.kt
|
||||
│ │ │ │ └── ShardEventTextTest.kt
|
||||
│ │ │ ├── theme/
|
||||
│ │ │ │ └── BrandColorTest.kt
|
||||
|
||||
742
installer/INSTALL.md
Normal file
742
installer/INSTALL.md
Normal file
@@ -0,0 +1,742 @@
|
||||
# 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.
|
||||
|
||||
> **Status: the installer binary is not released yet.**
|
||||
>
|
||||
> Everything it installs *is* released and published — the sidecar, the plugin overlay, and the
|
||||
> [bundle manifest](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json)
|
||||
> that names the checked combination of the two. This guide is the operator-facing contract those
|
||||
> phases build to, and it is written first on purpose: it is the specification of what the run
|
||||
> looks like, what it asks, where it writes, and what it prints.
|
||||
>
|
||||
> **You can install today without it** — [Appendix A](#appendix-a--installing-by-hand) is the same
|
||||
> deployment done by hand, with the commands verified against the current releases. When the binary
|
||||
> ships, Appendix A stays as the reference for what it 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 a cached copy of any patches it applied | 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)* | The base install works on any reasonably current ServUO. The patch tier is verified against stock 57.4 only, and is skipped with a warning on anything else. |
|
||||
| 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-windows-x86_64.exe
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
**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, and not offered at all if your
|
||||
ServUO is not 57.4. 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
|
||||
|
||||
Patch tier skipped (not selected)
|
||||
Without it: no vendor.sale events, no in-game moderation audit forwarding.
|
||||
|
||||
uo-link
|
||||
binary /usr/bin/runicgateway-link
|
||||
config /etc/runicgateway/sidecar.toml (created)
|
||||
database /var/lib/runicgateway/uo-link.db
|
||||
service runicgateway-link.service enabled, running
|
||||
|
||||
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` | Decide the patch tier non-interactively. `--patches` still refuses on a non-57.4 tree. |
|
||||
| `--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. |
|
||||
| `--purge` | `uninstall` | Also delete `sidecar.toml` and `uo-link.db`, which are otherwise kept. |
|
||||
|
||||
---
|
||||
|
||||
## 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 any patches applied, so `uninstall` can print the exact hunks long after the release tarball is gone |
|
||||
| `/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\uo-link.db` | The sidecar's SQLite store |
|
||||
| Service `RunicGatewayLink` | Automatic start, restart on failure |
|
||||
|
||||
**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 `UOLINK_CONFIG` and `UOLINK_DB_PATH` explicitly. The sidecar's own
|
||||
defaults are relative to its working directory, and a service manager's working directory is not
|
||||
somewhere you want a database — on Windows it can be `%SystemRoot%\System32` or, under
|
||||
`C:\Program Files\`, a silently redirected VirtualStore copy.
|
||||
|
||||
> **`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 (`git apply --check`) before anything is
|
||||
applied, and reported per patch. Most real shards are hand-modified; a patch that does not apply
|
||||
is expected, not alarming.
|
||||
- **All or nothing per feature.** The two vendor-sale patches are one unit and are applied together
|
||||
or not at all.
|
||||
- **Skipped entirely on a ServUO that is not 57.4**, with a warning. Unverified diffs are never
|
||||
applied to an unknown tree.
|
||||
- **Recorded, and the `.patch` files cached**, so re-runs stay idempotent and `uninstall` can print
|
||||
the exact hunks to revert.
|
||||
|
||||
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 the sidecar sits behind a reverse proxy, paste your **public** `https://` and `wss://` URLs
|
||||
instead of the two the installer printed — it composes those from the sidecar's own bind address,
|
||||
which knows nothing about what fronts it. Everything else on the page is unchanged.
|
||||
|
||||
### 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, the recommended arrangement is a **TLS reverse proxy in front of the
|
||||
sidecar** — this is a supported deployment and the one Runic Gateway itself runs, on a real domain
|
||||
name.
|
||||
|
||||
**Leave `[web] bind` on `127.0.0.1:8080`** and let the proxy be the only thing that talks to it.
|
||||
Widening the bind and firewalling the port is the alternative, not the default (see below).
|
||||
|
||||
Give the website the **proxied** URLs — `https://link.example.com` and
|
||||
`wss://link.example.com/ws` — in place of the `http://` / `ws://` pair the installer prints. Those
|
||||
values are the sidecar's own view of itself; the proxy is what the outside world sees.
|
||||
|
||||
What the proxy must do:
|
||||
|
||||
| Requirement | Why |
|
||||
|---|---|
|
||||
| **Forward the WebSocket upgrade** (`Upgrade` / `Connection` headers, HTTP/1.1 to the upstream) | `/ws` is the live event feed. Without it the site's REST calls work and events never arrive — a confusing half-working state. |
|
||||
| **Pass request headers through unmodified** | Auth is `Authorization: Bearer` (or `X-Api-Key`), and the website sends `X-UOLink-Version`. A proxy that strips unknown headers turns into a `401`, and a stripped version header just silently skips the mismatch check. |
|
||||
| **Do not buffer the WS connection, and allow long-lived ones** | The feed is idle between events. The sidecar sends a WebSocket **Ping every 30 s**, so a read timeout of 60 s or more is safe as it stands — but a proxy that buffers responses will hold events instead of streaming them. |
|
||||
| **Do not log query strings** | The sidecar also accepts `?token=…` (for clients that cannot set headers). If anything in your stack uses that form, a default access-log format writes your auth token to disk on every request. |
|
||||
|
||||
Nothing needs `X-Forwarded-For`: the sidecar never uses the client's IP for authorization, and the
|
||||
browser IP that account provisioning cares about is supplied by the website in the request body.
|
||||
|
||||
An nginx server block that satisfies all of the above:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name link.example.com;
|
||||
|
||||
# your certificate directives here
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade; # "upgrade" for WS, "" otherwise
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(with the usual `map $http_upgrade $connection_upgrade { default upgrade; '' close; }` at `http`
|
||||
level). Caddy and Traefik handle WebSocket upgrades automatically and need no equivalent stanza.
|
||||
|
||||
**Without a proxy**, on a trusted network only: set `[web] bind` to `0.0.0.0:8080` or a specific LAN
|
||||
address, restart the service, and **firewall the port to your website's address**. The auth token is
|
||||
always required, but the sidecar speaks HTTP — on that path the token and every event cross the
|
||||
network in the clear. Do not do this over the public internet.
|
||||
|
||||
The `[shard] bind` line is a different matter entirely: leave it on `127.0.0.1:7788` and never proxy
|
||||
it. 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.
|
||||
|
||||
```
|
||||
✓ ServUO found /opt/ServUO (57.4)
|
||||
✓ Overlay in sync 24 files, all hashes match install.json
|
||||
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
|
||||
✓ uo-link installed 1.1.0
|
||||
✓ Service running, enabled
|
||||
✓ Sidecar reachable 127.0.0.1:8080 /health ok
|
||||
✓ Protocol sidecar 3 = overlay manifest 3
|
||||
✗ Shard connected no shard has dialed in since boot
|
||||
```
|
||||
|
||||
Three of those 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 both `install.json` and the release
|
||||
manifest, which is how it tells "you edited a deployed file" from "the overlay moved on".
|
||||
|
||||
### `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.
|
||||
|
||||
### `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`, the cached patch set |
|
||||
| **Kept** | `sidecar.toml` and `uo-link.db` — config and history survive (`--purge` drops them) |
|
||||
| **Printed, not done** | Every overlay file deployed into your ServUO tree, by path, for you to delete |
|
||||
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert |
|
||||
|
||||
The report is also written to a file, so it survives the scrollback.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
|---|---|
|
||||
| **"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 the sidecar — bind address, firewall, or proxy. 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. |
|
||||
| REST reads work but **no live events arrive** | The classic reverse-proxy symptom: the WebSocket upgrade is not being forwarded. Confirm the proxy sets `Upgrade`/`Connection` and speaks HTTP/1.1 upstream, and that the site's WebSocket URL is `wss://…/ws` — not `https://`. |
|
||||
| The event feed connects, then drops every minute or two | A proxy read timeout below the sidecar's 30 s WebSocket ping interval, or response buffering. Raise the timeout and turn buffering off. |
|
||||
| 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. |
|
||||
| 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. It works today, on the current releases, and is the fallback
|
||||
whenever you would rather not run an unsigned binary.
|
||||
|
||||
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/main/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 the core files are CRLF: use `patch --binary`.
|
||||
|
||||
### A3. Install the sidecar
|
||||
|
||||
```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"
|
||||
|
||||
sc.exe create RunicGatewayLink binPath= "\"$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe\"" start= auto
|
||||
sc.exe failure RunicGatewayLink reset= 86400 actions= restart/5000
|
||||
[Environment]::SetEnvironmentVariable('UOLINK_CONFIG', "$env:ProgramData\RunicGateway\sidecar.toml", 'Machine')
|
||||
[Environment]::SetEnvironmentVariable('UOLINK_DB_PATH', "$env:ProgramData\RunicGateway\uo-link.db", 'Machine')
|
||||
sc.exe start RunicGatewayLink
|
||||
```
|
||||
|
||||
Machine environment variables are read at service start, so set them before starting — and never
|
||||
leave the config path to the default, which is relative to the service's working directory.
|
||||
|
||||
### 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 |
|
||||
717
installer/PLAN.md
Normal file
717
installer/PLAN.md
Normal file
@@ -0,0 +1,717 @@
|
||||
# Runic Gateway Installer — plan
|
||||
|
||||
Status: **Phase 0 complete.** Every prerequisite in another repo has landed, the installer repo
|
||||
publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) now specifies the operator-facing run
|
||||
— so *what* the installer installs and *what using it looks like* both exist ahead of the binary.
|
||||
No installer code exists yet; **Phase 1 is next.** This document is the design of record; it
|
||||
supersedes the informal overview it grew out of, which described a ServUO integration that does not
|
||||
match how `servuo-plugins` actually ships (see
|
||||
[Corrections](#corrections-to-the-original-overview)).
|
||||
|
||||
| Phase 0 item | State |
|
||||
|---|---|
|
||||
| 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) |
|
||||
| 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) |
|
||||
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json) |
|
||||
| 0.4 This file + `INSTALL.md` | 🟨 In review — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
|
||||
| — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Take a stock ServUO installation and configure it for Runic Gateway with minimal manual steps, while
|
||||
keeping the components separated and independently maintainable.
|
||||
|
||||
The installer handles environment detection, ServUO overlay deployment, the optional stock-file
|
||||
patch tier, uo-link installation and service registration, version tracking, diagnostics, and
|
||||
updates from Gitea releases.
|
||||
|
||||
**It is a deployment tool, not a hosted bootstrapper.** There is no `curl | bash`, no installer
|
||||
service, and no hosted bootstrap script. Artifacts are downloaded from a Gitea release page and run.
|
||||
|
||||
**It does not replace ServUO startup behavior.** ServUO keeps running through its existing
|
||||
release/start scripts. The installer never writes a launcher.
|
||||
|
||||
### Decisions locked
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Audience | **Public** — any ServUO operator, not just shards we run |
|
||||
| Code signing | **Unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen/Gatekeeper warnings are expected and documented, as with most self-hosted tooling |
|
||||
| Language | **Rust** — single static binary per OS, reuses the cross-compile pattern already proven in `link/.gitea/workflows/release.yml` |
|
||||
| Plugin source | **Release tarball artifact** — no git and no Gitea credentials on the shard host |
|
||||
| Composition | **Published bundle manifest** (§7.1). CI names an exact, protocol-checked combination of component versions; the installer fetches it at run time and `--bundle <tag>` pins one. Component releases regenerate JSON, not the installer binary |
|
||||
| Token handoff | **Print token + prefilled admin URL** at the end of the run |
|
||||
| Repo | **New repo**, `RunicGateway/installer`. It deploys *both* other components, so living inside `link/` would invert the dependency |
|
||||
| ServUO version | **Warn and skip.** Patches are verified against stock 57.4 only; on anything else the base install proceeds and the patch tier is skipped with a warning. Forks are the norm in a public audience — refusing outright would block most operators |
|
||||
| Uninstall | **Never touches the ServUO tree.** Removes uo-link and its service entry, then *prints* the overlay files to delete and the patch hunks to revert. Reverting is the operator's call |
|
||||
|
||||
---
|
||||
|
||||
## 2. Corrections to the original overview
|
||||
|
||||
These are not wording nits — each one changes what the installer has to do.
|
||||
|
||||
### 2.1 There is no `RunicGateway.dll` and no `Plugins/` directory
|
||||
|
||||
The plugin ships as **C# source** and ServUO compiles it at boot. The real deployable is
|
||||
`servuo-plugins/overlay/`, which mirrors the server root:
|
||||
|
||||
```
|
||||
overlay/
|
||||
├── Config/Bridge.cfg
|
||||
└── Scripts/
|
||||
├── Scripts.csproj # Phase 0 — whole-file overwrite of a stock file
|
||||
└── Custom/Bridge/*.cs # 22 files
|
||||
```
|
||||
|
||||
So the plugin step is a hash-compare file sync, not a DLL drop — mechanically easier than the
|
||||
overview assumed. The sting is that **a successful copy does not mean a working bridge.** Per
|
||||
`link/SHARD_PREREQS.md`, `ScriptCompiler.Compile()` shells out to `dotnet build`, prints the output,
|
||||
**ignores the exit code**, and reloads the existing `Scripts.dll`. A broken script build is
|
||||
invisible: the shard boots clean on stale code. Diagnostics must therefore verify *post-boot* state,
|
||||
never treat "files copied" as success.
|
||||
|
||||
### 2.2 Stock ServUO files *are* modified — by an optional tier
|
||||
|
||||
`servuo-plugins/patches/` holds unified diffs against stock ServUO 57.4, plus two `.cs` files that
|
||||
can only be copied *after* their patch lands (they reference symbols the patch introduces):
|
||||
|
||||
| Patch | Target | Companion file | Rebuild required |
|
||||
|---|---|---|---|
|
||||
| `playervendor-sale-eventsink.patch` | `Server/EventSink.cs` | `BridgeVendorSale.cs` | **Core** — `dotnet build ServUO.sln`; the dynamic script build is not enough |
|
||||
| `playervendor-sale-gump.patch` | `Scripts/Gumps/PlayerVendorGumps.cs` | (same unit as above) | script build |
|
||||
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | `BridgeModerationAudit.cs` | script build |
|
||||
|
||||
Plus `overlay/Scripts/Scripts.csproj`, which overwrites a stock file (Phase 0 — it fixes the silent
|
||||
ServUO build bug above).
|
||||
|
||||
This is the hardest part of the installer. `git apply` against a hand-modified shard will fail, and
|
||||
most real shards are hand-modified. Therefore:
|
||||
|
||||
- The patch tier is **opt-in and skippable**. The base install must complete without it.
|
||||
- Always dry-run (`git apply --check`) before applying, and report per-patch.
|
||||
- When skipped or failed, say plainly what is lost: **no `vendor.sale` events, no in-game moderation
|
||||
audit forwarding**.
|
||||
- The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a
|
||||
shard restart.
|
||||
- On any ServUO version other than stock **57.4**, skip the whole tier with a warning and continue
|
||||
with the base install. Do not attempt to apply unverified diffs to an unknown tree.
|
||||
- Record applied patches in `install.json`, **and cache the applied `.patch` files** next to it
|
||||
(`/etc/runicgateway/patches/`, `%ProgramData%\RunicGateway\patches\`). Re-runs stay idempotent,
|
||||
and uninstall can print the exact hunks offline long after the release tarball is gone (§5,
|
||||
Phase 4).
|
||||
|
||||
### 2.3 Config paths collide with what the sidecar actually reads
|
||||
|
||||
The sidecar reads `$UOLINK_CONFIG`, else `sidecar.toml` in the **working directory**
|
||||
(`link/sidecar/src/config.rs`), with keys `[shard].bind`, `[web].bind`, `[web].auth_token`,
|
||||
`[store].path`. The overview proposed a `config.toml` with `[updates]`, `[link]`, `[servuo]` — keys
|
||||
the sidecar cannot read.
|
||||
|
||||
Two files, two owners:
|
||||
|
||||
| File | Owner | Contents |
|
||||
|---|---|---|
|
||||
| `/etc/runicgateway/sidecar.toml` | uo-link | The sidecar's own schema, unchanged. Service sets `UOLINK_CONFIG` to this path |
|
||||
| `/etc/runicgateway/install.json` | installer | Deployed versions, file hashes, applied patches, ServUO path, timestamps |
|
||||
|
||||
**Working-directory trap:** the sidecar wrote both `sidecar.toml` and `uo-link.db` relative to CWD.
|
||||
Under `C:\Program Files\` that fails or silently lands in VirtualStore. Phase 0.2 fixed the second
|
||||
half in the sidecar — a relative `[store].path` now resolves against the directory holding
|
||||
`sidecar.toml`, so pinning the config alone is enough to put the database somewhere deterministic —
|
||||
but the config path itself is still CWD-relative by default, and "deterministic" is not the same as
|
||||
"where this install wants it". The service definitions therefore still pin `UOLINK_CONFIG` and
|
||||
`UOLINK_DB_PATH` explicitly:
|
||||
|
||||
- Linux: config `/etc/runicgateway/sidecar.toml`, db `/var/lib/runicgateway/uo-link.db`, dedicated
|
||||
service user
|
||||
- Windows: binary under `%ProgramFiles%\RunicGateway\`, **data under `%ProgramData%\RunicGateway\`**
|
||||
|
||||
### 2.4 The token handoff was missing entirely
|
||||
|
||||
The whole point is the website reaching the sidecar, and today that is manual and undocumented in
|
||||
the install flow: the sidecar generates a token on first run and logs it, then a human pastes base
|
||||
URL, WS URL, token, and protocol version into Admin → Shard, where it is AES-GCM encrypted and
|
||||
becomes write-only. This is the largest "I installed it and nothing happened" failure mode.
|
||||
|
||||
The installer closes it by printing a copy-paste block at the end of a successful run — see §6.
|
||||
|
||||
Phase 0.2 supplied the missing half of that: `uo-link-sidecar --print-config` provisions the config
|
||||
if absent and prints the resolved settings — token, both binds, `ws_path`, protocol version, db
|
||||
path — as JSON. The installer reads the block it prints out of that one call. **It never parses the
|
||||
log**, which was the alternative and would have made the handoff depend on a log format that is not
|
||||
a contract.
|
||||
|
||||
### 2.5 `deploy.ps1` cannot be the cross-platform deployer
|
||||
|
||||
It is PowerShell-only; a Linux ServUO host running .NET typically has no `pwsh`. It also hard-throws
|
||||
when the ServUO process is running — correct behavior, and the installer must inherit it (detect and
|
||||
refuse, rather than corrupt a live `Scripts.dll`). The installer reimplements the sync natively; it
|
||||
is a short hash-compare-and-copy that never deletes.
|
||||
|
||||
`deploy.ps1` **stays** in `servuo-plugins` as the developer-facing tool. The installer is for
|
||||
operators.
|
||||
|
||||
### 2.6 Prerequisites the overview assumed away
|
||||
|
||||
- **`servuo-plugins` had no release workflow.** Only `link` did. "Pull latest repository" is replaced
|
||||
by a release tarball, which had to be built first — Phase 0 item 1, now in review
|
||||
([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)).
|
||||
- **arm64 is not buildable today.** `link/release.yml` cross-compiles only
|
||||
`x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`. An arm64 `.deb` needs another cross
|
||||
toolchain.
|
||||
- **The compat matrix has no home.** `PROTOCOL_VERSION` lives in `link/sidecar/src/main.rs`. The
|
||||
sidecar publishes it via `X-UOLink-Version` and `/health`, and the website stores an expected
|
||||
value — but the *plugin's* protocol version is not queryable before boot. Phase 0 item 1 gives it
|
||||
a home: `servuo-plugins/overlay.toml`, declared into the overlay manifest. See §7.0 / §7.1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Distribution model
|
||||
|
||||
Components are published as Gitea release artifacts. Operators download from the release page
|
||||
(browser, `curl`/`wget`, or `scp` to the server) and run the binary.
|
||||
|
||||
```
|
||||
Runic Gateway Installer v1.0.0
|
||||
├── runicgateway-installer-windows-x86_64.exe
|
||||
├── runicgateway-installer-linux-x86_64
|
||||
└── SHA256SUMS
|
||||
|
||||
uo-link v1.1.0 (existing release, extended)
|
||||
├── uo-link-sidecar-windows-x86_64.exe
|
||||
├── uo-link-sidecar-linux-x86_64
|
||||
├── runicgateway-link_<ver>_amd64.deb (Phase 5)
|
||||
└── SHA256SUMS
|
||||
|
||||
servuo-plugins v<ver> (new release, Phase 0)
|
||||
├── runicgateway-overlay-<ver>.tar.gz # overlay/ + patches/ + manifest.json
|
||||
└── SHA256SUMS
|
||||
```
|
||||
|
||||
Binding those together is the **bundle manifest** (§7.1) — published by the installer repo's CI, not
|
||||
by any component, and the thing the installer actually resolves against.
|
||||
|
||||
```bash
|
||||
scp runicgateway-installer-linux-x86_64 user@server:/tmp/
|
||||
chmod +x runicgateway-installer-linux-x86_64
|
||||
sudo ./runicgateway-installer-linux-x86_64
|
||||
```
|
||||
|
||||
### Unsigned-binary posture
|
||||
|
||||
Because releases are unsigned, trust is anchored on checksums and the operator's own verification.
|
||||
The docs must state this up front rather than let users discover it as a scary dialog:
|
||||
|
||||
- Every release publishes `SHA256SUMS`; the install docs lead with the verification command for both
|
||||
OSes.
|
||||
- Windows will show a SmartScreen "unrecognized app" prompt. Documented, with the exact click path.
|
||||
- The installer verifies the SHA256 of everything **it** downloads (overlay tarball, sidecar binary)
|
||||
against the release's `SHA256SUMS` and refuses on mismatch. Self-verification is not optional just
|
||||
because the installer itself is unsigned.
|
||||
- Revisit signing if it ever becomes affordable; the release layout should not have to change.
|
||||
|
||||
---
|
||||
|
||||
## 4. Component architecture
|
||||
|
||||
```
|
||||
Runic Gateway Installer (Rust, one binary per OS)
|
||||
│
|
||||
┌───────────────┴────────────────┐
|
||||
▼ ▼
|
||||
ServUO integration uo-link
|
||||
│ │
|
||||
┌────────┴────────┐ ┌────────┴────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
overlay sync patch tier (opt-in) binary install service registration
|
||||
(never deletes) (git apply + guard) + config + data (systemd / Windows SCM)
|
||||
```
|
||||
|
||||
Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phases
|
||||
|
||||
### Phase 0 — prerequisites (no installer code)
|
||||
|
||||
Repo work that must land before an installer can exist.
|
||||
|
||||
1. **`servuo-plugins`: add `.gitea/workflows/release.yml`.** Retarget the release *engine* half of
|
||||
`link/release.yml` (its header comment explicitly anticipates this — the plan/release steps
|
||||
consume only `{version, changelog, artifacts}`). The adapter half produces
|
||||
`runicgateway-overlay-<ver>.tar.gz` containing `overlay/`, `patches/`, and a `manifest.json`
|
||||
(version, commit, per-file SHA256, declared protocol version, minimum ServUO version).
|
||||
|
||||
As built ([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)),
|
||||
with three deviations from `link`'s copy that each fell out of the repo rather than being chosen:
|
||||
|
||||
- **No build gates, structural gates instead.** Nothing in that repo can be compiled without
|
||||
ServUO reference assemblies, so CI asserts what it honestly can: `Bridge.cfg` and the Bridge
|
||||
scripts present, `Scripts.csproj` present (its absence ships code that never compiles while
|
||||
ServUO reports success — §2.1), every `.patch` parseable via `git apply --stat`, and each
|
||||
patch's companion `.cs` present.
|
||||
- **No bump commit, so no push to `main`.** `link` writes the version into `Cargo.toml` because
|
||||
the binary embeds it; the tarball embeds nothing but the generated manifest, so the tag *is*
|
||||
the version. That workflow needs no branch-protection exception.
|
||||
- **`overlay.toml` at the repo root** holds the declared `protocol` and the ServUO compatibility
|
||||
values, read by CI into the manifest. It exists because the number needs one maintained home —
|
||||
see §7 for why the plugin cannot simply be asked.
|
||||
|
||||
The tarball uses a **fixed** top-level directory, `runicgateway-overlay/`, not a versioned one:
|
||||
the installer looks for `overlay/`, `patches/` and `manifest.json` at known paths rather than
|
||||
parsing the version it is trying to read. Member order, mtime and ownership are pinned, so a
|
||||
given tree yields a byte-identical tarball and its checksum moves only when its contents do.
|
||||
2. **`link`: make the sidecar installable.** Confirm/settle default data paths, and add a way to
|
||||
read back config non-interactively (e.g. `--print-config` emitting JSON: bind addresses, token,
|
||||
protocol version, db path) so the installer does not have to scrape logs for the token.
|
||||
|
||||
As built ([link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24)) — the sidecar
|
||||
had **no CLI at all** before this, so the shape was chosen rather than inherited:
|
||||
|
||||
- **Four flags, hand-rolled:** `--print-config`, `--config <PATH>`, `--version`, `--help`. No
|
||||
argument-parsing crate — it would be larger than the code it replaced — and deliberately no
|
||||
flags that duplicate a config key, so `sidecar.toml` stays the single place settings live.
|
||||
An unrecognized argument exits `2`; silently ignoring a typo'd flag would start a sidecar that
|
||||
is not the one the installer asked for.
|
||||
- **`--print-config` performs first-run setup rather than only reporting.** It runs the same
|
||||
load path a normal start does, so a missing config file is written and a blank token is
|
||||
generated and saved. That collapses "provision the sidecar" and "find out its token" into one
|
||||
non-interactive call — which is exactly the sequence §6 needs. `config_created` and
|
||||
`token_generated` say whether *this* run did either, because the values alone cannot
|
||||
distinguish a fresh install from a re-read of an existing one, and a re-run must not report a
|
||||
token as newly minted.
|
||||
- **The document is the whole of stdout.** The log subscriber writes to stdout, so it is not
|
||||
started in this mode. `ws_path` is emitted from the same constant the route is registered
|
||||
with, so the installer's WebSocket URL cannot drift from the server's.
|
||||
- **Relative `[store].path` now anchors to the config file's directory, not the CWD** — see
|
||||
§2.3, which this half-closes on the sidecar side. Absolute paths are used as written; parent
|
||||
directories are created; `:memory:` and `file:` URIs are left alone.
|
||||
- **The db path is handed to sqlx as a path, not a `sqlite://` URL.** The URL spelling is
|
||||
parsed as one: it percent-decodes the path and splits it on `?`, so an installed path
|
||||
containing `%20` opened a different file than the operator named.
|
||||
- **No platform data directories are compiled in.** That is the "settle" half of this item, and
|
||||
the answer is that the *installer* owns layout (§2.3) and pins `UOLINK_CONFIG` /
|
||||
`UOLINK_DB_PATH` in the service definition. Baking `/etc` and `%ProgramData%` defaults into
|
||||
the binary would give the same paths two owners and break `cargo run` in a working tree.
|
||||
3. **Bundle CI in the installer repo** (§7). Compose job (read both repos' latest releases → run the
|
||||
two gates → publish `bundle.json`), the nightly cron, and the dispatch step appended to each
|
||||
component's release workflow. This must exist before Phase 1 is useful, since the installer
|
||||
resolves what to install *from* the bundle.
|
||||
|
||||
As built ([installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3),
|
||||
[link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25),
|
||||
[servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)) —
|
||||
`installer/.gitea/workflows/bundle.yml`, with the decisions §7 had left open:
|
||||
|
||||
- **Bundles are committed to the installer repo, not published as releases** — see §7.1 for
|
||||
where and why. That was the one genuinely open question here, and the deciding factor is that
|
||||
this repo's *own* releases are the installer binaries.
|
||||
- **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary.
|
||||
`--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0`
|
||||
onward, and `--bundle <tag>` has to be able to recompose a bundle from an older pair. Reading
|
||||
`sidecar/src/main.rs` at the tag the release was built from works uniformly, needs no execution
|
||||
of a downloaded artifact, and does not provision a throwaway config whose auth token would then
|
||||
be sitting in a CI log. A constant that has moved or been renamed is a hard failure — treating
|
||||
"could not read" as "matches" is exactly how a mismatched pair would ship.
|
||||
- **Gate 2 records the hash CI computed itself**, after verifying the download against the
|
||||
publishing repo's `SHA256SUMS`. It also asserts the reverse direction — an asset with *no*
|
||||
`SHA256SUMS` entry — because `sha256sum -c` silently passes over a file the sums file does not
|
||||
mention, which would put an unverified artifact in the bundle.
|
||||
- **Release metadata is read anonymously**, on purpose: those are exactly the requests the
|
||||
shipped installer makes on a host with no Gitea credentials, so a repo flipped to private
|
||||
fails CI here instead of on an operator's machine.
|
||||
- **An unrecognized asset name is a hard failure.** link's binaries are mapped onto platform keys
|
||||
by suffix; adding a target (aarch64, macOS) to its release workflow therefore reddens this job
|
||||
rather than silently omitting the new binary from every bundle.
|
||||
- **A run that changes nothing writes nothing** — the comparison excludes `bundle` and
|
||||
`generated`, which are metadata about the run. Without that the nightly cron would commit a
|
||||
dated duplicate of the same matrix every morning.
|
||||
|
||||
The workflow's compose steps were run against the live releases before merge, producing the
|
||||
first bundle (`2026.08.04`: link `v1.1.0` + overlay `v0.1.1`, protocol 3), which is committed so
|
||||
the manifest exists ahead of the binary that reads it.
|
||||
4. **`docs`: this file, plus `docs/installer/INSTALL.md`** (the operator-facing guide) once the
|
||||
shape is settled.
|
||||
|
||||
As built ([`INSTALL.md`](INSTALL.md)) — written *before* the binary on purpose. Everything it
|
||||
installs is already released (items 1–3), so the guide is not speculation about a tool that
|
||||
might exist; it is the specification of what the run asks, where it writes, what it prints, and
|
||||
what the operator does next. Phase 1–4 implement it.
|
||||
|
||||
- **It is useful before the installer exists.** Appendix A is the same deployment done by hand —
|
||||
bundle fetch, tarball verify + overlay copy, the optional patch tier, `--print-config`
|
||||
provisioning, and a systemd unit / `sc create` service — composed from the released artifacts'
|
||||
actual contents and the sidecar's config and CLI source rather than from memory. That appendix
|
||||
doubles as **Phase 1's acceptance test**: walking it end to end on a real shard is what proves
|
||||
the automated path has nothing left to discover.
|
||||
- **The installer does not install itself.** §5's `runicgateway doctor` sketch implied a name on
|
||||
`PATH`; nothing places one there, and adding self-installation would give the tool a second
|
||||
lifecycle to manage. The guide names the downloaded artifact, says to keep it, and shortens it
|
||||
in later examples.
|
||||
- **The flag surface got fixed here**, because a guide cannot describe a run in the abstract:
|
||||
`--verify`, `--bundle`, `--purge` were already named by §5/§7; `--servuo`, `--patches` /
|
||||
`--no-patches`, `--host`, `--site-url` and `--yes` are the remainder, chosen so every prompt
|
||||
in §6's handoff has a non-interactive equivalent and an unattended install is expressible.
|
||||
- **A modified `Bridge.cfg` must survive an update** — see Phase 1, where this changes the sync
|
||||
rule inherited from `deploy.ps1`.
|
||||
- **Remote-website deployments needed an answer, and it is a reverse proxy.** `[web] bind`
|
||||
defaults to `127.0.0.1`, which only works when the site runs on the shard host. The guide's
|
||||
recommended arrangement — already in production on a real domain — is to **leave the bind on
|
||||
loopback** and put a TLS reverse proxy in front, giving the website the proxied `https://` /
|
||||
`wss://` URLs in place of the pair the installer prints from the bind address. Four
|
||||
requirements make that work and are stated with an nginx block that satisfies them: forward
|
||||
the WebSocket upgrade (`/ws` is the whole live feed), pass headers through unmodified (auth is
|
||||
`Authorization: Bearer`, and a stripped `X-UOLink-Version` silently skips the mismatch check),
|
||||
do not buffer and allow long-lived connections (the sidecar pings every 30 s, so a ≥60 s read
|
||||
timeout is safe), and do not log query strings (`?token=` is an accepted auth form). Widening
|
||||
the bind and firewalling the port stays documented as the trusted-LAN alternative, not the
|
||||
default, because on that path the token crosses the network in the clear. `[shard] bind` is
|
||||
never proxied and never widened — that socket carries inbound commands *into* the game.
|
||||
|
||||
### Phase 1 — installer core
|
||||
|
||||
- ServUO root detection and validation (`ServUO.exe`, `Scripts/`, `Config/`), with version detection
|
||||
and an explicit refusal when the ServUO process is running.
|
||||
- Overlay sync: fetch tarball → verify SHA256 → hash-compare against the server tree → add/change,
|
||||
**never delete**. Port of `deploy.ps1` semantics including its `-Verify` dry run (`--verify`).
|
||||
- **One deviation from `deploy.ps1`: an operator-modified `Config/Bridge.cfg` is reported, not
|
||||
overwritten.** `deploy.ps1` overwrites every file whose hash differs, which is right for a
|
||||
developer redeploying their own tree and wrong for an operator who has set `LinkUrl`,
|
||||
`PublicConnectAddress` and sweep intervals — an `update` would silently revert the shard's entire
|
||||
configuration. `install.json` records the hash deployed, so the installer can distinguish "the
|
||||
operator edited this" from "the overlay moved on" (§7.0) and act only on the second. The rule is
|
||||
specific to `Bridge.cfg`: it is the only file in the overlay that is *meant* to be edited in
|
||||
place, and it carries no code, so a stale copy cannot break the build. Every `.cs` file and
|
||||
`Scripts.csproj` still overwrite unconditionally.
|
||||
- Write `install.json`: component, version, source commit, per-file hashes, applied patches,
|
||||
timestamp.
|
||||
- Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing.
|
||||
|
||||
### Phase 2 — uo-link install and service
|
||||
|
||||
- Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db →
|
||||
`/var/lib/runicgateway/`, systemd unit with a dedicated user, `enable` + `start`.
|
||||
- Windows: `%ProgramFiles%\RunicGateway\`, data in `%ProgramData%\RunicGateway\`, service
|
||||
registration with automatic start and restart-on-failure.
|
||||
- Both: `UOLINK_CONFIG` and `UOLINK_DB_PATH` pinned in the service definition (§2.3).
|
||||
- Token surfacing (§6): run the installed binary once as
|
||||
`uo-link-sidecar --print-config --config <the pinned path>` **before** registering the service.
|
||||
That both writes the config the service will read and returns the token to print, so the service
|
||||
never starts against a config that does not exist yet.
|
||||
|
||||
### Phase 3 — patch tier (opt-in)
|
||||
|
||||
Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and
|
||||
degrade loudly rather than silently.
|
||||
|
||||
### Phase 4 — diagnostics and updates
|
||||
|
||||
`runicgateway doctor` — the command that makes the whole thing supportable:
|
||||
|
||||
```
|
||||
✓ ServUO found /opt/ServUO (57.4)
|
||||
✓ Overlay in sync 24 files, all hashes match install.json
|
||||
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
|
||||
✓ uo-link installed 1.1.0
|
||||
✓ Service running, enabled
|
||||
✓ Sidecar reachable 127.0.0.1:8080 /health ok
|
||||
✓ Protocol sidecar 3 = overlay manifest 3
|
||||
✗ Shard connected no shard has dialed in since boot
|
||||
```
|
||||
|
||||
The last check matters most: it is the only thing that distinguishes "files copied" from "the bridge
|
||||
actually works" (§2.1).
|
||||
|
||||
Three of those rows are answered by the sidecar's own CLI rather than by inspecting the filesystem:
|
||||
`--version` prints `uo-link-sidecar <ver> (protocol <n>)`, and `--print-config` gives the config and
|
||||
db paths the *installed service* resolves — so `doctor` reports what the binary would actually do,
|
||||
not what `install.json` believes it was told to do. The protocol row compares that number against
|
||||
the overlay manifest's declared one (§7.0).
|
||||
|
||||
`runicgateway update` — resolves the current bundle (§7.1), then acts asymmetrically by component,
|
||||
deliberately:
|
||||
|
||||
- **uo-link**: compare the bundle's version against what is installed → download → verify checksum →
|
||||
replace binary → restart service.
|
||||
- **plugin overlay**: download the bundle's overlay tarball → verify → re-sync (leaving a modified
|
||||
`Bridge.cfg` alone — Phase 1) → record commit → tell the operator ServUO must restart (the
|
||||
installer does not restart the shard).
|
||||
|
||||
Because both come from one bundle, an update always moves to a combination whose protocol versions
|
||||
were checked together, rather than to two independently-latest artifacts that may disagree.
|
||||
|
||||
`runicgateway uninstall` — **removes only what it exclusively owns, and never edits the ServUO
|
||||
tree.** The installer cannot know what the operator has changed in those files since deployment, so
|
||||
a clever automatic revert risks silently eating their work. It removes and it reports:
|
||||
|
||||
| Action | Scope |
|
||||
|---|---|
|
||||
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` and the cached patch set |
|
||||
| Kept | `sidecar.toml` and `uo-link.db` (config and history survive; `--purge` to drop them) |
|
||||
| **Printed, not done** | Every overlay file deployed into the ServUO tree, listed by path, for the operator to delete |
|
||||
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs`, `Logging.cs`, rendered from the cached `.patch` files, for the operator to revert by hand |
|
||||
|
||||
The printed report is also written to a file, so it survives the terminal scrollback of a long
|
||||
uninstall.
|
||||
|
||||
### Phase 5 — packaging polish
|
||||
|
||||
`.deb` packaging, Windows MSI, arm64 cross build, and optional automated backup before upgrade.
|
||||
Deliberately last: v1 can register services directly (`sc create` / a written systemd unit) and ship
|
||||
plain binaries. Nothing in Phases 1–4 should have to change to add these.
|
||||
|
||||
---
|
||||
|
||||
## 6. Token handoff (the end of a successful run)
|
||||
|
||||
```
|
||||
Runic Gateway is installed.
|
||||
|
||||
One manual step remains — connect the website to this sidecar:
|
||||
|
||||
Base URL http://<this-host>:8080
|
||||
WebSocket URL ws://<this-host>: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>/admin/shard
|
||||
|
||||
The token is write-only once saved — the site will never show it back to you.
|
||||
```
|
||||
|
||||
Every value in that block except the host and the site URL comes from one
|
||||
`uo-link-sidecar --print-config` call (§2.4): `web.auth_token`, `protocol`, and `web.bind` +
|
||||
`web.ws_path` for the two URLs. Only the **host** is substituted — `web.bind` is frequently
|
||||
`0.0.0.0`, which is not something to hand a website — so the installer composes the URLs from the
|
||||
host it detects or prompts for, rather than echoing the bind address.
|
||||
|
||||
**It does not attempt to detect a reverse proxy**, which is the recommended arrangement for a
|
||||
website on another host (`INSTALL.md` §5). Nothing visible from the sidecar's side says what fronts
|
||||
it, so guessing would produce a confidently wrong `https://` URL. The two printed URLs always
|
||||
describe the sidecar itself, and the guide tells the operator to paste their public `https://` /
|
||||
`wss://` pair instead when there is a proxy. `--host` accepting a full origin later is a cheap
|
||||
improvement if this proves annoying in practice.
|
||||
|
||||
The installer prompts for the site URL only to build that link; it never contacts the website. A
|
||||
future "installer registers itself with the website" flow (claim code + authenticated endpoint) is
|
||||
explicitly **out of scope** — it is real backend work in a security-sensitive area and can be added
|
||||
later without changing anything here.
|
||||
|
||||
The printed token is a secret in transit: `--print-config` output must go to the operator's
|
||||
terminal and the config file, never into an installer log file or a support bundle.
|
||||
|
||||
---
|
||||
|
||||
## 7. Version tracking, the bundle, and release orchestration
|
||||
|
||||
Three components version independently, bound by a protocol contract:
|
||||
|
||||
- **sidecar** — `PROTOCOL_VERSION` in `link/sidecar/src/main.rs`, exposed on `/health` and as
|
||||
`X-UOLink-Version` on every response; a mismatch is rejected `409`.
|
||||
- **website** — stores an expected protocol version in `uoLinkConfig` (admin-managed).
|
||||
- **plugin overlay** — has no queryable version before ServUO boots. The overlay release
|
||||
`manifest.json` declares it, and `install.json` records what was deployed.
|
||||
|
||||
### 7.0 The overlay manifest
|
||||
|
||||
Shipped inside every `runicgateway-overlay-<ver>.tar.gz`, generated by that repo's release workflow:
|
||||
|
||||
```json
|
||||
{
|
||||
"component": "servuo-plugins-overlay",
|
||||
"version": "0.1.0",
|
||||
"commit": "968b526…",
|
||||
"repo": "RunicGateway/servuo-plugins",
|
||||
"protocol": 3,
|
||||
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
|
||||
"files": { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" }
|
||||
}
|
||||
```
|
||||
|
||||
`version` and `commit` come from the release engine; `protocol` and the `servuo` block are read from
|
||||
`servuo-plugins/overlay.toml`; `files` is a SHA256 per shipped file.
|
||||
|
||||
Two of these carry weight beyond documentation:
|
||||
|
||||
- **`protocol` is a hand-maintained declaration, and has to be.** The plugin announces no version on
|
||||
the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes
|
||||
this line the only thing §7.1's gate 1 has to compare the sidecar against. The duty is stated in
|
||||
`overlay.toml` and in that repo's README: **bump it in the same PR that changes the emitters**, the
|
||||
way `link` bumps `PROTOCOL_VERSION`.
|
||||
- **`files` is what makes `doctor` able to tell "the operator edited a deployed file" from "the
|
||||
overlay moved on"** (§5, Phase 4). The installer copies these hashes into `install.json` at deploy
|
||||
time; a later mismatch against *both* the manifest and `install.json` means upstream changed, a
|
||||
mismatch against `install.json` alone means local edits.
|
||||
|
||||
`min_version` and `patches_verified_against` are separate on purpose. The base overlay only *adds*
|
||||
files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified
|
||||
against exactly one version (§2.2).
|
||||
|
||||
### 7.1 The bundle manifest
|
||||
|
||||
**The bundle is the compat matrix.** Rather than the installer hardcoding versions or blindly
|
||||
resolving "latest", CI publishes a small manifest naming an exact, checked combination:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"bundle": "2026.08.04",
|
||||
"generated": "2026-08-04T16:07:13Z",
|
||||
"protocol": 3,
|
||||
"link": {
|
||||
"repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3,
|
||||
"assets": {
|
||||
"linux-x86_64": { "name": "uo-link-sidecar-linux-x86_64", "url": "…", "sha256": "27d491ef…" },
|
||||
"windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
|
||||
}
|
||||
},
|
||||
"overlay": {
|
||||
"repo": "RunicGateway/servuo-plugins", "tag": "v0.1.1", "version": "0.1.1",
|
||||
"commit": "3a52abb…", "protocol": 3,
|
||||
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
|
||||
"asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "75dc6d6c…" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note `link.assets` is a **map keyed by platform**, not the single `sha256` this section originally
|
||||
sketched: link publishes a Linux binary and a Windows `.exe`, and the installer runs on both, so one
|
||||
hash could only ever have described one of them. `schema` versions this document's shape and is
|
||||
independent of `protocol` and of either component's release version — all three move separately.
|
||||
|
||||
The installer fetches the current bundle at run time; `--bundle <tag>` pins an older one for a
|
||||
reproducible install. Because the bundle is data, **a new `link` release regenerates ~30 lines of
|
||||
JSON and leaves the installer binary untouched** — operators do not re-download the installer to
|
||||
pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical.
|
||||
|
||||
Two gates run at compose time, both cheap and both worth it:
|
||||
|
||||
1. The sidecar's `PROTOCOL_VERSION` must equal the overlay manifest's declared protocol version.
|
||||
This is the check that catches an `edge`/`main` protocol mismatch before it reaches an operator.
|
||||
The two halves are read from different places because they *are* different: the overlay's from
|
||||
`manifest.json` inside the tarball (the only statement of it that exists — §7.0), the sidecar's
|
||||
from `sidecar/src/main.rs` at the release tag (see Phase 0 item 3 for why not from the binary).
|
||||
2. Every referenced asset must exist and its SHA256 must match the publishing repo's `SHA256SUMS`.
|
||||
The hash recorded in the bundle is the one CI computed from the asset it downloaded, *after* that
|
||||
check — and the installer verifies every download against it. These artifacts are deliberately
|
||||
unsigned (§3), so the checksum is the whole trust anchor; a hash copied from a file nobody
|
||||
verified would make the chain decorative.
|
||||
|
||||
#### Where bundles are published
|
||||
|
||||
Committed to the installer repo under `bundles/`, so the installer's fetch is a plain anonymous
|
||||
`GET` against a public repo — the shard host has no Gitea credentials (§1):
|
||||
|
||||
```
|
||||
bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json
|
||||
bundles/bundle-<tag>.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle)
|
||||
```
|
||||
|
||||
Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle
|
||||
on the same day — a sidecar release in the morning and an overlay release in the afternoon is the
|
||||
normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix.
|
||||
|
||||
**Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases
|
||||
are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless
|
||||
of kind — interleaving bundle releases would make "latest" intermittently resolve to a release
|
||||
carrying no installer binary. Committing also yields a reviewable diff and a git history of the
|
||||
compat matrix, and needs no new branch-protection exception: `release.yml`'s version-bump commit
|
||||
already requires the CI user to be able to push to `main`.
|
||||
|
||||
### 7.2 What triggers a bundle
|
||||
|
||||
| Trigger | Why |
|
||||
|---|---|
|
||||
| `link` publishes a release | Its release job `POST`s to the installer repo's workflow-dispatch endpoint as its final step |
|
||||
| `servuo-plugins` publishes a release | Same. Phase 0 item 1 gave it the release workflow; the dispatch step was left as a marked TODO until there was something to dispatch, and landed with the bundle CI it calls (item 3) — a step that `404`s on every release is worse than no step |
|
||||
| Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar |
|
||||
|
||||
`repository_dispatch` is deliberately avoided — support for it is uncertain on this Gitea version,
|
||||
whereas dispatching an existing `workflow_dispatch` workflow via the API works today.
|
||||
|
||||
**A failed dispatch is a warning, never a failed release.** By the time that step runs the component
|
||||
release is published and correct; failing the job would misreport it. This also keeps the dispatch
|
||||
from becoming a new hard credential requirement — `REGISTRY_TOKEN` having write on the installer
|
||||
repo is a nicety, and without it the nightly cron picks the release up anyway. A dropped dispatch
|
||||
costs latency, not correctness, which is the whole reason the cron exists.
|
||||
|
||||
### 7.3 Stale-overlay handling: dispatch, don't wait
|
||||
|
||||
Each component **self-releases on merge to its own `main`**, using the same conventional-commit
|
||||
engine. Note that "updated since the last release" must mean *releasable* commits — the engine sets
|
||||
`RELEASE=false` when nothing but `docs:`/`chore:` has landed, so a docs typo correctly does **not**
|
||||
cut an overlay release, and the bundle keeps using the existing one.
|
||||
|
||||
The compose job's copy of that rule additionally **excludes merge commits**, whose subject is
|
||||
`Merge pull request '<the real subject>'`. Without that, every squash-free merge of a `feat:` branch
|
||||
would be counted twice, and worse, a merge of a `docs:` branch whose *title* happens to quote a
|
||||
`fix:` would be read as releasable — re-dispatching, every night, a release workflow that correctly
|
||||
declines to run.
|
||||
|
||||
So by the time the installer's CI looks, the release normally already exists. If it finds
|
||||
`servuo-plugins` main ahead of its latest release *with* releasable commits, it:
|
||||
|
||||
1. fires that repo's release workflow via workflow-dispatch and **does not wait for it**,
|
||||
2. composes this bundle from the assets that exist right now,
|
||||
3. writes a loud warning into the job summary.
|
||||
|
||||
The new overlay release lands minutes later on its own and the nightly cron folds it into the next
|
||||
bundle. This gets the automation without the flaky part: dispatching another repo's workflow is
|
||||
fine — that workflow still runs its own gates — but *polling* it is not, because Gitea's dispatch
|
||||
endpoint returns no run handle, so the job would have to guess which run is its own and hold a
|
||||
runner idle meanwhile. The warning exists so a genuinely broken release workflow surfaces once
|
||||
rather than being silently retriggered every night forever.
|
||||
|
||||
### 7.4 Open risk
|
||||
|
||||
**Settled as of the v3 cutover.** Protocol work landed on `edge` branches and the `edge → main`
|
||||
cutover has now merged, so `main` speaks protocol 3 consistently across the repos. The rule it
|
||||
motivated stands regardless and is not a temporary measure: **the installer hardcodes no protocol
|
||||
version anywhere.** It reads what the artifacts declare, and §7.1's gate 1 is what stops a
|
||||
mismatched pair from being published as a bundle — which is the mechanism that will matter at the
|
||||
*next* protocol bump, not just this one. See `docs/link/v3.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
1. **Windows service mechanism** — `sc create` against the plain console binary (simplest, works
|
||||
today), a bundled WinSW/NSSM shim, or a native `--service` mode in the sidecar using the
|
||||
`windows-service` crate (cleanest, but changes `link`). Recommendation: `sc create` for v1,
|
||||
revisit if restart semantics prove inadequate.
|
||||
2. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells
|
||||
the operator to restart afterward. Offering to stop/start would be friendlier but means owning
|
||||
another shard's process lifecycle, and the shard's own start scripts vary.
|
||||
3. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so
|
||||
sidecar and ServUO must share a host. Should the installer support installing only uo-link on a
|
||||
different host, or hard-assume co-location?
|
||||
Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version.
|
||||
|
||||
**Resolved — branch targeting for the new repo** (was question 4). The v3 cutover landed:
|
||||
`servuo-plugins#6` merged, so that repo's `main` and `edge` agree at protocol 3. The release
|
||||
workflow targets `main`, and the installer repo starts clean on `main`. §7.4's caution still applies
|
||||
in principle — the installer hardcodes no protocol version, it reads what the artifacts declare —
|
||||
but the specific `edge`/`main` disagreement that motivated it is gone.
|
||||
|
||||
---
|
||||
|
||||
## 9. Administrator experience
|
||||
|
||||
Before:
|
||||
|
||||
```
|
||||
find plugins → copy files → edit ServUO → download bridge → start bridge
|
||||
→ configure startup → find the token → troubleshoot paths
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```
|
||||
download artifact → verify checksum → run installer → select ServUO directory
|
||||
→ install components → paste 4 values into Admin → Shard → start ServUO normally
|
||||
```
|
||||
@@ -23,7 +23,31 @@ Every route **except `GET /health`** requires the shared token from `sidecar.tom
|
||||
| REST | `X-Api-Key: <token>` |
|
||||
| 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.
|
||||
|
||||
- Every response carries an **`X-UOLink-Version: 2`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
|
||||
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
- Every response carries an **`X-UOLink-Version: 3`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`.
|
||||
- **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
|
||||
```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.
|
||||
|
||||
**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
|
||||
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to
|
||||
`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main`
|
||||
cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and
|
||||
the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site
|
||||
repeatedly.
|
||||
**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
|
||||
`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
|
||||
the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
|
||||
ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3
|
||||
sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the
|
||||
version number alone tells you what is available.
|
||||
|
||||
Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints.
|
||||
That is safe in the direction that matters: event kinds are additive, and a client that ignores
|
||||
unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not**
|
||||
do is infer feature availability from the version number during this window — probe the endpoint, or
|
||||
treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no
|
||||
feature-negotiation array**: v3 implies all three kinds.
|
||||
**Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a
|
||||
client still declaring `2` gets a 409 on every protected route and, on the WebSocket, a closed
|
||||
connection on the `ws.hello` mismatch. So update the pinned version at the same time you deploy the
|
||||
v3 sidecar. Nothing that existed in v2 changed shape, so that is the whole migration — the website
|
||||
does it with a one-shot boot migration of its `uo_link_config.protocol` row ([`v3.md`](v3.md) §4.1);
|
||||
a third-party client changes the constant it sends.
|
||||
|
||||
---
|
||||
|
||||
@@ -68,7 +92,7 @@ GET /health (no auth)
|
||||
```json
|
||||
{
|
||||
"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?
|
||||
"database": "ok", // "ok" | "error"
|
||||
"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:
|
||||
|
||||
```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`.
|
||||
@@ -955,7 +979,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
|
||||
A typical character page:
|
||||
|
||||
```js
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "3" };
|
||||
|
||||
// 1. render the roster
|
||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||
|
||||
@@ -347,6 +347,12 @@ of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff)
|
||||
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`)
|
||||
|
||||
```ini
|
||||
|
||||
@@ -18,12 +18,14 @@ link/
|
||||
│ ├── scripts/
|
||||
│ │ └── gen_tree.py
|
||||
│ ├── workflows/
|
||||
│ │ ├── pr-checks.yml
|
||||
│ │ ├── release.yml
|
||||
│ │ ├── sonarqube.yml
|
||||
│ │ └── sync-project-tree.yml
|
||||
│ └── PULL_REQUEST_TEMPLATE.md
|
||||
├── sidecar/
|
||||
│ ├── src/
|
||||
│ │ ├── cli.rs
|
||||
│ │ ├── config.rs
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── rpc.rs
|
||||
|
||||
95
link/v3.md
95
link/v3.md
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
**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).
|
||||
@@ -16,13 +16,18 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
|
||||
| 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) |
|
||||
| 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) | 🟨 In review | 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 | — |
|
||||
| 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) | 🟨 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
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -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.
|
||||
- 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
|
||||
@@ -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
|
||||
it answers during a shard outage (`PROTOCOL_2.md` §12.2).
|
||||
|
||||
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so follow the
|
||||
`getPresence()` block's explicit form, not the array-only `snapshot()` helper); `shardIngest.js` →
|
||||
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so it cannot use the
|
||||
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`
|
||||
already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table
|
||||
(`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
|
||||
`/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
|
||||
|
||||
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
|
||||
`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
|
||||
|
||||
The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a
|
||||
@@ -887,8 +948,8 @@ not a blocker here.)
|
||||
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ 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 | ✅ Done |
|
||||
| 5b | **B/3** — `vendor.listing` (§8) | all four | new kinds | 🟨 In review |
|
||||
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | ⬜ |
|
||||
| 5b | **B/3** — `vendor.listing` (§8) | all four | new kinds | ✅ Done |
|
||||
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | 🟨 In review — `edge` → `main` held for Android parity (§10) |
|
||||
|
||||
---
|
||||
|
||||
@@ -910,8 +971,24 @@ not a blocker here.)
|
||||
- `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.
|
||||
|
||||
**Follow-up, not scoped for 3.0:** the Android app consumes the same public/player shard API and will
|
||||
need `/public/shard/features` to hide its own nav. Track separately against `android-app/`.
|
||||
**Android parity — now scoped, and it gates the cutover (decided 2026-07-30).** This was written as a
|
||||
"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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ they did before the table existed.
|
||||
|
||||
## 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.
|
||||
|
||||
| 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
|
||||
```
|
||||
|
||||
A UOFiddler GUI export works equally well — anything producing one of the two
|
||||
shapes above is fine.
|
||||
A UOFiddler GUI export works too, but **not unmodified**: its Cliloc tab writes
|
||||
`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
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ website/
|
||||
│ │ │ ├── heroLayout.js
|
||||
│ │ │ ├── shardEvents.js
|
||||
│ │ │ ├── useAsync.js
|
||||
│ │ │ ├── useShardFeatures.js
|
||||
│ │ │ └── useShardFeed.js
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin/
|
||||
@@ -190,6 +191,8 @@ website/
|
||||
│ │ │ │ │ ├── SettingsAdmin.jsx
|
||||
│ │ │ │ │ ├── ShardAdmin.jsx
|
||||
│ │ │ │ │ ├── ShardOps.jsx
|
||||
│ │ │ │ │ ├── ShardVisibility.jsx
|
||||
│ │ │ │ │ ├── SpawnAtlas.jsx
|
||||
│ │ │ │ │ ├── UserDetail.jsx
|
||||
│ │ │ │ │ ├── UserEditor.jsx
|
||||
│ │ │ │ │ ├── UsersAdmin.jsx
|
||||
@@ -213,17 +216,23 @@ website/
|
||||
│ │ │ │ └── ResetPassword.jsx
|
||||
│ │ │ ├── public/
|
||||
│ │ │ │ ├── About.jsx
|
||||
│ │ │ │ ├── Atlas.jsx
|
||||
│ │ │ │ ├── AtlasCreature.jsx
|
||||
│ │ │ │ ├── ChampSpawns.jsx
|
||||
│ │ │ │ ├── CmsPage.jsx
|
||||
│ │ │ │ ├── FiveOnFriday.jsx
|
||||
│ │ │ │ ├── Governors.jsx
|
||||
│ │ │ │ ├── Guilds.jsx
|
||||
│ │ │ │ ├── Houses.jsx
|
||||
│ │ │ │ ├── Leaderboards.jsx
|
||||
│ │ │ │ ├── Maintenance.jsx
|
||||
│ │ │ │ ├── Market.jsx
|
||||
│ │ │ │ ├── MarketVendor.jsx
|
||||
│ │ │ │ ├── News.jsx
|
||||
│ │ │ │ ├── Newsletter.jsx
|
||||
│ │ │ │ ├── NewsletterIssue.jsx
|
||||
│ │ │ │ ├── Portal.jsx
|
||||
│ │ │ │ ├── Rules.jsx
|
||||
│ │ │ │ ├── Screenshots.jsx
|
||||
│ │ │ │ ├── Shard.jsx
|
||||
│ │ │ │ ├── ShardActivity.jsx
|
||||
@@ -257,9 +266,12 @@ website/
|
||||
│ └── sonar-test-reporter.mjs
|
||||
├── server/
|
||||
│ ├── db/
|
||||
│ │ ├── data/
|
||||
│ │ │ └── spawnAtlas.art.example.json
|
||||
│ │ ├── schema.sql
|
||||
│ │ └── seed.js
|
||||
│ ├── scripts/
|
||||
│ │ ├── importSpawnAtlas.js
|
||||
│ │ └── routeManifest.js
|
||||
│ ├── src/
|
||||
│ │ ├── auth/
|
||||
@@ -365,15 +377,27 @@ website/
|
||||
│ │ │ ├── settings/
|
||||
│ │ │ │ ├── settings.db.js
|
||||
│ │ │ │ └── settings.model.js
|
||||
│ │ │ ├── shardAtlas/
|
||||
│ │ │ │ ├── shardAtlas.db.js
|
||||
│ │ │ │ └── shardAtlas.model.js
|
||||
│ │ │ ├── shardClilocs/
|
||||
│ │ │ │ ├── shardClilocs.db.js
|
||||
│ │ │ │ └── shardClilocs.model.js
|
||||
│ │ │ ├── shardEvents/
|
||||
│ │ │ │ ├── shardEvents.db.js
|
||||
│ │ │ │ └── shardEvents.model.js
|
||||
│ │ │ ├── shardLinks/
|
||||
│ │ │ │ ├── shardLinks.db.js
|
||||
│ │ │ │ └── shardLinks.model.js
|
||||
│ │ │ ├── shardMarket/
|
||||
│ │ │ │ ├── shardMarket.db.js
|
||||
│ │ │ │ └── shardMarket.model.js
|
||||
│ │ │ ├── shardState/
|
||||
│ │ │ │ ├── shardState.db.js
|
||||
│ │ │ │ └── shardState.model.js
|
||||
│ │ │ ├── shardVisibility/
|
||||
│ │ │ │ ├── shardVisibility.db.js
|
||||
│ │ │ │ └── shardVisibility.model.js
|
||||
│ │ │ ├── trustedDevices/
|
||||
│ │ │ │ ├── trustedDevices.db.js
|
||||
│ │ │ │ └── trustedDevices.model.js
|
||||
@@ -398,12 +422,14 @@ website/
|
||||
│ │ │ │ │ ├── account.router.js
|
||||
│ │ │ │ │ ├── activity.router.js
|
||||
│ │ │ │ │ ├── admin.controller.js
|
||||
│ │ │ │ │ ├── admin.routes.js
|
||||
│ │ │ │ │ ├── authProviders.controller.js
|
||||
│ │ │ │ │ ├── authProviders.router.js
|
||||
│ │ │ │ │ ├── botActivity.controller.js
|
||||
│ │ │ │ │ ├── botActivity.router.js
|
||||
│ │ │ │ │ ├── dashboard.router.js
|
||||
│ │ │ │ │ ├── discordBot.controller.js
|
||||
│ │ │ │ │ ├── discordBot.router.js
|
||||
│ │ │ │ │ ├── email.router.js
|
||||
│ │ │ │ │ ├── emailConfig.controller.js
|
||||
│ │ │ │ │ ├── imageUpload.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
@@ -414,16 +440,25 @@ website/
|
||||
│ │ │ │ │ ├── pages.controller.js
|
||||
│ │ │ │ │ ├── pages.router.js
|
||||
│ │ │ │ │ ├── posts.router.js
|
||||
│ │ │ │ │ ├── settings.router.js
|
||||
│ │ │ │ │ ├── shard.router.js
|
||||
│ │ │ │ │ ├── shardAtlas.controller.js
|
||||
│ │ │ │ │ ├── shardClilocs.controller.js
|
||||
│ │ │ │ │ ├── shardOps.controller.js
|
||||
│ │ │ │ │ ├── shardVisibility.controller.js
|
||||
│ │ │ │ │ ├── uoLink.controller.js
|
||||
│ │ │ │ │ ├── uoLink.router.js
|
||||
│ │ │ │ │ ├── uploads.router.js
|
||||
│ │ │ │ │ ├── users.router.js
|
||||
│ │ │ │ │ ├── usersShard.controller.js
|
||||
│ │ │ │ │ └── wiki.router.js
|
||||
│ │ │ │ ├── auth/
|
||||
│ │ │ │ │ ├── auth.controller.js
|
||||
│ │ │ │ │ ├── auth.routes.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── invite.controller.js
|
||||
│ │ │ │ │ ├── invite.router.js
|
||||
│ │ │ │ │ ├── login.router.js
|
||||
│ │ │ │ │ ├── loginGuards.js
|
||||
│ │ │ │ │ ├── me.routes.js
|
||||
│ │ │ │ │ ├── mobile.controller.js
|
||||
│ │ │ │ │ ├── mobile.routes.js
|
||||
@@ -431,7 +466,10 @@ website/
|
||||
│ │ │ │ │ ├── mobileSso.routes.js
|
||||
│ │ │ │ │ ├── notifications.controller.js
|
||||
│ │ │ │ │ ├── notifications.routes.js
|
||||
│ │ │ │ │ ├── password.router.js
|
||||
│ │ │ │ │ ├── passwordReset.controller.js
|
||||
│ │ │ │ │ ├── register.router.js
|
||||
│ │ │ │ │ ├── session.router.js
|
||||
│ │ │ │ │ ├── sso.controller.js
|
||||
│ │ │ │ │ ├── sso.routes.js
|
||||
│ │ │ │ │ └── trustDevice.helper.js
|
||||
@@ -439,13 +477,23 @@ website/
|
||||
│ │ │ │ │ ├── internal.controller.js
|
||||
│ │ │ │ │ └── internal.routes.js
|
||||
│ │ │ │ ├── player/
|
||||
│ │ │ │ │ ├── account.router.js
|
||||
│ │ │ │ │ ├── appeals.controller.js
|
||||
│ │ │ │ │ ├── player.routes.js
|
||||
│ │ │ │ │ └── shard.controller.js
|
||||
│ │ │ │ │ ├── appeals.router.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── shard.controller.js
|
||||
│ │ │ │ │ └── shard.router.js
|
||||
│ │ │ │ ├── public/
|
||||
│ │ │ │ │ ├── atlas.controller.js
|
||||
│ │ │ │ │ ├── atlas.router.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── pages.router.js
|
||||
│ │ │ │ │ ├── posts.router.js
|
||||
│ │ │ │ │ ├── public.controller.js
|
||||
│ │ │ │ │ ├── public.routes.js
|
||||
│ │ │ │ │ └── shard.controller.js
|
||||
│ │ │ │ │ ├── shard.controller.js
|
||||
│ │ │ │ │ ├── shard.router.js
|
||||
│ │ │ │ │ ├── site.router.js
|
||||
│ │ │ │ │ └── wiki.router.js
|
||||
│ │ │ │ └── v1.router.js
|
||||
│ │ │ ├── api.router.js
|
||||
│ │ │ ├── cspReport.controller.js
|
||||
@@ -455,6 +503,8 @@ website/
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── botInternalClient.js
|
||||
│ │ │ ├── botInternalKey.js
|
||||
│ │ │ ├── clilocParse.js
|
||||
│ │ │ ├── clilocSource.js
|
||||
│ │ │ ├── db.js
|
||||
│ │ │ ├── logger.js
|
||||
│ │ │ ├── mailer.js
|
||||
@@ -465,6 +515,9 @@ website/
|
||||
│ │ │ ├── shardBroadcast.js
|
||||
│ │ │ ├── shardIngest.js
|
||||
│ │ │ ├── shardSales.js
|
||||
│ │ │ ├── shardVisibility.js
|
||||
│ │ │ ├── spawnAtlasParse.js
|
||||
│ │ │ ├── spawnAtlasSource.js
|
||||
│ │ │ ├── totp.js
|
||||
│ │ │ ├── trustProxy.js
|
||||
│ │ │ ├── uoLinkClient.js
|
||||
@@ -483,11 +536,14 @@ website/
|
||||
│ │ ├── appeals.pure.test.js
|
||||
│ │ ├── appeals.test.js
|
||||
│ │ ├── appLinks.test.js
|
||||
│ │ ├── atlasController.test.js
|
||||
│ │ ├── authController.test.js
|
||||
│ │ ├── authMe.test.js
|
||||
│ │ ├── authTrustedDevice.test.js
|
||||
│ │ ├── botInternalKey.test.js
|
||||
│ │ ├── botScore.test.js
|
||||
│ │ ├── clilocParse.test.js
|
||||
│ │ ├── clilocSource.test.js
|
||||
│ │ ├── csp.test.js
|
||||
│ │ ├── emailConfig.model.test.js
|
||||
│ │ ├── honeypot.test.js
|
||||
@@ -521,17 +577,32 @@ website/
|
||||
│ │ ├── secretBox.test.js
|
||||
│ │ ├── selfTrustedDevices.test.js
|
||||
│ │ ├── session.test.js
|
||||
│ │ ├── shardBroadcast.visibility.test.js
|
||||
│ │ ├── shardControllerPublic.test.js
|
||||
│ │ ├── shardIngest.champsPages.test.js
|
||||
│ │ ├── shardIngest.market.test.js
|
||||
│ │ ├── shardIngest.points.test.js
|
||||
│ │ ├── shardIngest.protocol2.test.js
|
||||
│ │ ├── shardIngest.ruleset.test.js
|
||||
│ │ ├── shardMarket.model.test.js
|
||||
│ │ ├── shardState.governorTerms.test.js
|
||||
│ │ ├── shardState.model.test.js
|
||||
│ │ ├── shardVisibility.test.js
|
||||
│ │ ├── spawnAtlas.parse.test.js
|
||||
│ │ ├── spawnAtlas.source.test.js
|
||||
│ │ ├── ssoCallback.test.js
|
||||
│ │ ├── ssoState.test.js
|
||||
│ │ ├── ssoTrustedDevice.test.js
|
||||
│ │ ├── totp.test.js
|
||||
│ │ ├── trustedDevices.test.js
|
||||
│ │ ├── trustProxy.test.js
|
||||
│ │ ├── uoLinkClient.test.js
|
||||
│ │ └── usernamePolicy.test.js
|
||||
│ ├── tools/
|
||||
│ │ └── cliloc-export/
|
||||
│ │ ├── clilocexport.csproj
|
||||
│ │ ├── Program.cs
|
||||
│ │ └── README.md
|
||||
│ ├── .env.example
|
||||
│ ├── package-lock.json
|
||||
│ ├── package.json
|
||||
|
||||
@@ -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
|
||||
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
|
||||
any art extractor).
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user