Compare commits

11 Commits

Author SHA1 Message Date
7fa8953ffa Merge pull request 'ci(release): recompose the installer bundle after publishing' (#9) from ci/dispatch-bundle into main
All checks were successful
Release overlay / release (push) Successful in 7s
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 16:18:27 +00:00
4720a214a2 ci(release): recompose the installer bundle after publishing
Phase 0 item 3 of docs/installer/PLAN.md wired up from this side. The installer
does not resolve "latest" at run time — it installs the exact combination named
by a published bundle manifest (PLAN.md §7.1), so until now a new overlay release
was invisible to operators until the installer repo's nightly cron noticed it.

Adds a final step that POSTs to RunicGateway/installer's bundle workflow-dispatch
endpoint. That job re-reads this tarball's manifest.json and checks its declared `protocol`
against the sidecar's PROTOCOL_VERSION before publishing anything (gate 1) — the
check this repo cannot perform for itself, since the C# plugin announces no
version on the wire. It replaces the TODO the header has carried since #7, which
was deliberately left unimplemented while there was nothing to dispatch.

Dispatch, don't wait (PLAN.md §7.3): Gitea's dispatch endpoint returns no run
handle, so there is nothing to poll — a waiting step would have to guess which
run is its own while holding a runner idle. The bundle job runs its own gates
regardless of who started it.

A dispatch failure is a warning, never a failure of this job. By the time this
step runs the release is published and correct, so failing the run would
misreport that; the installer's nightly cron recomposes from whatever the latest
releases actually are, making a dropped dispatch cost latency rather than
correctness. That also means REGISTRY_TOKEN having write on the installer repo
is a nicety, not a new hard requirement — noted in the header.

Verified the workflow still parses and that the new step is last, gated on
release=='true', and contains no path that can exit non-zero.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:13:47 -05:00
3a52abbd77 Merge pull request 'fix(release): preflight credentials and recover the orphaned v0.1.0 tag' (#8) from fix/release-credential-preflight into main
All checks were successful
Release overlay / release (push) Successful in 10s
Reviewed-on: #8
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 15:26:54 +00:00
eebc74ac8d fix(release): preflight credentials and recover the orphaned v0.1.0 tag
The first release run tagged the repo and then failed, leaving v0.1.0 with
no release behind it and no way to ever get one.

REGISTRY_USER and REGISTRY_TOKEN are not configured on this repo, but the
tag push SUCCEEDED anyway: actions/checkout leaves an
`http.<host>.extraheader` credential in the local git config, so
`git remote set-url` to a URL with empty credentials still authenticated
through that leftover header. The release API call had no such fallback and
returned 401 (visible in the run log as `REGISTRY_USER:` / `REGISTRY_TOKEN:`
with empty values). So the run got exactly far enough to do the one thing
that is hard to undo.

Worse, that state was self-perpetuating. The plan step treated any existing
tag as "nothing to release", so every subsequent push to main would see
v0.1.0, set RELEASE=false, and stand down — the release would never appear,
and no amount of re-running would fix it.

Two fixes:

  A credential preflight, before anything is built or pushed, gated on the
  run intending to publish so a docs:/chore:-only merge still passes on a
  repo without secrets. It names the missing secrets and the scope they
  need, rather than failing at whichever step happens to use them first.

  Orphan-tag recovery. The plan step now asks the API whether a release
  exists for the tag: 200 means stand down, 404 means an earlier run died
  after tagging, so reuse the tag and publish the release it is missing.
  This deliberately overrides the RELEASE=false the bump logic just decided
  — with the tag already in place there are no releasable commits after it,
  which is precisely why the stuck state could not clear itself.

  Anything other than 200/404 (network failure, bad token) is refused
  rather than guessed, because assuming "no release" would republish over a
  good one.

  The tag step reuses an existing tag instead of failing on `git tag`, and a
  recovery run's changelog summarizes what the tag contains
  (previous-tag..this-tag) instead of the empty range after it.

Once REGISTRY_USER / REGISTRY_TOKEN are set, the next push to main will
finish the release that the first run started — v0.1.0, from the same
commit it already points at.

Verified against the live repo state: the plan step now reports
release=true reuse_tag=true for the orphaned v0.1.0 and renders the correct
changelog; a tag that does have a release (checked against link's v0.3.0)
still stands down; a fresh repo still takes the seed path; and the
preflight fails loudly on empty secrets and passes on populated ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 10:15:13 -05:00
724262548b Merge pull request 'ci(release): publish the overlay as a release tarball with a manifest' (#7) from ci/overlay-release-workflow into main
Some checks failed
Release overlay / release (push) Failing after 10s
Reviewed-on: #7
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 14:26:46 +00:00
ebbfab51fc ci(release): publish the overlay as a release tarball with a manifest
Phase 0 item 1 of the installer plan (docs/installer/PLAN.md §5). The
installer deploys the plugin from a release tarball rather than from git,
because the shard host gets neither git nor Gitea credentials — but this
repo published no releases at all, so there was nothing for it to fetch.
`link` was the only repo with a release workflow.

Reuses link/.gitea/workflows/release.yml's conventional-commit engine, as
that file's own header anticipated: the plan and release steps consume only
{version, changelog, artifacts}. Three things had to change, each forced by
this repo rather than chosen:

  No build. The plugin ships as C# source and ServUO compiles it at boot;
  it needs ServUO reference assemblies, so nothing here can be compiled in
  CI. The build gates are replaced by structural ones that assert what can
  honestly be asserted without a ServUO tree: Bridge.cfg and the Bridge
  scripts are present, Scripts.csproj (the silent-build-bug fix) is present,
  every .patch parses as a unified diff via `git apply --stat`, and each
  patch's companion .cs exists. Each of those has a way of shipping broken
  and only failing on an operator's live shard.

  No bump commit, so no push to main. link writes the version into
  Cargo.toml because the binary embeds it; a tarball embeds nothing but the
  manifest CI generates, so the git tag is the version. This workflow
  therefore never needs main to accept a direct push — no branch-protection
  exception for it.

  A manifest. The tarball carries manifest.json: version, commit, declared
  protocol version, ServUO compatibility, and a SHA256 per shipped file.

The manifest matters more than it looks. The plugin announces no version on
the wire and none is queryable before ServUO boots (PLAN.md §2.6), so its
declared protocol version is the ONLY thing that lets the installer's bundle
CI verify sidecar/overlay agreement before an operator installs the pair
(PLAN.md §7.1 gate 1). That declaration lives in the new overlay.toml
alongside the ServUO compatibility values, so it is one commented line to
maintain rather than a literal buried in a workflow — currently protocol 3,
per docs/link/v3.md.

Tarball layout uses a FIXED top-level directory (runicgateway-overlay/)
rather than a versioned one, so the installer can find overlay/, patches/
and manifest.json at known paths instead of parsing the version it is trying
to read. tar's member order, mtime and ownership are pinned, so a given tree
produces a byte-identical tarball and its checksum changes only when the
contents do.

Verified locally against the real tree before pushing: YAML parses, all six
run blocks pass bash -n, the plan step produces v0.1.0 from actual history,
the gates pass (22 bridge scripts, all three patches parse), the manifest
renders with protocol=3 and 30 file hashes, and two consecutive builds of
the tarball produce the same SHA256.

One real bug caught by running it rather than reading it: sha256sum marks
binary mode by prefixing the path with `*` instead of the two-space
text-mode separator, which would have put a leading `*` on every key in the
manifest. The capture now tolerates both.

Not included: the workflow-dispatch call into the installer's bundle CI
(PLAN.md §7.2). That is Phase 0 item 3 and there is nothing to dispatch yet;
the insertion point is marked in the header. A step that 404s on every
release is worse than no step.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 09:23:56 -05:00
968b526fac Merge pull request 'feat(bridge)!: Protocol 3.0 cutover — world.ruleset, points.board, vendor.listing' (#6) from edge into main
Reviewed-on: #6
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:33:27 +00:00
7215ae5fe1 Merge pull request 'feat(bridge): publish the player-vendor market index as vendor.listing' (#5) from feat/vendor-listing into edge
Reviewed-on: #5
2026-07-29 20:06:43 +00:00
48d57e6278 feat(bridge): publish the player-vendor market index as vendor.listing
Protocol 3.0 §8. Every player vendor's shop name, owner, location and priced
inventory, so the website can offer the search the in-game Vendor Search gump
offers — from outside the game, and honouring the same per-player opt-out.

It cannot be an RPC. rpc.rs 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
would not fit in one frame inside the 10 s timeout either. So it is a diff sweep
on the broadcast stream, one authoritative frame per vendor.

The one genuinely new pattern here is an amortized round-robin: every other sweep
walks its whole collection per tick, which is fine for tens of houses and is not
fine for a world of shops whose inventories recurse into containers.
MarketSweepBatch (25) vendors are inventoried per tick from a persistent cursor,
so per-tick cost is bounded by the batch rather than by world size.

VendorSearch.GetItemName is never called: it builds an ObjectPropertyList,
serialises it and byte-parses the packet per item. The frame carries itemId, hue,
amount, price, the plain item.Name field and item.LabelNumber; the website
resolves names against its own cliloc table. (It would not work anyway — every
current client ships its cliloc files compressed and ServUO's Ultima.StringList
cannot read them, so the in-game gump has the same gap.)

Measured on the live shard (27 vendors x 40 listings, 209k items / 43k mobiles):
15.4 ms for the first cold tick of 25 vendors, 3.4 ms for the next, 0.3 ms in
steady state. `[bridge status` now reports lastMs/maxMs and a tick over 50 ms
warns, naming the knob — the batch cap is a claim about that number and an
operator tuning it was otherwise tuning blind.

- location is ONE nested object, not flat map/x/y/region, so the website's single
  market.location visibility rule can hide a vendor's whereabouts on both the
  live frame and the stored read model. Flat keys would need five rules.
- Owner is flat ownerSerial/ownerName, never BridgeJson.Actor, which would add
  acct and webId. Same argument points.board makes.
- pv.VendorSearch is honoured, so a shop hidden in game is hidden on the site;
  the seen-set removal then emits vendor.listing.remove.
- Container-priced items carry child:true, exactly as DoSearch reports them.
- Over MarketMaxListings (250) the frame says truncated and carries the real
  total, so the site shows "250 of 3,104" rather than a partial shop as complete.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:00 -05:00
a38afe4c90 Merge pull request 'feat(bridge): publish points/loyalty leaderboards as points.board' (#4) from feat/points-board into edge
Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:54:09 +00:00
ed8f568d94 feat(bridge): publish points/loyalty leaderboards as points.board
Protocol 3.0 §7 (docs/link/v3.md). ServUO carries ~25 separate point currencies
— Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city
loyalties, the Doom/Khaldun/Kotl treasure systems — every one a standing players
build over months, and none of them visible outside an in-game gump until now.

BridgePoints.cs
  - A diff sweep shaped like BridgeHousing: ServerStarted arms the timer, a
    sidecar connect clears the diff state so a fresh sidecar gets every board,
    and each pass emits only the systems whose top N or participant count moved.
    One ~600 B frame per system rather than one 12 KB frame, matching
    champ.update / guild.update. No points.remove — the system set is fixed at
    startup by PointsSystem.Configure, the same argument city.update makes.
  - Selection is a single bounded pass into a fixed N-element array kept sorted
    by insertion, NOT OrderByDescending().Take(N). PlayerTable is a plain List
    and ten of the ~25 systems have AutoAdd = true, so they hold a row for every
    character ever created: the naive version is ~25 full sorts on the Core
    thread, which BRIDGE_PLUGIN_PLAN.md §1 measured as the second thing in the
    bridge capable of blowing a frame budget.
  - Which systems publish defaults to the shard's OWN answer — ShowOnLoyaltyGump
    — rather than a list here that would drift; Bridge.cfg PointsSystems=
    overrides it, and an unrecognised name is logged rather than dropped.
  - Entries are written inline as {serial, name}, never via BridgeJson.Actor. A
    board is the widest-audience surface the bridge has, so acct/webId
    deliberately do not cross the wire; the site resolves serial → user from its
    own link mirror.

char.profile gains a points block, the titles precedent from PROTOCOL_2.md §10.3
  - Never uses PointsSystem.GetEntry/GetPoints: both MUTATE THE WORLD, since
    GetEntry(create: false) still calls AddEntry when the system has AutoAdd
    (PointsSystem.cs:207). Using them would have appended up to ten rows to the
    points save file every time anyone opened a character sheet. Hand-rolled
    read-only scan instead.
  - rank is off by default (PointsProfileRank). A points lookup stops at the
    character's own row; a rank must count every row that beats them, in every
    system, on every profile build.

Verified by running it, not by reading it: the whole Scripts tree (6,207 files)
compiles clean against real ServUO 57.4 assemblies, and a boot against the local
shard with a 43,011-mobile world emitted five live boards. That run caught a bug
no fake shard could — ServUO's uncapped idiom is MaxPoints = double.MaxValue,
and (long) on it is an UNCHECKED conversion yielding long.MinValue, so the first
sweep published "maxPoints": -9223372036854775808 for three of the five boards.
Cap()/Score() now normalise anything unrepresentable, and maxPoints: 0 is the
documented "uncapped" value — which on a real shard is the common case, not an
edge case. Re-verified after the fix: 0 for the uncapped systems, 15000 and
10000 for the two that genuinely cap.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:08 -05:00
10 changed files with 1849 additions and 2 deletions

View File

@@ -0,0 +1,476 @@
# Automated release for the deployable ServUO overlay.
#
# Trigger: every push to `main` (i.e. every merged PR).
#
# Why this exists: the Runic Gateway installer deploys the plugin from a release
# tarball, not from git — the shard host gets no git and no Gitea credentials
# (docs/installer/PLAN.md §1, §5 Phase 0.1). Until this workflow, `link` was the
# only repo that published releases, so there was nothing for the installer to
# fetch. This is Phase 0 item 1.
#
# Flow (two conceptual halves, kept separate on purpose):
#
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
# │ reads: latest v* git tag + conventional-commit subjects │
# │ produces: next version, changelog, and (at the end) the release │
# └───────────────────────────────────────────────────────────────────┘
# ┌── OVERLAY ADAPTER (the only repo-specific part) ──────────────────┐
# │ consumes: the version │
# │ produces: runicgateway-overlay-<ver>.tar.gz + SHA256SUMS │
# └───────────────────────────────────────────────────────────────────┘
#
# The engine is `link/.gitea/workflows/release.yml`'s, reused as its own header
# anticipated — the plan and release steps consume only {version, changelog,
# artifacts} and know nothing about what is inside the artifacts.
#
# ── Three differences from link's copy, all forced by this repo ──────────────
#
# 1. NO BUILD. The plugin ships as C# source and ServUO compiles it at boot; it
# needs ServUO reference assemblies, so there is no way to compile it here.
# The build gates are replaced by the structural gates below, which is the
# most this repo can honestly assert about an artifact.
#
# 2. NO BUMP COMMIT, and so no push to `main`. link has to write the version
# into Cargo.toml because the binary embeds it; a tarball embeds nothing but
# the manifest.json this job generates, so the git tag IS the version. That
# removes a failure mode outright: this workflow never needs `main` to accept
# a direct push, so no branch-protection exception is required for it.
#
# 3. A MANIFEST. The tarball carries manifest.json — version, commit, declared
# protocol version, ServUO compatibility, and a SHA256 for every file. The
# installer needs it because the plugin announces no version on the wire and
# none is queryable before ServUO boots (PLAN.md §2.6): the manifest is the
# only thing that lets the bundle CI verify sidecar/overlay protocol
# agreement BEFORE an operator installs the pair (PLAN.md §7.1, gate 1).
#
# Version bump (conventional commits since the last v* tag):
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut (a docs:/chore:-only merge
# deliberately does NOT cut one — PLAN.md §7.3)
# (first ever run, no tag) -> releases SEED_VERSION below
#
# Prerequisites (Settings → Actions → Secrets on RunicGateway/servuo-plugins):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
# tag and create the release. The final step also dispatches
# RunicGateway/installer's bundle workflow, so the token
# ideally has write there too — a nicety, not a requirement:
# without it the step warns and that repo's nightly cron
# picks the release up instead.
# REGISTRY_USER — the Gitea username that token belongs to.
#
# These are checked by an explicit preflight step rather than left to fail
# wherever they happen to be used first — see the comment on that step for why
# an absent token does NOT simply fail the tag push.
#
# The final step POSTs to the installer repo's bundle workflow, so a new overlay
# release recomposes the compat matrix immediately instead of waiting for that
# repo's nightly cron (PLAN.md §7.2). It was deliberately absent until Phase 0
# item 3 landed something to dispatch — a step that 404s on every release is
# worse than no step.
name: Release overlay
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: release-overlay
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/servuo-plugins
# Artifact naming per PLAN.md §3.
ARTIFACT: runicgateway-overlay
# Used only for the very first release, when no v* tag exists yet. Matches the
# house style set by link (pre-1.0; the release version is independent of the
# protocol version, which lives in overlay.toml).
SEED_VERSION: "0.1.0"
# Notified after a release so the installer's compat matrix picks up this
# overlay immediately rather than at its next nightly run (PLAN.md §7.2).
INSTALLER_REPO: RunicGateway/installer
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Check out full history (need tags + commit log for the bump)
uses: actions/checkout@v4
with:
fetch-depth: 0
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
RELEASE=true
if [ -z "$LAST_TAG" ]; then
VERSION="$SEED_VERSION" # first release: seed
elif [ "$BUMP" = none ]; then
RELEASE=false # no feat/fix/breaking since last tag
VERSION="${LAST_TAG#v}"
else
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing — which is exactly what happened on the first run here,
# when the missing REGISTRY_* secrets took the release API call to 401
# after the tag had already been pushed. Standing down on the tag alone
# would make that state permanent: every later run would see the tag,
# set RELEASE=false, and the release would never appear. So distinguish
# the two cases and finish the job the earlier run started.
# Note this OVERRIDES the RELEASE=false decided just above. With the tag
# already in place there are no releasable commits after it, so the
# normal path stands down — which is precisely why the stuck state
# could never clear itself. Recovery has to be able to say "yes,
# publish" for a version the bump logic considers already done.
REUSE_TAG=false
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
if [ "$REL_HTTP" = "200" ]; then
echo "Tag v${VERSION} already has a release — nothing to do."
RELEASE=false
elif [ "$REL_HTTP" = "404" ]; then
echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true
RELEASE=true
else
# Anything else (000 from a network failure, 401/403 from a bad
# token) is not evidence of absence. Guessing "no release" here
# would re-publish over a good one, so refuse instead.
echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess."
exit 1
fi
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## ${ARTIFACT} v${VERSION}"
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-<none>}"
# ── Credential preflight ─────────────────────────────────────────────
# Runs BEFORE anything is built or pushed, and only when this run intends
# to publish, so a docs:/chore:-only merge stays green on a repo that has
# no secrets.
#
# This exists because of how the first run failed. REGISTRY_USER and
# REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway:
# actions/checkout leaves an `http.<host>.extraheader` credential in the
# local git config, so `git remote set-url` to a URL with empty
# credentials still authenticated through that leftover header. The
# release API call had no such fallback and returned 401 — so the run
# tagged the repo and then failed, which is the worst of both outcomes.
# Checking the secrets up front turns that into an immediate, legible
# failure instead of a half-published release.
- name: Verify release credentials are configured
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
MISSING=""
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
if [ -n "$MISSING" ]; then
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs the write:repository scope to push the tag and create the release."
exit 1
fi
echo "Release credentials present."
- name: Install jq
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
command -v jq >/dev/null 2>&1 && exit 0
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq
# ── OVERLAY ADAPTER: gates ───────────────────────────────────────────
# There is no compiler to run, so these assert the things that CAN be
# checked without a ServUO tree — and each one has actually been a way to
# ship a broken overlay:
#
# • overlay/ mirrors the server root; if Bridge.cfg or the Bridge scripts
# go missing the deploy silently no-ops (PLAN.md §2.1).
# • overlay/Scripts/Scripts.csproj is Phase 0 of the plugin itself — it
# overwrites a stock file to fix ServUO's silent script-build bug. An
# overlay shipped without it installs code that never compiles, and
# ServUO reports success anyway.
# • a malformed .patch is invisible until an operator runs the patch tier
# on their live shard. `git apply --stat` parses the diff without
# needing the target files present.
# • each patch's companion .cs must exist, since it references symbols
# the patch introduces and is meaningless without it (PLAN.md §2.2).
- name: Validate the overlay and patch tier
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
fail() { echo "::error::$*"; exit 1; }
[ -f overlay/Config/Bridge.cfg ] || fail "overlay/Config/Bridge.cfg is missing"
[ -f overlay/Scripts/Scripts.csproj ] || fail "overlay/Scripts/Scripts.csproj is missing (the silent-build-bug fix)"
[ -d overlay/Scripts/Custom/Bridge ] || fail "overlay/Scripts/Custom/Bridge/ is missing"
CS_COUNT="$(find overlay/Scripts/Custom/Bridge -name '*.cs' | wc -l)"
[ "$CS_COUNT" -gt 0 ] || fail "overlay/Scripts/Custom/Bridge/ contains no .cs files"
echo "overlay: ${CS_COUNT} bridge script(s)"
for p in patches/*.patch; do
[ -e "$p" ] || fail "patches/ contains no .patch files"
echo "--- ${p}"
git apply --stat "$p" || fail "${p} is not a parseable unified diff"
done
# Companion files that can only be copied after their patch lands.
for f in patches/BridgeVendorSale.cs patches/BridgeModerationAudit.cs; do
[ -f "$f" ] || fail "${f} is missing (a patch's companion source)"
done
[ -f overlay.toml ] || fail "overlay.toml is missing (protocol + ServUO declarations)"
# ── OVERLAY ADAPTER: stage, manifest, package ────────────────────────
# The tarball has a FIXED top-level directory (runicgateway-overlay/), not a
# versioned one: the installer extracts and then looks for overlay/,
# patches/ and manifest.json at known paths, and a version-dependent prefix
# would make it parse the very version it is trying to read.
#
# tar flags pin ownership, mtime and member order so the same tree produces
# a byte-identical tarball — a checksum that changes only when content
# changes is worth more than one that changes every run.
- name: Build manifest.json and the release tarball
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
STAGE="dist/stage/${ARTIFACT}"
mkdir -p "${STAGE}"
cp -r overlay "${STAGE}/overlay"
cp -r patches "${STAGE}/patches"
# Declarations from overlay.toml. Read, don't hardcode — the point of
# that file is that the protocol number lives in one place.
PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')"
MIN_SERVUO="$(grep -m1 -E '^min_servuo_version[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')"
PATCHED_AGAINST="$(grep -m1 -E '^patches_verified_against[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')"
[ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; }
[ -n "$MIN_SERVUO" ] || { echo "::error::could not read min_servuo_version from overlay.toml"; exit 1; }
[ -n "$PATCHED_AGAINST" ] || { echo "::error::could not read patches_verified_against from overlay.toml"; exit 1; }
echo "==> protocol=${PROTOCOL} min_servuo=${MIN_SERVUO} patches_verified_against=${PATCHED_AGAINST}"
# Per-file SHA256 of everything shipped, as a {path: sha} object. The
# installer records these in install.json so a later `doctor` can tell
# "operator edited a deployed file" from "the overlay drifted".
# The `\*?` is not paranoia: sha256sum marks binary mode by prefixing the
# path with `*` (`<hash> *path`) instead of the two-space text-mode
# separator. Coreutils on Linux defaults to text mode, but a build host
# that doesn't would otherwise put a leading `*` on EVERY key here and
# silently produce a manifest whose paths match nothing.
FILES="$(cd "${STAGE}" \
&& find overlay patches -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum \
| jq -R -s '
split("\n") | map(select(length > 0))
| map(capture("^(?<sha>[0-9a-f]+)[ \t]+\\*?(?<path>.+)$"))
| map({ (.path): .sha }) | add')"
jq -n \
--arg component "servuo-plugins-overlay" \
--arg version "${VERSION}" \
--arg commit "${GITHUB_SHA}" \
--arg repo "${REPO}" \
--argjson protocol "${PROTOCOL}" \
--arg min_servuo "${MIN_SERVUO}" \
--arg patched_against "${PATCHED_AGAINST}" \
--argjson files "${FILES}" \
'{
component: $component,
version: $version,
commit: $commit,
repo: $repo,
protocol: $protocol,
servuo: {
min_version: $min_servuo,
patches_verified_against: $patched_against
},
files: $files
}' > "${STAGE}/manifest.json"
echo "----- manifest.json (files elided) -----"
jq 'del(.files) + {file_count: (.files | length)}' "${STAGE}/manifest.json"
TARBALL="${ARTIFACT}-${VERSION}.tar.gz"
tar --sort=name --mtime='UTC 1970-01-01' \
--owner=0 --group=0 --numeric-owner \
-czf "dist/${TARBALL}" -C dist/stage "${ARTIFACT}"
( cd dist && sha256sum "${TARBALL}" > SHA256SUMS )
echo "tarball=${TARBALL}" >> "$GITHUB_OUTPUT"
ls -l dist && echo "----" && cat dist/SHA256SUMS
id: package
# ── RELEASE ENGINE: tag ──────────────────────────────────────────────
# Tag only — no bump commit, so `main` is never pushed to (see header).
- name: Push the release tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
# Secrets can arrive with a trailing newline (depending on how they were
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
# be parsed"). Strip line breaks before building the URL.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "servuo-plugins-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
# The tag may already exist when we are finishing a run that died after
# tagging (see the plan step). `git tag` on an existing name fails under
# `set -e`, and pushing an identical existing tag is a harmless no-op —
# so create it only if it is new, then push either way. A push that
# fails here means the remote tag points somewhere else, which SHOULD
# stop the run.
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Tag ${TAG} already exists — reusing it."
else
git tag "${TAG}"
fi
git push origin "${TAG}"
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
- name: Create Gitea release and upload assets
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
TARBALL="${{ steps.package.outputs.tarball }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
BODY="$(cat dist/CHANGELOG.md)"
# Same newline hygiene as the tag step: a stray CR/LF in the token would
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${TARBALL}" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done
# ── Recompose the installer's bundle manifest ────────────────────────
# The installer does not resolve "latest" at run time — it deploys the
# exact overlay named by a published bundle (docs/installer/PLAN.md §7.1).
# An overlay release that nobody recomposes around is therefore a release
# no operator will ever be offered. This tells the installer repo to
# rebuild that manifest now rather than leaving the new version invisible
# until its nightly cron.
#
# That job re-reads this tarball's manifest.json and checks its declared
# `protocol` against the sidecar's PROTOCOL_VERSION before publishing
# anything (PLAN.md §7.1, gate 1) — which is the check this repo cannot
# perform for itself, since the C# plugin announces no version on the wire.
#
# DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint
# returns no run handle, so there is nothing to poll: a waiting step would
# have to guess which run is its own and hold a runner idle to do it.
#
# A failure here is a WARNING, never a failure of this job. The release is
# already published and correct by this point, and failing the run would
# misreport that. The installer's nightly cron recomposes from whatever the
# latest releases actually are, so a dropped dispatch costs latency, not
# correctness.
- name: Ask the installer repo to recompose its bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ref":"main"}' \
"https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)"
case "$HTTP" in
20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;;
403|404)
echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;;
*)
echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;;
esac

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ obj/
*.exe
*.pdb
*.log
dist/

View File

@@ -27,6 +27,8 @@ integration guide, protocol spec, research — with full history preserved).
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
| `tools/` | Never deployed. Test scaffolding (C# probes + PowerShell stub sidecars) and anything else that must not reach a server. |
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
| `overlay.toml` | Release metadata: the wire-protocol version this overlay speaks, and its ServUO compatibility. Read by CI into the release manifest — see [Releases](#releases). |
| `.gitea/workflows/release.yml` | Publishes `runicgateway-overlay-<ver>.tar.gz` on every merge to `main`. |
| [INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md) | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. |
| [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) | Implementation plan, measured performance budget, and the full data catalog. |
| [RESEARCH.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/RESEARCH.md) | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
@@ -41,8 +43,9 @@ The two are deployed **together** but built **independently**:
- **This plugin** is deployed as *source*`deploy.ps1` copies `overlay/` into the ServUO server
root, and ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)).
There is no separate build artifact and no CI build — it cannot be compiled standalone without the
ServUO reference assemblies.
There is **no CI build** — it cannot be compiled standalone without the ServUO reference
assemblies. CI does publish a *source* tarball for the installer to fetch; see
[Releases](#releases).
- **The sidecar** is a standalone Rust binary, released from its own repo.
The **only** coupling is the loopback JSON protocol (the shard dials out to the sidecar on
@@ -59,6 +62,54 @@ without the sidecar running.
.\deploy.ps1 -ServerPath <servuo> # write
```
`deploy.ps1` is the **developer-facing** tool and stays that way. Operators get the
[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer), which does the
same sync cross-platform from the release tarball below.
## Releases
Every merge to `main` that carries a releasable conventional commit (`feat:`, `fix:`, `perf:`, or a
breaking change — a `docs:`/`chore:`-only merge deliberately cuts nothing) publishes a Gitea release:
```
runicgateway-overlay-<ver>.tar.gz
└── runicgateway-overlay/
├── manifest.json
├── overlay/ # exactly what deploy.ps1 would copy
└── patches/ # the opt-in stock-file diffs + their companion sources
SHA256SUMS
```
This is a **source** tarball, not a build — nothing here is compiled. It exists so the installer can
deploy the plugin onto a shard host that has no git and no Gitea credentials.
`manifest.json` is what makes the tarball self-describing:
```json
{
"component": "servuo-plugins-overlay",
"version": "0.1.0",
"commit": "968b526…",
"protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"files": { "overlay/Config/Bridge.cfg": "32718424…", }
}
```
- **`protocol`** comes from `overlay.toml` and is the plugin half of the compatibility contract. The
plugin announces no version on the wire and none is queryable before ServUO boots, so this
declaration is the only way the installer can check it against the sidecar's `PROTOCOL_VERSION`
*before* an operator installs the pair. **When the protocol changes, bump it in the same PR that
changes the emitters.**
- **`files`** carries a SHA256 per shipped file, so a deployment can later tell "an operator edited
this" from "the overlay moved on".
The version is derived from git tags — there is no version to maintain by hand and no bump commit,
so this workflow never pushes to `main`.
The tarball is byte-reproducible for a given tree (`tar --sort=name`, pinned mtime and ownership), so
its checksum changes only when its contents do.
## Status
| Phase | State |

42
overlay.toml Normal file
View File

@@ -0,0 +1,42 @@
# Release metadata for the deployable overlay.
#
# Consumed by .gitea/workflows/release.yml, which folds these values into the
# manifest.json shipped inside runicgateway-overlay-<ver>.tar.gz. The Runic
# Gateway installer reads that manifest to decide what it is deploying and
# whether it is compatible with the sidecar it is about to install
# (docs/installer/PLAN.md §5 Phase 0, §7.1).
#
# There is deliberately NO version key here. The release version is derived from
# git tags and conventional commits by the release workflow, so there is no bump
# commit to keep in sync and no way for this file to disagree with the tag.
# ── The loopback wire-protocol version this overlay speaks ───────────────────
#
# This is the plugin half of the compatibility contract. It MUST equal the
# sidecar's PROTOCOL_VERSION (link/sidecar/src/main.rs) for a deployment to
# work: the sidecar rejects a mismatch with 409 rather than mis-parsing.
#
# The C# plugin has no queryable version before ServUO boots — it does not
# announce one on the wire — so this declaration is the only thing that lets the
# installer's bundle CI check the pair BEFORE an operator installs them
# (docs/installer/PLAN.md §2.6, §7.1 gate 1). Keeping it honest is therefore a
# manual duty: when the protocol changes, bump it here in the same PR that
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
#
# Current: 3 — see docs/link/v3.md (world.ruleset, points.board, vendor.listing).
protocol = 3
# ── ServUO compatibility ─────────────────────────────────────────────────────
#
# The base overlay (Config/Bridge.cfg + Scripts/Custom/Bridge/*.cs) only ADDS
# files and is expected to work on any reasonably current ServUO. This is the
# oldest version it is known good on.
min_servuo_version = "57.4"
# The patches/ tier is a different matter: those are unified diffs against STOCK
# ServUO files, so they are verified against exactly one version and nothing
# else. On any other version the installer skips the whole tier with a warning
# and completes the base install (docs/installer/PLAN.md §1, §2.2) — losing
# vendor.sale events and in-game moderation-audit forwarding, but never
# half-patching an unknown tree.
patches_verified_against = "57.4"

View File

@@ -48,6 +48,64 @@ PresenceSweepSeconds=30
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
HousingSweepSeconds=300
# Points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7). ServUO carries ~25 point
# currencies (Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city loyalties,
# the Doom/Khaldun/Kotl treasure systems, …). Each is diffed on this interval and emitted as
# one points.board frame per system when its top N moves.
#
# Slow on purpose: these are month-scale standings, and ten of the systems keep a row for
# every character ever created, so the pass is the widest read in the bridge. It is still
# cheap — a single bounded pass, never a sort — but there is nothing to gain by hurrying it.
PointsSweepSeconds=300
# Master switch for the boards. Off leaves char.profile points alone (see below).
PointsLeaderboardEnabled=true
# How many players per board. Clamped to 1..100 — the frame is emitted PER SYSTEM, so a big
# N is multiplied by ~25.
PointsTopN=10
# Which systems to publish, as a comma-separated list of PointsType names, e.g.
# PointsSystems=QueensLoyalty,CleanUpBritannia,VoidPool
# Blank (the default) publishes whatever the shard itself shows on the in-game loyalty gump
# (ShowOnLoyaltyGump), so a subsystem you add later gets a board without an edit here.
# An unrecognized name is logged and ignored, never silently dropped.
PointsSystems=
# Include a per-character "points" block in char.profile (the website character sheet). This
# is a lookup across every published system's table, so it is the dominant cost of building a
# profile; turn it off on a very large shard that does not want the sheet paying for it.
PointsProfileEnabled=true
# Also compute each system's rank in that block. OFF by default and worth leaving off: 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. The website already derives rank from the
# board for anyone in the top N.
PointsProfileRank=false
# Player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8). Every player vendor's shop name,
# owner, location and priced inventory, published as one vendor.listing frame per vendor so the
# website can offer the search the in-game Vendor Search gump offers. Honours each player's own
# in-game opt-out (the vendor's VendorSearch flag) — hide your vendor in game and it is hidden
# on the site too.
MarketEnabled=true
# Sweep interval. UNLIKE every other sweep here, a tick does NOT walk the whole world: it
# inventories at most MarketSweepBatch vendors and a persistent cursor round-robins through the
# rest, so the per-tick cost is bounded by the batch rather than by how many vendors exist. Full
# coverage takes ceil(vendors / batch) x MarketSweepSeconds — 500 vendors at the defaults is one
# complete pass every 20 minutes, and the site labels the data with how stale it may be.
#
# Lower this (or raise the batch) for faster coverage; both trade directly against per-tick cost,
# and the expensive part is the item walk, which recurses into every container a vendor is selling.
MarketSweepSeconds=60
MarketSweepBatch=25
# Per-vendor listing cap, after which the frame carries "truncated": true. A commodity reseller
# with thousands of stacked resources is a real thing, and an uncapped frame for one is measured
# in megabytes. Clamped to 1..5000.
MarketMaxListings=250
# Shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5). One world.ruleset frame — expansion, which
# systems are on, skill/stat caps, account and house limits, champion scroll rules —
# emitted on every sidecar connect (and on [bridge reload), so the website's rules page

View File

@@ -165,6 +165,8 @@ namespace Server.Custom.Bridge
BridgeGovernance.Rearm();
BridgePresence.Rearm();
BridgeHousing.Rearm();
BridgePoints.Rearm();
BridgeMarket.Rearm();
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
// .cfg wants the change on the site now, not after a shard restart.
BridgeRuleset.Emit();
@@ -184,6 +186,8 @@ namespace Server.Custom.Bridge
BridgeGovernance.SweepOnce();
BridgePresence.SweepOnce();
BridgeHousing.SweepOnce();
BridgePoints.SweepOnce();
BridgeMarket.SweepOnce();
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
@@ -191,6 +195,8 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
break;
default:
@@ -205,6 +211,8 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
break;

View File

@@ -35,6 +35,20 @@ namespace Server.Custom.Bridge
public static int CitySweepSeconds { get; private set; }
public static int PresenceSweepSeconds { get; private set; }
public static int HousingSweepSeconds { get; private set; }
public static int PointsSweepSeconds { get; private set; }
public static int MarketSweepSeconds { get; private set; }
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
public static bool MarketEnabled { get; private set; }
public static int MarketSweepBatch { get; private set; }
public static int MarketMaxListings { get; private set; }
// ---- points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7) ----
public static bool PointsLeaderboardEnabled { get; private set; }
public static int PointsTopN { get; private set; }
public static string PointsSystems { get; private set; }
public static bool PointsProfileEnabled { get; private set; }
public static bool PointsProfileRank { get; private set; }
// ---- shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5) ----
public static bool RulesetEnabled { get; private set; }
@@ -112,6 +126,65 @@ namespace Server.Custom.Bridge
if (HousingSweepSeconds < 1)
HousingSweepSeconds = 1;
// Points/loyalty boards. The sweep touches every point entry on the shard, and ten of
// the ~25 systems keep a row per character ever created, so the default interval is
// deliberately slow — these are month-scale standings, not live state.
PointsSweepSeconds = Config.Get("Bridge.PointsSweepSeconds", 300);
if (PointsSweepSeconds < 1)
PointsSweepSeconds = 1;
PointsLeaderboardEnabled = Config.Get("Bridge.PointsLeaderboardEnabled", true);
// Board size. Bounded below at 1 because the selection indexes the Nth slot directly,
// and above at 100 because the frame is emitted per system — a large N multiplied by
// ~25 systems is how a "board" turns into a bandwidth problem.
PointsTopN = Config.Get("Bridge.PointsTopN", 10);
if (PointsTopN < 1)
PointsTopN = 1;
if (PointsTopN > 100)
PointsTopN = 100;
// Blank (the default) means "publish whatever the shard itself shows on the loyalty
// gump", so a shard that adds a subsystem gets its board without an edit here.
PointsSystems = Config.Get("Bridge.PointsSystems", "");
PointsProfileEnabled = Config.Get("Bridge.PointsProfileEnabled", true);
// Off by default, and the default is the point: a rank cannot early-exit the way a
// points lookup can — it must count every row that beats the player, in every system,
// on every profile build. See BridgeProfile.WritePoints.
PointsProfileRank = Config.Get("Bridge.PointsProfileRank", false);
// Player-vendor market index. Unlike every other sweep, this one does NOT walk its whole
// collection per tick: MarketSweepBatch caps how many vendors are inventoried, and a
// persistent cursor round-robins through the rest, so the per-tick cost is bounded by
// the batch rather than by how many vendors the world holds.
MarketEnabled = Config.Get("Bridge.MarketEnabled", true);
MarketSweepSeconds = Config.Get("Bridge.MarketSweepSeconds", 60);
if (MarketSweepSeconds < 1)
MarketSweepSeconds = 1;
// Bounded below at 1 (a batch of 0 would advance the cursor nowhere and publish nothing,
// silently) and above at 500, past which the batch stops bounding anything on any
// realistic shard and the tick is a whole-world pass by another name.
MarketSweepBatch = Config.Get("Bridge.MarketSweepBatch", 25);
if (MarketSweepBatch < 1)
MarketSweepBatch = 1;
if (MarketSweepBatch > 500)
MarketSweepBatch = 500;
// Per-vendor listing cap. BridgeJson.Parse caps INBOUND frames at 1 MB; outbound is
// uncapped and the sidecar's read_line will allocate whatever arrives, so the cap here
// is what keeps one commodity reseller with 8,000 stacked resources from emitting a
// multi-megabyte frame. Over the cap the frame carries "truncated": true and the site
// says so.
MarketMaxListings = Config.Get("Bridge.MarketMaxListings", 250);
if (MarketMaxListings < 1)
MarketMaxListings = 1;
if (MarketMaxListings > 5000)
MarketMaxListings = 5000;
// The ruleset frame is not a sweep — it is emitted once per sidecar connect (and on
// `[bridge reload`), so it has no interval. PublicConnectAddress is the ONE connection
// detail the bridge will publish, and only because an operator typed it here for that

View File

@@ -0,0 +1,591 @@
using System;
using System.Collections.Generic;
using System.Text;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
using Server.Engines.VendorSearching;
namespace Server.Custom.Bridge
{
/// <summary>
/// The shard-wide player-vendor index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8). Every player vendor's
/// shop name, owner, location and priced inventory, published as one authoritative
/// <c>vendor.listing</c> frame per vendor, so the website can offer the search the in-game
/// Vendor Search gump offers — from outside the game.
///
/// ---- Why this is a sweep and not an RPC ----
///
/// The obvious shape is a <c>market.snapshot</c> request/reply like vendor.snapshot next
/// door. It cannot work: the sidecar's rpc router correlates on the FIRST frame carrying a
/// matching reqId and resolves a single oneshot, 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 in one frame is not an option either — the reply timeout is 10 s and
/// 40,000 listings do not serialize in time.
///
/// So it is a diff sweep on the broadcast stream, shaped like <see cref="BridgeHousing"/>:
/// one frame per vendor, authoritative for that vendor, plus vendor.listing.remove when one
/// goes away. The per-account <c>vendor.snapshot</c> RPC is untouched; the player portal
/// keeps using it.
///
/// ---- The two perf traps, and what this does about them ----
///
/// 1. **VendorSearch.GetItemName is a packet builder, not a field read.** It constructs an
/// ObjectPropertyList, calls GetProperties, serialises it and then byte-parses the
/// resulting packet — PER ITEM. Across a full pass that is a multi-hundred-millisecond
/// stall on the Core thread. It is never called here. The frame carries `itemId`, `hue`,
/// `amount`, `price`, the plain `item.Name` field (null for most items) and
/// `item.LabelNumber`; the website resolves display names against its own cliloc table,
/// exactly as char.profile.equipment already does.
///
/// (On any modern client the call would not even work: every current client ships its
/// Cliloc.* files compressed, ServUO's bundled Ultima.StringList reads only the old plain
/// layout, so VendorSearch.StringList is null and GetItemName returns item.Name anyway.
/// The in-game gump has the same gap.)
///
/// 2. **A full pass is unbounded in world size.** 500 vendors × 80 listings is ~40,000 item
/// reads, and the reusable public GetItems(Container, List&lt;Item&gt;) recurses into
/// sub-containers, so the real count runs ABOVE the top-level pack.Items a naive estimate
/// would use. So the sweep is amortized: a persistent round-robin cursor over
/// PlayerVendor.PlayerVendors advances at most MarketSweepBatch vendors per tick, which
/// makes the PER-TICK cost bounded independently of how many vendors exist. Full coverage
/// takes ceil(vendors / batch) × MarketSweepSeconds. This is the one genuinely new pattern
/// versus the other sweeps, which all walk their whole collection every tick.
///
/// ---- Privacy ----
///
/// `pv.VendorSearch` is ServUO's own per-vendor opt-out and DoSearch filters on it, so a
/// player who hid their vendor in game is hidden on the website too: an opted-out vendor is
/// skipped entirely and the seen-set removal then drops it from the board. Map.Internal and
/// a null Backpack are skipped for the same reason DoSearch skips them.
///
/// Owner is written as flat `ownerSerial`/`ownerName` — never through BridgeJson.Actor,
/// which would add `acct` and `webId`. Same argument BridgePoints makes: this is the widest-
/// audience surface the bridge has, and the site resolves serial → user from its own
/// shard_account_links mirror when staff need it.
/// </summary>
public static class BridgeMarket
{
private static Timer _timer;
// vendor serial -> last-emitted signature.
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
// Round-robin cursor: an INDEX into PlayerVendor.PlayerVendors, not a serial. The list is
// mutated by placement/deletion between ticks, so the cursor is a hint, not a promise — it
// is wrapped and clamped every tick, and a shifted list at worst re-visits or defers a
// vendor by one cycle. Tracking a serial instead would cost a lookup to find "where was I"
// and buy nothing: the sweep is idempotent per vendor.
private static int _cursor;
private static long _sweeps, _emitted, _removed, _scanned, _skipped, _truncated;
// Per-tick cost, in milliseconds. Reported by `[bridge status` because the
// whole design of this sweep is a claim about that number — the batch cap is what makes it
// independent of world size — and an operator tuning MarketSweepBatch is otherwise tuning
// blind. `_maxMs` is the one that matters: the Core thread runs this between frames, so the
// worst tick is the budget, not the average.
private static double _lastMs, _maxMs;
private static readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
// Reused across ticks. The item walk is single-threaded (Core thread) and the list is
// cleared before each vendor, so one buffer serves the whole sweep — the alternative is a
// fresh List<Item> per vendor per tick, which at 25 vendors × every 60 s is pure garbage.
private static readonly List<Item> _items = new List<Item>();
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
BridgeLink.Connected_Core += OnConnected;
Rearm();
}
private static void OnConnected()
{
// A new sidecar knows nothing, so drop the diff state and start the round-robin from
// the top. The re-emit of the whole world is self-throttled by the batch window — this
// is the one place the amortized sweep pays for itself twice, because a reconnect on a
// whole-world sweep would otherwise be the biggest burst the bridge ever produces.
_last.Clear();
_cursor = 0;
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.MarketSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.MarketSweepSeconds),
MarketSweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
/// <summary>
/// A bare (key-less) string value, or JSON null.
///
/// <see cref="BridgeJson.Escape"/> takes a non-null string — it dereferences
/// <c>value.Length</c> immediately — and <see cref="BridgeJson.Str"/> writes the `,"key":`
/// prefix itself, so neither serves a value written inside a hand-built object. Most of
/// what this frame writes is legitimately null (an item's plain Name is null for nearly
/// every item, a vendor standing in the street has no house), so this is the common path
/// rather than an edge case.
/// </summary>
private static void Text(StringBuilder sb, string value)
{
if (value == null)
sb.Append("null");
else
BridgeJson.Escape(sb, value);
}
public static string Status()
{
var all = PlayerVendor.PlayerVendors;
return String.Format(
"market(enabled={0} sweeps={1} scanned={2} emitted={3} removed={4} skipped={5} truncated={6} tracked={7} vendors={8} cursor={9} batch={10} lastMs={11:F2} maxMs={12:F2})",
BridgeConfig.MarketEnabled, _sweeps, _scanned, _emitted, _removed, _skipped,
_truncated, _last.Count, all == null ? 0 : all.Count, _cursor,
BridgeConfig.MarketSweepBatch, _lastMs, _maxMs);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
MarketSweep();
}
/// <summary>
/// One tick: at most <c>MarketSweepBatch</c> vendors starting at the cursor, then the
/// removal pass.
///
/// The removal pass is the part the batching makes subtle. `_last` holds every vendor
/// seen in ANY previous tick, but this tick only visited a window — so "not in this
/// tick's seen set" does NOT mean gone. Removals are therefore decided against the
/// CURRENT vendor list (plus the opt-out/validity rules), not against the window, which
/// is a cheap pass over serials rather than a second inventory walk.
/// </summary>
private static void MarketSweep()
{
try
{
if (!BridgeConfig.MarketEnabled)
return;
_sweeps++;
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
_clock.Restart();
var all = PlayerVendor.PlayerVendors;
if (all == null || all.Count == 0)
{
Reap(null);
return;
}
// A live set of every serial that SHOULD be on the board right now, built as the
// window is walked plus a cheap pass over the rest. Built here rather than reusing
// a field so a throwing vendor cannot leave a half-built set behind.
var present = new HashSet<Serial>();
var count = all.Count;
var batch = Math.Min(BridgeConfig.MarketSweepBatch, count);
if (_cursor >= count)
_cursor = 0;
for (int i = 0; i < count; i++)
{
var vendor = all[i];
if (Eligible(vendor))
present.Add(vendor.Serial);
}
for (int n = 0; n < batch; n++)
{
var index = (_cursor + n) % count;
var vendor = all[index];
if (!Eligible(vendor))
{
_skipped++;
continue;
}
// One bad vendor must not cost the rest of the window: the item walk touches
// arbitrary Item subclasses on a shard running modified scripts.
try
{
SweepVendor(vendor);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] market sweep threw for 0x{0:X}: {1}",
vendor.Serial.Value, ex.Message);
}
}
_cursor = count == 0 ? 0 : (_cursor + batch) % count;
Reap(present);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] market sweep threw: {0}", ex.Message);
}
finally
{
// In `finally` so a throwing tick still records what it cost — a sweep that blows
// the budget and then throws is exactly the one worth seeing in the status line.
if (_clock.IsRunning)
{
_clock.Stop();
_lastMs = _clock.Elapsed.TotalMilliseconds;
if (_lastMs > _maxMs)
_maxMs = _lastMs;
WarnIfSlow();
}
}
}
/// <summary>
/// Per-tick budget, milliseconds. The batch cap exists to hold a tick under this
/// regardless of world size, so exceeding it means MarketSweepBatch is too large for
/// this shard's shops — the one thing an operator needs told, and the one thing
/// `[bridge status` cannot tell them unprompted. Generous: a tick is off the frame
/// budget, and the alternative to a rare 50 ms tick is a permanently stale market.
/// </summary>
private const double SlowTickMs = 50.0;
// At most one warning a minute. A shard whose batch is genuinely too big would otherwise
// print every MarketSweepSeconds forever, and a log nobody can read is a log nobody reads.
private static DateTime _lastWarn = DateTime.MinValue;
private static void WarnIfSlow()
{
if (_lastMs <= SlowTickMs)
return;
var now = DateTime.UtcNow;
if (now - _lastWarn < TimeSpan.FromMinutes(1))
return;
_lastWarn = now;
Console.WriteLine(
// ASCII only. The ServUO console writes in the OS code page, so an em dash here
// renders as "???" in the log an operator would paste into an issue.
"[Bridge] market sweep took {0:F1} ms (budget {1:F0} ms) - lower Bridge.MarketSweepBatch (now {2}) if this persists",
_lastMs, SlowTickMs, BridgeConfig.MarketSweepBatch);
}
/// <summary>
/// The same filter DoSearch applies, so the website's index is the in-game index.
/// <c>VendorSearch</c> is the player's own opt-out toggle and is honoured first.
/// </summary>
private static bool Eligible(PlayerVendor vendor)
{
return vendor != null
&& !vendor.Deleted
&& vendor.VendorSearch
&& vendor.Map != null
&& vendor.Map != Map.Internal
&& vendor.Backpack != null;
}
/// <summary>
/// Drops from the board every tracked vendor that is no longer eligible.
/// <paramref name="present"/> null means "there are no vendors at all", which clears it.
/// </summary>
private static void Reap(HashSet<Serial> present)
{
if (_last.Count == 0)
return;
List<Serial> gone = null;
foreach (var serial in _last.Keys)
{
if (present != null && present.Contains(serial))
continue;
if (gone == null)
gone = new List<Serial>();
gone.Add(serial);
}
if (gone == null)
return;
for (int i = 0; i < gone.Count; i++)
{
_last.Remove(gone[i]);
BridgeLink.Emit(BridgeJson.Begin("vendor.listing.remove").Ser("serial", gone[i]).End());
_removed++;
}
}
private static void SweepVendor(PlayerVendor vendor)
{
_scanned++;
CollectItems(vendor);
var sig = Signature(vendor);
string prior;
if (_last.TryGetValue(vendor.Serial, out prior) && prior == sig)
return; // nothing about this shop changed since it was last published
_last[vendor.Serial] = sig;
BridgeLink.Emit(WriteVendor(vendor));
_emitted++;
}
/// <summary>
/// Every sellable item on one vendor, into the shared buffer.
///
/// Mirrors VendorSearch's own private GetItems(PlayerVendor): the vendor's own movable
/// equipment (minus the backpack itself and hair layers, which are not merchandise)
/// followed by a recursive walk of the backpack. The recursion uses the PUBLIC
/// GetItems(Container, List&lt;Item&gt;) rather than a hand-rolled one so that ServUO's
/// rule about which containers are sold whole (quivers, seed boxes, jewelry boxes, …)
/// stays ServUO's to define — the predicate that decides it is private, and a copy here
/// would silently diverge the first time that list changes.
/// </summary>
private static void CollectItems(PlayerVendor vendor)
{
_items.Clear();
var own = vendor.Items;
if (own != null)
{
for (int i = 0; i < own.Count; i++)
{
var item = own[i];
if (item == null || !item.Movable || item == vendor.Backpack)
continue;
if (item.Layer == Layer.Hair || item.Layer == Layer.FacialHair)
continue;
_items.Add(item);
}
}
if (vendor.Backpack != null)
VendorSearch.GetItems(vendor.Backpack, _items);
}
/// <summary>
/// A listing's price, and whether it was priced by an enclosing container.
///
/// ServUO prices a container as a unit: an item inside a priced bag has no VendorItem of
/// its own and inherits the bag's price, which DoSearch surfaces as `isChild`. Reproduced
/// exactly, because a website that priced every item in a 40k bag at 40k would be lying
/// about the shard.
/// </summary>
private static int PriceOf(PlayerVendor vendor, Item item, out bool child)
{
child = false;
var vi = vendor.GetVendorItem(item);
if (vi != null)
return vi.Price;
var parent = item.Parent as Container;
while (parent != null)
{
vi = vendor.GetVendorItem(parent);
if (vi != null)
{
child = true;
return vi.Price;
}
parent = parent.Parent as Container;
}
return 0;
}
/// <summary>
/// The diff key. Location, shop name and owner are in it because they move a vendor's
/// row on the site; every listing's serial, price and amount are in it because those are
/// what a shopper searches on.
///
/// Built over the SAME buffer the frame is written from, in the same order, so a
/// signature match really does mean an identical frame — a cheaper hash (count + a sum
/// of serial^price, as §8.3 first proposed) collides on the common case of two items
/// swapping prices, which is exactly what re-pricing a shop looks like.
/// </summary>
private static string Signature(PlayerVendor vendor)
{
var sb = new StringBuilder(256);
sb.Append(vendor.ShopName ?? "").Append('|');
sb.Append(vendor.Owner == null ? 0 : vendor.Owner.Serial.Value).Append('|');
sb.Append(vendor.Map == null ? "" : vendor.Map.Name).Append('|');
sb.Append(vendor.X).Append(',').Append(vendor.Y).Append('|');
var limit = Math.Min(_items.Count, BridgeConfig.MarketMaxListings);
sb.Append(_items.Count).Append('|');
for (int i = 0; i < limit; i++)
{
var item = _items[i];
if (item == null || item.Deleted)
continue;
bool child;
var price = PriceOf(vendor, item, out child);
if (price <= 0)
continue;
sb.Append(item.Serial.Value.ToString("X")).Append(':')
.Append(price).Append(':')
.Append(item.Amount).Append(';');
}
return sb.ToString();
}
/// <summary>
/// One vendor frame — authoritative for that vendor, so the website replaces its whole
/// listing set from it rather than merging.
///
/// `location` is one nested object rather than flat map/x/y/region because it is ONE
/// admin-configurable field on the site (`market.location`): the visibility projection
/// matches literal JSON keys, so a nested object is what lets a single rule hide a
/// vendor's whereabouts on both the live frame and the stored read model. Flat keys
/// would need five rules that could drift apart.
///
/// `count` is the number of listings PUBLISHED, and `truncated` says the shop holds
/// more. A shop over the cap is a real thing (commodity resellers run thousands of
/// stacks) and the site says so rather than quietly showing a partial shop as complete.
/// </summary>
private static string WriteVendor(PlayerVendor vendor)
{
var sb = BridgeJson.Begin("vendor.listing")
.Ser("serial", vendor.Serial)
.Str("shopName", vendor.ShopName);
var owner = vendor.Owner;
if (owner != null)
{
sb.Ser("ownerSerial", owner.Serial);
sb.Str("ownerName", owner.Name);
}
sb.Append(",\"location\":{\"map\":");
Text(sb, vendor.Map == null ? null : vendor.Map.Name);
sb.Append(",\"x\":").Append(vendor.X);
sb.Append(",\"y\":").Append(vendor.Y);
sb.Append(",\"z\":").Append(vendor.Z);
var region = vendor.Region;
sb.Append(",\"region\":");
Text(sb, region == null ? null : region.Name);
// The house name is the sign's, which is what a player would be told to look for
// ("Bob's Villa"), not the house type. Null for a vendor standing outside one.
var house = vendor.House;
var sign = house == null ? null : house.Sign;
sb.Append(",\"house\":");
Text(sb, sign == null ? null : sign.GetName());
sb.Append('}');
var max = BridgeConfig.MarketMaxListings;
var published = 0;
var considered = 0;
var items = new StringBuilder(512);
for (int i = 0; i < _items.Count; i++)
{
var item = _items[i];
if (item == null || item.Deleted)
continue;
bool child;
var price = PriceOf(vendor, item, out child);
// Unpriced items are inventory, not listings — DoSearch drops them the same way.
if (price <= 0)
continue;
considered++;
if (published >= max)
continue;
if (published > 0)
items.Append(',');
items.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
items.Append(",\"itemId\":").Append(item.ItemID);
items.Append(",\"hue\":").Append(item.Hue);
items.Append(",\"amount\":").Append(item.Amount);
items.Append(",\"price\":").Append(price);
// The PLAIN Name field, which is null for most items — never GetItemName, which
// builds and parses a property packet per item. LabelNumber is the cliloc the
// website resolves against its own table.
items.Append(",\"name\":");
Text(items, item.Name);
items.Append(",\"cliloc\":").Append(item.LabelNumber);
if (child)
items.Append(",\"child\":true");
items.Append('}');
published++;
}
sb.Num("count", published);
sb.Num("total", considered);
sb.Bool("truncated", considered > published);
if (considered > published)
_truncated++;
sb.Append(",\"items\":[").Append(items).Append(']');
return sb.End();
}
}
}

View File

@@ -0,0 +1,404 @@
using System;
using System.Collections.Generic;
using System.Text;
using Server.Engines.Points;
namespace Server.Custom.Bridge
{
/// <summary>
/// Points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7). ServUO carries ~25 separate point
/// currencies (Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city
/// loyalties, Blackthorn, the Doom/Khaldun/Kotl treasure systems, …), every one of them a
/// standing a player accumulates over months — and none of them has ever been visible
/// anywhere but an in-game gump. This is the diff sweep that publishes them as boards.
///
/// Shaped like <see cref="BridgeHousing"/>: ServerStarted arms a timer, a sidecar connect
/// clears the diff state so a fresh sidecar gets every board, and each pass emits only the
/// systems whose top N actually moved. One frame per system (~600 B) rather than one 12 KB
/// frame, matching champ.update / guild.update.
///
/// **There is no `points.remove`.** The set of systems is fixed at Configure() time by
/// PointsSystem.Configure — a system cannot disappear at runtime — which is the same
/// argument city.update already makes for cities.
///
/// ---- The perf trap, and why the selection looks like this ----
///
/// `PlayerTable` is a plain List&lt;PointsEntry&gt;, and QueensLoyalty has AutoAdd = true, so it
/// holds an entry for every PlayerMobile that has ever logged in — zero-point rows included.
/// The obvious `.OrderByDescending(e =&gt; e.Points).Take(N)` is a full sort PER SYSTEM: at
/// 20,000 historical characters that is ~25 sorts and ~7.5 M comparisons on the Core thread,
/// tens of milliseconds, which BRIDGE_PLUGIN_PLAN.md §1 measured as the second thing in the
/// whole bridge capable of blowing a frame budget (bulk profile generation being the first).
///
/// So: a single pass per system into a fixed N-element array kept sorted by insertion.
/// O(n·N) with tiny constants, one allocation for the whole sweep, and the common case is a
/// single comparison against the running Nth place before the row is rejected. ~500 k cheap
/// iterations per pass at the default 300 s interval.
/// </summary>
public static class BridgePoints
{
private static Timer _timer;
// PointsType name -> last-emitted signature.
private static readonly Dictionary<string, string> _last =
new Dictionary<string, string>(StringComparer.Ordinal);
private static long _sweeps, _emitted;
// Reused across systems and across sweeps: the selection is single-threaded (Core thread)
// and fully overwritten each time, so there is nothing to allocate per pass.
private static PointsEntry[] _top = new PointsEntry[0];
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
BridgeLink.Connected_Core += OnConnected;
Rearm();
}
private static void OnConnected()
{
// A new sidecar knows nothing; drop the diff state so the next pass re-emits every board.
_last.Clear();
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.PointsSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.PointsSweepSeconds),
PointsSweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
public static string Status()
{
return String.Format("points(enabled={0} sweeps={1} emitted={2} tracked={3} topN={4})",
BridgeConfig.PointsLeaderboardEnabled, _sweeps, _emitted, _last.Count,
BridgeConfig.PointsTopN);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
PointsSweep();
}
private static void PointsSweep()
{
try
{
if (!BridgeConfig.PointsLeaderboardEnabled)
return;
_sweeps++;
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
// Systems is a mutable static populated by ~25 separate subsystem constructors in
// PointsSystem.Configure(). It is null before that runs and could in principle hold
// a null element, so neither is assumed.
var systems = PointsSystem.Systems;
if (systems == null)
return;
var selected = SelectedSystems();
var n = BridgeConfig.PointsTopN;
if (_top.Length != n)
_top = new PointsEntry[n];
for (int i = 0; i < systems.Count; i++)
{
var sys = systems[i];
if (sys == null)
continue;
// One bad system must not cost the rest of the sweep: Name/MaxPoints are
// abstract members implemented by 25 unrelated subsystems, any of which could
// throw on a shard running modified scripts.
try
{
SweepSystem(sys, selected);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] points sweep threw for {0}: {1}",
sys.Loyalty, ex.Message);
}
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] points sweep threw: {0}", ex.Message);
}
}
private static void SweepSystem(PointsSystem sys, HashSet<string> selected)
{
var key = sys.Loyalty.ToString();
if (!IsPublished(sys, key, selected))
return;
int ranked;
var count = SelectTop(sys, out ranked);
var sig = Signature(count, ranked);
string prior;
if (_last.TryGetValue(key, out prior) && prior == sig)
return; // top N and participant count both unchanged since last emit
_last[key] = sig;
BridgeLink.Emit(WriteBoard(sys, key, count, ranked));
_emitted++;
}
/// <summary>
/// Which systems are published. The default is the shard's OWN answer to "is this
/// player-facing?" — ShowOnLoyaltyGump, the flag that decides whether a system appears
/// on the in-game loyalty gump — rather than a list invented here that would drift from
/// the server every time a subsystem is added. `Bridge.cfg PointsSystems=` overrides it
/// with an explicit comma-separated list of PointsType names.
/// </summary>
private static bool IsPublished(PointsSystem sys, string key, HashSet<string> selected)
{
if (selected != null)
return selected.Contains(key);
return sys.ShowOnLoyaltyGump;
}
// Parsed form of BridgeConfig.PointsSystems, rebuilt when the raw string changes so
// `[bridge reload` picks up an edit without a restart. null == "no override, use
// ShowOnLoyaltyGump".
private static string _selectedRaw;
private static HashSet<string> _selected;
private static HashSet<string> SelectedSystems()
{
var raw = BridgeConfig.PointsSystems ?? "";
if (raw == _selectedRaw)
return _selected;
_selectedRaw = raw;
_selected = null;
if (raw.Trim().Length == 0)
return null;
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var part in raw.Split(','))
{
var name = part.Trim();
if (name.Length == 0)
continue;
// Resolve through the enum so a typo is reported loudly rather than silently
// publishing one board fewer than the operator asked for.
PointsType parsed;
if (Enum.TryParse(name, true, out parsed) && Enum.IsDefined(typeof(PointsType), parsed))
set.Add(parsed.ToString());
else
Console.WriteLine("[Bridge] unknown PointsSystems entry '{0}', ignoring", name);
}
_selected = set;
return _selected;
}
/// <summary>
/// Single pass over one system's PlayerTable, keeping the best <c>_top.Length</c> entries
/// in descending order. Returns how many slots were filled; <paramref name="ranked"/>
/// receives the number of players actually holding points.
///
/// Ties do not displace (the shift test is strict, and the reject test is inclusive), so
/// an unchanged table produces an unchanged board — which is what makes the diff
/// signature meaningful rather than a source of spurious re-emits.
/// </summary>
private static int SelectTop(PointsSystem sys, out int ranked)
{
ranked = 0;
var table = sys.PlayerTable;
var top = _top;
if (table == null || top.Length == 0)
return 0;
var count = 0;
for (int i = 0; i < table.Count; i++)
{
var entry = table[i];
if (entry == null)
continue;
var player = entry.Player;
// A deleted character keeps its row until the next save/load cycle, and AutoAdd
// systems are mostly zero-point rows. Neither belongs on a leaderboard.
if (player == null || player.Deleted || entry.Points <= 0)
continue;
ranked++;
var points = entry.Points;
// The common case for a big table: worse than the running Nth place, one compare.
if (count == top.Length && points <= top[count - 1].Points)
continue;
var pos = count < top.Length ? count : top.Length - 1;
while (pos > 0 && top[pos - 1].Points < points)
{
top[pos] = top[pos - 1];
pos--;
}
top[pos] = entry;
if (count < top.Length)
count++;
}
return count;
}
/// <summary>
/// A system's point ceiling as a whole number, or **0 meaning "uncapped"**.
///
/// `MaxPoints` is a double, and ServUO's idiom for "no cap" is `double.MaxValue`
/// (DespiseCrystals, ShameCrystals and VoidPool all do this). A plain `(long)` cast of
/// that is an UNCHECKED conversion — it does not throw, it produces `long.MinValue` —
/// which is exactly what the first sweep against a real shard published:
/// `"maxPoints": -9223372036854775808`. Anything not representable as a positive long
/// therefore becomes 0, which the website already renders as "no maximum".
/// </summary>
internal static long Cap(double value)
{
// NaN first: every comparison against NaN is false, so it would otherwise fall through
// to the same unchecked cast.
if (Double.IsNaN(value) || value <= 0 || value >= 9.2233720368547758E18)
return 0;
return (long)value;
}
/// <summary>
/// A score as a whole number. Same unchecked-cast hazard as <see cref="Cap"/>, but the
/// saturating direction is the opposite: an implausibly large score is still a large
/// score, so it clamps to long.MaxValue rather than collapsing to 0.
/// </summary>
internal static long Score(double value)
{
if (Double.IsNaN(value) || value <= 0)
return 0;
if (value >= 9.2233720368547758E18)
return Int64.MaxValue;
return (long)value;
}
/// <summary>
/// The diff key: every published serial and its whole-point score, plus the participant
/// count. Points are compared exactly as they are emitted, so a fractional award that
/// does not move the displayed number does not cost a frame either.
/// </summary>
private static string Signature(int count, int ranked)
{
var sb = new StringBuilder(64);
sb.Append(ranked).Append('|');
for (int i = 0; i < count; i++)
{
var entry = _top[i];
sb.Append(entry.Player.Serial.Value.ToString("X"))
.Append(':')
.Append(Score(entry.Points))
.Append(';');
}
return sb.ToString();
}
/// <summary>
/// One board frame.
///
/// `nameString` AND `nameNumber` are both emitted because Name is a TextDefinition, which
/// may carry either a literal or a cliloc id — the same contract titles.reward already
/// documents at BridgeProfile.cs:107-110. Resolving clilocs is the website's job.
///
/// **Entries are written inline as {serial, name} — never through BridgeJson.Actor.**
/// That is deliberate even though the website can now reveal fields by audience rung:
/// Actor would add `acct` and `webId`, and neither is needed here, because the site
/// resolves serial → user from its own shard_account_links mirror for staff views. A
/// board is the widest-audience surface the bridge has; the account name of every ranked
/// player has no business crossing the wire to reach it.
/// </summary>
private static string WriteBoard(PointsSystem sys, string key, int count, int ranked)
{
var name = sys.Name;
var sb = BridgeJson.Begin("points.board")
.Str("system", key)
.Str("nameString", name == null ? null : name.String)
.Num("nameNumber", name == null ? 0 : name.Number)
.Num("maxPoints", Cap(sys.MaxPoints))
.Bool("showOnGump", sys.ShowOnLoyaltyGump)
// Players actually HOLDING points, not PlayerTable.Count: an AutoAdd system has a
// zero-point row for every character that ever logged in, so the raw count would
// report the shard's whole character census as this system's participants.
.Num("players", ranked);
sb.Append(",\"top\":[");
for (int i = 0; i < count; i++)
{
var entry = _top[i];
if (i > 0)
sb.Append(',');
sb.Append("{\"rank\":").Append(i + 1);
sb.Append(",\"serial\":\"0x").Append(entry.Player.Serial.Value.ToString("X")).Append('"');
sb.Append(",\"name\":");
BridgeJson.Escape(sb, entry.Player.Name ?? "");
// Whole points: every one of these systems awards and displays integers in game,
// and a board that renders 29500.00000000001 would be a bug report.
sb.Append(",\"points\":").Append(Score(entry.Points));
sb.Append('}');
}
sb.Append(']');
return sb.End();
}
}
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Text;
using Server.Accounting;
using Server.Engines.Points;
using Server.Items;
using Server.Mobiles;
@@ -99,6 +100,7 @@ namespace Server.Custom.Bridge
sb.Append(']');
WriteTitles(sb, m);
WritePoints(sb, m);
return sb.End();
}
@@ -147,6 +149,147 @@ namespace Server.Custom.Bridge
sb.Append("]}");
}
/// <summary>
/// The point/loyalty standings this character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7.3). Read-model
/// enrichment on an existing kind, exactly like <see cref="WriteTitles"/> — there is no
/// request kind for "one character's points", because the profile is already the place
/// the website asks for everything about one character.
///
/// Systems with no entry, or an entry at zero, are omitted: ten of the ~25 systems have
/// AutoAdd = true and therefore hold a zero-point row for every character that has ever
/// logged in, so emitting them all would be ~25 lines of noise on every sheet.
///
/// **Never call PointsSystem.GetEntry / GetPoints here.** Both look benign and both
/// MUTATE THE WORLD: `GetEntry(from, create: false)` still calls AddEntry when the system
/// has AutoAdd (PointsSystem.cs:207), which appends a row to PlayerTable and fires
/// OnPlayerAdded. A read model that used them would silently grow the points save file by
/// up to ten rows every time anyone viewed a character sheet. Hence the manual scan.
///
/// Cost: one early-exiting pass over each published system's PlayerTable. The AutoAdd
/// tables are census-sized, so this is the dominant term in the profile — roughly 10 × n
/// comparisons, against the ~0.069 ms/2.4 KB the rest of the profile measures at. That is
/// acceptable because profiles are built on demand at human rates and never in a sweep;
/// PointsProfileEnabled turns it off for a shard where it isn't.
///
/// **Deliberately no `rank`.** Rank cannot early-exit — it must count every row that
/// beats the player, in every system, every time — and the website can derive it from
/// the points.board frame for anyone who is actually on a board. See PointsProfileRank.
/// </summary>
private static void WritePoints(StringBuilder sb, PlayerMobile m)
{
if (!BridgeConfig.PointsProfileEnabled)
return;
sb.Append(",\"points\":[");
try
{
var systems = PointsSystem.Systems;
if (systems != null)
{
bool first = true;
for (int i = 0; i < systems.Count; i++)
{
var sys = systems[i];
if (sys == null || !sys.ShowOnLoyaltyGump)
continue;
var points = LookupPoints(sys, m);
if (points <= 0)
continue;
if (!first) sb.Append(',');
first = false;
var name = sys.Name;
sb.Append("{\"system\":\"").Append(sys.Loyalty).Append('"');
sb.Append(",\"nameString\":");
if (name == null || name.String == null)
sb.Append("null");
else
BridgeJson.Escape(sb, name.String);
sb.Append(",\"nameNumber\":").Append(name == null ? 0 : name.Number);
sb.Append(",\"points\":").Append(BridgePoints.Score(points));
sb.Append(",\"maxPoints\":").Append(BridgePoints.Cap(sys.MaxPoints));
// Off by default. The field is absent rather than null when disabled, so a
// consumer can tell "this shard does not compute rank" from "unranked".
if (BridgeConfig.PointsProfileRank)
sb.Append(",\"rank\":").Append(RankOf(sys, points));
sb.Append('}');
}
}
}
catch (Exception ex)
{
// A profile is worth more than its points block; never fail the sheet over one.
Console.WriteLine("[Bridge] profile points threw: {0}", ex.Message);
}
sb.Append(']');
}
/// <summary>
/// This character's score in one system, or 0 if it has no entry. A hand-rolled scan
/// rather than GetEntry/GetPoints for the mutation reason above; it stops at the match,
/// which the rank computation could not.
/// </summary>
private static double LookupPoints(PointsSystem sys, PlayerMobile m)
{
var table = sys.PlayerTable;
if (table == null)
return 0;
for (int i = 0; i < table.Count; i++)
{
var entry = table[i];
if (entry != null && entry.Player == m)
return entry.Points;
}
return 0;
}
/// <summary>
/// 1-based standing in one system: how many live characters hold strictly more points,
/// plus one. Ties share a rank, which is what a player expects to see.
///
/// Only reachable with PointsProfileRank=true, and off by default for the reason stated
/// in <see cref="WritePoints"/>: unlike the points lookup, this visits every row of the
/// table every time, so it turns a bounded early-exiting scan into a guaranteed full one
/// per published system per profile.
/// </summary>
private static int RankOf(PointsSystem sys, double points)
{
var table = sys.PlayerTable;
if (table == null)
return 1;
var better = 0;
for (int i = 0; i < table.Count; i++)
{
var entry = table[i];
if (entry == null || entry.Player == null || entry.Player.Deleted)
continue;
if (entry.Points > points)
better++;
}
return better + 1;
}
private static bool IsGearLayer(Layer layer)
{
switch (layer)