16 Commits

Author SHA1 Message Date
79cc611ee0 Merge pull request 'feat(bridge)!: protocol 4 — guild rosters and per-member leaves (Teams cutover 1/6)' (#14) from edge into main
All checks were successful
Release overlay / release (push) Successful in 13s
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-19 08:54:43 +00:00
d57d9aad84 Merge pull request 'feat(bridge): carry guild rank on roster members' (#13) from feat/protocol4-guild-rank into edge
Reviewed-on: #13
2026-08-17 22:49:10 +00:00
8c6db9f0d5 feat(bridge): carry guild rank on roster members
Protocol 4 is still on `edge` and unreleased, so this amends it in place rather
than bumping: `PROTOCOL_VERSION` and `overlay.toml` both stay at 4. A bump is
only owed once a protocol has reached `main`.

Phase 1 shipped the roster member as the standard actor object, which carries no
guild rank. The consequence surfaced in Teams phase 2: the website could only
learn leadership from the board's single `leader` field, so `getTeamLeaders()`
could return exactly one member — while a UO guild routinely has several at rank
4, and TEAMS.md §2.5 treats multiple leaders as the normal case.

Roster members now carry `rank` (0-4, 4 being Leader per RankDefinition.Ranks)
plus `rankCliloc`, or `rankName` when a custom rank definition uses a literal
string instead of a cliloc. Only the raw rank goes on the wire: ServUO names the
five standard ranks with cliloc ids and ships no text for them, so this shard
cannot produce "Warlord" without a client-file table it does not have. The
website module has one, and resolving a game term is its job in any case.

`withGuildRank` is a parameter on `Actors()` rather than a change to the shared
actor writer. Rank is a property of a mobile's membership of THIS guild, not of
the mobile, and every other actor this bridge writes is a bystander, a killer or
a governor, where guild rank is meaningless. `WriteActor` is split into a
fields-only writer so both forms share one definition of an actor.

## The trap this found

**`PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at
GameMaster or above, whatever their actual rank.** It is a gameplay convenience
so staff can operate a guild stone, and it is emphatically not a claim about who
leads the guild -- but it is what the only public accessor returns, and the true
value sits in a private field. Emitting it verbatim would have published every
staff member in a guild as a guild leader on a public website.

Staff are therefore written with no rank fields at all. A staff account that
genuinely leads its guild shows as an unranked member, which is a visible gap
rather than a false claim -- the right way round, given the name on that roster
reaches a public page.

## Verification

This repo has no CI build, so compiling is not evidence. Run against the local
ServUO tree with a throwaway probe that synthesised a guild from real
PlayerMobiles across the rank ladder, with one account promoted to GameMaster.
The emitted frame:

  tester    rank 4  cliloc 1062959   (Leader)
  Seed000A  rank 4  cliloc 1062959   (Leader)  <- two at once, the point of this
  Seed000B  rank 3  cliloc 1062960   (Warlord)
  Seed000C  rank 2  cliloc 1062961   (Emissary)
  Seed001A  rank 1  cliloc 1062962   (Member)
  Seed001B  no rank fields                     <- GameMaster, stored rank 0,
                                                  getter reported rank 4

The probe printed stored vs reported rank per member, so the getter's substitution
is recorded rather than inferred: `Seed001B storedRank=0 reportedRank=4
access=GameMaster`. The line parsed as valid JSON.

`dotnet build Scripts.csproj` clean, 0 warnings. Probe deleted, tree rebuilt, and
`deploy.ps1 -Verify` reports 0 changes against the overlay. The shard was killed
without a world save, so the synthetic guild did not persist (Guilds.bin still 0
bytes).

**The sidecar needs no change.** It treats roster members as opaque values and
never reads a field inside one -- `accumulate_roster` moves them and
`upsert_guild_roster` stores them, both by value. That is the forwarder design
paying off.

Refs docs/link/v4.md §2.3

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:34:51 -05:00
65562eea40 Merge pull request 'feat(bridge)!: guild rosters and per-member leaves, on protocol 4' (#12) from feat/teams-phase1-guild-roster into edge
Reviewed-on: #12
2026-08-17 19:28:40 +00:00
cc4f58317e feat(bridge)!: guild rosters and per-member leaves, on protocol 4
Protocol 2 could say how many members a guild had, not who they were, and there
is no EventSink for leaving a guild — so PROTOCOL_2.md §10.1 deferred the whole
membership half. This closes it.

The sweep now holds each guild's member serial **set** instead of folding it into
the signature as a sum. That buys two things. A set comparison cannot collide,
where a sum could: one member joining and another leaving between two passes
offset each other and the guild looked unchanged. And a set can be *differenced*,
which is what makes a per-member `guild.leave` possible without a core tap —
departures are simply the prior set minus the current one.

A changed set also re-emits `guild.roster`, the full member list. That is what
lets the departure events stay advisory: a consumer building a "so-and-so left"
feed wants them, but a consumer holding a membership table only needs the roster,
so nothing downstream has to replay deltas to stay correct. On a guild's first
sweep there is no prior set, so nothing is reported as leaving — an unknown
roster becoming known is not 155 people leaving at once.

A roster is the only fat frame this plugin emits — measured at roughly 69 bytes
per member against a real 155-member guild — and the sidecar reads a line with no
length bound. So members per frame are capped (default 500, about 35 KB), and a
guild over the cap is split into frames carrying `seq`, `more` and `total`. Every
realistic guild emits exactly one frame with `seq` 0 and `more` false, which is
the same shape as if chunking did not exist. Verified against the real sidecar
with the cap forced down to 50, which produced 50/50/50/5 across four frames.

The reconnect baseline is spread rather than fired in one pass. `OnConnected`
clears the diff caches, so every guild looks changed at once, and building
hundreds of fat frames in a single Core-thread tick is exactly the stall this
bridge exists to avoid. At most GuildRosterGuildsPerTick guilds emit a roster per
sweep; a guild over budget keeps its old member set, so it still reads as changed
next pass. The sweep re-arms itself after 2s while a baseline is draining, so
catch-up takes seconds rather than one full sweep interval per batch.

BridgeJson gained the array writer it never had — there was no way to express a
list of objects at all. Every field helper emits a leading `,"name":`, so Actor
is split into a bare-object writer that both the single and array forms use.

overlay.toml protocol -> 4, in this commit rather than a later one: CI folds it
into the release manifest and the installer refuses to pair an overlay and a
sidecar that disagree, so a bump landing separately from the emitters would
silently fail to compose into a bundle.

Verified on a live ServUO shard against the real Rust sidecar (not a stub): 155
members seeded from real PlayerMobiles, four roster frames reassembled to 153
entries on the board after two members were removed, two guild.leave frames with
the correct serials, and the departed serials absent from the re-emitted roster.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:52:07 -05:00
0eda2d3a97 Merge pull request 'docs: make the installer the documented way to deploy the overlay' (#11) from docs/installer-first-setup into main
All checks were successful
Release overlay / release (push) Successful in 5s
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 21:32:13 +00:00
48b16dc70e docs: make the installer the documented way to deploy the overlay
"## Deploy" led with deploy.ps1 and mentioned the installer only
afterwards, which is backwards now that the installer is released.

- Deploy leads with the installer, with the by-hand overlay copy
  (INSTALL.md Appendix A2) as the supported alternative.
- deploy.ps1 gets its own subsection as the developer path: it deploys
  from a working tree, which is the one thing the installer cannot do,
  and it installs no sidecar and checks no protocol pairing.
- CONTRIBUTING: note that changes reach shards through a release, so a
  change that only works when deploy.ps1 copies it does not ship.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:05:56 -05:00
c045bdd566 Merge pull request 'feat(patches): declare the patch tier in tier.json and the manifest' (#10) from feat/patch-tier-metadata into main
All checks were successful
Release overlay / release (push) Successful in 11s
Reviewed-on: #10
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-05 01:08:31 +00:00
8828382e41 feat(patches): declare the patch tier in tier.json and the manifest
A .patch file does not carry enough for an installer to run the tier safely.
The installer additionally needs to know which patches form one all-or-nothing
unit (the two vendor-sale patches are useless apart), which companion .cs may
only be copied once that unit has landed, whether the change needs a core
solution rebuild or just ServUO's dynamic script build, and what capability an
operator loses by declining. None of that is derivable from the diffs.

patches/tier.json declares it, and release.yml folds it into manifest.json as
`patch_tier` — so a new or changed patch regenerates release metadata rather
than requiring an installer release, which is the same rule §7.1 already
applies to the bundle. The staged copy is removed from patches/ so the tarball
carries exactly one statement of the table.

The release gate now checks the table in both directions: every .patch
described by exactly one feature, every named patch and companion present,
every declared target equal to the file the diff actually edits, and every
rebuild kind one the installer understands. All four were previously invisible
until someone ran the tier on a live shard.

Refs: docs/installer/PLAN.md §2.2, §7.0

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 19:30:20 -05:00
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
11 changed files with 1085 additions and 31 deletions

View File

@@ -0,0 +1,535 @@
# 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).
# • patches/tier.json must describe every .patch and nothing but. That
# table is what tells the installer which patches form one unit, which
# companion follows which, and whether a CORE rebuild is needed — a
# patch added without it would be shipped and silently never offered.
- 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
# The tier table, checked in BOTH directions. A patch missing from
# tier.json ships but is never offered to an operator; a tier.json
# entry naming a file that is not there makes the installer report a
# feature it cannot apply. Neither surfaces until someone runs the
# tier on a live shard, so both fail the release here instead.
[ -f patches/tier.json ] || fail "patches/tier.json is missing (the patch-tier declaration)"
jq -e . patches/tier.json >/dev/null || fail "patches/tier.json is not valid JSON"
DESCRIBED="$(jq -r '.features[].patches[].file' patches/tier.json | LC_ALL=C sort)"
PRESENT="$(cd patches && ls *.patch | LC_ALL=C sort)"
if [ "$DESCRIBED" != "$PRESENT" ]; then
echo "described by tier.json:"; echo "$DESCRIBED" | sed 's/^/ /'
echo "present in patches/:"; echo "$PRESENT" | sed 's/^/ /'
fail "patches/tier.json and patches/*.patch disagree — every patch must be described by exactly one feature"
fi
# Each patch's declared target must be the file its diff actually
# edits. The installer cross-checks the same pair at install time and
# refuses on a mismatch, so catching it here saves an operator the run.
while IFS=$'\t' read -r PFILE PTARGET; do
DIFF_TARGET="$(sed -n 's|^+++ b/||p' "patches/${PFILE}" | head -1 | tr -d '\r')"
[ "$DIFF_TARGET" = "$PTARGET" ] \
|| fail "patches/${PFILE} edits ${DIFF_TARGET} but tier.json declares ${PTARGET}"
done < <(jq -r '.features[].patches[] | [.file, .target] | @tsv' patches/tier.json)
# Companions can only be copied after their feature's patches land, so
# they live here rather than in overlay/ — and a missing one turns a
# successfully patched shard into one that does not compile.
for f in $(jq -r '.features[].companions[].file' patches/tier.json); do
[ -f "patches/${f}" ] || fail "patches/${f} is missing (a feature's companion source)"
done
for r in $(jq -r '.features[].rebuild' patches/tier.json); do
case "$r" in
core|scripts) ;;
*) fail "tier.json declares rebuild=\"${r}\"; only \"core\" or \"scripts\" are understood" ;;
esac
done
echo "patch tier: $(jq -r '.features | length' patches/tier.json) feature(s), $(echo "$PRESENT" | wc -l) patch(es)"
[ -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"
# tier.json is folded into manifest.json below, so the staged copy is
# removed: shipping it twice would give the tarball two statements of
# the same table, one of which nothing reads and both of which are
# free to drift.
rm -f "${STAGE}/patches/tier.json"
# 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}"
# The patch tier, folded in verbatim minus its comment block. Paths are
# rewritten to be relative to the tarball root (`patches/<file>`), which
# is where the installer will find them after extraction — tier.json
# names them relative to patches/ because that is where a maintainer
# editing it is looking.
TIER="$(jq '
del(._comment)
| .features |= map(
.patches |= map(.file |= "patches/" + .)
| .companions |= map(.file |= "patches/" + .)
)' patches/tier.json)"
# 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 tier "${TIER}" \
--argjson files "${FILES}" \
'{
component: $component,
version: $version,
commit: $commit,
repo: $repo,
protocol: $protocol,
servuo: {
min_version: $min_servuo,
patches_verified_against: $patched_against
},
patch_tier: $tier,
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

@@ -33,6 +33,13 @@ under `overlay/` (or `patches/` for changes to stock ServUO files) and deploy:
.\deploy.ps1 -ServerPath C:\path\to\servuo
```
`deploy.ps1` deploys from *this working tree*, which is what you want while
developing. It is not how a shard is set up: operators run the
[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer),
which syncs the released overlay tarball and installs the sidecar alongside it.
Changes here reach shards through a [release](README.md#releases), so a change
that only works when `deploy.ps1` copies it is a change that does not ship.
- `overlay/` — copied over an install (the only thing `deploy.ps1` deploys).
- `patches/` — unified diffs against stock ServUO for files we must modify.
- `tools/` — never deployed: test scaffolding and stub sidecars.

View File

@@ -26,7 +26,9 @@ integration guide, protocol spec, research — with full history preserved).
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
| `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. |
| `deploy.ps1` | **Developer tool** — copies `overlay/` from this working tree into a server root. `-Verify` diffs instead of writing. Operators use the [installer](https://gitea.whitlocktech.com/RunicGateway/installer); see [Deploy](#deploy). |
| `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. |
@@ -37,13 +39,17 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
## Sidecar & deployment
The Rust sidecar is the other half of the bridge and lives in **[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**.
The two are deployed **together** but built **independently**:
The two are deployed **together** — by the
[installer](https://gitea.whitlocktech.com/RunicGateway/installer), in one run — 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.
- **The sidecar** is a standalone Rust binary, released from its own repo.
- **This plugin** is deployed as *source*: `overlay/` is copied into the ServUO server root and
ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)). There is
**no CI build** — it cannot be compiled standalone without the ServUO reference assemblies. CI
publishes a *source* tarball, which is what the installer fetches and syncs; see
[Releases](#releases).
- **The sidecar** is a standalone Rust binary, released from its own repo and installed from that
release.
The **only** coupling is the loopback JSON protocol (the shard dials out to the sidecar on
`127.0.0.1`). Compatibility is a **protocol** concern, not a build-order one: keep the event/command
@@ -54,11 +60,77 @@ without the sidecar running.
## Deploy
**On a shard, use the [Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer).**
One binary syncs this overlay from the release tarball below, offers the patch tier, installs the
uo-link sidecar as a service, and prints the values your website needs — cross-platform, with a
`doctor` afterwards to tell a copied file from a working bridge:
```bash
sudo ./runicgateway-installer-linux-x86_64 install
```
Guide: [installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md).
To place the overlay yourself instead — a host that cannot run the binary, or you want to see every
file land — [Appendix A2](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#a2-deploy-the-plugin-overlay)
is the same copy done by hand, and stays supported.
### `deploy.ps1` — the developer path
`deploy.ps1` deploys from a **working tree**, which is what you want while writing plugin code and
is the one thing the installer cannot do (it deploys from a release):
```powershell
.\deploy.ps1 -ServerPath <servuo> -Verify # show what would change
.\deploy.ps1 -ServerPath <servuo> # write
```
It is Windows-only and stays developer-facing; it never installs the sidecar, registers a service,
or checks the protocol pairing. Nothing shipped to an operator depends on it.
## 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: 4 — see docs/link/v4.md (guild.roster, guild.leave).
protocol = 4
# ── 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

@@ -34,6 +34,18 @@ PageSweepSeconds=5
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
GuildSweepSeconds=60
# Members per guild.roster frame (Protocol 4). A roster is the only fat frame the bridge emits
# (~69 bytes per member) and the sidecar reads a line with no length bound, so this caps it; a
# guild over the cap is split across continuation frames carrying seq/more. 500 members is ~35 KB,
# past any realistic guild, so the split path is an edge case rather than the norm.
GuildRosterMembersPerLine=500
# Guilds that may emit a roster in one sweep. Every guild looks changed right after a sidecar
# reconnect, and building hundreds of fat frames in a single Core-thread pass is exactly the stall
# the bridge exists to avoid. The sweep re-arms itself every 2s while a baseline is draining, so
# lowering this slows the catch-up without making the site wait a full sweep interval per batch.
GuildRosterGuildsPerTick=25
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).

View File

@@ -38,6 +38,10 @@ namespace Server.Custom.Bridge
public static int PointsSweepSeconds { get; private set; }
public static int MarketSweepSeconds { get; private set; }
// ---- guild rosters (Protocol 4) ----
public static int GuildRosterMembersPerLine { get; private set; }
public static int GuildRosterGuildsPerTick { 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; }
@@ -114,6 +118,24 @@ namespace Server.Custom.Bridge
if (GuildSweepSeconds < 1)
GuildSweepSeconds = 1;
// A roster line is the only fat frame this plugin emits — measured at roughly 69 bytes
// per member — and the sidecar reads a line with no length bound. The cap turns an
// unbounded frame into a bounded one; a guild above it is split across continuation
// lines. 500 members is ~35 KB, comfortably past any real guild, so the split path is
// an edge case rather than the norm.
GuildRosterMembersPerLine = Config.Get("Bridge.GuildRosterMembersPerLine", 500);
if (GuildRosterMembersPerLine < 16)
GuildRosterMembersPerLine = 16;
// How many guilds may emit a roster in a single sweep. Every guild re-emits after a
// reconnect (the diff caches are cleared), and building a few hundred fat JSON frames in
// one Core-thread pass is exactly the stall this bridge exists to avoid. The sweep
// re-arms itself promptly while a baseline is still draining, so this throttles the work
// without making the site wait a full sweep interval per batch.
GuildRosterGuildsPerTick = Config.Get("Bridge.GuildRosterGuildsPerTick", 25);
if (GuildRosterGuildsPerTick < 1)
GuildRosterGuildsPerTick = 1;
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
if (CitySweepSeconds < 1)
CitySweepSeconds = 1;

View File

@@ -85,14 +85,153 @@ namespace Server.Custom.Bridge
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
{
sb.Append(",\"").Append(name).Append("\":");
WriteActor(sb, m);
return sb;
}
/// <summary>
/// Writes a named array of actor objects — a guild roster (Protocol 4) being the first
/// caller. Every other outbound helper here emits a leading `,"name":`, so an array
/// element needs the bare object; that is why <see cref="WriteActor"/> exists separately
/// rather than <see cref="Actor"/> being reused.
///
/// `count` bounds how many are written, because a roster frame must stay a bounded line
/// (Bridge.GuildRosterMembersPerLine). A null entry in the sequence is skipped rather
/// than written as null, so the array is always a list of real members and a caller can
/// trust its length.
///
/// `withGuildRank` adds each member's guild rank to their object. It is a parameter
/// rather than always-on because rank is a property of a mobile's membership of THIS
/// guild, not of the mobile — every other actor this bridge writes is a bystander,
/// a killer, a governor, and guild rank is meaningless on all of them.
/// </summary>
public static StringBuilder Actors(
this StringBuilder sb, string name, IList<Mobile> mobiles, int start, int count,
bool withGuildRank = false)
{
sb.Append(",\"").Append(name).Append("\":[");
if (mobiles != null)
{
var end = Math.Min(start + count, mobiles.Count);
bool first = true;
for (int i = start; i < end; i++)
{
var m = mobiles[i];
if (m == null)
continue;
if (!first)
sb.Append(',');
if (withGuildRank)
WriteGuildMember(sb, m);
else
WriteActor(sb, m);
first = false;
}
}
sb.Append(']');
return sb;
}
/// <summary>
/// A roster member: the standard actor object plus the member's rank in their guild.
///
/// **Only the raw rank is emitted, never a resolved label.** ServUO names the five
/// standard ranks with cliloc ids (1062959–1062963) and ships no text for them, so the
/// shard cannot produce "Warlord" without a client-file table it does not have. The
/// website module does have one, and resolving a game term is its job in any case.
///
/// `rank` is the numeric rank, 0–4, with 4 being Leader (`RankDefinition.Ranks`). A
/// custom rank definition may carry a literal string instead of a cliloc, so `rankName`
/// is written when there is one and `rankCliloc` when there is not; a shard that has
/// replaced the rank table therefore keeps its own naming rather than being flattened
/// into the stock five.
///
/// A member with no readable rank — a mobile that is not a PlayerMobile, or one whose
/// GuildRank is null — is written with no rank fields at all rather than a fabricated
/// default. Absent means "not known", and a consumer that treated a missing rank as 0
/// would silently demote them.
///
/// **Staff are deliberately written with no rank, and this is not a rounding error.**
/// `PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at GameMaster or
/// above, whatever their actual rank — a gameplay convenience so staff can operate a
/// guild stone, and emphatically not a claim about who leads the guild. The true value
/// is in a private field with no accessor, so the only honest options are "Leader" and
/// "not known", and publishing a staff member as a guild leader on a public roster is
/// the worse of the two by a wide margin. A staff account that genuinely leads its guild
/// shows as an unranked member, which is a visible gap rather than a false claim.
/// </summary>
private static void WriteGuildMember(StringBuilder sb, Mobile m)
{
if (m == null)
{
sb.Append("null");
return sb;
return;
}
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
sb.Append('{');
WriteActorFields(sb, m);
var pm = m as Server.Mobiles.PlayerMobile;
var rank = pm == null || pm.AccessLevel >= AccessLevel.GameMaster ? null : pm.GuildRank;
if (rank != null)
{
sb.Append(",\"rank\":").Append(rank.Rank);
if (!string.IsNullOrEmpty(rank.Name.String))
{
sb.Append(",\"rankName\":");
Escape(sb, rank.Name.String);
}
else if (rank.Name.Number > 0)
{
sb.Append(",\"rankCliloc\":").Append(rank.Name.Number);
}
}
sb.Append('}');
}
/// <summary>
/// One bare actor object, with no leading field name: serial, name, account (when there
/// is one), the linked webId (when the account is linked), and the player flag. A `null`
/// mobile writes null.
///
/// `acct` and `webId` are the site-identity fields, and they are emitted here
/// unconditionally by design — the sidecar is a forwarder, and deciding who may see them
/// is the website's job (it projects per the shard visibility rungs). Note that `acct` is
/// genuinely optional: a PlayerMobile can have no Account at all.
/// </summary>
private static void WriteActor(StringBuilder sb, Mobile m)
{
if (m == null)
{
sb.Append("null");
return;
}
sb.Append('{');
WriteActorFields(sb, m);
sb.Append('}');
}
/// <summary>
/// The actor fields, with no braces, so a caller can add its own.
///
/// Split out for <see cref="WriteGuildMember"/>, which is the same object plus guild
/// rank. Note the first field is written WITHOUT a leading comma and every later one
/// with, so this must be the first thing inside its object.
/// </summary>
private static void WriteActorFields(StringBuilder sb, Mobile m)
{
sb.Append("\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
sb.Append(",\"name\":");
Escape(sb, m.Name ?? "");
@@ -112,8 +251,6 @@ namespace Server.Custom.Bridge
}
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
sb.Append('}');
return sb;
}
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>

View File

@@ -15,10 +15,14 @@ namespace Server.Custom.Bridge
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
///
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
/// so joined" feed does not wait for the next sweep. A membership change also moves the board
/// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in
/// the next `guild.update`; per-member leave events would need a core tap and are a later
/// refinement (§10.1).
/// so joined" feed does not wait for the next sweep.
///
/// Protocol 4 adds the membership half that §10.1 deferred. The sweep holds each guild's
/// member serial **set** rather than a sum of it, so a change is detected by set comparison
/// (no hash collisions, unlike the old sum where two offsetting changes could cancel) and the
/// departures are recoverable by difference — which is what makes a per-member `guild.leave`
/// possible without a core tap. A changed set also re-emits `guild.roster`, the full member
/// list, so the board self-corrects and nothing downstream has to replay deltas to stay right.
///
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
@@ -32,7 +36,16 @@ namespace Server.Custom.Bridge
// was cleared on reconnect), so its next sweep counts as a change.
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
private static long _sweeps, _emitted, _removed, _joins;
// guild id -> last-emitted member serial set (Protocol 4). Held rather than summed so a
// departure can be recovered as a set difference; see the class remarks.
private static readonly Dictionary<int, HashSet<int>> _members =
new Dictionary<int, HashSet<int>>();
private static long _sweeps, _emitted, _removed, _joins, _rosters, _leaves;
// Set while a post-reconnect baseline is still draining, so the sweep re-arms promptly
// instead of leaving the site a sweep interval behind. See GuildSweep.
private static bool _draining;
public static void Initialize()
{
@@ -52,6 +65,7 @@ namespace Server.Custom.Bridge
private static void OnConnected()
{
_last.Clear();
_members.Clear();
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
@@ -72,8 +86,9 @@ namespace Server.Custom.Bridge
public static string Status()
{
return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
_sweeps, _emitted, _removed, _joins, _last.Count);
return String.Format(
"guilds(sweeps={0} emitted={1} removed={2} joins={3} rosters={4} leaves={5} tracked={6} draining={7})",
_sweeps, _emitted, _removed, _joins, _rosters, _leaves, _last.Count, _draining);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
@@ -93,6 +108,12 @@ namespace Server.Custom.Bridge
var seen = new HashSet<int>();
// Guilds whose roster this sweep is still allowed to emit. Every guild looks changed
// right after a reconnect, and a roster is this plugin's only fat frame, so the
// baseline is spread over several passes rather than built in one Core-thread tick.
var rosterBudget = BridgeConfig.GuildRosterGuildsPerTick;
var deferred = false;
foreach (var bg in BaseGuild.List.Values)
{
var g = bg as Guild;
@@ -104,15 +125,64 @@ namespace Server.Custom.Bridge
seen.Add(g.Id);
var current = MemberSerials(g);
HashSet<int> priorMembers;
var known = _members.TryGetValue(g.Id, out priorMembers);
var membersChanged = !known || !priorMembers.SetEquals(current);
var sig = Signature(g);
string prior;
if (_last.TryGetValue(g.Id, out prior) && prior == sig)
var sigChanged = !_last.TryGetValue(g.Id, out prior) || prior != sig;
if (!sigChanged && !membersChanged)
continue; // unchanged since last emit
_last[g.Id] = sig;
BridgeLink.Emit(WriteGuild(g));
_emitted++;
if (sigChanged)
{
_last[g.Id] = sig;
BridgeLink.Emit(WriteGuild(g));
_emitted++;
}
if (!membersChanged)
continue;
// Over budget: leave _members untouched so this guild is still "changed" next
// pass and gets its roster then. The guild.update above has already gone, so the
// board's counts are current either way.
if (rosterBudget <= 0)
{
deferred = true;
continue;
}
rosterBudget--;
// Departures, per member, before the roster that supersedes them: a consumer
// building a "so-and-so left" feed needs the individual events, while a consumer
// holding the membership table only needs the roster. On the very first sweep for
// a guild there is no prior set, so nothing is reported as having left — an
// unknown roster becoming known is not 155 people leaving.
if (known)
{
foreach (var serial in priorMembers)
{
if (current.Contains(serial))
continue;
BridgeLink.Emit(BridgeJson.Begin("guild.leave")
.Num("id", g.Id)
.Str("name", g.Name)
.Ser("who", (Serial)serial)
.End());
_leaves++;
}
}
EmitRoster(g);
_members[g.Id] = current;
}
// Anything tracked last sweep but not seen now has disbanded or been removed.
@@ -120,9 +190,19 @@ namespace Server.Custom.Bridge
foreach (var id in gone)
{
_last.Remove(id);
_members.Remove(id);
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
_removed++;
}
// Re-arm promptly while a baseline is still draining. Without this the remaining
// guilds would each wait a full GuildSweepSeconds, so a 200-guild shard would take
// hours to publish its rosters after a reconnect instead of seconds. The sweep is
// idempotent, so an extra pass that finds nothing changed costs a few field reads.
_draining = deferred;
if (deferred)
Timer.DelayCall(TimeSpan.FromSeconds(2.0), GuildSweep);
}
catch (Exception ex)
{
@@ -130,14 +210,15 @@ namespace Server.Custom.Bridge
}
}
// The volatile fields that define a meaningful change: name, abbreviation, leader, member
// count, the member set (order-independent serial sum), and alliance.
private static string Signature(Guild g)
/// <summary>
/// The guild's live member serials. Held per guild between sweeps so a membership change
/// yields both the fact that it changed and *who* left (Protocol 4).
/// </summary>
private static HashSet<int> MemberSerials(Guild g)
{
long memberSum = 0;
int count = 0;
var set = new HashSet<int>();
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
@@ -145,8 +226,28 @@ namespace Server.Custom.Bridge
var m = members[i];
if (m == null)
continue;
count++;
unchecked { memberSum += (uint)m.Serial.Value; }
set.Add(m.Serial.Value);
}
}
return set;
}
// The volatile fields that define a meaningful change to the *board row*: name, abbreviation,
// leader, member count and alliance. Membership is no longer folded in here as a serial sum —
// the sweep compares the real member set instead, which cannot collide the way a sum can when
// one member joins and another leaves between two passes.
private static string Signature(Guild g)
{
int count = 0;
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
{
if (members[i] != null)
count++;
}
}
@@ -157,7 +258,6 @@ namespace Server.Custom.Bridge
g.Abbreviation ?? "", "|",
leaderSerial.ToString(), "|",
count.ToString(), "|",
memberSum.ToString(), "|",
g.Alliance == null ? "" : (g.AllianceName ?? ""));
}
@@ -191,6 +291,55 @@ namespace Server.Custom.Bridge
return sb.End();
}
/// <summary>
/// Emits the guild's full member list as one or more `guild.roster` frames (Protocol 4).
///
/// A roster is the only fat frame this plugin produces — roughly 69 bytes per member — and
/// the sidecar reads a line with no length bound, so the member count per line is capped
/// (Bridge.GuildRosterMembersPerLine). A guild over the cap is split, and each frame
/// carries `seq` plus `more` so a consumer can tell a complete roster from a partial one:
/// `seq` 0 begins a roster and replaces whatever was held, and `more` false ends it. A
/// guild inside the cap — every realistic one — emits exactly one frame with `seq` 0 and
/// `more` false, which is the same shape as if chunking did not exist.
/// </summary>
private static void EmitRoster(Guild g)
{
var members = g.Members;
var total = members == null ? 0 : members.Count;
var perLine = BridgeConfig.GuildRosterMembersPerLine;
var seq = 0;
var start = 0;
// do/while, not while: a guild with no members must still emit one empty roster frame,
// or a consumer could never learn that a roster it holds has emptied.
do
{
var more = start + perLine < total;
var sb = BridgeJson.Begin("guild.roster")
.Num("id", g.Id)
.Str("name", g.Name)
.Str("abbr", g.Abbreviation)
.Num("total", total)
.Num("seq", seq)
.Bool("more", more);
// `withGuildRank` — the roster is the one place a member's rank in THIS guild is
// meaningful, and the only frame that carries it. Leadership is rank 4
// (RankDefinition.Ranks), and a guild can have several members at it, which is why
// the board's single `leader` field was never enough to answer "who leads this".
sb.Actors("members", members, start, perLine, withGuildRank: true);
BridgeLink.Emit(sb.End());
_rosters++;
start += perLine;
seq++;
}
while (start < total);
}
// ---- real-time join ----
private static void OnJoinGuild(JoinGuildEventArgs e)

