9 Commits

Author SHA1 Message Date
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 316 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,21 @@ async fn main() -> anyhow::Result<()> {
}
}
}
// 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,35 @@ 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()))
}
// ---- Town Cryer news (Protocol 2.1) ----
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
@@ -392,4 +421,13 @@ CREATE TABLE IF NOT EXISTS news (
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,10 @@ 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))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new()
@@ -790,6 +794,23 @@ 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()})),
),
}
}
/// 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

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