13 Commits

Author SHA1 Message Date
05e192ca70 Merge pull request 'feat(sidecar): store and serve the player-vendor market index' (#19) from feat/vendor-listing into edge
Reviewed-on: #19
2026-07-29 20:03:40 +00:00
480423090a feat(sidecar): store and serve the player-vendor market index
Protocol 3.0 §8. Ingests vendor.listing / vendor.listing.remove into a `vendors`
table and serves GET /market.

The frame is authoritative for one vendor, so the upsert is a whole-row
overwrite. Unlike the other 3.0 boards there IS a remove: a vendor is dismissed,
expires, or its owner switches off the in-game Vendor Search flag — the last of
those is a privacy control, so dropping the row promptly is the point.

Items ride inside the stored blob and are deliberately not normalized into a
vendor_items table. The sidecar's job for the market is outage resilience
(PROTOCOL_2.md §12.2), not search; search lives in MariaDB on the website side,
where the query surface, the indexes and the cliloc-resolved names already are.

/market is the only PAGED read the sidecar serves, because it is the only board
that can be a whole world's inventory. limit clamps to 1..1000 (default 200) and
`total` comes back 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 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 not /vendors: /vendors/:account next door is the
per-account RPC, and two routes a prefix apart meaning "this player's shops" and
"every shop on the shard" is a readability trap.

Frames are served verbatim, owner names and coordinates included — the sidecar
defines no audiences (v3.md §3.2).

Verified against the live shard: 27 vendors / 1,040 listings ingested from the
plugin, plus a synthetic insert-then-remove confirming the delete path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:14 -05:00
2e386a9d5c Merge pull request 'feat(store): persist points.board and serve it from GET /points' (#18) from feat/points-board into edge
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:53:17 +00:00
21a1462e62 feat(store): persist points.board and serve it from GET /points
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards as one points.board frame per system; the sidecar folds each into a
projection table and serves them back, so the site's leaderboards page renders
during a shard outage.

  - points_boards(system PK, name, json, updated_t), keyed by the shard's own
    PointsType name. `name` is hoisted only for the ORDER BY.
  - main.rs gains a points.board arm keyed on `system`, alongside the existing
    champ/guild/governor/house/ruleset projections. There is deliberately no
    delete counterpart: the shard's set of point systems is fixed at startup, so
    it emits no points.remove — the same shape the governor board already has.
  - GET /points returns every board ordered by display name; GET /points/:system
    returns one, or 404 when the shard has never published that system. 404 and
    "a published board nobody has scored in yet" (200, empty top) are different
    answers, and the website renders them differently.

Store-backed rather than an RPC for the same reason as the other boards, and it
matters more here: these are standings accumulated over months, so blanking them
during a shard restart reads as data loss rather than as staleness.

PROTOCOL_VERSION stays at 2 — the bump to 3 is the one-time edge → main cutover
in v3.md §4, not a per-phase change.

cargo build and cargo clippy --all-targets are clean. Smoke-tested against a
driver on the loopback link: two boards stored and served, a re-emitted system
overwriting rather than accumulating, 404 for an unknown system, 401
unauthenticated. Also verified against the real ServUO shard, which fed five
live boards through this path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:23 -05:00
41811d40af Merge pull request 'feat(store): persist world.ruleset and serve it from GET /ruleset' (#17) from feat/ruleset-endpoint into edge
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 20:35:31 +00:00
8e018a01e9 feat(store): persist world.ruleset and serve it from GET /ruleset
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame
per connect describing how it is configured; the sidecar folds it into a
singleton row and serves it back.

Store-backed rather than an RPC, for the same reason /guilds and /houses are
(PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is
worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the
shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false —
from a published ruleset, which the website renders differently.

`rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can
tell "same ruleset, re-sent on reconnect" from "the operator changed something"
without diffing.

PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and
happens exactly once, at the end of v3 (§4), not per phase.

Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset
returns it with plugin_connected=false (outage path), and the route sits behind
the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy
clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:19:10 -05:00
7e4177c6c0 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#16) from chore/sync-project-tree-ci into main
All checks were successful
Release sidecar / release (push) Successful in 6s
sync-project-tree / sync (push) Successful in 13s
SonarQube / analysis (push) Successful in 47s
Reviewed-on: #16
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:20:43 +00:00
ecdaf9171b ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:19:43 -05:00
f23b9030ed Merge pull request 'ci(sonarqube): correct project key to Runic-Gateway-link' (#15) from ci/sonarqube-fix-project-key into main
All checks were successful
Release sidecar / release (push) Successful in 8s
SonarQube / analysis (push) Successful in 2m9s
Reviewed-on: #15
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:23:45 +00:00
31497c38b2 ci(sonarqube): correct project key to Runic-Gateway-link
The SonarQube server already has a project keyed Runic-Gateway-link and
refuses to create a case-variant duplicate, so the scan (added in #13)
failed to auto-create runic-gateway-link. Match the existing key.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:22:58 -05:00
21b9920f80 Merge pull request 'ci(sonarqube): add non-blocking SonarQube analysis on push to main' (#13) from ci/sonarqube-analysis into main
Some checks failed
Release sidecar / release (push) Successful in 15s
SonarQube / analysis (push) Failing after 55s
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:15:14 +00:00
7224b9b834 ci(sonarqube): add non-blocking SonarQube analysis on push to main
Mirrors the website repo's setup: a source-based scan of sidecar/src that
reports to the self-hosted SonarQube server after merge, never gating PRs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:13:28 -05:00
45bb8b0de4 Merge pull request 'chore: add open-source governance files (GPLv3 + contributing docs)' (#12) from chore/open-source-governance into main
All checks were successful
Release sidecar / release (push) Successful in 24s
Reviewed-on: #12
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 00:30:33 +00:00
7 changed files with 629 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,52 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements release.yml
# (which builds + cuts releases) — this one only feeds the dashboard.
#
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# Runic-Gateway-link project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
#
# The runner (self-hosted `ubuntu-latest`, same as release.yml) must be able to
# reach SONAR_HOST_URL on your network. Nothing here waits on the SonarQube
# Quality Gate, so a failing gate does not fail this job — check the dashboard
# when you want to.
#
# Scope: this analyses the Rust source directly (the Sonar scanner reads
# sonar-project.properties). It does NOT build the crate or run Clippy — see the
# "Optional enrichment" note in sonar-project.properties for wiring in a Clippy
# report if your SonarQube edition supports Rust lint import.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
# SonarQube uses git history to attribute issues to authors and to
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

View File

@@ -0,0 +1,111 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/link/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/link
DOCS_PATH: link/PROJECT_TREE.md
TREE_TITLE: uo-link
ROOT_LABEL: link
PR_BRANCH: chore/sync-link-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

View File

@@ -194,6 +194,79 @@ async fn main() -> anyhow::Result<()> {
}
}
}
// Points/loyalty boards (Protocol 3.0): one row per point system, keyed by
// the shard's own PointsType name. The plugin only emits a system whose top N
// actually moved, so this is a sparse stream of overwrites — and there is no
// `points.remove` to handle, because the shard's set of systems is fixed at
// startup and cannot shrink.
"points.board" => {
if let Some(system) = ev.value.get("system").and_then(|s| s.as_str()) {
if let Err(e) = event_store
.upsert_points_board(
system,
ev.value.get("nameString").and_then(|n| n.as_str()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert points board");
}
}
}
// Player-vendor market index (Protocol 3.0). Each frame is authoritative for
// one vendor — the shard's round-robin sweep only emits a shop whose contents,
// prices or location actually moved — so this is a whole-row overwrite.
//
// Unlike the boards above there IS a remove: a vendor is dismissed, expires, or
// its owner switches off the in-game Vendor Search flag, and any of those must
// take the shop off the site. The last of the three is a privacy control, so
// dropping the row promptly is the point rather than housekeeping.
"vendor.listing" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
let loc = ev.value.get("location");
let field = |k: &str| loc.and_then(|l| l.get(k));
if let Err(e) = event_store
.upsert_vendor(
serial,
ev.value.get("shopName").and_then(|v| v.as_str()),
ev.value.get("ownerName").and_then(|v| v.as_str()),
field("map").and_then(|v| v.as_str()),
field("x").and_then(|v| v.as_i64()),
field("y").and_then(|v| v.as_i64()),
field("region").and_then(|v| v.as_str()),
ev.value.get("count").and_then(|v| v.as_i64()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert vendor listing");
}
}
}
"vendor.listing.remove" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store.delete_vendor(serial).await {
tracing::warn!(error = %e, "failed to remove vendor listing");
}
}
}
// Shard ruleset (Protocol 3.0): a singleton projection. The shard re-emits
// world.ruleset on every connect, so this row is simply overwritten; `rev`
// lets a reader tell a re-send from an actual config change.
"world.ruleset" => {
if let Err(e) = event_store
.upsert_ruleset(
ev.value.get("rev").and_then(|r| r.as_str()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert ruleset");
}
}
_ => {}
}
}

View File

@@ -293,6 +293,169 @@ impl Store {
Ok(parse_json_column(rows))
}
// ---- shard ruleset (Protocol 3.0) ----
/// Stores the shard's published ruleset. A singleton (`id = 1`): the shard emits one
/// `world.ruleset` frame per connect describing how it is configured, and only the latest one
/// matters. `rev` is the shard's FNV-1a of the body, kept so a reader can tell "same ruleset,
/// re-sent on reconnect" from "the operator changed something" without diffing the JSON.
pub async fn upsert_ruleset(&self, rev: Option<&str>, json: &str, t: i64) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO ruleset (id, rev, json, updated_t) VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET rev = excluded.rev, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(rev)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// The stored ruleset, or `None` if the shard has never published one. Returning `None` rather
/// than an empty object is deliberate: "not published yet" and "published, everything off" are
/// different answers and the website renders them differently.
pub async fn ruleset(&self) -> anyhow::Result<Option<Value>> {
let row = sqlx::query("SELECT json FROM ruleset WHERE id = 1")
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
}
// ---- points / loyalty boards (Protocol 3.0) ----
/// Upserts one system's leaderboard, keyed by its `PointsType` name (`QueensLoyalty`,
/// `CleanUpBritannia`, …). Fed from `points.board`; one row per system, always the most recent
/// top-N snapshot.
///
/// There is no matching delete, and that is deliberate rather than an omission: the shard's set
/// of point systems is fixed at startup by `PointsSystem.Configure`, so a system cannot vanish
/// at runtime and the plugin emits no `points.remove`. Same argument the governor board makes.
pub async fn upsert_points_board(
&self,
system: &str,
name: Option<&str>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO points_boards (system, name, json, updated_t) VALUES (?, ?, ?, ?)
ON CONFLICT(system) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(system)
.bind(name)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Every system's latest board, ordered by display name then system key. Systems the shard has
/// never published are simply absent — the website renders the set it is given.
pub async fn points_boards_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM points_boards ORDER BY name, system")
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
/// One system's board, or `None` when that system has never published one. `None` is a real
/// answer (an unknown system name, or one the operator excluded via `Bridge.cfg PointsSystems`),
/// which the website turns into a 404 rather than an empty board.
pub async fn points_board(&self, system: &str) -> anyhow::Result<Option<Value>> {
let row = sqlx::query("SELECT json FROM points_boards WHERE system = ?")
.bind(system)
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
}
// ---- player-vendor market index (Protocol 3.0) ----
/// Upserts one vendor's whole listing, keyed by serial. Fed from `vendor.listing`, which the
/// shard emits as an authoritative per-vendor frame — so this replaces the row outright rather
/// than merging anything.
///
/// The items ride inside `json` and are deliberately NOT normalized into a `vendor_items`
/// table. The sidecar's job for the market is outage resilience (`PROTOCOL_2.md` §12.2) — hand
/// the website back what the shard last said — not search. Search lives in MariaDB on the
/// website side, where the query surface, the indexes and the cliloc-resolved display names
/// already are; a second search implementation here would be one more thing to keep in step
/// with it for no reader.
#[allow(clippy::too_many_arguments)]
pub async fn upsert_vendor(
&self,
serial: &str,
shop_name: Option<&str>,
owner_name: Option<&str>,
map: Option<&str>,
x: Option<i64>,
y: Option<i64>,
region: Option<&str>,
count: Option<i64>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO vendors (serial, shop_name, owner_name, map, x, y, region, count, json, updated_t)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(serial) DO UPDATE SET shop_name = excluded.shop_name,
owner_name = excluded.owner_name, map = excluded.map, x = excluded.x, y = excluded.y,
region = excluded.region, count = excluded.count, json = excluded.json,
updated_t = excluded.updated_t",
)
.bind(serial)
.bind(shop_name)
.bind(owner_name)
.bind(map)
.bind(x)
.bind(y)
.bind(region)
.bind(count)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Drops one vendor from the index. Fed from `vendor.listing.remove` — a vendor dismissed,
/// expired, or whose owner switched off its in-game Vendor Search flag.
pub async fn delete_vendor(&self, serial: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM vendors WHERE serial = ?")
.bind(serial)
.execute(&self.pool)
.await?;
Ok(())
}
/// One page of the index, ordered by serial.
///
/// Paged where the other boards are not, and the ordering is why it can be: a whole-world
/// market is the one board that does not fit in a response. Ordering by SERIAL rather than by
/// shop name is deliberate — the page is a snapshot cursor for the website's reconnect
/// backfill, and a serial is stable while a shop name is renameable, so a rename mid-backfill
/// cannot make a vendor skip or repeat a page.
pub async fn vendors_page(&self, limit: i64, offset: i64) -> anyhow::Result<Vec<Value>> {
let limit = limit.clamp(1, 1000);
let offset = offset.max(0);
let rows = sqlx::query("SELECT json FROM vendors ORDER BY serial LIMIT ? OFFSET ?")
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
/// How many vendors the index holds, so a paging caller knows when to stop.
pub async fn vendors_count(&self) -> anyhow::Result<i64> {
let row = sqlx::query("SELECT COUNT(*) AS n FROM vendors")
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("n"))
}
// ---- Town Cryer news (Protocol 2.1) ----
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
@@ -392,4 +555,39 @@ CREATE TABLE IF NOT EXISTS news (
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
-- Points/loyalty leaderboards (Protocol 3.0). One row per point system, keyed by the shard's
-- own PointsType name; `name` is the resolved display name, hoisted only for the ORDER BY.
CREATE TABLE IF NOT EXISTS points_boards (
system TEXT PRIMARY KEY,
name TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
-- Player-vendor market index (Protocol 3.0). One row per vendor, holding the whole authoritative
-- `vendor.listing` frame including its items. The hoisted columns exist for the ORDER BY and for
-- an operator eyeballing the table; nothing here is searched, because search is the website's job
-- (see upsert_vendor). Rows are dropped on `vendor.listing.remove`.
CREATE TABLE IF NOT EXISTS vendors (
serial TEXT PRIMARY KEY,
shop_name TEXT,
owner_name TEXT,
map TEXT,
x INTEGER,
y INTEGER,
region TEXT,
count INTEGER,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
-- The shard's published ruleset (Protocol 3.0). Singleton: the CHECK is what makes it one,
-- so an upsert can target id = 1 unconditionally and no second row can ever appear.
CREATE TABLE IF NOT EXISTS ruleset (
id INTEGER PRIMARY KEY CHECK (id = 1),
rev TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
"#;

View File

@@ -82,6 +82,20 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/governors", get(governors))
.route("/online", get(online))
.route("/houses", get(houses))
// The shard ruleset (Protocol 3.0), likewise store-backed: the shard publishes it once per
// connect, so serving it from the store is what lets the site's rules page render while the
// shard is down.
.route("/ruleset", get(ruleset))
// Points/loyalty leaderboards (Protocol 3.0), store-backed like the other boards: the
// whole set, or one system by its PointsType name.
.route("/points", get(points))
.route("/points/:system", get(points_system))
// The player-vendor market index (Protocol 3.0). `/market`, NOT `/vendors`: axum would
// route the latter fine, but `/vendors/:account` next door is the per-account RPC, and two
// routes a prefix apart that mean "this player's shops" and "every shop on the shard" is a
// readability trap nobody wins. The only PAGED read the sidecar serves — a whole-world
// market does not fit in one response.
.route("/market", get(market))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new()
@@ -790,6 +804,60 @@ async fn houses(State(st): State<AppState>) -> impl IntoResponse {
}
}
/// The shard's published ruleset: expansion, which optional systems are on, skill/stat caps,
/// account and house limits, champion scroll rules, the save/restart schedule. Served from the
/// store, so it answers during a shard outage with the last-known ruleset — which is the whole
/// point, since a rules page that goes blank when the shard restarts is worse than a stale one.
///
/// `{"ruleset": null}` means the shard has never published one (an old plugin, or
/// `Bridge.RulesetEnabled=false`), which the website renders differently from a published ruleset.
async fn ruleset(State(st): State<AppState>) -> impl IntoResponse {
match st.store.ruleset().await {
Ok(r) => (StatusCode::OK, Json(json!({ "ruleset": r }))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// Every points/loyalty leaderboard the shard publishes: one entry per point system, each with its
/// display name (literal and/or cliloc), max points, participant count and top N. Store-backed like
/// the other boards, so the site's leaderboards page renders during a shard outage — which matters
/// more here than elsewhere, since these are month-scale standings that a restart must not blank.
async fn points(State(st): State<AppState>) -> impl IntoResponse {
match st.store.points_boards_all().await {
Ok(boards) => (StatusCode::OK, Json(json!({"boards": boards}))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// One system's board by its `PointsType` name (`QueensLoyalty`, `CleanUpBritannia`, …).
///
/// 404 rather than an empty board when the system is unknown: the shard publishes only the systems
/// it shows on the loyalty gump (or the explicit `Bridge.cfg PointsSystems` list), so "no such
/// board" and "a board with nobody on it" are different answers and the website renders them
/// differently.
async fn points_system(
State(st): State<AppState>,
Path(system): Path<String>,
) -> impl IntoResponse {
match st.store.points_board(&system).await {
Ok(Some(board)) => (StatusCode::OK, Json(board)),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown points system", "system": system})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// The current online population: total plus per-facet and per-region counts. This is the most
/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the
/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the
@@ -810,6 +878,54 @@ async fn online(State(st): State<AppState>) -> impl IntoResponse {
}
}
#[derive(Deserialize)]
struct PageQuery {
limit: Option<i64>,
offset: Option<i64>,
}
/// The player-vendor market index: every vendor's shop name, owner, location and priced inventory,
/// as the shard last published it. Store-backed like the other boards, which is what lets the
/// website's market page render (labelled stale) while the shard is down.
///
/// Paged — `?limit=&offset=`, limit clamped to 1..1000, default 200 — because this is the one board
/// that can be a whole world's inventory. `total` is returned alongside so the caller knows when to
/// stop rather than paging until it sees a short page, which would race a concurrent sweep.
///
/// The frames are served VERBATIM, including owner names and coordinates. That is not an oversight:
/// the sidecar defines no audiences (docs/link/v3.md §3.2). Deciding who may see a vendor's owner
/// or whereabouts is the website's job and is admin-configurable there.
async fn market(State(st): State<AppState>, Query(q): Query<PageQuery>) -> impl IntoResponse {
let limit = q.limit.unwrap_or(200);
let offset = q.offset.unwrap_or(0);
let total = match st.store.vendors_count().await {
Ok(n) => n,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
}
};
match st.store.vendors_page(limit, offset).await {
Ok(vendors) => (
StatusCode::OK,
Json(json!({
"vendors": vendors,
"total": total,
"limit": limit.clamp(1, 1000),
"offset": offset.max(0),
})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
// ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {

25
sonar-project.properties Normal file
View File

@@ -0,0 +1,25 @@
# SonarQube analysis config for the link (uo-link sidecar) repo.
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
# The project key must match the one created in SonarQube (dashboard URL
# ?id=Runic-Gateway-link).
sonar.projectKey=Runic-Gateway-link
sonar.projectName=runic gateway link
# Analysed application code. The Rust sidecar crate lives under sidecar/src.
# Rust unit tests live inline (#[cfg(test)] modules) rather than in a separate
# tree, so there is no distinct sonar.tests path to declare.
sonar.sources=sidecar/src
# Never analyse build output, the vendored lockfile, or generated config.
sonar.exclusions=**/target/**,**/*.lock
sonar.sourceEncoding=UTF-8
# ── Optional enrichment (enable if your SonarQube edition/version supports it) ──
# SonarQube imports Clippy findings when given a JSON report. To turn this on:
# 1. In sonarqube.yml, add a step before the scan that runs:
# cargo clippy --message-format=json > sidecar/clippy-report.json
# (needs the Rust toolchain + `rustup component add clippy` on the runner).
# 2. Uncomment the line below.
# sonar.rust.clippy.reportPaths=sidecar/clippy-report.json