View File

@@ -9,6 +9,14 @@ git apply --check patches/<name>.patch # dry run
git apply patches/<name>.patch
```
## `tier.json` — adding or changing a patch
A `.patch` file does not say enough on its own. The Runic Gateway installer's patch tier also has to know which patches form **one all-or-nothing unit**, which companion `.cs` may only be copied once that unit has landed, whether the change needs a **core** solution rebuild or just the dynamic script build, and what the operator loses by declining. None of that is derivable from a diff, so it is declared in [`tier.json`](tier.json).
**Adding a patch means adding it there in the same PR.** The release workflow checks the table in both directions — every `.patch` described by exactly one feature, every named patch and companion present, every `target` equal to the file the diff actually edits — so a patch without an entry fails the release rather than shipping a tier that silently never offers it.
`tier.json` is folded into the tarball's `manifest.json` as `patch_tier` and removed from the staged `patches/` directory, so the artifact carries exactly one copy of the table and it is the one the installer reads. Installers older than this key ignore it; an installer newer than the overlay it is deploying falls back to a built-in copy. See `docs/installer/PLAN.md` §2.2 and §7.0.
## Phase 7 — player-vendor sale (a coupled unit)
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §6.

69
patches/tier.json Normal file
View File

@@ -0,0 +1,69 @@
{
"_comment": [
"The patch tier, described for the Runic Gateway installer.",
"",
"A .patch file on its own does not say enough to run the tier safely. The installer",
"additionally has to know which patches form ONE all-or-nothing unit (the two",
"vendor-sale patches are useless apart), which companion .cs may only be copied once",
"that unit has landed, whether the change needs a CORE solution rebuild or just the",
"dynamic script build, and what capability the operator loses by declining. None of",
"that is derivable from the diffs, so it is declared here.",
"",
"This file is the maintainer-facing source of truth. release.yml folds it into",
"manifest.json as `patch_tier` and removes it from the staged patches/ directory, so",
"the tarball carries exactly one copy and it is the one the installer reads",
"(docs/installer/PLAN.md §7.0). CI also asserts that every .patch here is named by",
"exactly one feature and every named patch and companion exists — adding a patch",
"without describing it fails the release rather than shipping a tier that silently",
"ignores it.",
"",
"Older installers ignore `patch_tier` entirely, and an installer newer than the",
"overlay it is deploying falls back to its own built-in copy of this table."
],
"features": [
{
"name": "vendor-sale",
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
"lost": "no vendor.sale events",
"rebuild": "core",
"patches": [
{
"name": "playervendor-sale-eventsink",
"file": "playervendor-sale-eventsink.patch",
"target": "Server/EventSink.cs"
},
{
"name": "playervendor-sale-gump",
"file": "playervendor-sale-gump.patch",
"target": "Scripts/Gumps/PlayerVendorGumps.cs"
}
],
"companions": [
{
"file": "BridgeVendorSale.cs",
"install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs"
}
]
},
{
"name": "moderation-audit",
"summary": "in-game moderation actions ([ban, [kick, [bcast) forwarded to the website as admin.audit",
"lost": "no in-game moderation audit forwarding",
"rebuild": "scripts",
"patches": [
{
"name": "commandlogging-event",
"file": "commandlogging-event.patch",
"target": "Scripts/Commands/Logging.cs"
}
],
"companions": [
{
"file": "BridgeModerationAudit.cs",
"install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs"
}
]
}
]
}