51 Commits

Author SHA1 Message Date
706b450828 Merge pull request 'docs(installer): record Phase 3 — the patch tier as built' (#92) from docs/installer-phase3 into main
Reviewed-on: #92
2026-08-05 01:07:46 +00:00
42e6f3a0cb docs(installer): record Phase 3 — the patch tier as built
PLAN.md gains a "Phase 3 as built" section covering the decisions the plan had
left open, and §2.2, §2.2.1 and §7.0 are brought in line with what shipped:

* The engine is fully native. §2.2.1 wrote rung 1 as "apply verbatim with
  git apply", but §1 chose the release tarball so there would be no git on the
  shard host, and rung 2 needs a native applier anyway. Rung 1 keeps its
  stronger verdict and shares rung 2's write path. On the real files this is
  not academic — the shipped patches are CRLF and two of their three targets
  are LF, so git apply refuses patches the installer places correctly.

* §7.0 documents `patch_tier` in the overlay manifest. Which patches form one
  unit, which companion follows which, whether a core rebuild is needed and
  what declining costs are not derivable from a diff, so the release declares
  them and adding a patch regenerates metadata rather than an installer.

* §2.2 gains the pre-image cache and the widened patch cache, and §2.2.1 gains
  the second, per-feature level of the all-or-nothing rule.

INSTALL.md's illustrated tier output is replaced with the real thing, the
status banner now says `install` is complete, §3's path tables list
patches/originals/, and Appendix A2 names the line-ending trap that makes
git apply refuse a patch whose region is visibly untouched.

Refs: RunicGateway/installer#6, RunicGateway/servuo-plugins#10

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 19:57:42 -05:00
5ae53d287f Merge pull request 'docs(installer): record Phase 2 as built — sidecar install and service' (#91) from docs/installer-phase2 into main
Reviewed-on: #91
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 20:44:59 +00:00
3f12e5f49c docs(installer): record Phase 2 as built — sidecar install and service
Phases 1 and 2 now live on the installer repo's `edge` branch, so PLAN.md's
status, the config-path section, and the operator guide all move with them.

PLAN.md
- Status: phases 1 and 2 built. The `edge -> main` cutover now follows Phase 3
  rather than Phase 2, because INSTALL.md §4 describes the patch tier as part of
  the run and a release that answers "not implemented" to all of it is the same
  half-capable binary that kept Phase 1 off `main`.
- §2.3: the service definition always pins the config path, but only Linux pins
  the database. On Windows config and data share a directory, so the sidecar's
  own anchoring rule already lands it correctly — and `sc.exe` offers no
  per-service environment, only a machine-wide one that every process inherits
  and that outlives an uninstall.
- New "Phase 2 as built" section: the virtual service account, the config
  lockdown and why its two halves straddle registration, `--verify` running no
  part of the sidecar half, the protocol check against the installed binary,
  `RUNICGATEWAY_STATE_DIR` relocating the binary and suppressing service
  registration, degrading to a printed recipe with no root/LocalSystem fallback,
  and the token never entering install.json.
- §8 question 1 (Windows service mechanism) resolved: `sc create`, as
  recommended — plus the service identity the recommendation did not anticipate.

INSTALL.md
- Status banner: what is built, and that the patch tier is the remaining gap.
- §2: the illustrated run matches the sidecar block the binary actually prints.
- §3: a table of how each platform pins config and database, the dedicated
  service account on both, and the fact that sidecar.toml's permissions are
  restricted because it holds the auth token.
- Appendix A4: the Windows recipe now matches what the installer does —
  `--config` in binPath (single-quoted so PowerShell keeps the inner quotes),
  `obj=` for the virtual account, the icacls lockdown before and grants after,
  and no machine-wide environment variables.
- Troubleshooting: a row for a run that could not register a service, and one
  for a service that starts and immediately stops.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:40:26 -05:00
ce02abbc11 Merge pull request 'docs(installer): record Phase 1 as built' (#90) from feat/installer-phase-1 into main
Reviewed-on: #90
2026-08-04 20:00:56 +00:00
4bdc764742 docs(installer): record Phase 1 as built
PLAN.md moves from "Phase 0 complete, no code exists" to "Phase 1 built, on
edge", and the Phase 1 section gains an "As built" block in the same shape as
the Phase 0 entries — covering the decisions that were not already settled by
the design: why the crate lands on `edge` instead of `main`, why the library
target is not named after the binary (Windows UAC installer detection makes
`cargo test` unrunnable under that name), the dependency choices that follow
from the MinGW cross-build, path-based rather than name-based shard-running
detection, reading ServUO's version from Server/AssemblyInfo.cs, and the two
rules the smoke test corrected — install.json recording a state rather than the
run's verb, and the Bridge.cfg keep comparing against the last hash deployed
rather than the last hash seen.

INSTALL.md gains the same status note and one troubleshooting row: Windows
elevates the binary on launch because its file name contains "install", which
is expected and needs no action beyond running from an elevated shell.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 14:58:46 -05:00
a98fceb4bb Merge pull request 'docs(installer): review the patched region, not the whole-file hash' (#89) from docs/installer-patch-region-review into main
Reviewed-on: #89
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 19:19:14 +00:00
5a6cb58a33 docs(installer): review the patched region, not the whole-file hash
The patch tier refused on a whole-file hash mismatch, which is the wrong
question: the three patches touch three small regions of three large files, so
an operator who edited Logging.cs somewhere else entirely was handed a manual
patch job they did not need. Hand-modified shards are the norm, so that refusal
covered most of the audience.

Replace the single hash test with a four-rung ladder (PLAN §2.2.1), cheapest and
safest first:

  0  post-patch text already present  -> no-op, keeps re-runs idempotent
  1  whole file matches the pre-image -> apply verbatim
  2  file differs, patched region is still byte-identical -> apply at the
     matched offset
  3  anything else -> do not touch the file; print the hunk to apply by hand

Rung 2 needs no new metadata: a unified diff already carries the stock text of
the region it edits (context lines plus the '-' lines). Guardrails keep it from
becoming a fuzzy apply -- exact match with only CRLF/trailing-whitespace
normalisation, exactly one occurrence or it fails, line numbers advisory only,
and all-or-nothing per patch file so a half-patched EventSink.cs cannot happen.
install.json records which rung applied each patch, and doctor and uninstall
report it.

This retires the blanket 57.4-only version gate, so PLAN gains §2.2.2 to draw
the line the ladder does not: content matching is a mechanical guarantee about
where text lands, not a support commitment. 57.4 stays the only supported
version. A non-57.4 tree may attempt the tier, but unsupported, untested and not
guaranteed -- behind a loud banner, a prompt defaulted to no, and its own
--patches-unsupported-servuo flag, because a bare --patches can be hit by
accident in a copied script. The unsupported marker persists into install.json,
every later doctor run, and the uninstall report.

INSTALL.md gets the operator-facing half: a block-quoted warning naming the
silent-script-build failure mode, the updated prerequisite row, prompts and flag
table, and a sample run showing all three outcomes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 14:17:33 -05:00
d5838f7d4c Merge pull request 'docs(installer): mark Phase 0.4 merged in the status table' (#88) from docs/installer-0.4-status into main
Reviewed-on: #88
2026-08-04 19:03:55 +00:00
90459f2c49 docs(installer): mark Phase 0.4 merged in the status table
docs#87 landed; the row it added still said 'in review'. The status table is
the first thing anyone reads in this file, so a stale row there is worse than
no row.
2026-08-04 14:02:32 -05:00
a2e859bf9a Merge pull request 'docs(tree): sync installer/PROJECT_TREE.md' (#86) from chore/sync-installer-tree into main
Reviewed-on: #86
2026-08-04 19:01:27 +00:00
2dbe4d8388 Merge branch 'main' into chore/sync-installer-tree 2026-08-04 17:15:57 +00:00
e85ca632ce Merge pull request 'docs(installer): add the operator install guide (Phase 0.4)' (#87) from docs/installer-phase-0.4 into main
Reviewed-on: #87
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 17:10:33 +00:00
83bd4ec2d4 docs(installer): link the Phase 0.4 PR from the status table 2026-08-04 11:40:06 -05:00
5c8585fe75 docs(installer): add the operator install guide (Phase 0.4)
Closes the last Phase 0 item. INSTALL.md is written before the installer
binary on purpose: everything it installs is already released (0.1-0.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.

It is useful today. Appendix A is the same deployment done by hand - bundle
fetch, tarball verify and overlay copy, the optional patch tier,
--print-config provisioning, systemd unit / sc create - composed from the
released artifacts' actual contents and the sidecar's config and CLI source.
That appendix doubles as Phase 1's acceptance test.

Writing it settled four things the plan had left implicit, now recorded in
PLAN.md:

- The installer does not install itself; day-two commands run from the
  downloaded binary.
- The flag surface: --servuo, --patches/--no-patches, --host, --site-url and
  --yes join the --verify/--bundle/--purge the plan already named, so every
  prompt has a non-interactive equivalent.
- A modified Config/Bridge.cfg is reported, not overwritten - one deliberate
  deviation from deploy.ps1, whose overwrite-on-hash-differs rule is right for
  a developer and would silently revert an operator's whole shard config on
  update. install.json's recorded hashes are what make the distinction
  possible.
- Remote-website deployments: widen [web] bind, firewall it to the site's
  address, front it with TLS or a VPN off a trusted network. [shard] bind
  stays on loopback because that socket carries commands into the game.

Also adds the missing installer/ section to the docs index, and refreshes two
stale examples in PLAN.md (overlay file count, sidecar version).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:39:02 -05:00
fc79bb6ed0 Merge pull request 'docs(installer): record Phase 0.3 — the bundle CI and where bundles live' (#85) from docs/installer-phase-0.3 into main
Reviewed-on: #85
2026-08-04 16:20:05 +00:00
runic-docs-bot
a6b20cb273 docs(tree): sync installer/PROJECT_TREE.md from RunicGateway/installer@0e7d5f3 [skip ci] 2026-08-04 16:19:51 +00:00
af6036b9c7 Merge pull request 'docs(tree): sync link/PROJECT_TREE.md' (#83) from chore/sync-link-tree into main
Reviewed-on: #83
2026-08-04 16:17:52 +00:00
e0245a209c Merge branch 'main' into chore/sync-link-tree 2026-08-04 16:17:39 +00:00
10ebce706f docs(installer): record Phase 0.3 — the bundle CI and where bundles live
Documentation half of installer Phase 0.3. Code PRs: RunicGateway/installer#3,
RunicGateway/link#25, RunicGateway/servuo-plugins#9.

Phase 0 item 3 asked for a compose job, a nightly cron, and a dispatch step in
each component. All three landed, and building them settled questions §7 had
left open — so those sections are corrected rather than appended to.

Status: Phase 0 is now all but complete. 0.2 is merged and released as link
v1.1.0; 0.3 is in review; 0.4 (INSTALL.md) is the remaining item, and the shape
it was waiting on has settled.

Phase 0 item 3 gains an "As built" subsection matching items 1 and 2, recording
what was chosen rather than inherited: why gate 1 reads the sidecar's protocol
from source at the release tag instead of asking the binary via --print-config
(it only answers for v1.1.0+, and --bundle <tag> must be able to recompose an
older pair); why gate 2 records the hash CI computed itself and separately
checks for assets absent from SHA256SUMS; why release metadata is read
anonymously; why an unrecognized asset name is a hard failure; and why a run
that changes nothing writes nothing.

§7.1 is corrected in two places. The manifest shape now shows link.assets as a
map keyed by platform — the single sha256 this section sketched could only ever
have described one of the two binaries link publishes — plus schema/generated
and the servuo block. And it gains a subsection naming where bundles are
published: committed under bundles/ in the installer repo, NOT one Gitea release
per bundle, because that repo's own releases are the installer binaries and
/releases/latest returns whichever release is newest regardless of kind.

§7.2 records that the dispatch steps now exist, and that a failed dispatch is a
warning rather than a failed release — which is what keeps the installer repo's
token out of the components' hard requirements.

§7.3 records that the releasable-commit rule must also exclude merge commits,
whose subject quotes the real one: without that, merging a docs: branch whose
title mentions a fix: would re-dispatch a declining release workflow nightly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:17:28 -05:00
d1cb3e9511 Merge pull request 'docs(installer): record Phase 0.2 — the sidecar's CLI and settled data paths' (#84) from docs/installer-phase-0.2 into main
Reviewed-on: #84
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 15:55:52 +00:00
c1957f0bfb docs(installer): record Phase 0.2 — the sidecar's CLI and settled data paths
Phase 0.2 landed in link#24: the sidecar gained a four-flag CLI, --print-config,
and config-anchored data paths. Three sections of the installer plan asserted
facts that change as a result, so they are corrected rather than appended to.

- Status table: 0.1 merged (servuo-plugins#7/#8, overlay v0.1.1 released), 0.2 in
  review, repo bootstrap merged. 0.3 (bundle CI) is next and now unblocked — both
  components it composes exist.
- Phase 0 item 2 gains an "As built" subsection matching item 1's: why four
  hand-rolled flags rather than a parsing crate, why --print-config provisions
  instead of only reporting, why config_created/token_generated exist, and why no
  platform data directories are compiled into the binary.
- §2.3 (working-directory trap): half-closed in the sidecar — a relative
  [store].path now anchors to the config file's directory — while the service
  definitions still pin both env vars, and why that is not redundant.
- §2.4 (token handoff): the installer reads the handoff block out of one
  --print-config call and never parses the log, which is not a contract.
- §5 Phase 2 / Phase 4, §6: the ordering that follows (print-config before service
  registration), which doctor rows the CLI answers, that only the host is
  substituted into the printed URLs because web.bind is often 0.0.0.0, and that the
  printed token must not reach a log or support bundle.
- link/INTEGRATION.md §1: how to read the token back, replacing "the sidecar logs
  it" with the supported command and its output.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 10:48:44 -05:00
runic-docs-bot
b6343a0937 docs(tree): sync link/PROJECT_TREE.md from RunicGateway/link@654a08a [skip ci] 2026-08-04 15:48:24 +00:00
e1608bb280 Merge pull request 'docs(installer): record Phase 0 progress and the overlay manifest' (#82) from docs/installer-phase0 into main
Reviewed-on: #82
2026-08-04 15:29:17 +00:00
f0975df598 docs(installer): record Phase 0 progress and the overlay manifest
Tracks what actually landed while starting the installer plan, and corrects
the parts of the plan that the work proved wrong or stale.

Progress:

  A Phase 0 status table at the top, so the plan says where it is rather
  than needing a reader to reconstruct it from PR links.

  Phase 0 item 1 (§5) now records the release workflow as built, including
  its three deviations from link's copy — structural gates instead of build
  gates, no bump commit and therefore no push to main, and overlay.toml as
  the home for the declared protocol version. Plus the fixed tarball prefix
  and why: the installer would otherwise have to parse the version it is
  trying to read.

New §7.0 documents the overlay manifest as generated, and states plainly the
two things about it that carry weight: `protocol` is hand-maintained and has
to be (nothing in CI can derive it, which is exactly why §7.1's gate 1 has
something to compare), and `files` is what lets `doctor` distinguish
"operator edited a deployed file" from "the overlay moved on".

Corrections:

  §2.6 the plugin's protocol version now has a home (overlay.toml), and
        servuo-plugins now has a release workflow.
  §7.2  the dispatch step is deliberately deferred to Phase 0 item 3.
  §7.4  no longer "open risk" — the v3 cutover merged. The rule it motivated
        (never hardcode a protocol version) is restated as permanent rather
        than as a workaround for a mid-flight cutover.
  §8    open question 4 (branch targeting) resolved: servuo-plugins#6 merged,
        main == edge, everything targets main.

Version examples in §3, §5 and §7.1 said uo-link v3.x.y / 3.0.1, conflating
the release version with the protocol version. link is actually at v0.3.0 —
the two are independent, and the bundle names release versions, so an example
implying they track each other is actively misleading. Now uses the real
values (link 0.3.0, overlay 0.1.0, 30 overlay files).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 09:28:23 -05:00
8322e8318c Merge pull request 'docs(installer): add release orchestration and the bundle manifest' (#81) from docs/installer-release-orchestration into main
Reviewed-on: #81
2026-08-01 21:42:33 +00:00
25a5734107 docs(installer): add release orchestration and the bundle manifest
The installer needs CI that reacts when a component publishes a release. Adds
that as section 7, folded into version tracking because the bundle IS the compat
matrix -- which closes the "where does the compat matrix live" gap section 7
previously left open.

- 7.1 Bundle manifest: CI publishes an exact, protocol-checked combination of
  component versions; the installer resolves against it at run time and
  --bundle <tag> pins one. A link release regenerates JSON and leaves the
  installer binary untouched, so operators don't re-download the installer for a
  sidecar patch and the repo doesn't accumulate releases with identical code.
  Two compose-time gates: sidecar PROTOCOL_VERSION must equal the overlay
  manifest's declared version, and every asset's SHA256 must match.
- 7.2 Triggers: each component's release job POSTs to the installer's
  workflow-dispatch endpoint (link's release.yml already declares
  workflow_dispatch and already holds a write:repository token), plus a nightly
  cron so a missed dispatch self-heals. repository_dispatch avoided -- support
  is uncertain on this Gitea version.
- 7.3 Stale overlay: dispatch, don't wait. Components self-release on merge to
  their own main, so the release normally already exists. If main is ahead with
  *releasable* commits (docs:/chore: correctly cut nothing), fire that repo's
  workflow, compose from what exists now, warn loudly, and let the nightly fold
  in the result. Dispatching another repo's workflow is fine -- it still runs
  its own gates -- but polling it is not, since Gitea's dispatch endpoint
  returns no run handle.

Bundle CI becomes a Phase 0 deliverable, since Phase 1 resolves what to install
from the bundle. `update` now moves between checked combinations rather than two
independently-latest artifacts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 06:11:08 -05:00
c583ddb77f Merge pull request 'docs(installer): plan the Runic Gateway installer' (#80) from docs/installer-plan into main
Reviewed-on: #80
2026-08-01 10:46:13 +00:00
29056ba996 docs(installer): plan the Runic Gateway installer
Design of record for a deployment tool that takes a stock ServUO install and
configures it for Runic Gateway. Supersedes the informal overview it grew from,
which described a ServUO integration that does not match how servuo-plugins
actually ships.

Corrections that change the design:

- There is no RunicGateway.dll and no Plugins/ dir. The plugin ships as C#
  source compiled by ServUO at boot, so the step is a hash-compare sync of
  overlay/ -- but a successful copy does not mean a working bridge, because
  ScriptCompiler.Compile() ignores dotnet build's exit code and reloads the
  stale Scripts.dll.
- Stock ServUO files ARE modified, by three diffs in patches/. Made an opt-in,
  skippable tier: git apply against a hand-modified shard will fail, and the
  EventSink.cs patch needs a full core solution rebuild.
- Config paths collided with what the sidecar actually reads. Split ownership:
  sidecar.toml stays the sidecar's schema, install.json is the installer's.
  Service definitions pin UOLINK_CONFIG and UOLINK_DB_PATH, since the sidecar
  writes relative to CWD and would land in VirtualStore under Program Files.
- The token handoff was missing entirely -- the largest "installed it and
  nothing happened" failure mode.
- deploy.ps1 cannot be the cross-platform deployer; it stays the developer tool.

Decisions: public audience, unsigned binaries anchored on SHA256SUMS, Rust,
release-tarball plugin distribution, printed token handoff, new installer repo,
warn-and-skip on non-57.4 ServUO, and an uninstall that never edits the shard
tree -- it prints the files to delete and the hunks to revert.

Phases 0-5, with Phase 0 (a release workflow for servuo-plugins, which has
none today) gating everything else. Two open questions remain in section 8.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 05:44:44 -05:00
186f057bc0 Merge pull request 'docs(tree): sync android/PROJECT_TREE.md' (#79) from chore/sync-android-tree into main
Reviewed-on: #79
2026-08-01 07:50:46 +00:00
runic-docs-bot
33a013ca98 docs(tree): sync android/PROJECT_TREE.md from RunicGateway/Android-app@5eaf5d2 [skip ci] 2026-08-01 07:22:23 +00:00
5e2bc22a94 Merge pull request 'docs(tree): sync link/PROJECT_TREE.md' (#77) from chore/sync-link-tree into main
Reviewed-on: #77
2026-08-01 07:21:29 +00:00
04cd64b838 Merge branch 'main' into chore/sync-link-tree 2026-08-01 07:21:09 +00:00
916c11ee92 Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#78) from chore/sync-website-tree into main
Reviewed-on: #78
2026-08-01 07:20:49 +00:00
runic-docs-bot
ea3755760d docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@5103b74 [skip ci] 2026-08-01 07:19:52 +00:00
runic-docs-bot
54b3002701 docs(tree): sync link/PROJECT_TREE.md from RunicGateway/link@295defb [skip ci] 2026-08-01 06:50:30 +00:00
9d98109628 Merge pull request 'docs!: Protocol 3.0 cutover — the 3.0 documentation set' (#73) from edge into main
Reviewed-on: #73
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:29:36 +00:00
d0808b766b Merge branch 'main' into edge 2026-08-01 06:28:21 +00:00
eab0a83f26 Merge pull request 'docs(link): the shard-name fallback, the atlas places shape, and two traps' (#76) from docs/protocol-3-smoke-findings into edge
Reviewed-on: #76
2026-08-01 06:02:59 +00:00
1444c77413 docs(link): the shard-name fallback, the atlas places shape, and two traps
Records what the live Protocol 3.0 smoke test (ServUO + sidecar + website +
AVD) turned up, so none of it has to be rediscovered.

v3.md §5.3 — the ruleset `shard` field now falls back to the instance's own
name when the shard publishes ServUO's stock "My Shard", why that is done at
ingest rather than on read (the frame is also broadcast live), and why the
backfill snapshot must go through the dispatcher instead of writing state
directly: a direct call made it a second writer that skipped the
normalization.

v3.md §7.4 — an unscored board renders a placeholder row rather than a blank
card, and why it is deliberately not shaped like a real entry.

PLAN.md §9 M11 — `places` is a list of {facet,label,spawners,maxAlive} OBJECTS,
not of place-name strings, and typing it `List<String>` makes the whole detail
route fail to decode while the request itself returns 200. Adds the rule that
came out of it: decode tests must feed real captured JSON, because the fakes
build DTOs in Kotlin and can never catch a wire mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:58:53 -05:00
2e24427032 Merge pull request 'docs(website): an operator runbook for extracting from your own UO client' (#75) from docs/operator-uofiddler-guide into edge
Reviewed-on: #75
2026-07-30 09:51:31 +00:00
afcdb373ec docs(website): an operator runbook for extracting from your own UO client
CLILOCS.md and SPAWN_ATLAS.md each explain WHY the operator has to supply
something out of their own client, but neither says how. UOFIDDLER.md is the
missing procedure: where to get UOFiddler, which two files in the zip matter,
which runtime it needs, where Cliloc.enu actually lives, the conversion, how to
point the site at the result, and how to confirm it took.

Verified end to end on a stock Windows box: UOFiddler 4.22.2 (Ultima.dll is
net10.0), .NET SDK 9.0.312 building the net8.0 converter, RollForward carrying
it onto runtime 10.0.8, and the site's own parser reading the output back.

Corrects one claim while doing it. CLILOCS.md said a UOFiddler GUI export
"works equally well"; it does not. Its Cliloc tab writes `Number;Text;Flag` --
three columns, flag LAST -- and parseClilocText splits on the first separator
only, so the flag is absorbed into the name and every item renders as
`quarter staff;0`. The parser already handles `number,flag,text` with the flag
in the middle, but a trailing `;0` is indistinguishable from a name that
genuinely ends that way, so this stays a documented `sed` on the operator's
side rather than a heuristic that would corrupt real names.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 03:34:29 -05:00
45fb4a3f15 Merge pull request 'docs(android): scope M11 — Protocol 3.0 shard parity for the app' (#74) from docs/android-v3-parity into edge
Reviewed-on: #74
2026-07-30 07:23:00 +00:00
5a091157d6 docs(android): scope M11 — Protocol 3.0 shard parity for the app
The v3 work added four shard features and an admin-configurable visibility
framework the Android client knows nothing about. v3.md §10 deferred the app
side as a follow-up; re-examining it before the cutover found the gap is wider
than nav hiding:

  - no consumer for any of ruleset / leaderboards / market / atlas,
  - no `points` block on the character sheet (§7.3),
  - no cliloc-resolved item names (§8.6), and
  - shard nav gated 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.

Scoped as PLAN.md §9 M11 in two PRs (the visibility rules + read-model adds,
then the four screens), with the traps a real shard exposes recorded inline:
uncapped `maxPoints: 0`, cliloc-named boards with a null `nameString`, skill
caps in tenths, the required market staleness banner, the market stream being
off by default, atlas delays in seconds, and `points`-count vs `spawners`-list.

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 404s and the app falls back to
today's behavior — so holding the cutover is a schedule decision, not a
technical dependency.

Also records two things verified as already correct, so they are not
re-derived: the app's SSE request rides the authenticated client (same audience
rung as the same account on web), and every shard DTO is nullable-with-defaults
(field projection cannot cause a decode failure).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 00:46:16 -05:00
3a1bbdd165 Merge pull request 'docs(link): the Protocol 3.0 cutover (v3.md order 6)' (#72) from docs/protocol-3-cutover into edge
Reviewed-on: #72
2026-07-30 03:03:10 +00:00
32def88c4e docs(link): fill in the cutover PR numbers
The order-6 row was written before the seven PRs existed. Same follow-up as the
cliloc row got.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:09:50 -05:00
71207cef16 docs(link): the Protocol 3.0 cutover (v3.md order 6)
INTEGRATION.md was written for the window that just closed -- it told integrators
the version had NOT been bumped yet and that a sidecar on `edge` reports 2 while
already carrying v3 kinds. That guidance is now wrong in the direction that
matters, so the version section states 3 (header, /health, ws.hello, the 409
example and the §8 worked example) and replaces the "until then" paragraph with
what a v2 integration actually has to do to upgrade: change the constant it
sends, and nothing else, because nothing that existed in v2 changed shape.

v3.md gains §4.1 for what the bump touches and, more importantly, WHY the
website's boot migration is gated on a marker row: schema.sql is re-run on every
boot and uo_link_config.protocol is admin-editable, so an ungated UPDATE would
silently un-pin an operator running an older sidecar. That is the one piece of
the cutover a reader could not infer from the code being one constant.

Progress tables: 5b done, 6 in review.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:04:00 -05:00
cdea1aa7cd Merge pull request 'docs(link): the player-vendor marketplace (Protocol 3.0 §8)' (#71) from docs/vendor-listing into edge
Reviewed-on: #71
2026-07-29 20:02:31 +00:00
6ce60a82c3 docs(link): the player-vendor marketplace (Protocol 3.0 §8)
Documents order 5b across the four repos, and records what building it changed
about §8 as designed.

- NEW website/MARKETPLACE.md — the operator guide: what the pages must say out
  loud and why, the privacy contract (the player's in-game Vendor Search toggle
  wins, and no admin setting overrides it), the Bridge.cfg knobs and how they
  trade against each other, and the measured sweep costs.
- INTEGRATION.md — catalog entry for vendor.listing / vendor.listing.remove with
  its six consumer gotchas, and the GET /market REST section (the sidecar's only
  paged read, and why it orders by serial rather than shop name).
- BACKEND_DESIGN.md — shard_vendors / shard_vendor_items, the routes, and the
  marketplace search as the only rate-limited public read.
- SHARD_VISIBILITY.md — why the market's fields default to Everyone (the in-game
  gump already shows exactly that set), why location is one setting covering
  four things, and why hiding the owner name without the owner id achieves
  nothing.
- PLAN.md — the amortized round-robin as the one sweep pattern the bridge did not
  previously have, and an update to §7's cliloc note: pushing name resolution to
  the plugin was never an option, because ServUO cannot read a modern client's
  compressed cliloc files either.
- v3.md §8.8 — the four things the build settled differently, chief among them
  that §8.1's FLAT location payload would have made Part A's pre-wired
  market.location rule inert, exactly like the characterName miss one part
  earlier.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:52:14 -05:00
06b4a06baa Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#59) from chore/sync-website-tree into main
Reviewed-on: #59
2026-07-28 15:02:41 +00:00
runic-docs-bot
f2fa6abff7 docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@a3407ae [skip ci] 2026-07-28 06:12:43 +00:00
17 changed files with 3006 additions and 56 deletions

View File

@@ -7,10 +7,11 @@ so they live in one place, independent of either codebase.
## Layout ## Layout
``` ```
website/ docs from the shard website (Node/Express + MariaDB + React/Vite) website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS) link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
android/ docs from the native Android client (Kotlin + Jetpack Compose) android/ docs from the native Android client (Kotlin + Jetpack Compose)
ci/ cross-cutting CI/quality notes installer/ docs for the installer that deploys a shard's bridge components
ci/ cross-cutting CI/quality notes
``` ```
### `website/` ### `website/`
@@ -20,6 +21,10 @@ ci/ cross-cutting CI/quality notes
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec | | [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes | | [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework | | [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
| [CLILOCS.md](website/CLILOCS.md) | UO's id → name table: converting one from your client so items have names |
| [UOFIDDLER.md](website/UOFIDDLER.md) | **Operator runbook** — step-by-step extraction from your own UO client (cliloc table, creature art) |
| [MARKETPLACE.md](website/MARKETPLACE.md) | The player-vendor index: how it is gathered, what it costs, how to tune it |
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) | | [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout | | [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
@@ -46,6 +51,12 @@ ci/ cross-cutting CI/quality notes
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes | | [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes |
| [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout | | [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
### `installer/`
| Doc | What it covers |
|---|---|
| [INSTALL.md](installer/INSTALL.md) | **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 ## Provenance
- `website/*` was extracted from `RunicGateway/website` via `git filter-repo`. - `website/*` was extracted from `RunicGateway/website` via `git filter-repo`.

View File

@@ -1,6 +1,6 @@
# Android App — Plan # Android App — Plan
Status: **M0M7 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: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)). **M11 (Protocol 3.0 shard parity)** is scoped and next: the app sees none of the four shard features v3 added (`ruleset`, `leaderboards`, `market`, `atlas`) and does not consult `GET /public/shard/features`, so it gates shard nav on session role alone while an admin can switch any of those surfaces off or raise its audience — the v3 `edge``main` cutover is held until it lands (§9 M11).** This document is the
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
@@ -631,6 +631,8 @@ not rank).
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` | | News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` | | Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
| Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) | | Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) |
| **Rules / Leaderboards / Market** | everyone, *if the shard publishes them* | `/public/shard/{ruleset,points,market}` (M11) |
| **Atlas** (bestiary) | everyone, *if the shard publishes it* | `/public/atlas/*` (M11) |
| Contact | everyone | `/public/contact` | | Contact | everyone | `/public/contact` |
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) | | **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` | | **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
@@ -639,6 +641,12 @@ not rank).
Guidelines: Guidelines:
- The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries - The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries
with a `minAccess`/`requiredCapability` field, filtered by the session. with a `minAccess`/`requiredCapability` field, filtered by the session.
- **Session role is not the only gate on shard surfaces (M11).** Every shard-derived feature is
*admin-configurable* — it can be switched off or raised to a higher audience rung — so a shard entry
is filtered by the session role **and** by `GET /public/shard/features`, which reports the features
the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app
shows everything: the server gates regardless, and a nav that flickers in on every load is worse than
a link that briefly `403`s.
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public - Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
groups and a "Sign in" affordance. groups and a "Sign in" affordance.
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still - The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
@@ -663,9 +671,15 @@ Guidelines:
### 6.2 Public shard (live) ### 6.2 Public shard (live)
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the - Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
`/public/shard/*` GETs. `/public/shard/*` GETs.
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE, safe kinds only) and patch the - **Live updates** — subscribe to `GET /public/shard/stream` (SSE) and patch the in-memory boards in
in-memory boards in place (champ/guild/city/house/presence update+remove frames). Reconnect with place (champ/guild/city/house/presence update+remove frames). Reconnect with backoff; fall back to
backoff; fall back to poll if SSE drops. poll if SSE drops. What arrives on the stream is **resolved from the caller's audience rung at
subscribe time**, not from a fixed allowlist (Protocol 3.0 §3.6) — the stream request carries the
bearer like every other call, so a signed-in app session sees exactly what the same account sees on
the web.
- **Visibility + the Protocol 3.0 surfaces (M11)** — `GET /public/shard/features` drives which of these
the menu offers; `GET /public/shard/{ruleset,points,points/:system,market,market/meta,market/vendors/:serial}`
and `GET /public/atlas/*` are the new reads. Full contract and traps in §9 M11.
### 6.3 Player self-service & game data (bearer) ### 6.3 Player self-service & game data (bearer)
- **Account** — `GET /player/account`; `PATCH /player/account/username`; - **Account** — `GET /player/account`; `PATCH /player/account/username`;
@@ -675,6 +689,11 @@ Guidelines:
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`, - **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
`/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an `/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
"offline, retry" state (see §7). "offline, retry" state (see §7).
- **The character sheet carries two things the app does not yet read (M11):** the `points` block
(per-character loyalty/points standings, Protocol 3.0 §7.3) and the server-resolved cliloc names on
`equipment[].clilocName` / `titles.rewardResolved` (§8.6). Both are served **ungated** on this route —
a character's own standings are self-service data and do not depend on the public `leaderboards`
feature being visible, which is the behavior the app must mirror rather than re-gate.
- **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no - **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no
item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the
art/asset work on the platform side) and is explicitly out of the first release. art/asset work on the platform side) and is explicitly out of the first release.
@@ -883,10 +902,98 @@ push, and Play (M6M8) follow the designed app.
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
editor, Discord-bot config, uo-link config, OAuth-provider setup. editor, Discord-bot config, uo-link config, OAuth-provider setup.
12. **M11 — Protocol 3.0 shard parity** (post-v1; scoped 2026-07-30). The website's Protocol 3.0 work
added four shard features and, with them, an **admin-configurable visibility framework** the app
knows nothing about. `link/v3.md` §10 deferred the app side as a follow-up; it is now scoped
deliberately, and **the v3 `edge` → `main` cutover is held until both parts land** so web and app
surface the same shard on the same day (decided 2026-07-30).
Neither part is coupled to the cutover *merge order*, which is what makes holding it a schedule
decision rather than a technical one: against a pre-v3 website every new route and
`/public/shard/features` simply `404`s, and each consumer below falls back to exactly today's
behavior. The app declares no protocol version and never talks to the sidecar.
- **Part 1 — the visibility rules + the read-model adds.** The security-shaped half, reviewed on
its own:
- `GET /public/shard/features` → `{ level, features[] }`: the features **this caller** may reach.
A new singleton cache mirrors the web client's (`lib/useShardFeatures.js`): per-viewer but
stable for a session, invalidated on sign-in/out and on a server switch.
- `MenuEntry` gains `feature: String?` beside its existing `access`, so the one declarative menu
(§5) filters on the session role **and** the shard's live feature config. While the lookup is
in flight or has failed, **show everything** — the same deliberate fail-open the web client
takes, because the server gates regardless and a nav that flickers in on every load is worse
than a link that briefly `403`s. The gate is server-side; hiding is presentation.
- **`404` and `403` mean different things here** and neither is a generic error:
`requireFeature` `404`s a *disabled* feature (deliberately not disclosing that it exists) and
`403`s a viewer *below its audience*. Both render "not available on this shard", alongside the
existing `503` = shard offline (§7).
- **The `level` from `/features` is authoritative — do not re-derive the rung from the role.**
The server's ladder is `anonymous → logged_in → player → staff → admin`, where `player` means
*a linked game account* and staff always satisfy `player` (the same superset rule `Menu.kt`
already encodes as `isPlayer || isStaff`).
- **`char.profile.points`** → the "Loyalty & Points" block the web character sheet gained:
`CharProfileDto.points[{system, nameString, points, maxPoints, rank?}]`. Three traps, all of
them things a real shard does and a fake one does not (`v3.md` §7.5): `maxPoints == 0` means
**uncapped** and is the *common* case, so nothing may divide by it; `nameString` is usually
`null` because most systems name themselves with a cliloc, making the humanise-the-`system`-key
path the **primary** one rather than a fallback; and `rank` is absent unless the shard runs
`PointsProfileRank=true` — absent and "unranked" are different answers.
- **Cliloc-resolved names** (`v3.md` §8.6, already live on the website): `EquipmentDto` gains
`name` + `clilocName` and `TitlesDto` gains `rewardResolved`, so equipment stops rendering as a
layer or a bare id. Precedence is `name → clilocName → layer`: a player-given name outranks the
resolved type name, and the server applies the same order. A shard with no cliloc table
configured sends neither field and the sheet renders exactly as it does today.
- `ActorDto` keeps its `acct` / `webId` fields (nullable, so nothing breaks) but its KDoc stops
describing them as available: they are **locked to the admin rung**, always, and stripped from
every response below it.
- **Part 2 — the four new screens**, each hidden by its feature name in the menu:
- **Rules** — `GET /public/shard/ruleset` (`ruleset`). A `null` body means "the shard has not
published its ruleset yet", which is a different state from the feature being disabled. Every
block is optional and omitted when its system is off. **`caps.skill` / `caps.totalSkill` are in
tenths** (1000 = 100.0) and must be converted — the raw number is actively misleading, not
merely unhelpful. Live via the `world.ruleset` frame, which is on the public stream by default.
- **Leaderboards** — `GET /public/shard/points`, `/points/:system` (`leaderboards`). The same
`maxPoints`/`nameString` traps as the profile block. Live via `points.board`.
- **Market** — `GET /public/shard/market` (`q`, `minPrice`, `maxPrice`, `itemId`, `map`, `region`,
`sort`, `limit`, `offset`), `/market/meta` for the filter options + staleness, and
`/market/vendors/:serial` (`market`). Four things this screen must get right: it is the site's
first **rate-limited** public endpoint, so handle `429` the way the contact form does; the
*"prices last refreshed N minutes ago"* banner is **required, not decoration** — the shard
sweeps vendors round-robin, so a listing can legitimately be a full cycle stale and a page
implying live prices sends people to an item that sold twenty minutes ago; a `truncated` shop
must say so; and `location` is a **nested object** that an admin may gate away entirely, which
the vendor screen renders as "hidden by the shard" (a real answer) rather than as blank
coordinates — same for `ownerName` / `ownerSerial`. **The `market` SSE fan-out is off by
default** (a live firehose of vendor inventories would be the site's biggest bandwidth
consumer), so the screen is a plain paginated read and must never depend on live frames.
- **Atlas** — `GET /public/atlas/{creatures,creatures/:slug,regions,landmarks,champions,meta}`
(`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard
*content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like
`/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar.
Three traps. Two are units/naming, from `v3.md` §6.3: respawn delays are **seconds**
throughout, and `points` is a *count* on the search route while `spawners` is the *list* on
the detail route. The third is a **shape**: `places` is a list of
`{facet, label, spawners, maxAlive}` **objects**, not of place-name strings — it is the
aggregate the screen exists to show ("Shrines, Isamu-Jima, Yew"), it arrives only on the
detail route, and typing it `List<String>` makes that whole route fail to decode while the
request itself returns `200`.
- **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`)
against a local website on the cutover branch, per
[`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with
**every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface
instead of erroring on it. Unit tests cover the menu filter (role × feature set), the
`404`/`403`/`503` mapping, and DTO decode for each new shape. **Decode tests must feed real
captured JSON**, not DTOs built in Kotlin: the fakes under `data/api/fake/` construct objects
directly, so they can never catch a wire/type mismatch — which is how the `places` shape above
shipped past a green suite.
- **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard
Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot
config, uo-link config and OAuth-provider setup.
### Deferred (not a milestone) ### Deferred (not a milestone)
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now - **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone
**deferred with no app work scheduled**. The website's router refactor is being done in place with (2026-07-22), now **deferred with no app work scheduled**. The website's router refactor is being done in place with
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…` every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
to diverge from web, the migration comes back — starting from a one-line alias mount on the server, to diverge from web, the migration comes back — starting from a one-line alias mount on the server,

View File

@@ -85,6 +85,7 @@ android-app/
│ │ │ │ │ │ │ ├── PlayerShardDto.kt │ │ │ │ │ │ │ ├── PlayerShardDto.kt
│ │ │ │ │ │ │ ├── PostDto.kt │ │ │ │ │ │ │ ├── PostDto.kt
│ │ │ │ │ │ │ ├── PublicDto.kt │ │ │ │ │ │ │ ├── PublicDto.kt
│ │ │ │ │ │ │ ├── ShardContentDto.kt
│ │ │ │ │ │ │ ├── ShardDto.kt │ │ │ │ │ │ │ ├── ShardDto.kt
│ │ │ │ │ │ │ ├── SsoDto.kt │ │ │ │ │ │ │ ├── SsoDto.kt
│ │ │ │ │ │ │ └── WikiDto.kt │ │ │ │ │ │ │ └── WikiDto.kt
@@ -106,6 +107,7 @@ android-app/
│ │ │ │ │ ├── NotificationsRepository.kt │ │ │ │ │ ├── NotificationsRepository.kt
│ │ │ │ │ ├── PlayerShardRepository.kt │ │ │ │ │ ├── PlayerShardRepository.kt
│ │ │ │ │ ├── SettingsRepository.kt │ │ │ │ │ ├── SettingsRepository.kt
│ │ │ │ │ ├── ShardFeaturesRepository.kt
│ │ │ │ │ ├── ShardRepository.kt │ │ │ │ │ ├── ShardRepository.kt
│ │ │ │ │ └── WikiRepository.kt │ │ │ │ │ └── WikiRepository.kt
│ │ │ │ ├── di/ │ │ │ │ ├── di/
@@ -171,6 +173,8 @@ android-app/
│ │ │ │ │ ├── session/ │ │ │ │ │ ├── session/
│ │ │ │ │ │ └── SessionViewModel.kt │ │ │ │ │ │ └── SessionViewModel.kt
│ │ │ │ │ ├── shard/ │ │ │ │ │ ├── shard/
│ │ │ │ │ │ ├── AtlasScreen.kt
│ │ │ │ │ │ ├── AtlasViewModel.kt
│ │ │ │ │ │ ├── ChampsScreen.kt │ │ │ │ │ │ ├── ChampsScreen.kt
│ │ │ │ │ │ ├── ChampsViewModel.kt │ │ │ │ │ │ ├── ChampsViewModel.kt
│ │ │ │ │ │ ├── FrameFields.kt │ │ │ │ │ │ ├── FrameFields.kt
@@ -180,7 +184,13 @@ android-app/
│ │ │ │ │ │ ├── GuildsViewModel.kt │ │ │ │ │ │ ├── GuildsViewModel.kt
│ │ │ │ │ │ ├── HousesScreen.kt │ │ │ │ │ │ ├── HousesScreen.kt
│ │ │ │ │ │ ├── HousesViewModel.kt │ │ │ │ │ │ ├── HousesViewModel.kt
│ │ │ │ │ │ ├── LeaderboardsScreen.kt
│ │ │ │ │ │ ├── LeaderboardsViewModel.kt
│ │ │ │ │ │ ├── LiveBoard.kt │ │ │ │ │ │ ├── LiveBoard.kt
│ │ │ │ │ │ ├── MarketScreen.kt
│ │ │ │ │ │ ├── MarketViewModel.kt
│ │ │ │ │ │ ├── RulesScreen.kt
│ │ │ │ │ │ ├── RulesViewModel.kt
│ │ │ │ │ │ ├── ShardComponents.kt │ │ │ │ │ │ ├── ShardComponents.kt
│ │ │ │ │ │ ├── ShardEventText.kt │ │ │ │ │ │ ├── ShardEventText.kt
│ │ │ │ │ │ ├── ShardScreen.kt │ │ │ │ │ │ ├── ShardScreen.kt
@@ -284,6 +294,7 @@ android-app/
│ │ │ │ │ ├── PlayerShardDtoTest.kt │ │ │ │ │ ├── PlayerShardDtoTest.kt
│ │ │ │ │ ├── PublicDtoTest.kt │ │ │ │ │ ├── PublicDtoTest.kt
│ │ │ │ │ ├── ShardBoardDtoTest.kt │ │ │ │ │ ├── ShardBoardDtoTest.kt
│ │ │ │ │ ├── ShardContentDtoTest.kt
│ │ │ │ │ ├── ShardDtoTest.kt │ │ │ │ │ ├── ShardDtoTest.kt
│ │ │ │ │ ├── SsoDtoTest.kt │ │ │ │ │ ├── SsoDtoTest.kt
│ │ │ │ │ └── WikiDtoTest.kt │ │ │ │ │ └── WikiDtoTest.kt
@@ -294,7 +305,8 @@ android-app/
│ │ │ │ └── FakeShardStream.kt │ │ │ │ └── FakeShardStream.kt
│ │ │ └── repository/ │ │ │ └── repository/
│ │ │ ├── AccountTrustedDevicesTest.kt │ │ │ ├── AccountTrustedDevicesTest.kt
│ │ │ ── ConnectionVersionGuardTest.kt │ │ │ ── ConnectionVersionGuardTest.kt
│ │ │ └── ShardFeaturesRepositoryTest.kt
│ │ ├── ui/ │ │ ├── ui/
│ │ │ ├── admin/ │ │ │ ├── admin/
│ │ │ │ ├── AdminContentViewModelTest.kt │ │ │ │ ├── AdminContentViewModelTest.kt
@@ -304,7 +316,8 @@ android-app/
│ │ │ ├── contact/ │ │ │ ├── contact/
│ │ │ │ └── ContactViewModelTest.kt │ │ │ │ └── ContactViewModelTest.kt
│ │ │ ├── navigation/ │ │ │ ├── navigation/
│ │ │ │ ── MenuAccessTest.kt │ │ │ │ ── MenuAccessTest.kt
│ │ │ │ └── MenuFeatureGatingTest.kt
│ │ │ ├── notifications/ │ │ │ ├── notifications/
│ │ │ │ └── NotificationRoutingTest.kt │ │ │ │ └── NotificationRoutingTest.kt
│ │ │ ├── player/ │ │ │ ├── player/
@@ -315,6 +328,8 @@ android-app/
│ │ │ │ ├── FrameFieldsTest.kt │ │ │ │ ├── FrameFieldsTest.kt
│ │ │ │ ├── LiveBoardTest.kt │ │ │ │ ├── LiveBoardTest.kt
│ │ │ │ ├── ShardBoardViewModelTest.kt │ │ │ │ ├── ShardBoardViewModelTest.kt
│ │ │ │ ├── ShardContentHelpersTest.kt
│ │ │ │ ├── ShardContentViewModelTest.kt
│ │ │ │ └── ShardEventTextTest.kt │ │ │ │ └── ShardEventTextTest.kt
│ │ │ ├── theme/ │ │ │ ├── theme/
│ │ │ │ └── BrandColorTest.kt │ │ │ │ └── BrandColorTest.kt

827
installer/INSTALL.md Normal file
View File

@@ -0,0 +1,827 @@
# 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.**
>
> Phases 1 to 3 are built and live on the installer repo's `edge` branch: the installer core
> (bundle resolution, ServUO detection, the overlay sync, `install.json`), the sidecar half (the
> binary, its config, its service, and the token handoff), and the
> [patch tier](#4-the-patch-tier-optional). `install` is therefore complete; what is still missing
> is [`doctor`, `update` and `uninstall`](#7-day-two), each of which reports which phase it arrives
> in rather than failing as though you had mistyped it. The first release is being cut from that
> branch now.
>
> 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 cached copies of the patches and of every file the patch tier edited | Written by the installer |
```
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
(1) overlay (2) binary + service (yours)
```
The shard **dials out**; it never listens for the website and is never reachable from the internet.
Only the sidecar is exposed, and only to your website.
### What it deliberately does not do
- **It never restarts or manages ServUO.** Your shard keeps starting the way it always has. The
installer refuses to run while ServUO is up, and tells you when a restart is required.
- **It never deletes anything from your server tree.** The overlay sync only adds and overwrites.
- **It never contacts your website.** It prints four values for you to paste into Admin → Shard.
- **It never edits stock ServUO files without asking.** That is the opt-in
[patch tier](#4-the-patch-tier-optional), and skipping it still leaves you with a working bridge.
---
## Before you begin
| Requirement | Detail |
|---|---|
| A working ServUO install | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
| ServUO **57.4** *(patch tier only)* | **57.4 is the only supported version.** The base install works on any reasonably current ServUO. The patch tier is written and tested against stock 57.4; on any other version it is **unsupported and untested** — you can still choose to run it, behind an explicit opt-in, and it applies only where the exact lines it patches are unchanged. See [§4](#4-the-patch-tier-optional). |
| ServUO **stopped** | `ServUO.exe` holds a lock on `Scripts.dll` and writes `Saves/` on exit. The installer refuses to deploy under a running shard. |
| Administrator / root | It writes into system directories and registers a service. |
| Outbound HTTPS | To `gitea.whitlocktech.com`, to fetch the bundle and the two artifacts. Nothing inbound is needed, and no Gitea account or git client is required. |
| The sidecar on the **same host** as the shard | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — the loopback socket *is* the trust boundary for inbound commands. |
| Admin access to your Runic Gateway site | The last step is pasting four values into Admin → Shard. |
**Back up first.** The overlay overwrites `Scripts/Scripts.csproj` (a stock file), and the patch
tier edits stock sources. A copy of `Scripts/` and `Config/` before you start costs nothing.
---
## 1. Download and verify
Releases are **unsigned**. There is no code-signing certificate and no notarization, so the
`SHA256SUMS` file published beside every artifact is the whole trust anchor — check it.
Download the installer for your OS, plus `SHA256SUMS`, from the
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases):
```
runicgateway-installer-linux-x86_64
runicgateway-installer-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. On a ServUO that is not 57.4 the
prompt defaults to **no** and carries an unsupported-version warning you have to answer past.
See [§4](#4-the-patch-tier-optional).
3. **The hostname your website should use to reach this machine** — used only to compose the two
URLs it prints at the end. The sidecar's bind address is frequently `127.0.0.1` or `0.0.0.0`,
neither of which is something to hand to a website.
4. **Your site's URL** — used only to print a clickable link to its Admin → Shard page. The
installer never contacts your website.
### An illustrative run
```
Runic Gateway installer — bundle 2026.08.04 (protocol 3)
ServUO /opt/ServUO (57.4)
Shard process not running
Overlay servuo-plugins v0.1.1 protocol 3
Sidecar uo-link v1.1.0 protocol 3
✓ overlay tarball verified sha256 75dc6d6c…
✓ sidecar binary verified sha256 27d491ef…
Overlay sync
ADD Config/Bridge.cfg
ADD Scripts/Custom/Bridge/*.cs (22 files)
CHANGE Scripts/Scripts.csproj
deployed. add=23 change=1 unchanged=0 kept=0
Patch tier not selected
Without it: no vendor.sale events, no in-game moderation audit forwarding.
uo-link sidecar
binary /usr/bin/runicgateway-link install
✓ sidecar binary verified sha256 27d491ef…
config /etc/runicgateway/sidecar.toml created
database /var/lib/runicgateway/uo-link.db
listening on shard 127.0.0.1:7788 website 127.0.0.1:8080
service runicgateway-link.service active, enabled
running as runicgateway
Recorded /etc/runicgateway/install.json
Scripts.csproj changed — ServUO rebuilds Scripts.dll on next boot.
Start your shard when ready; the installer does not start it for you.
```
Then the [token handoff](#5-connect-the-website).
### Commands and flags
The surface this guide specifies. Each command is idempotent: a second run with nothing new to do
reports "unchanged" and writes nothing.
| Command | What it does |
|---|---|
| `install` | The full run above. |
| `doctor` | Diagnoses an existing deployment end to end — see [§7](#7-day-two). |
| `update` | Re-resolves the bundle; updates the sidecar (replace + restart) and the overlay (re-sync + tell you to restart ServUO). |
| `uninstall` | Removes only what the installer exclusively owns; prints — never performs — anything inside your ServUO tree. |
| Flag | Applies to | Meaning |
|---|---|---|
| `--verify` | `install`, `update` | Dry run. Report every change that would be made; write nothing. |
| `--servuo <path>` | `install`, `doctor`, `update` | Name the ServUO root instead of detecting or prompting. |
| `--bundle <tag>` | `install`, `update` | Pin an exact published bundle instead of the current one. |
| `--patches` / `--no-patches` | `install` | Decide the patch tier non-interactively. `--patches` never loosens the region check: patches whose target lines are not stock are reported for you to apply by hand, not forced. |
| `--patches-unsupported-servuo` | `install` | Required *in addition to* `--patches` to run the patch tier on a ServUO that is not 57.4. Unsupported and untested — see [§4](#4-the-patch-tier-optional). Ignored on 57.4. |
| `--host <name>` | `install` | The hostname to print in the website URLs. |
| `--site-url <url>` | `install` | Your site's base URL, for the Admin → Shard link. |
| `--yes` | all | Assume the default answer to every prompt. Combine with the flags above for an unattended run. |
| `--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 the patches the tier evaluated, so `uninstall` can print the exact hunks long after the release tarball is gone, and a refused one is still on hand to apply yourself |
| `/etc/runicgateway/patches/originals/` | Each file the patch tier edited, exactly as it was beforehand — a revert you can verify rather than reconstruct |
| `/var/lib/runicgateway/uo-link.db` | The sidecar's SQLite store (event history, cached profiles, link map) |
| `/etc/systemd/system/runicgateway-link.service` | The service unit, running as a dedicated user |
**Windows**
| Path | What |
|---|---|
| `%ProgramFiles%\RunicGateway\uo-link-sidecar.exe` | The sidecar binary |
| `%ProgramData%\RunicGateway\sidecar.toml` | Sidecar config, including the auth token |
| `%ProgramData%\RunicGateway\install.json` | As above |
| `%ProgramData%\RunicGateway\patches\` | As above |
| `%ProgramData%\RunicGateway\patches\originals\` | As above |
| `%ProgramData%\RunicGateway\uo-link.db` | The sidecar's SQLite store |
| Service `RunicGatewayLink` | Automatic start, restart on failure, running as `NT SERVICE\RunicGatewayLink` |
**Inside your ServUO tree** (added by the overlay sync — 24 files):
```
Config/Bridge.cfg every bridge setting, heavily commented
Scripts/Custom/Bridge/*.cs 22 files: the plugin itself
Scripts/Scripts.csproj OVERWRITES a stock file (see below)
```
**Both service definitions pin the config path**, because the sidecar's own default is relative to
its working directory — and a service manager's working directory is not somewhere you want a
database or a config file. On Windows it can be `%SystemRoot%\System32` or, under
`C:\Program Files\`, a silently redirected VirtualStore copy.
How the *database* path is pinned differs by platform, and that is deliberate:
| | Config | Database |
|---|---|---|
| **Linux** | `Environment=UOLINK_CONFIG=` in the unit | `Environment=UOLINK_DB_PATH=` in the unit — `/etc` and `/var/lib` are different directories, so both need naming |
| **Windows** | `--config` inside the service's own `binPath` | nothing to set: a relative `[store] path` resolves against the config's directory, which *is* `%ProgramData%\RunicGateway` |
The Windows service would otherwise need a **machine-wide** environment variable — `sc.exe` has no
per-service one — which every process on the host inherits and which outlives an uninstall.
**Both run as a dedicated, unprivileged account.** Linux gets a `runicgateway` system user; Windows
gets a virtual service account, `NT SERVICE\RunicGatewayLink`, which Windows creates as part of
registering the service and which has no password. Neither runs as root or `LocalSystem`.
**`sidecar.toml` is locked down, because it holds your auth token.** Neither default location
protects it on its own — `/etc` is world-readable, and `%ProgramData%` grants `Users` read access by
inheritance — so the installer sets the permissions itself: `chmod 600` plus `chown` to the service
user on Linux, and an explicit ACL of SYSTEM, Administrators and the service account on Windows.
> **`Scripts.csproj` is overwritten deliberately.** The stock file omits `Scripts/Custom/`, so the
> plugin would sit in the tree and never compile — and ServUO would not tell you, because it
> ignores the script build's exit code and silently reloads the previous `Scripts.dll`. That
> failure mode is the reason [§6](#6-start-servuo-and-verify) exists.
---
## 4. The patch tier (optional)
Most of the plugin ships as **added** files, which is why the base install is a safe file copy. Two
features cannot: they need edits to stock ServUO sources, because the events they depend on do not
exist.
| Patch | Edits | Gives you | Rebuild needed |
|---|---|---|---|
| `playervendor-sale-eventsink.patch` + `playervendor-sale-gump.patch` | `Server/EventSink.cs`, `Scripts/Gumps/PlayerVendorGumps.cs` | `vendor.sale` events — player-vendor purchases with buyer, owner, price and commission, which is what cheat detection needs | **Core solution rebuild** (`dotnet build ServUO.sln`) — the dynamic script build is not enough |
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | In-game moderation actions (`[ban`, `[kick`, `[bcast`) forwarded to the website's moderation log as `admin.audit` | Script build only — a shard restart is enough |
Each patch has a companion `.cs` file that is copied **only after** its patch applies, because it
references symbols the patch introduces. That is why they are not in the base overlay: shipping them
unconditionally would break the build on every unpatched install.
How the installer handles it:
- **Opt-in.** The base install completes without it, and declining is a supported outcome, not a
degraded one.
- **Dry-run first, always.** Every patch is checked before anything is applied, and reported per
patch. Most real shards are hand-modified; a patch that does not apply is expected, not alarming.
- **A modified file is not automatically a refusal.** These patches touch three small regions of
three large files. If you have edited `Logging.cs` somewhere else entirely, the installer says so
and still applies the patch — it checks whether *the lines the patch edits* are still stock, not
whether the whole file is. It applies only where the surrounding lines match the patch exactly and
appear exactly once; anything less and it stops and hands you the hunk to apply by hand. It never
force-fits a patch by loosening the match.
- **All or nothing per feature.** The two vendor-sale patches are one unit and are applied together
or not at all — and within a patch, if one hunk cannot be placed safely, none are. A patch that
could have been placed but was held back by its sibling says exactly that; it is never reported as
applied.
- **It does not need `git`, and does not use it.** The matching and the writing are the installer's
own, which is why it can place a patch on a shard where `git apply` refuses — the shipped patches
and their target files do not all use the same line endings, and that alone defeats `git apply`.
Nothing outside a patched region is touched, down to the byte, and inserted lines take your file's
own line ending.
- **Your ServUO version is reported, not decisive** — but see the warning below before running this
on anything other than 57.4.
- **Recorded, and the `.patch` files cached**, so re-runs stay idempotent and `uninstall` can print
the exact hunks to revert — along with how each was applied, since a patch placed into a file you
had already modified is one to look at more carefully when reverting. Patches that were *not*
applied are cached too, because that is the copy the run tells you to apply by hand.
- **A copy of every file it edits is kept, exactly as it was beforehand**, under
`patches/originals/` in the installer's own directory — not in your ServUO tree. It is written
before the first edit and never overwritten, so however many times you re-run `install`, it stays
the version from before the tier ever touched the file. That is what lets you verify a revert
rather than reconstruct one.
- **Re-running is safe.** A patch already in place is recognised and left alone, and the record
keeps the way it originally landed rather than relabelling it.
### ⚠ On any ServUO that is not 57.4: unsupported, untested, no guarantees
> **Runic Gateway is designed, built and tested against stock ServUO 57.4.** That is the only
> supported version.
>
> On any other version — a newer release, an older one, or a fork — the patch tier is
> **UNSUPPORTED, UNTESTED, and NOT GUARANTEED TO WORK.** You may run it. If you do, you are on your
> own: it is not covered by support, and a bad outcome may not show up until your shard is live,
> because ServUO's script build reports success even when it failed and quietly keeps running the
> previous `Scripts.dll`.
>
> The installer will still refuse to place a patch anywhere the exact lines it edits have changed —
> but matching text is not the same as matching behaviour. A hunk can land correctly and still be
> wrong for a tree that has diverged around it.
>
> **Back up your ServUO tree first, and verify your shard boots and compiles afterwards.**
Because of that, on a non-57.4 tree the tier is off by default and takes a deliberate yes:
- the interactive prompt defaults to **no** and prints the warning above;
- `--patches` on its own is **not** enough — an unattended run must also pass
`--patches-unsupported-servuo`;
- the choice is recorded, and `doctor` keeps showing an unsupported-version row for the life of the
install — so whoever looks after this shard next can see it without being told.
A run where the tier is selected on a shard that has been worked on looks like this:
```
Patch tier 2 of 3 applied
✓ playervendor-sale-eventsink Server/EventSink.cs
stock file — applied at line 171, 1521, 1771, 2416
✓ playervendor-sale-gump Scripts/Gumps/PlayerVendorGumps.cs
file modified, patched region stock — applied at line 95
✗ commandlogging-event Scripts/Commands/Logging.cs
patched region has been modified (hunk 1) — not applied
apply this by hand, then re-run install to record it:
/etc/runicgateway/patches/commandlogging-event.patch
⚠ Server/EventSink.cs — a CORE ServUO file was patched. Rebuild the solution:
dotnet build ServUO.sln
A shard restart is not enough; ServUO's dynamic script build does not rebuild the core, and it
will not tell you so.
Not applied, so you do not get: no in-game moderation audit forwarding.
Everything else works. Apply the hunks by hand if you want them, then re-run install to record it.
```
The line numbers are where each hunk was actually found in *your* file, not where it sits in stock
ServUO — they differ as soon as anything above the region has been edited, and yours is the one to
go to.
If it is skipped or fails, you lose exactly two things — **`vendor.sale` events** and **in-game
moderation audit forwarding**. Everything else works. You can apply the patches later by hand (see
`patches/README.md` in the tarball) and re-run `install` to record it.
---
## 5. Connect the website
The installer ends a successful run by printing the one manual step it cannot do for you:
```
Runic Gateway is installed.
One manual step remains — connect the website to this sidecar:
Base URL http://shard.example.com:8080
WebSocket URL ws://shard.example.com:8080/ws
Protocol version 3
Auth token 4f9c… (also in /etc/runicgateway/sidecar.toml)
Paste these into Admin → Shard on your Runic Gateway site:
https://your-site.example/admin/shard
The token is write-only once saved — the site will never show it back to you.
```
Every value there comes from asking the installed sidecar itself (`--print-config`), not from a
log file or a guess, so it cannot drift from what the service actually runs.
On your site, sign in as an administrator and open **Admin → Shard (uo-link)**:
| Field on the page | Paste |
|---|---|
| Enable the shard integration | ✔ on |
| Base URL (REST) | the **Base URL** line |
| WebSocket URL (feed) | the **WebSocket URL** line |
| Auth token | the **Auth token** line |
| Protocol | the **Protocol version** line (`3`) |
Saving restarts the site's ingest client, so the change takes effect immediately. The token is
AES-GCM encrypted at rest and **never returned to any client** — losing it means reading it back
from `sidecar.toml` on the shard host, not from the website.
### If your website is on a different machine
The sidecar binds `127.0.0.1:8080` by default, which is reachable only from the shard host. If your
website runs elsewhere, you must widen the bind — and then narrow the access:
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` (or a specific LAN address) and restart the
service.
2. **Firewall port 8080 to your website's address only.** The auth token is always on, but it
travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse proxy or a
VPN/WireGuard link, and give the website the proxied `https://` / `wss://` URLs.
The `[shard] bind` line is a different matter: leave it on `127.0.0.1:7788`. That socket accepts
*inbound commands* to the game, and being loopback-only is what makes that safe.
---
## 6. Start ServUO and verify
Start your shard the way you always do. Then confirm the bridge is actually live — not merely
installed. **A successful file copy is not a working bridge**: ServUO shells out to `dotnet build`,
prints the output, ignores the exit code, and reloads the existing `Scripts.dll`, so a broken script
build looks exactly like a clean boot.
**a. Watch the boot output.** You want to see the build succeed *and* the bridge announce itself:
```
Core: Compiling scripts...
Build succeeded.
[Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 sweeps(stat=30s decay=60s …
```
If you scrolled past it, force the question:
```bash
cd <servuo root>
dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors
```
**b. Ask the shard, in game.** As an Administrator:
```
[bridge status
```
It reports the config plus `connected=True depth=0 sent=… dropped=0 …`. `connected=False` means the
shard cannot reach the sidecar; `dropped` climbing means the sidecar is wedged and the shard is
shedding events rather than stalling — which it is designed to do. `[bridge reload` re-reads
`Bridge.cfg` without a restart; `[bridge sweepnow` forces one pass of every stream.
**c. Ask the sidecar.** `/health` needs no auth, so it is safe to curl from a terminal:
```bash
curl -s http://127.0.0.1:8080/health
{"status":"ok","protocol":3,"plugin_connected":true,"database":"ok","uptime":"2m","last_event":"2026-08-04T18:22:10.412Z"}
```
`plugin_connected: true` is the one that matters — it is the only value in this whole guide that
distinguishes "files copied" from "the bridge works".
**d. Ask the website.** The public site should stop showing the shard as offline, and live events
should appear on the admin dashboard within seconds.
---
## 7. Day two
### `runicgateway doctor`
The command that makes this supportable. Run it before asking anyone for help — its output is the
first thing a maintainer will want.
```
✓ ServUO found /opt/ServUO (57.4)
✓ Overlay in sync 24 files, all hashes match install.json
⚠ Patch tier 1 of 3 applied (region-match) — 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 — with how each landed, since one placed into a file you had already modified is worth a closer look. The pre-patch copy kept under `patches/originals/` is there to diff against. |
The report is also written to a file, so it survives the scrollback.
---
## Troubleshooting
| Symptom | Cause and fix |
|---|---|
| **Windows asks for Administrator as soon as you launch it** | Expected, and it needs Administrator anyway. Windows applies *installer detection* to unsigned executables whose file name contains `install` and elevates them before the program starts. Run it from an already-elevated PowerShell and you will not see the prompt. |
| **"ServUO is running — stop it before installing"** | Correct, and not overridable. `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit; deploying underneath it corrupts one or both. Stop the shard, install, start it again. |
| Shard boots clean but nothing reaches the site | The classic silent failure: ServUO ignores the script build's exit code and reloaded a **stale `Scripts.dll`**. Run `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` and read the errors it prints. |
| `[bridge status` says `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service is running, and that `[shard] bind` in `sidecar.toml` matches `Host`/`Port` in `Bridge.cfg`. |
| `[bridge` is not a command | The plugin did not compile, or `Bridge.cfg` has the bridge disabled. See the row above. |
| Website says the shard is offline; `/health` is fine locally | The website cannot reach port 8080 — bind address or firewall. See [§5](#if-your-website-is-on-a-different-machine). Note that the site is *designed* to render normally with the shard offline, so this fails quietly by design. |
| Website logs `409` from the sidecar | Protocol mismatch: the number in Admin → Shard does not match the sidecar's. The sidecar rejects rather than mis-parsing. Set the field to what `/health` reports (`protocol`). If the *sidecar* and *overlay* disagree, you have a hand-assembled pair — reinstall from a bundle. |
| `401` from the sidecar | Wrong or missing auth token. Read the live one back with `uo-link-sidecar --print-config --config <path>`; do not retype it from a screenshot. |
| **"service NOT REGISTERED" at the end of an otherwise successful run** | The host has no service manager the installer can drive — most often no systemd (a container, or a distro that never had it), or the `runicgateway` user could not be created. The binary and config *are* installed; the run prints the exact unit and commands to finish by hand. It never falls back to running the service as root or `LocalSystem`. |
| Service registered but stops immediately | It cannot read its config. On Windows check that `sc qc RunicGatewayLink` shows `--config` in `BINARY_PATH_NAME` and that `NT SERVICE\RunicGatewayLink` has read access to `sidecar.toml`; on Linux check the `runicgateway` user can read `/etc/runicgateway/sidecar.toml` and write `/var/lib/runicgateway/`. |
| 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 that some core files are CRLF while others are LF: use `patch --binary`.
**If `git apply` refuses a patch whose target region is visibly untouched, line endings are the
usual cause** — the `.patch` files and their targets do not all use the same ones, and `git apply`
compares them literally. The installer's own tier normalizes line endings and trailing whitespace
for the *comparison* while writing back your file's own endings, which is why it can place patches
`git apply` rejects. Running the installer is the easier route here.
### A3. Install the sidecar
```bash
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing
sudo install -m 0755 uo-link-sidecar-linux-x86_64 /usr/bin/runicgateway-link
sudo mkdir -p /etc/runicgateway /var/lib/runicgateway
```
Provision the config and read back the token in one step. `--print-config` writes the file if it is
missing, generates the auth token if there is none, and prints the resolved settings as JSON — it is
the supported alternative to scraping the startup log:
```bash
sudo UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db \
/usr/bin/runicgateway-link --print-config --config /etc/runicgateway/sidecar.toml
```
```json
{
"component": "uo-link-sidecar",
"version": "1.1.0",
"protocol": 3,
"config_path": "/etc/runicgateway/sidecar.toml",
"config_created": true,
"token_generated": true,
"shard": { "bind": "127.0.0.1:7788" },
"web": {
"bind": "127.0.0.1:8080",
"ws_path": "/ws",
"auth_required": true,
"auth_token": "4f9c…"
},
"store": { "path": "/var/lib/runicgateway/uo-link.db" }
}
```
`config_created` and `token_generated` tell you whether *this* run provisioned anything — the values
alone cannot distinguish a fresh install from a re-read. **The output contains the auth token in
clear text**: keep it out of shell transcripts, logs and support bundles.
### A4. Register the service
**Linux**`/etc/systemd/system/runicgateway-link.service`:
```ini
[Unit]
Description=Runic Gateway uo-link sidecar
After=network.target
[Service]
Type=simple
User=runicgateway
Environment=UOLINK_CONFIG=/etc/runicgateway/sidecar.toml
Environment=UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db
ExecStart=/usr/bin/runicgateway-link
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo useradd --system --no-create-home runicgateway
sudo chown -R runicgateway /var/lib/runicgateway /etc/runicgateway
sudo systemctl daemon-reload
sudo systemctl enable --now runicgateway-link
systemctl status runicgateway-link
```
**Windows** (elevated PowerShell) — binary under `%ProgramFiles%`, data under `%ProgramData%`:
```powershell
New-Item -ItemType Directory -Force "$env:ProgramFiles\RunicGateway", "$env:ProgramData\RunicGateway" | Out-Null
Copy-Item .\uo-link-sidecar-windows-x86_64.exe "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe"
& "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe" --print-config --config "$env:ProgramData\RunicGateway\sidecar.toml"
# The config file now holds your auth token. Lock it down before anything else can read it:
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r '*S-1-5-32-544:(F)'
# binPath carries the config path. The single quotes matter: the value itself contains the double
# quotes the service manager needs around a path with spaces in it.
sc.exe create RunicGatewayLink `
binPath= '"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --config "C:\ProgramData\RunicGateway\sidecar.toml"' `
obj= 'NT SERVICE\RunicGatewayLink' start= auto
sc.exe failure RunicGatewayLink reset= 86400 actions= restart/5000
# The service account exists only once sc create has created it, so its grants come after:
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /grant 'NT SERVICE\RunicGatewayLink:(R)'
icacls "$env:ProgramData\RunicGateway" /grant 'NT SERVICE\RunicGatewayLink:(OI)(CI)M'
sc.exe start RunicGatewayLink
```
Three things there are easy to get wrong:
- **The config path goes in `binPath`, not in a machine environment variable.** `sc.exe` has no
per-service environment, and a machine-wide `UOLINK_CONFIG` would be inherited by every process on
the host and survive an uninstall. Never leave the config path to the default — it is relative to
the service's working directory, which for a service is `%SystemRoot%\System32`.
- **The database needs no pinning here.** A relative `[store] path` resolves against the directory
holding `sidecar.toml`, which is already `%ProgramData%\RunicGateway`.
- **`obj=` is what keeps this off `LocalSystem`.** `NT SERVICE\RunicGatewayLink` is a virtual
service account: Windows creates it with the service, it has no password, and it exists only for
this service. Omit `obj=` and you get the most privileged local identity there is, for a process
listening on two TCP ports.
### 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 |

1048
installer/PLAN.md Normal file

File diff suppressed because it is too large Load Diff

37
installer/PROJECT_TREE.md Normal file
View File

@@ -0,0 +1,37 @@
# Runic Gateway installer — Project Tree
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
> the [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) repository, which
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
> by hand — changes will be overwritten by the next sync.
A snapshot of the tracked files in the repository (build output, dependencies, and other
git-ignored paths are excluded).
```text
installer/
├── .gitea/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── config.yaml
│ │ └── feature_request.md
│ ├── scripts/
│ │ └── gen_tree.py
│ ├── workflows/
│ │ ├── bundle.yml
│ │ ├── pr-checks.yml
│ │ ├── release.yml
│ │ └── sync-project-tree.yml
│ └── PULL_REQUEST_TEMPLATE.md
├── bundles/
│ ├── bundle-2026.08.04.json
│ ├── current.json
│ └── README.md
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE.md
├── README.md
└── SECURITY.md
```

View File

@@ -23,7 +23,31 @@ Every route **except `GET /health`** requires the shared token from `sidecar.tom
| REST | `X-Api-Key: <token>` | | REST | `X-Api-Key: <token>` |
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) | | WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting. Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run; rotate by editing `sidecar.toml` and restarting.
To read it back afterwards, ask the sidecar rather than hunting through the startup log or the TOML:
```console
$ uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
{
"component": "uo-link-sidecar",
"config_created": false,
"config_path": "/etc/runicgateway/sidecar.toml",
"protocol": 3,
"shard": { "bind": "127.0.0.1:7788" },
"store": { "path": "/var/lib/runicgateway/uo-link.db" },
"token_generated": false,
"version": "0.1.0",
"web": {
"auth_required": true,
"auth_token": "c0f04ace…",
"bind": "127.0.0.1:8080",
"ws_path": "/ws"
}
}
```
That is the same set of values Admin → Shard asks for — base URL and WS URL are `web.bind` (substituting a reachable host if it is `0.0.0.0`) plus `web.ws_path`. The output **contains the token in clear text**, so treat it as a secret: it belongs in a terminal, not in a log or a CI artifact. `--print-config` also performs first-run setup, writing the config file and generating a token if there is none, and reports whether it did via `config_created` / `token_generated`.
--- ---
@@ -31,31 +55,31 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`.
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
- Every response carries an **`X-UOLink-Version: 2`** header. - Every response carries an **`X-UOLink-Version: 3`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`. - `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`.
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: - **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json ```json
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" } { "error": "protocol version mismatch", "sidecar_protocol": 3, "client_protocol": "2" }
``` ```
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. **v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
**v3 (Protocol 3.0) is being built and the version has not been bumped yet.** It is defined as *adds **v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main` the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3
the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the
repeatedly. version number alone tells you what is available.
Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints. **Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a
That is safe in the direction that matters: event kinds are additive, and a client that ignores client still declaring `2` gets a 409 on every protected route and, on the WebSocket, a closed
unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not** connection on the `ws.hello` mismatch. So update the pinned version at the same time you deploy the
do is infer feature availability from the version number during this window — probe the endpoint, or v3 sidecar. Nothing that existed in v2 changed shape, so that is the whole migration — the website
treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no does it with a one-shot boot migration of its `uo_link_config.protocol` row ([`v3.md`](v3.md) §4.1);
feature-negotiation array**: v3 implies all three kinds. a third-party client changes the constant it sends.
--- ---
@@ -68,7 +92,7 @@ GET /health (no auth)
```json ```json
{ {
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded" "status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
"protocol": 1, "protocol": 3,
"plugin_connected": true, // is the shard link up right now? "plugin_connected": true, // is the shard link up right now?
"database": "ok", // "ok" | "error" "database": "ok", // "ok" | "error"
"uptime": "3d 12h", "uptime": "3d 12h",
@@ -91,7 +115,7 @@ A push-only stream of game events as they happen. You do **not** send commands o
**On connect**, the first frame is: **On connect**, the first frame is:
```json ```json
{ "kind": "ws.hello", "protocol": 1 } { "kind": "ws.hello", "protocol": 3 }
``` ```
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`. **Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
@@ -449,6 +473,78 @@ would carry ~25 zeroes. `maxPoints` follows the same `0 == uncapped` rule as the
a points lookup stops at the character's own row, but a rank must count every row that beats them, in a points lookup stops at the character's own row, but a rank must count every row that beats them, in
every system, on every profile build. Derive rank from `points.board` instead for anyone in the top N. every system, on every profile build. Derive rank from `points.board` instead for anyone in the top N.
#### Player-vendor marketplace (Protocol 3.0)
The shard-wide shop index: every player vendor's shop name, owner, location and priced inventory —
the same set the in-game **Vendor Search** gump reads, published so a site can offer the same search
from outside the game.
An **amortized round-robin diff sweep**, not a snapshot RPC, and the distinction is load-bearing:
`rpc.rs::try_route` correlates a reply on the FIRST frame carrying a matching `reqId`, so a chunked
reply sharing one `reqId` would deliver chunk 1 to the HTTP caller and leak chunks 2..N onto the
broadcast feed. A whole-world snapshot could not fit in one frame inside the 10 s reply timeout
either. The per-account `vendor.snapshot` RPC (§5) is unaffected and still serves the player portal.
Each tick inventories at most `Bridge.MarketSweepBatch` vendors (default 25) starting from a
persistent cursor, so **per-tick cost is bounded independently of world size**; full coverage takes
`ceil(vendors / batch) × MarketSweepSeconds`. A vendor is emitted only when its contents, prices,
shop name or location actually change.
| kind | fields | notes |
|------|--------|-------|
| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `location{}`, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. |
| `vendor.listing.remove` | `serial` | The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag. |
```json
{"kind":"vendor.listing","serial":"0x40001234",
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
"region":"Britain","house":"Darrow's Villa"},
"count":2,"total":2,"truncated":false,
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
"price":25000,"name":null,"cliloc":1023721},
{"serial":"0x40012ABD","itemId":7026,"hue":1157,"amount":3,
"price":500,"name":"a shard sigil","cliloc":1041243}],
"t":1752489280000}
```
**Six things consumers get wrong.**
1. **`name` is `null` for nearly every item; `cliloc` is the real label.** Items carry a
`LabelNumber`, not a name. The plugin deliberately never calls `VendorSearch.GetItemName`, which
builds an `ObjectPropertyList`, serialises it and byte-parses the packet **per item** — a
multi-hundred-millisecond stall across a full pass. (It would not work anyway: every current
client ships its cliloc files compressed and ServUO's bundled `Ultima.StringList` cannot read
them, so the in-game gump has the same gap.) Resolve clilocs consumer-side; a non-null `name` is a
player-set literal and is strictly more specific, so **prefer it over the cliloc**.
2. **`location` is one nested object, and it may be absent entirely.** It is nested so that a
consumer gating vendor whereabouts gates one field rather than five that can drift apart — the
website's `market.location` rule removes the whole object. Treat a missing `location` as "not
published", not as an error.
3. **`truncated` means the shop holds more than the frame carries.** `count` is what was published,
`total` is what the shop actually holds, capped by `Bridge.MarketMaxListings` (default 250). A
commodity reseller with thousands of stacked resources is real and an uncapped frame for one is
measured in megabytes. Say "showing 250 of 3,104" rather than presenting a partial shop as
complete.
4. **`child: true` means the price buys the ENCLOSING CONTAINER.** ServUO prices a container as a
unit and everything inside inherits that price with no `VendorItem` of its own; `DoSearch`
surfaces the same flag. A UI that prints the container's price against each item inside it is
lying about the shard.
5. **Opted-out vendors are absent, and that is a privacy control.** `pv.VendorSearch` is the player's
own in-game toggle and the sweep honours it — hide your vendor in game and it is hidden here too.
The same goes for `Map.Internal` and a null backpack, matching `DoSearch`. Process
`vendor.listing.remove` promptly: it is how a player *revoking* that consent reaches you.
6. **Prices are inherently stale, by design.** The round-robin sweep means a shop can be a full cycle
behind. Any UI over this must say how old the data may be — the website derives it from the oldest
vendor row.
Entries carry `ownerSerial`/`ownerName` and **never `acct` or `webId`**, the same rule `points.board`
follows. Absent entirely if the shard runs `Bridge.MarketEnabled=false` or an older plugin. Render
from `GET /market` (§6) on connect, then keep live with these events — though note that a live
firehose of whole vendor inventories is the largest stream the bridge produces, and a consumer that
only needs a browsable index (as the website does) is better served by the REST read plus the
periodic re-sweep.
--- ---
## 5. REST — read queries ## 5. REST — read queries
@@ -829,6 +925,35 @@ standings built over months and blanking them during a restart reads as data los
one excluded by `Bridge.PointsSystems`). That is distinct from a published board nobody has scored in one excluded by `Bridge.PointsSystems`). That is distinct from a published board nobody has scored in
yet, which is **200** with an empty `top[]` — and the two are worth rendering differently. yet, which is **200** with an empty `top[]` — and the two are worth rendering differently.
### Player-vendor marketplace (Protocol 3.0)
```
GET /market?limit=200&offset=0
→ { "vendors": [ {"kind":"vendor.listing","serial":"0x40001234",
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
"region":"Britain","house":"Darrow's Villa"},
"count":2,"total":2,"truncated":false,"items":[ ... ],"t":...}, ... ],
"total": 137, "limit": 200, "offset": 0 }
```
Every vendor's latest shop, exactly as `vendor.listing` published it (§4 for the frame and its six
gotchas). Served from the sidecar's projection, so it answers while the shard is down.
**This is the only PAGED read the sidecar serves**, because it is the only board that can be a whole
world's inventory. `limit` is clamped to 1..1000 (default 200); `total` is returned so a caller knows
when to stop rather than paging until it sees a short page, which would race a concurrent sweep.
Ordering is by **serial**, not by shop name — a serial is stable while a shop name is renameable, so
a rename mid-walk cannot make a vendor skip or repeat a page.
The route is `/market` and deliberately **not** `/vendors`: `/vendors/{account}` next door is the
per-account RPC (§5), and two routes a prefix apart meaning "this player's shops" and "every shop on
the shard" is a trap nobody wins.
Frames are served **verbatim**, owner names and coordinates included. That is not an oversight: the
sidecar defines no audiences. Deciding who may see what is the consuming site's job — see
[`v3.md`](v3.md) §3 for how the website does it.
--- ---
## 7. Status codes ## 7. Status codes
@@ -854,7 +979,7 @@ yet, which is **200** with an empty `top[]` — and the two are worth rendering
A typical character page: A typical character page:
```js ```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" }; const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "3" };
// 1. render the roster // 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

View File

@@ -284,6 +284,15 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo**`BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory. `Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo**`BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
**Update (3.0).** That recommendation held, and the reason it had to hold turned out to be stronger
than "avoids a dependency": **ServUO cannot resolve clilocs either.** Every current client ships its
`Cliloc.*` files compressed, and the bundled `Ultima.StringList` reads only the older plain layout —
so `VendorSearch.StringList` is null and `VendorSearch.GetItemName` returns `item.Name` on any modern
shard. The in-game Vendor Search gump has the same gap, which is why `vendor.listing` never calls it.
Pushing name resolution to the plugin was never an option. See [`v3.md`](v3.md) §8.6 and
`docs/website/CLILOCS.md` for how the site gets a table instead (the operator converts one from their
own client, once).
--- ---
## 8. Corrections to `BRIDGE_FINDINGS.md` ## 8. Corrections to `BRIDGE_FINDINGS.md`
@@ -327,6 +336,23 @@ leaderboards. `BridgePoints` is the widest read the bridge performs: ten of Serv
keep a row for every character ever created, so it selects the top N in a single bounded pass rather keep a row for every character ever created, so it selects the top N in a single bounded pass rather
than sorting, and runs on a deliberately slow 300 s interval. than sorting, and runs on a deliberately slow 300 s interval.
Also shipped: **`vendor.listing`** ([`v3.md`](v3.md) §8), `BridgeMarket.cs`, the shard-wide
player-vendor index. It introduces the one sweep pattern the bridge did not previously have — an
**amortized round-robin**. Every other sweep walks its whole collection per tick, which is fine for
tens of houses or a fixed set of point systems and is not fine for a world of shops whose inventories
recurse into containers. `BridgeMarket` inventories at most `MarketSweepBatch` vendors per tick from
a persistent cursor, so the per-tick cost is bounded by the batch rather than by world size, and full
coverage takes `ceil(vendors / batch) x MarketSweepSeconds`. Measured at **15.4 ms** for a cold tick
of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff), on a shard of 209k
items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own
`PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site.
That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 →
3** and the coordinated `edge``main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1).
The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints
but changes nothing that already existed in v2 — so the operator-visible break is limited to
re-pinning the version, which the website does for itself in a one-shot boot migration.
### Config keys (`Config/Bridge.cfg`) ### Config keys (`Config/Bridge.cfg`)
```ini ```ini
@@ -342,7 +368,8 @@ Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is
The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals
for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's
`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` block). `RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` and `Market*`
blocks).
**`servuo-plugins/overlay/Config/Bridge.cfg` **`servuo-plugins/overlay/Config/Bridge.cfg`
is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults. is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults.

View File

@@ -18,12 +18,14 @@ link/
│ ├── scripts/ │ ├── scripts/
│ │ └── gen_tree.py │ │ └── gen_tree.py
│ ├── workflows/ │ ├── workflows/
│ │ ├── pr-checks.yml
│ │ ├── release.yml │ │ ├── release.yml
│ │ ├── sonarqube.yml │ │ ├── sonarqube.yml
│ │ └── sync-project-tree.yml │ │ └── sync-project-tree.yml
│ └── PULL_REQUEST_TEMPLATE.md │ └── PULL_REQUEST_TEMPLATE.md
├── sidecar/ ├── sidecar/
│ ├── src/ │ ├── src/
│ │ ├── cli.rs
│ │ ├── config.rs │ │ ├── config.rs
│ │ ├── main.rs │ │ ├── main.rs
│ │ ├── rpc.rs │ │ ├── rpc.rs

View File

@@ -1,6 +1,6 @@
# Protocol 3.0 — Shard content, standings & the visibility framework # Protocol 3.0 — Shard content, standings & the visibility framework
**Status:** In progress. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover. **Status:** Feature-complete on `edge`; the cutover (order 6) is in review. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover.
**Date:** 2026-07-28 **Date:** 2026-07-28
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**. **Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API). **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API).
@@ -15,14 +15,19 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
| 2 | **B/1**`world.ruleset` (§5) | ✅ **Done** | servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3), link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17), website [#111](https://gitea.whitlocktech.com/RunicGateway/website/pulls/111), docs [#66](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/66) | | 2 | **B/1**`world.ruleset` (§5) | ✅ **Done** | servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3), link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17), website [#111](https://gitea.whitlocktech.com/RunicGateway/website/pulls/111), docs [#66](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/66) |
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) | | 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
| 4 | **B/2**`points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) | | 4 | **B/2**`points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
| 5a | **B/3 dependency** — cliloc table (§8.6) | 🟨 In review | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) | | 5a | **B/3 dependency** — cliloc table (§8.6) | **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) |
| 5b | **B/3**`vendor.listing` (§8) | ⬜ Not started | — | | 5b | **B/3**`vendor.listing` (§8) | **Done** | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — | | 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | 🟨 In review | the bump: link [#20](https://gitea.whitlocktech.com/RunicGateway/link/pulls/20), website [#117](https://gitea.whitlocktech.com/RunicGateway/website/pulls/117), docs [#72](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/72) — then `edge``main`: servuo-plugins [#6](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/6), link [#21](https://gitea.whitlocktech.com/RunicGateway/link/pulls/21), website [#118](https://gitea.whitlocktech.com/RunicGateway/website/pulls/118), docs [#73](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/73) |
Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather
than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item
names; 5b is the four-repo wire change. names; 5b is the four-repo wire change.
**The `edge` → `main` half of order 6 is held for Android parity** (decided 2026-07-30, see §10): the
app sees none of the four new features and gates shard nav on session role alone, so merging the
cutover first would ship a shard whose app client silently disagrees with the web client about what is
public. The **bump** PRs into `edge` are unaffected and merge normally.
--- ---
## 1. Why 3.0 ## 1. Why 3.0
@@ -242,6 +247,39 @@ admin-set `uo_link_config.protocol` column — so it happens **exactly once**, a
from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides. from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides.
- No feature-negotiation array anywhere — v3 implies all three kinds. - No feature-negotiation array anywhere — v3 implies all three kinds.
### 4.1 What the bump actually touches
The version lives in five places, and all five move together:
| Where | Change |
|---|---|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION` 2 → 3 (with the v3 note beside the v2 one), plus the sidecar README's worked example |
| `website/server/db/schema.sql` | `uo_link_config.protocol` column default 1 → 3, plus the boot migration below |
| `website/server/src/model/uoLinkConfig/uoLinkConfig.model.js` | `DEFAULT_PROTOCOL` — what a site with nothing saved yet declares |
| `website/server/src/utils/uoLinkClient.js` + `uoLinkSocket.js` | the `config.protocol || …` fallbacks, so an unset value can never quietly send `1` and 409 with a confusing message |
| `website/client/.../ShardAdmin.jsx`, `website/.env.example` | the admin form's initial value and the documented env default |
**The migration has to be one-shot, and that is the only subtle part.** `schema.sql` is re-run on
*every* boot (`utils/db.js::ensureSchema`), and every other statement in its migration block is an
idempotent `ADD COLUMN IF NOT EXISTS` / `MODIFY`. A bare `UPDATE uo_link_config SET protocol = 3`
would not be idempotent in the sense that matters: `protocol` is **admin-editable**, so an operator
who deliberately pins an older sidecar in Admin → Shard would silently be un-pinned on the next
restart. It is therefore gated on a marker row in `settings`:
```sql
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
UPDATE uo_link_config SET protocol = 3
WHERE id = 1 AND protocol < 3
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
```
The marker is written *after* the `UPDATE`, so the first boot on the new build migrates and every
later boot is a no-op. A fresh install has no `uo_link_config` row to update and simply gets the
marker plus the new column default. `protocol < 3` rather than `= 2` so an install that never left
the old default of `1` is carried across too — it could not have been talking to a v2 sidecar
anyway.
--- ---
## 5. Part B/1 — `world.ruleset` ✅ Done ## 5. Part B/1 — `world.ruleset` ✅ Done
@@ -312,8 +350,11 @@ Sidecar — `store.rs`: singleton `ruleset(id CHECK(id=1), rev, json, updated_t)
`main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so `main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so
it answers during a shard outage (`PROTOCOL_2.md` §12.2). it answers during a shard outage (`PROTOCOL_2.md` §12.2).
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so follow the Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so it cannot use the
`getPresence()` block's explicit form, not the array-only `snapshot()` helper); `shardIngest.js` array-only `snapshot()` helper — but it **must still go through `shardIngest.ingest()`**, as
`ingestEach` does, rather than calling `shardState.setRuleset` directly: the two arrival orders have
to produce the same stored frame, and a direct call quietly made backfill a second writer that
skipped the normalization below); `shardIngest.js`
`shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello` `shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello`
already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table
(`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind (`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind
@@ -322,6 +363,17 @@ already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_rulese
Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside
`/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`. `/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`.
**The `shard` field falls back to the instance's own name.** ServUO ships `Server.cfg` with
`Name=My Shard`, so an operator who never edited it publishes that verbatim — which is the shard
saying *unnamed*, not naming anything, and the rules page then reads "My Shard" under a header
carrying the real one. `shardIngest` substitutes `settings.getInstanceName()` (the admin-editable
site title, else `BRAND_NAME` — the same resolution `getPublic().brand.name` uses, so one install
never shows two names) when `shard` is absent, blank, or exactly the stock default, matched
case-insensitively and trim-tolerantly but only as a **whole** value: a shard genuinely called
*"My Shard Reborn"* has named itself and keeps it. Applied at **ingest**, not on read, because the
ruleset is also broadcast live — the same object goes to the SSE fan-out, so a read-time
substitution would be undone by the next reconnect's frame.
### 5.4 Risk ### 5.4 Risk
Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit
@@ -600,6 +652,15 @@ Client — NEW `routes/public/Leaderboards.jsx` at `/site/leaderboards`; a "Loya
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
`AdminCharacter.jsx`. `AdminCharacter.jsx`.
**An unscored board still renders a row.** Most systems on a young shard have `top: []`, and a page
of blank cards reads as broken rather than as new — so a board with no entries shows a single
placeholder bearing the **instance's own name** with an em dash where a score goes, above the
existing "nobody has earned points here yet" line. It is deliberately **not** shaped like an entry —
no rank, no medal, no bar, muted — because a placeholder that looked like a real standing would be a
fabricated one; the first real entry replaces it outright. Purely presentational: the API keeps
sending an empty `top`, so no consumer ever receives an invented row. Web and app render it the same
way (`Leaderboards.jsx`, `LeaderboardsScreen.kt`).
### 7.5 What the run against a real shard changed ### 7.5 What the run against a real shard changed
The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a
@@ -639,7 +700,7 @@ if it is renamed back.
--- ---
## 8. Part B/3 — `vendor.listing` ## 8. Part B/3 — `vendor.listing` 🟨 In review
### 8.1 It cannot be an RPC, and this is load-bearing ### 8.1 It cannot be an RPC, and this is load-bearing
@@ -807,6 +868,75 @@ and text paths converge on identical content.
driven by `staleAt` (the oldest `shard_vendors.updated_at`). The round-robin sweep means data is driven by `staleAt` (the oldest `shard_vendors.updated_at`). The round-robin sweep means data is
inherently up to one full cycle old, and the UI must say so. inherently up to one full cycle old, and the UI must say so.
Shipped with a second page, `routes/public/MarketVendor.jsx` at `/site/market/vendors/:serial`
where a search result points. It is the only surface that can render the two states the result list
cannot: a `truncated` shop (*"showing 250 of 3,104 — this shop holds more than the shard
publishes"*) and a `location` an admin has gated away, which is a real answer rather than an empty
coordinate.
### 8.8 What the build changed
Four things the implementation settled differently from §8 as written, all of them found by building
against the live shard.
**1. `location` is a nested object, not flat `map`/`x`/`y`/`region`.** §8.1's payload sketch had them
flat, and it would have made `market.location` — a rule Part A pre-wired — **inert**, exactly like
the `characterName` miss §7.5 records: `projectValue` matches literal JSON keys, so there is no
`location` key for the rule to match. Flat keys would have needed five rules that could drift apart.
Nesting makes one rule hide the facet, the coordinates, the region and the house together, on the
live frame and the stored read model alike, because both now spell it the same way.
The other pre-wired rule, `market.ownerName`, checked out — it is a real key on the frame. Owner is
written as flat `ownerSerial`/`ownerName` rather than through `BridgeJson.Actor`, which would add
`acct` and `webId`; same argument `points.board` makes. `ownerSerial` was **added** to the
configurable fields alongside `ownerName`, because an admin who hides the owner's name and leaves a
serial every other board resolves back to that name has not hidden anything.
**2. The per-vendor diff signature is the full listing set, not §8.3's `count | Σ(serial ^ price)`.**
That hash collides on the single most common change a shop makes: two items swapping prices, which
is what re-pricing looks like. The signature is built over the same buffer the frame is written
from, in the same order, so a match really does mean an identical frame.
**3. There is no `payload` column on `shard_vendors`.** §8.5 implied the board pattern (whole frame
in JSON, columns hoisted for display). It does not apply here: the items ARE the searchable rows, so
they are normalized into `shard_vendor_items` and there is nothing left worth duplicating. The
sidecar keeps the whole blob, because outage resilience is its job and search is not.
**4. Sweep cost is reported, and a slow tick warns.** The batch cap is a *claim* about per-tick cost,
and an operator tuning `MarketSweepBatch` was otherwise tuning blind. `[bridge status` now carries
`lastMs`/`maxMs`, and a tick over 50 ms prints a rate-limited warning naming the knob.
Measured on the live shard (27 vendors × 40 listings, 209k items / 43k mobiles):
| | |
|---|---|
| First tick — 25 vendors emitted cold | **15.4 ms** |
| Second tick — the remaining 2 | **3.4 ms** |
| Steady state — nothing changed | **0.3 ms** |
| Website `/market` search over 1,040 listings | 1,040 total, names resolved |
| Cliloc re-resolution pass over 1,040 rows | **50 ms** |
The diff is what makes the steady state ~free; the batch cap is what bounds the cold case. Note the
arithmetic the warning exists for: at the default cap of 250 listings, a batch of 25 **full** shops
is 6,250 items ≈ 95 ms — over budget. Real shops hold tens, which is why 25 is the default, but a
shard of commodity resellers should lower the batch, and now it will be told to.
Two smaller things worth not rediscovering:
- **`BridgeJson.Escape` takes a NON-NULL string** — it dereferences `value.Length` immediately — and
`BridgeJson.Str` writes its own `,"key":` prefix, so neither serves a value inside a hand-built
object. Nearly everything this frame writes is legitimately null (an item's plain `Name` is null
for almost every item; a vendor in the street has no house), so that is the common path, not an
edge case. `BridgeMarket.Text()` is the two-line writer that was missing.
- **The ServUO console writes in the OS code page**, so an em dash in a `Console.WriteLine` renders
as `???` in the log an operator would paste into an issue. Bridge console output is ASCII.
Search-side, one thing the site had to fix rather than inherit: `%` and `_` in a user's query are
**LIKE** metacharacters, not SQL ones, so parameterization does not neutralize them — a search for
`%` would otherwise match every listing on the shard. `shardMarket.db.js` escapes them. (The atlas's
`LIKE` searches predate this and have the same shape over a much smaller table; worth a follow-up,
not a blocker here.)
--- ---
## 9. Sequencing ## 9. Sequencing
@@ -817,9 +947,9 @@ inherently up to one full cycle old, and the UI must say so.
| 2 | **B/1**`world.ruleset` (§5) | all four | new kind | ✅ Done | | 2 | **B/1**`world.ruleset` (§5) | all four | new kind | ✅ Done |
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done | | 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done | | 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
| 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | 🟨 In review | | 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done |
| 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | | | 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | ✅ Done |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | | | 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | 🟨 In review — `edge``main` held for Android parity (§10) |
--- ---
@@ -841,8 +971,24 @@ inherently up to one full cycle old, and the UI must say so.
- `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed - `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed
artifacts, and `test/routeManifest.test.js` fails on drift. artifacts, and `test/routeManifest.test.js` fails on drift.
**Follow-up, not scoped for 3.0:** the Android app consumes the same public/player shard API and will **Android parity — now scoped, and it gates the cutover (decided 2026-07-30).** This was written as a
need `/public/shard/features` to hide its own nav. Track separately against `android-app/`. "track separately" follow-up. It was re-examined before the cutover and the gap is wider than nav
hiding: the app consumes the same public/player shard API but has **no consumer for any of the four new
features** (`ruleset`, `leaderboards`, `market`, `atlas`), no `points` block on its character sheet, no
cliloc-resolved item names (§8.6), and — the part that matters for §3 — **it gates shard navigation on
session role alone**, so an admin who disables a feature or raises its audience leaves the app
rendering entries that `404`/`403` into a generic error where the web client hides them.
Two things were verified as already correct and are recorded so they are not re-derived: the app's SSE
request rides the same authenticated OkHttp client as every other call, so an app session resolves to
the same audience rung as the same account on the web; and every shard DTO in the app is
nullable-with-defaults, so field projection strips fields without a deserialization failure.
Scoped as **M11 in [`../android/PLAN.md`](../android/PLAN.md) §9**, two PRs (the visibility rules +
read-model adds, then the four screens). `edge``main` is held until both land, so web and app
surface the same shard on the same day. Neither PR is coupled to the merge order — on a pre-v3 website
every new route and `/public/shard/features` `404`s and the app falls back to today's behavior — so
holding the cutover is a schedule decision, not a technical dependency.
--- ---

View File

@@ -407,6 +407,50 @@ Two values carry non-obvious meanings, both set by the plugin and both documente
cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards
awaiting cliloc resolution sort by their own key rather than clumping together under NULL. awaiting cliloc resolution sort by their own key rather than clumping together under NULL.
### shard_vendors / shard_vendor_items — the player-vendor marketplace (Protocol 3.0)
The shard-wide shop index, fed by `vendor.listing` / `vendor.listing.remove`. One row per player
vendor and one per priced listing. Full operator detail in [`MARKETPLACE.md`](MARKETPLACE.md); the
design is `docs/link/v3.md` §8.
| Table | Shape |
|---|---|
| `shard_vendors` | `serial` (PK), `shop_name`, `owner_serial`, `owner_name`, `map`/`x`/`y`/`z`, `region`, `house`, `item_count`, `item_total`, `truncated`, `t`, `updated_at`. Indexes on owner, map, region and `updated_at`. |
| `shard_vendor_items` | `id` (PK), `vendor_serial`, `serial`, `item_id`, `hue`, `amount`, `price`, `name`, `cliloc`, `display_name`, `child`. Indexes on `vendor_serial`, `price`, `item_id`, `display_name`, and `(display_name, price)`. |
**Ingest is per-vendor and authoritative**: the frame is the whole shop, so ingest is
delete-then-insert of that vendor's listings inside one transaction. All-or-nothing matters
specifically because the two writes are "the shop" and "what is in it" — a failure between them
leaves a shop advertising an inventory it no longer has, which is visibly wrong and indistinguishable
from a genuinely empty shop. No foreign keys, consistent with every other `shard_*` table.
**There is deliberately no `payload` column**, unlike `shard_points_boards` directly above. The
board's top-N is a fixed-size list read whole, so it lives in JSON; here the items *are* the
searchable rows, so they are normalized and nothing is left worth duplicating. The sidecar keeps the
whole blob — outage resilience is its job, search is ours.
Market state, not events: neither kind is in `LOGGED_KINDS`, and this is the strongest case of the
three v3 kinds. One frame carries up to 250 listings and the sweep re-emits a shop on any price
change, so logging would turn `shard_events` into a price history nobody reads.
Two columns carry non-obvious meanings:
- **`item_count` vs `item_total`.** `item_count` is what the frame published; `item_total` is what
the shop actually holds. They differ when `truncated` — the shard caps listings per frame
(`Bridge.MarketMaxListings`, 250 by default), and a commodity reseller with thousands of stacks
genuinely exceeds it. Any UI must show both or it presents a partial shop as complete.
- **`display_name` is denormalized at ingest**, resolved from the item's literal `name` (preferred —
a player set it, so it is more specific) else its `cliloc` against `shard_clilocs`. Resolving at
query time would put the cliloc table on the hot path and make search-by-name impossible. Because
the shard's diff sweep will not re-send an unchanged shop just because the site learned what its
items are called, **a cliloc import triggers a bulk re-resolution** of this column (after a boot
import and after an admin import; ~50 ms per thousand rows, never throws).
`updated_at` is written explicitly on every upsert rather than left to `ON UPDATE CURRENT_TIMESTAMP`,
which MariaDB does not fire when every column is written back unchanged. A shop re-published
identically is still *freshly confirmed*, and without this the staleness banner would age a perfectly
current shop forever.
### shard_feature_visibility — per-feature audience config (Protocol 3.0) ### shard_feature_visibility — per-feature audience config (Protocol 3.0)
One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5), One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5),
@@ -745,6 +789,9 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). | | GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. | | GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). | | GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
| GET | `/shard/market?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=` | search the player-vendor marketplace (Protocol 3.0 `vendor.listing`). Returns **listings**, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from `shard_vendors` + `shard_vendor_items`, so it renders while the shard is down. Behind `requireFeature('market')` **and rate-limited** — the first genuinely expensive public read on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). `sort ∈ {price_asc, price_desc, recent}`. `q` matches the resolved display name **or** the item's literal name, with `%`/`_` escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let `?q=%` match every listing on the shard. Every response repeats `staleAt` (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. |
| GET | `/shard/market/meta` | index size, staleness (`staleAt`/`freshAt`) and which facets and regions actually hold vendors, so a client builds its filters without running a search it will discard. |
| GET | `/shard/market/vendors/:serial` | one shop and its listings; `:serial` must match `/^0x[0-9A-Fa-f]{1,16}$/` or **400** before any query runs. **404** = a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer, and distinguishing them would leak that a hidden vendor exists. `truncated` (with `total` exceeding `count`) means the shop holds more than the shard publishes per frame. |
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. | | GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
| GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. | | GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. |
| GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. | | GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |
@@ -835,7 +882,11 @@ who"; `activity_log` provides the history feed.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). - **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`).
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too. - **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned. - **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`. - **Rate limiting** (`express-rate-limit`) on `/auth/login`, `/public/contact`, and — the only limited
*read*`/public/shard/market` and `/public/shard/market/vendors/:serial` (60/min/IP). Every other
public read is an indexed lookup of bounded size; the marketplace search is a `LIKE` scan plus a
`COUNT` over the largest `shard_*` table, anonymous by default, so it is the one public GET that is
worth money to serve.
- **Validation** (`express-validator`) on all writes; centralized error handler. - **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in - **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in
**`server/src/config/csp.js`** (`app.js` only wires them up): **`server/src/config/csp.js`** (`app.js` only wires them up):

View File

@@ -44,6 +44,11 @@ they did before the table existed.
## Converting ## Converting
> **Step-by-step operator instructions — where to get UOFiddler, where your
> client files are, and how to verify the import — are in
> [`UOFIDDLER.md`](UOFIDDLER.md).** This section covers the formats and the
> reasoning behind them.
Either format below is accepted; the site sniffs which one it was handed. Either format below is accepted; the site sniffs which one it was handed.
| Format | Fidelity | Notes | | Format | Fidelity | Notes |
@@ -77,8 +82,16 @@ dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/cli
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
``` ```
A UOFiddler GUI export works equally well — anything producing one of the two A UOFiddler GUI export works too, but **not unmodified**: its Cliloc tab writes
shapes above is fine. `Number;Text;Flag` — three columns, the flag *last* — and the parser reads
`number<separator>text`, so the trailing field is absorbed into the name and
every item renders as `quarter staff;0`. Stripping it is one `sed`, given in
[`UOFIDDLER.md`](UOFIDDLER.md) §Route B.
The parser already tolerates `number,flag,text`, with the flag in the *middle*.
It is not extended to cover the trailing form because a final `;0` is
indistinguishable from a name that genuinely ends that way — a heuristic there
would corrupt real names to save the operator one command.
## Shard-added and shard-edited items ## Shard-added and shard-edited items

167
website/MARKETPLACE.md Normal file
View File

@@ -0,0 +1,167 @@
# Marketplace — the player-vendor index
**Status:** On `edge` — servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116).
**Design:** [`docs/link/v3.md` §8](../link/v3.md) — Protocol 3.0 Part B/3.
**Depends on:** [`CLILOCS.md`](CLILOCS.md) — without a cliloc table, listings render as item ids.
The marketplace is a searchable index of every player vendor on the shard: what
each shop is selling, for how much, and where it is standing. It is the same set
the in-game **Vendor Search** gump reads, offered from outside the game — so a
player can find the vanquishing kryss they want before logging in, and someone
who does not play at all can see that the economy exists.
Page: `/site/market`, plus `/site/market/vendors/:serial` for one shop.
## Three things the pages must say out loud
Everything below follows from how the data is gathered, and each has a visible
consequence the UI is required to surface.
**1. The prices are not live.** The shard sweeps vendors **round-robin** — at
most `Bridge.MarketSweepBatch` shops per tick — so a given shop can be a full
cycle behind. The page carries a *"prices last refreshed N minutes ago"* banner
driven by the **oldest** vendor row, not the newest: the one stale shop is the
one that wastes somebody's trip.
**2. A shop can be truncated.** `Bridge.MarketMaxListings` (250 by default) caps
how many listings one frame carries. A commodity reseller with thousands of
stacked resources is a real thing, and an uncapped frame for one is measured in
megabytes. Over the cap the shop reports `truncated`, and the vendor page says
*"showing 250 of 3,104 — this shop holds more than the shard publishes"* rather
than presenting a partial shop as complete.
**3. An item may have no name.** Items on the wire carry a cliloc id, not a name.
On a shard whose operator has not converted a cliloc table
([`CLILOCS.md`](CLILOCS.md)) the honest render is the item id — never an invented
label, which would be indistinguishable from a real one.
## Privacy: the player's own toggle wins
Only vendors whose owner left the in-game **Vendor Search** flag ON are ever sent
to the site. A player who hides their shop in game is hidden here too, and no
admin setting overrides that. When they hide one that was already indexed, the
shard emits `vendor.listing.remove` and the row is deleted — so revoking consent
takes effect, it does not merely stop refreshing.
Shop name, owner character name and location default to **Everyone**, because the
stock Vendor Search gump already shows exactly that set to any player in game.
They remain admin-configurable; see [`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md).
Account names and website user ids never cross the wire at all.
## How it is put together
```
ServUO uo-link sidecar website
────── ─────────────── ───────
BridgeMarket.cs vendors table shard_vendors
round-robin sweep ──────► (whole frame blob) ──────► shard_vendor_items
per-vendor diff GET /market (paged) + display_name
vendor.listing resolved at ingest
vendor.listing.remove
```
**The shard side** walks at most `MarketSweepBatch` vendors per tick from a
persistent cursor, diffs each against what it last published, and emits a whole
frame for any shop that moved. Per-tick cost is therefore bounded by the batch,
not by how many vendors the world holds — full coverage takes
`ceil(vendors / batch) × MarketSweepSeconds`.
**The sidecar** stores each frame whole and serves `GET /market`, its only paged
read. It normalizes nothing and defines no audiences: it is a dumb forwarder, and
search is the website's job.
**The website** splits each frame into a vendor row and its listings, replacing
that vendor's whole listing set inside one transaction (the frame is
authoritative for that vendor, never a delta). Item names are resolved against
the cliloc table **on the way in** and stored denormalized, which is what makes
search-by-name possible and keeps the cliloc table off the hot path.
## Operating it
Everything is in `Config/Bridge.cfg` on the shard. There is nothing to configure
on the website.
| Setting | Default | What it does |
|---|---|---|
| `MarketEnabled` | `true` | Master switch. Off publishes nothing; the page shows an empty index. |
| `MarketSweepSeconds` | `60` | Tick interval. |
| `MarketSweepBatch` | `25` | Vendors inventoried per tick. Clamped 1..500. |
| `MarketMaxListings` | `250` | Per-shop listing cap, after which `truncated`. Clamped 1..5000. |
**Faster coverage vs. per-tick cost.** Lowering `MarketSweepSeconds` or raising
`MarketSweepBatch` both refresh the index sooner and both cost more per tick.
The expensive part is the item walk, which recurses into every container a vendor
is selling — so a shard of big shops should raise the interval rather than the
batch.
`[bridge status` reports the sweep, including `lastMs` and `maxMs`:
```
market(enabled=True sweeps=42 scanned=108 emitted=27 removed=0 skipped=0
truncated=0 tracked=27 vendors=27 cursor=2 batch=25 lastMs=0.31 maxMs=15.40)
```
A tick over **50 ms** prints a rate-limited warning naming the knob:
```
[Bridge] market sweep took 82.4 ms (budget 50 ms) - lower Bridge.MarketSweepBatch (now 25) if this persists
```
Measured on a shard with 27 vendors × 40 listings (209k items, 43k mobiles):
**15.4 ms** for the first cold tick of 25 vendors, **0.3 ms** in steady state —
the diff is what makes an unchanged world nearly free. Note the arithmetic: 25
*full* shops at the 250-listing cap is 6,250 items ≈ 95 ms, over budget. Real
shops hold tens, which is why 25 is the default and why the warning exists.
`[bridge sweepnow` runs one tick immediately; `[bridge reload` re-reads the
settings above without a restart.
## Names arriving late
Item names come from the cliloc table, and the market sweep will **not** re-send
an unchanged shop just because the site learned what its items are called. So a
cliloc import triggers a bulk re-resolution of every stored listing — otherwise
an operator who configures clilocs after the first sweep would see item ids until
every shop happened to change on its own. It runs after a boot import and after
an admin import, takes ~50 ms per thousand listings, and never throws: a failure
leaves names exactly as they were.
## API
All under `/api/v1/public/shard`, gated by the `market` feature and
**rate-limited** — these are the first genuinely expensive public reads on the
site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*`
table, reachable with no session).
| Route | What |
|---|---|
| `GET /market` | Search. Returns **listings**, not vendors — "who sells X and for how much" is the question. `?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=`, `sort ∈ {price_asc, price_desc, recent}`. |
| `GET /market/meta` | Index size, staleness, and which facets and regions actually hold vendors — so a client builds its filters without running a search it will discard. |
| `GET /market/vendors/:serial` | One shop and its listings. **404** for a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer. |
`q` matches the resolved display name **or** the item's own literal name, because
an item with a player-set name (most of what is worth searching for on a
player-run shard) may carry a generic cliloc. `%` and `_` in a query are escaped:
they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let
a search for `%` match every listing on the shard.
Full schemas are in the OpenAPI spec (`ShardMarketPage`, `ShardMarketVendor`,
`ShardMarketMeta`, `ShardMarketListing`, `ShardMarketLocation`).
## Tables
`shard_vendors` (one row per shop) and `shard_vendor_items` (one row per priced
listing). Both are ingest-owned; nothing else writes to them. No foreign keys,
in keeping with every other `shard_*` table — the ingest transaction is what
keeps them consistent, and an FK would turn a malformed frame into a failed write
rather than a dropped row.
There is deliberately **no `payload` column** on `shard_vendors`, unlike the
points board next door. The board's top-N is a fixed-size list read whole, so it
lives in JSON; here the items *are* the searchable rows, so they are normalized
and there is nothing left worth duplicating. The sidecar keeps the whole blob,
because outage resilience is its job.
`shard_vendor_items.display_name` is denormalized and indexed (alone, and
composite with `price` for "cheapest matching X"). See "Names arriving late"
above for how it is kept current.

View File

@@ -164,6 +164,7 @@ website/
│ │ │ ├── heroLayout.js │ │ │ ├── heroLayout.js
│ │ │ ├── shardEvents.js │ │ │ ├── shardEvents.js
│ │ │ ├── useAsync.js │ │ │ ├── useAsync.js
│ │ │ ├── useShardFeatures.js
│ │ │ └── useShardFeed.js │ │ │ └── useShardFeed.js
│ │ ├── routes/ │ │ ├── routes/
│ │ │ ├── admin/ │ │ │ ├── admin/
@@ -190,6 +191,8 @@ website/
│ │ │ │ │ ├── SettingsAdmin.jsx │ │ │ │ │ ├── SettingsAdmin.jsx
│ │ │ │ │ ├── ShardAdmin.jsx │ │ │ │ │ ├── ShardAdmin.jsx
│ │ │ │ │ ├── ShardOps.jsx │ │ │ │ │ ├── ShardOps.jsx
│ │ │ │ │ ├── ShardVisibility.jsx
│ │ │ │ │ ├── SpawnAtlas.jsx
│ │ │ │ │ ├── UserDetail.jsx │ │ │ │ │ ├── UserDetail.jsx
│ │ │ │ │ ├── UserEditor.jsx │ │ │ │ │ ├── UserEditor.jsx
│ │ │ │ │ ├── UsersAdmin.jsx │ │ │ │ │ ├── UsersAdmin.jsx
@@ -213,17 +216,23 @@ website/
│ │ │ │ └── ResetPassword.jsx │ │ │ │ └── ResetPassword.jsx
│ │ │ ├── public/ │ │ │ ├── public/
│ │ │ │ ├── About.jsx │ │ │ │ ├── About.jsx
│ │ │ │ ├── Atlas.jsx
│ │ │ │ ├── AtlasCreature.jsx
│ │ │ │ ├── ChampSpawns.jsx │ │ │ │ ├── ChampSpawns.jsx
│ │ │ │ ├── CmsPage.jsx │ │ │ │ ├── CmsPage.jsx
│ │ │ │ ├── FiveOnFriday.jsx │ │ │ │ ├── FiveOnFriday.jsx
│ │ │ │ ├── Governors.jsx │ │ │ │ ├── Governors.jsx
│ │ │ │ ├── Guilds.jsx │ │ │ │ ├── Guilds.jsx
│ │ │ │ ├── Houses.jsx │ │ │ │ ├── Houses.jsx
│ │ │ │ ├── Leaderboards.jsx
│ │ │ │ ├── Maintenance.jsx │ │ │ │ ├── Maintenance.jsx
│ │ │ │ ├── Market.jsx
│ │ │ │ ├── MarketVendor.jsx
│ │ │ │ ├── News.jsx │ │ │ │ ├── News.jsx
│ │ │ │ ├── Newsletter.jsx │ │ │ │ ├── Newsletter.jsx
│ │ │ │ ├── NewsletterIssue.jsx │ │ │ │ ├── NewsletterIssue.jsx
│ │ │ │ ├── Portal.jsx │ │ │ │ ├── Portal.jsx
│ │ │ │ ├── Rules.jsx
│ │ │ │ ├── Screenshots.jsx │ │ │ │ ├── Screenshots.jsx
│ │ │ │ ├── Shard.jsx │ │ │ │ ├── Shard.jsx
│ │ │ │ ├── ShardActivity.jsx │ │ │ │ ├── ShardActivity.jsx
@@ -257,9 +266,12 @@ website/
│ └── sonar-test-reporter.mjs │ └── sonar-test-reporter.mjs
├── server/ ├── server/
│ ├── db/ │ ├── db/
│ │ ├── data/
│ │ │ └── spawnAtlas.art.example.json
│ │ ├── schema.sql │ │ ├── schema.sql
│ │ └── seed.js │ │ └── seed.js
│ ├── scripts/ │ ├── scripts/
│ │ ├── importSpawnAtlas.js
│ │ └── routeManifest.js │ │ └── routeManifest.js
│ ├── src/ │ ├── src/
│ │ ├── auth/ │ │ ├── auth/
@@ -365,15 +377,27 @@ website/
│ │ │ ├── settings/ │ │ │ ├── settings/
│ │ │ │ ├── settings.db.js │ │ │ │ ├── settings.db.js
│ │ │ │ └── settings.model.js │ │ │ │ └── settings.model.js
│ │ │ ├── shardAtlas/
│ │ │ │ ├── shardAtlas.db.js
│ │ │ │ └── shardAtlas.model.js
│ │ │ ├── shardClilocs/
│ │ │ │ ├── shardClilocs.db.js
│ │ │ │ └── shardClilocs.model.js
│ │ │ ├── shardEvents/ │ │ │ ├── shardEvents/
│ │ │ │ ├── shardEvents.db.js │ │ │ │ ├── shardEvents.db.js
│ │ │ │ └── shardEvents.model.js │ │ │ │ └── shardEvents.model.js
│ │ │ ├── shardLinks/ │ │ │ ├── shardLinks/
│ │ │ │ ├── shardLinks.db.js │ │ │ │ ├── shardLinks.db.js
│ │ │ │ └── shardLinks.model.js │ │ │ │ └── shardLinks.model.js
│ │ │ ├── shardMarket/
│ │ │ │ ├── shardMarket.db.js
│ │ │ │ └── shardMarket.model.js
│ │ │ ├── shardState/ │ │ │ ├── shardState/
│ │ │ │ ├── shardState.db.js │ │ │ │ ├── shardState.db.js
│ │ │ │ └── shardState.model.js │ │ │ │ └── shardState.model.js
│ │ │ ├── shardVisibility/
│ │ │ │ ├── shardVisibility.db.js
│ │ │ │ └── shardVisibility.model.js
│ │ │ ├── trustedDevices/ │ │ │ ├── trustedDevices/
│ │ │ │ ├── trustedDevices.db.js │ │ │ │ ├── trustedDevices.db.js
│ │ │ │ └── trustedDevices.model.js │ │ │ │ └── trustedDevices.model.js
@@ -398,12 +422,14 @@ website/
│ │ │ │ │ ├── account.router.js │ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── activity.router.js │ │ │ │ │ ├── activity.router.js
│ │ │ │ │ ├── admin.controller.js │ │ │ │ │ ├── admin.controller.js
│ │ │ │ │ ├── admin.routes.js
│ │ │ │ │ ├── authProviders.controller.js │ │ │ │ │ ├── authProviders.controller.js
│ │ │ │ │ ├── authProviders.router.js │ │ │ │ │ ├── authProviders.router.js
│ │ │ │ │ ├── botActivity.controller.js │ │ │ │ │ ├── botActivity.controller.js
│ │ │ │ │ ├── botActivity.router.js │ │ │ │ │ ├── botActivity.router.js
│ │ │ │ │ ├── dashboard.router.js
│ │ │ │ │ ├── discordBot.controller.js │ │ │ │ │ ├── discordBot.controller.js
│ │ │ │ │ ├── discordBot.router.js
│ │ │ │ │ ├── email.router.js
│ │ │ │ │ ├── emailConfig.controller.js │ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── imageUpload.js │ │ │ │ │ ├── imageUpload.js
│ │ │ │ │ ├── index.js │ │ │ │ │ ├── index.js
@@ -414,16 +440,25 @@ website/
│ │ │ │ │ ├── pages.controller.js │ │ │ │ │ ├── pages.controller.js
│ │ │ │ │ ├── pages.router.js │ │ │ │ │ ├── pages.router.js
│ │ │ │ │ ├── posts.router.js │ │ │ │ │ ├── posts.router.js
│ │ │ │ │ ├── settings.router.js
│ │ │ │ │ ├── shard.router.js
│ │ │ │ │ ├── shardAtlas.controller.js
│ │ │ │ │ ├── shardClilocs.controller.js
│ │ │ │ │ ├── shardOps.controller.js │ │ │ │ │ ├── shardOps.controller.js
│ │ │ │ │ ├── shardVisibility.controller.js
│ │ │ │ │ ├── uoLink.controller.js │ │ │ │ │ ├── uoLink.controller.js
│ │ │ │ │ ├── uoLink.router.js
│ │ │ │ │ ├── uploads.router.js │ │ │ │ │ ├── uploads.router.js
│ │ │ │ │ ├── users.router.js │ │ │ │ │ ├── users.router.js
│ │ │ │ │ ├── usersShard.controller.js │ │ │ │ │ ├── usersShard.controller.js
│ │ │ │ │ └── wiki.router.js │ │ │ │ │ └── wiki.router.js
│ │ │ │ ├── auth/ │ │ │ │ ├── auth/
│ │ │ │ │ ├── auth.controller.js │ │ │ │ │ ├── auth.controller.js
│ │ │ │ │ ├── auth.routes.js │ │ │ │ │ ├── index.js
│ │ │ │ │ ├── invite.controller.js │ │ │ │ │ ├── invite.controller.js
│ │ │ │ │ ├── invite.router.js
│ │ │ │ │ ├── login.router.js
│ │ │ │ │ ├── loginGuards.js
│ │ │ │ │ ├── me.routes.js │ │ │ │ │ ├── me.routes.js
│ │ │ │ │ ├── mobile.controller.js │ │ │ │ │ ├── mobile.controller.js
│ │ │ │ │ ├── mobile.routes.js │ │ │ │ │ ├── mobile.routes.js
@@ -431,7 +466,10 @@ website/
│ │ │ │ │ ├── mobileSso.routes.js │ │ │ │ │ ├── mobileSso.routes.js
│ │ │ │ │ ├── notifications.controller.js │ │ │ │ │ ├── notifications.controller.js
│ │ │ │ │ ├── notifications.routes.js │ │ │ │ │ ├── notifications.routes.js
│ │ │ │ │ ├── password.router.js
│ │ │ │ │ ├── passwordReset.controller.js │ │ │ │ │ ├── passwordReset.controller.js
│ │ │ │ │ ├── register.router.js
│ │ │ │ │ ├── session.router.js
│ │ │ │ │ ├── sso.controller.js │ │ │ │ │ ├── sso.controller.js
│ │ │ │ │ ├── sso.routes.js │ │ │ │ │ ├── sso.routes.js
│ │ │ │ │ └── trustDevice.helper.js │ │ │ │ │ └── trustDevice.helper.js
@@ -439,13 +477,23 @@ website/
│ │ │ │ │ ├── internal.controller.js │ │ │ │ │ ├── internal.controller.js
│ │ │ │ │ └── internal.routes.js │ │ │ │ │ └── internal.routes.js
│ │ │ │ ├── player/ │ │ │ │ ├── player/
│ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── appeals.controller.js │ │ │ │ │ ├── appeals.controller.js
│ │ │ │ │ ├── player.routes.js │ │ │ │ │ ├── appeals.router.js
│ │ │ │ │ ── shard.controller.js │ │ │ │ │ ── index.js
│ │ │ │ │ ├── shard.controller.js
│ │ │ │ │ └── shard.router.js
│ │ │ │ ├── public/ │ │ │ │ ├── public/
│ │ │ │ │ ├── atlas.controller.js
│ │ │ │ │ ├── atlas.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── pages.router.js
│ │ │ │ │ ├── posts.router.js
│ │ │ │ │ ├── public.controller.js │ │ │ │ │ ├── public.controller.js
│ │ │ │ │ ├── public.routes.js │ │ │ │ │ ├── shard.controller.js
│ │ │ │ │ ── shard.controller.js │ │ │ │ │ ── shard.router.js
│ │ │ │ │ ├── site.router.js
│ │ │ │ │ └── wiki.router.js
│ │ │ │ └── v1.router.js │ │ │ │ └── v1.router.js
│ │ │ ├── api.router.js │ │ │ ├── api.router.js
│ │ │ ├── cspReport.controller.js │ │ │ ├── cspReport.controller.js
@@ -455,6 +503,8 @@ website/
│ │ │ ├── auth.js │ │ │ ├── auth.js
│ │ │ ├── botInternalClient.js │ │ │ ├── botInternalClient.js
│ │ │ ├── botInternalKey.js │ │ │ ├── botInternalKey.js
│ │ │ ├── clilocParse.js
│ │ │ ├── clilocSource.js
│ │ │ ├── db.js │ │ │ ├── db.js
│ │ │ ├── logger.js │ │ │ ├── logger.js
│ │ │ ├── mailer.js │ │ │ ├── mailer.js
@@ -465,6 +515,9 @@ website/
│ │ │ ├── shardBroadcast.js │ │ │ ├── shardBroadcast.js
│ │ │ ├── shardIngest.js │ │ │ ├── shardIngest.js
│ │ │ ├── shardSales.js │ │ │ ├── shardSales.js
│ │ │ ├── shardVisibility.js
│ │ │ ├── spawnAtlasParse.js
│ │ │ ├── spawnAtlasSource.js
│ │ │ ├── totp.js │ │ │ ├── totp.js
│ │ │ ├── trustProxy.js │ │ │ ├── trustProxy.js
│ │ │ ├── uoLinkClient.js │ │ │ ├── uoLinkClient.js
@@ -483,11 +536,14 @@ website/
│ │ ├── appeals.pure.test.js │ │ ├── appeals.pure.test.js
│ │ ├── appeals.test.js │ │ ├── appeals.test.js
│ │ ├── appLinks.test.js │ │ ├── appLinks.test.js
│ │ ├── atlasController.test.js
│ │ ├── authController.test.js │ │ ├── authController.test.js
│ │ ├── authMe.test.js │ │ ├── authMe.test.js
│ │ ├── authTrustedDevice.test.js │ │ ├── authTrustedDevice.test.js
│ │ ├── botInternalKey.test.js │ │ ├── botInternalKey.test.js
│ │ ├── botScore.test.js │ │ ├── botScore.test.js
│ │ ├── clilocParse.test.js
│ │ ├── clilocSource.test.js
│ │ ├── csp.test.js │ │ ├── csp.test.js
│ │ ├── emailConfig.model.test.js │ │ ├── emailConfig.model.test.js
│ │ ├── honeypot.test.js │ │ ├── honeypot.test.js
@@ -521,17 +577,32 @@ website/
│ │ ├── secretBox.test.js │ │ ├── secretBox.test.js
│ │ ├── selfTrustedDevices.test.js │ │ ├── selfTrustedDevices.test.js
│ │ ├── session.test.js │ │ ├── session.test.js
│ │ ├── shardBroadcast.visibility.test.js
│ │ ├── shardControllerPublic.test.js │ │ ├── shardControllerPublic.test.js
│ │ ├── shardIngest.champsPages.test.js │ │ ├── shardIngest.champsPages.test.js
│ │ ├── shardIngest.market.test.js
│ │ ├── shardIngest.points.test.js
│ │ ├── shardIngest.protocol2.test.js │ │ ├── shardIngest.protocol2.test.js
│ │ ├── shardIngest.ruleset.test.js
│ │ ├── shardMarket.model.test.js
│ │ ├── shardState.governorTerms.test.js │ │ ├── shardState.governorTerms.test.js
│ │ ├── shardState.model.test.js │ │ ├── shardState.model.test.js
│ │ ├── shardVisibility.test.js
│ │ ├── spawnAtlas.parse.test.js
│ │ ├── spawnAtlas.source.test.js
│ │ ├── ssoCallback.test.js │ │ ├── ssoCallback.test.js
│ │ ├── ssoState.test.js │ │ ├── ssoState.test.js
│ │ ├── ssoTrustedDevice.test.js
│ │ ├── totp.test.js │ │ ├── totp.test.js
│ │ ├── trustedDevices.test.js │ │ ├── trustedDevices.test.js
│ │ ├── trustProxy.test.js │ │ ├── trustProxy.test.js
│ │ ├── uoLinkClient.test.js
│ │ └── usernamePolicy.test.js │ │ └── usernamePolicy.test.js
│ ├── tools/
│ │ └── cliloc-export/
│ │ ├── clilocexport.csproj
│ │ ├── Program.cs
│ │ └── README.md
│ ├── .env.example │ ├── .env.example
│ ├── package-lock.json │ ├── package-lock.json
│ ├── package.json │ ├── package.json

View File

@@ -60,12 +60,32 @@ board while holding back one column. See the table in §3.
| **Shard rules** | Skill/stat caps, house limits, vet rewards, the ruleset | Everyone | Connect address → Everyone | | **Shard rules** | Skill/stat caps, house limits, vet rewards, the ruleset | Everyone | Connect address → Everyone |
| **Spawn atlas** | Bestiary and spawn locations (static content) | Everyone | — | | **Spawn atlas** | Bestiary and spawn locations (static content) | Everyone | — |
| **Leaderboards** | Point and loyalty standings | Everyone | Character names → Everyone | | **Leaderboards** | Point and loyalty standings | Everyone | Character names → Everyone |
| **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Location → Everyone | | **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Vendor owner character id → Everyone · In-game location → Everyone |
**Why the marketplace ships with live updates off.** A live feed of every vendor's full inventory **Why the marketplace ships with live updates off.** A live feed of every vendor's full inventory
would be the single largest thing the site sends. No page needs it — the marketplace is a search over would be the single largest thing the site sends. No page needs it — the marketplace is a search over
stored data with a “prices last refreshed N minutes ago” stamp. Turn it on only if you want it. stored data with a “prices last refreshed N minutes ago” stamp. Turn it on only if you want it.
**Why the marketplace's fields default to Everyone.** A vendor's shop name, its owner's character
name and where it is standing are *already* visible to every player in game: the stock Vendor Search
gump surfaces exactly that set to anyone who opens it. Publishing them on the site is not a new
disclosure. They stay configurable because a shard may still prefer to keep its economy behind a
login — and because "already public in game" is a judgement about your shard, not ours.
**Location is one setting covering four things.** Hiding it removes the facet, the coordinates, the
region *and* the house name together. That is deliberate: those are four ways of saying the same
thing, and a setting that hid the coordinates while publishing the house name would not have hidden
anything.
**Hiding the owner name also hides the owner character id.** They are separate settings so you can
be explicit, but leaving the id published while hiding the name achieves nothing — the leaderboards
and guild boards resolve that same id back to a character name. Set both.
**What hiding a vendor cannot do.** Only vendors whose owner left the in-game *Vendor Search* flag ON
are ever sent to the site, so a player who hides their shop in game is hidden here too — and no
setting on this page can override that. It works the other way as well: these settings control who
sees the index, not whether players can find each other's shops in game.
**Why house owner/price default to Staff.** The public Houses page has always been a "where are the **Why house owner/price default to Staff.** The public Houses page has always been a "where are the
falling houses" board — location only. Owner and price are the staff view. That split is preserved. falling houses" board — location only. Owner and price are the staff view. That split is preserved.

View File

@@ -223,7 +223,8 @@ The atlas is fully functional as text. `shard_spawn_creatures.art` is nullable
and is NULL on every fresh import; pages render without images, which is the and is NULL on every fresh import; pages render without images, which is the
normal and supported state, not a degraded one. normal and supported state, not a degraded one.
An operator who wants art: An operator who wants art — step-by-step, with the UOFiddler side spelled out, in
[`UOFIDDLER.md`](UOFIDDLER.md) §Part 2:
1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or 1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or
any art extractor). any art extractor).

282
website/UOFIDDLER.md Normal file
View 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.