13 Commits

Author SHA1 Message Date
4d21ef0b63 Merge pull request 'fix(ci): strip CR/LF from registry secrets so the push/release steps work' (#6) from fix/release-secret-newline into main
All checks were successful
Release sidecar / release (push) Successful in 8m19s
Reviewed-on: UOM/link#6
2026-07-14 17:10:43 +00:00
0550129f8e fix(ci): strip CR/LF from registry secrets before use
The release run built its push URL and auth header directly from
`secrets.REGISTRY_USER` / `REGISTRY_TOKEN`. A trailing newline in
REGISTRY_USER produced a malformed remote:

    warning: url contains a newline in its username component
    fatal: credential url cannot be parsed

Pass the secrets through `env:` and strip CR/LF (`tr -d '\r\n'`) before
building the URL (push step) and the `Authorization: token` header (release
step). Using env instead of inline `${{ }}` also stops a newline from
breaking the shell script itself. Verified the file still parses and the
trim turns `Whitlocktech\n` into a clean single-line URL.

Underlying cause is secret hygiene (the value was saved with a trailing
newline); this makes the workflow robust to it either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 12:09:46 -05:00
09c59b256e Merge pull request 'fix(ci): sync Cargo.lock after version bump so --locked steps pass' (#5) from fix/release-locked-lockfile into main
Some checks failed
Release sidecar / release (push) Failing after 8m27s
Reviewed-on: UOM/link#5
2026-07-14 16:58:10 +00:00
45227b1a74 fix(ci): sync Cargo.lock after version bump so --locked steps pass
The release step rewrites the crate version in Cargo.toml (0.1.0 -> next),
which desyncs this crate's own entry in Cargo.lock. The following
`cargo test/build --locked` steps then abort:

    error: cannot update the lock file ... because --locked was passed

Run `cargo update --manifest-path sidecar/Cargo.toml --workspace` right
after the bump: it updates only the workspace member's version in the lock
and leaves every dependency pin untouched, so --locked still guarantees
reproducible deps. Verified locally — reproduced the exact failure, then
confirmed the sync makes `cargo build --locked` succeed with 6 dependencies
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 11:57:11 -05:00
78fcb7effb Merge pull request 'fix(ci): quote release workflow if: so the YAML parses' (#4) from fix/release-workflow-yaml into main
Some checks failed
Release sidecar / release (push) Failing after 2m15s
Reviewed-on: UOM/link#4
2026-07-14 16:51:50 +00:00
ddf14bdab0 fix(ci): quote release workflow if: so YAML parses
The job-level `if:` expression contains a colon (`chore(release):`), which
an unquoted YAML scalar misreads as a mapping value — Gitea rejected the
workflow with "yaml: line 59: mapping values are not allowed in this
context". Double-quote the whole `${{ }}` expression so it is treated as a
string. Verified the file parses end-to-end after the change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 11:51:22 -05:00
4badd15f91 Merge pull request 'feat(champ): stream champion-spawn state to the sidecar board' (#3) from feature/champ-spawns into main
Reviewed-on: UOM/link#3
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-14 16:47:12 +00:00
e30eec5e4d Merge branch 'main' into feature/champ-spawns 2026-07-14 16:46:55 +00:00
b96a867691 Merge pull request 'ci(sidecar): automated build + release workflow (Gitea Actions)' (#2) from ci/sidecar-release into main
Reviewed-on: UOM/link#2
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-14 16:46:07 +00:00
fa7f1f786b Merge branch 'main' into ci/sidecar-release 2026-07-14 16:44:48 +00:00
b258ee3e60 feat(champ): stream champion-spawn state to the sidecar board
Champion spawns have no ServUO EventSink, so add a fourth polled stream
(BridgeChamps) modeled on BridgeSweeps: enumerate every spawn each tick,
fold to a small record, and emit champ.update only on change. No core
patch — every field used is public.

Covers all three families via a `category` field:
  - champion: ChampionSpawn (type/level/kills/boss/cooldown ETA)
  - mini:     MiniChamp (type/level; auto-restarts, no kill counter)
  - sea:      BaseSeaChampion (a High Seas world-boss mobile, alive only
              while summoned; removed via champ.remove when slain)

Status folds to active/cooldown/dormant. A (re)connection clears the diff
cache so the next sweep re-emits the full board, rebuilding a sidecar that
restarted on its own. Transient entries leave via champ.remove.

Sidecar: a `champs` current-state table (one row per serial) fed by
champ.update (upsert) and champ.remove (delete), exposed at GET /champs as
the live board. New ChampSweepSeconds config (default 10s), wired into
[bridge reload/sweepnow/status. Documented in docs/INTEGRATION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 05:46:21 -05:00
9df337e186 ci(sidecar): add Gitea Actions release workflow
Auto-build and release the Rust sidecar on every push to main.

A language-agnostic "release engine" computes the next version from
conventional-commit subjects since the last v* tag (feat!/BREAKING ->
major, feat -> minor, fix|perf -> patch; first run ships the current
Cargo.toml version). An isolated "Rust adapter" runs cargo fmt --check /
test, builds x86_64-unknown-linux-gnu, and cross-builds
x86_64-pc-windows-gnu via MinGW (libsqlite3-sys is the only native dep).
It then commits the version bump ([skip ci]), tags vX.Y.Z, pushes, and
creates the Gitea release with the linux binary, windows .exe, and
SHA256SUMS.

Reuses the REGISTRY_USER / REGISTRY_TOKEN secrets; the token additionally
needs write:repository scope and main must accept a direct push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 14:40:05 -05:00
359bb937b2 style(sidecar): apply rustfmt
Format the sidecar source with `cargo fmt` so the new `cargo fmt --check`
CI gate passes on the first run. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 14:40:05 -05:00
11 changed files with 758 additions and 20 deletions

View File

@@ -0,0 +1,258 @@
# Automated build + release for the uo-link Rust sidecar.
#
# Trigger: every push to `main` (i.e. every merged PR).
#
# 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 │
# └───────────────────────────────────────────────────────────────────┘
# ┌── RUST ADAPTER (the only Rust-specific part) ─────────────────────┐
# │ consumes: the version │
# │ produces: the artifacts (linux bin, windows exe, SHA256SUMS) │
# └───────────────────────────────────────────────────────────────────┘
#
# To retarget this engine at a C#/Node/Docker/static project later, only the
# "Rust adapter" steps change — the plan + release steps consume just
# {version, changelog, artifacts} and know nothing about Rust.
#
# Version bump (conventional commits since the last v* tag):
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases the current Cargo.toml version as-is
#
# Prerequisites (Settings → Actions → Secrets on UOM/link):
# REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token. For image builds it needed
# write:package; THIS workflow additionally needs
# `write:repository` so it can push the bump commit + tag
# and create the release. Grant that scope to the token.
# Also: `main` must accept a direct push from that user (disable branch
# protection for it, or add it as an exception) — the bump commit lands on main.
#
# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
name: Release sidecar
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: release-sidecar
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: UOM/link
WORKDIR: sidecar
BIN: uo-link-sidecar
LINUX_TARGET: x86_64-unknown-linux-gnu
WINDOWS_TARGET: x86_64-pc-windows-gnu
jobs:
release:
runs-on: ubuntu-latest
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
# Quoted because the expression contains a colon (`chore(release):`), which an
# unquoted YAML scalar would misparse as a mapping value.
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
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
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
CARGO_VERSION="$(grep -m1 '^version' "${WORKDIR}/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')"
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="$CARGO_VERSION" # first release: ship what's in Cargo.toml
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
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "Tag v${VERSION} already exists — nothing to release."
RELEASE=false
fi
{
echo "## ${BIN} v${VERSION}"
echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$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 "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
echo "$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 "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
- name: Install Rust toolchain, Windows target, and MinGW linker
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable
fi
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt
rustup target add "${WINDOWS_TARGET}"
- name: Set the crate version to match the release
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
# Replace only the [package] version (the first `version = "..."`).
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml"
grep -m1 '^version' "${WORKDIR}/Cargo.toml"
# Bumping the manifest version desyncs this crate's own entry in
# Cargo.lock, which would make the `--locked` fmt/test/build steps below
# fail ("cannot update the lock file ... --locked was passed"). Sync just
# the workspace member(s) into the lock — dependency pins are untouched.
cargo update --manifest-path "${WORKDIR}/Cargo.toml" --workspace
# ── RUST ADAPTER: gates ──────────────────────────────────────────────
- name: cargo fmt --check
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo fmt --check
- name: cargo test
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo test --locked
# ── RUST ADAPTER: build both targets ─────────────────────────────────
- name: cargo build --release (Linux)
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo build --release --locked --target "${LINUX_TARGET}"
- name: cargo build --release (Windows, cross via MinGW)
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
- name: Package artifacts and SHA256SUMS
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
- name: Commit version bump and push tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
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. Passing them via
# env (not inline ${{ }}) also keeps a newline from breaking this script.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "uo-link-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git add "${WORKDIR}/Cargo.toml" "${WORKDIR}/Cargo.lock"
if ! git diff --cached --quiet; then
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
git push origin "HEAD:main"
else
echo "Version unchanged (first release) — no bump commit needed."
fi
git tag "${TAG}"
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 }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
BODY="$(cat dist/CHANGELOG.md)"
# Same newline hygiene as the push 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 "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" 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

View File

@@ -194,6 +194,47 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
#### Champion spawns
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
| `category` | source | what it is |
|------------|--------|-----------|
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
| kind | fields | notes |
|------|--------|-------|
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
`status` is one of:
- **`active`** — running (or, for `sea`, the boss is alive).
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
Category-specific fields on `champ.update`:
| category | extra fields |
|----------|--------------|
| `champion` | `level` (016), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
```json
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
```
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
---
## 5. REST — read queries
@@ -402,6 +443,29 @@ GET /economy?limit=200
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
```
### Champion-spawn board
```
GET /champs
```
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
```
GET /champs
→ { "spawns": [
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
] }
```
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
---
## 7. Status codes

View File

@@ -19,6 +19,11 @@ StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
# this interval to emit champ.update on any status/level/kills/boss change. The world holds
# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
ChampSweepSeconds=10
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
# support queue; the full open queue is also available on demand via pages.snapshot.

View File

@@ -160,6 +160,7 @@ namespace Server.Custom.Bridge
BridgeConfig.Load();
BridgeSweeps.Rearm();
BridgePages.Rearm();
BridgeChamps.Rearm();
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
break;
@@ -171,8 +172,10 @@ namespace Server.Custom.Bridge
case "sweepnow":
BridgeSweeps.SweepOnce();
BridgeChamps.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());
break;
default:
@@ -182,6 +185,7 @@ namespace Server.Custom.Bridge
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
break;
}

View File

@@ -0,0 +1,287 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Server.Engines.CannedEvil;
using Server.Engines.MiniChamps;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// The champion-spawn stream. Like the streams in <see cref="BridgeSweeps"/>, this is polled:
/// none of the three champion families expose an EventSink, so their whole lifecycle lives
/// inside a per-second SliceTimer and is invisible to a subscriber. Instead we enumerate them
/// each tick, fold each to a small record, and emit `champ.update` only when that record
/// changes. A 5-10s sweep is well within the site's tolerance and the world holds only a
/// handful of spawns, so the pass is trivially cheap.
///
/// Three families, distinguished by the `category` field:
/// champion - ChampionSpawn: the classic Felucca-style altar (type/level/kills/boss/cooldown)
/// mini - MiniChamp: the TerMur mini-champ controller (type/level, auto-restarts)
/// sea - BaseSeaChampion: a High Seas world boss Mobile, alive only while summoned
///
/// Status folds public fields into three values (no core patch needed):
/// active - running / alive
/// cooldown - stopped but a restart is pending (ChampionSpawn: RestartTime ahead; MiniChamp:
/// inactive, since it always re-arms a restart)
/// dormant - stopped with nothing scheduled (ChampionSpawn only; a GM must turn it on)
///
/// The sidecar keeps the latest record per serial as a live board. A permanent controller's
/// row lives as long as the item; a transient sea boss is removed with `champ.remove` when it
/// dies or despawns. A (re)connection clears the diff cache (see OnConnected) so the next
/// sweep re-emits every spawn in full, rebuilding a sidecar that restarted on its own.
/// </summary>
public static class BridgeChamps
{
private static Timer _timer;
// Last-emitted signature per tracked serial. A serial absent from this map has never been
// emitted (or the cache was cleared on reconnect), so its next sweep counts as a change.
// Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
private static long _sweeps, _emitted, _removed;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
// Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
// independently of the shard rebuilds its state within one sweep.
BridgeLink.Connected_Core += OnConnected;
Rearm();
}
private static void OnConnected()
{
_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.ChampSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds),
ChampSweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
public static string Status()
{
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})",
_sweeps, _emitted, _removed, _last.Count);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
ChampSweep();
}
private static void ChampSweep()
{
try
{
_sweeps++;
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
var seen = new HashSet<Serial>();
foreach (var s in World.Items.Values.OfType<ChampionSpawn>())
{
if (s.Deleted)
continue;
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
}
foreach (var s in World.Items.Values.OfType<MiniChamp>())
{
if (s.Deleted)
continue;
Track(seen, s.Serial, SigMini(s), WriteMini(s));
}
foreach (var b in World.Mobiles.Values.OfType<BaseSeaChampion>())
{
if (b.Deleted || !b.Alive)
continue;
Track(seen, b.Serial, SigSea(b), WriteSea(b));
}
// Anything tracked last sweep but not seen now has gone away (a controller deleted, a
// sea boss slain). Tell the sidecar to drop its board row.
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
foreach (var serial in gone)
{
_last.Remove(serial);
BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
_removed++;
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] champ sweep threw: {0}", ex.Message);
}
}
/// <summary>Records a spawn as seen and emits it only if its signature changed since last sweep.</summary>
private static void Track(HashSet<Serial> seen, Serial serial, string sig, string line)
{
seen.Add(serial);
string prior;
if (_last.TryGetValue(serial, out prior) && prior == sig)
return; // unchanged since last emit
_last[serial] = sig;
BridgeLink.Emit(line);
_emitted++;
}
// ---- ChampionSpawn (classic) ----
private static string StatusOf(ChampionSpawn s)
{
if (s.Active)
return "active";
if (s.RestartTime > DateTime.UtcNow)
return "cooldown";
return "dormant";
}
// The volatile fields that define a meaningful change. Kept in sync with WriteChampion so the
// site never misses a level, a kill-count tick, a boss pop, or a status/cooldown transition.
private static string SigChampion(ChampionSpawn s)
{
return String.Concat(
"champion|", StatusOf(s), "|",
s.Level.ToString(), "|",
s.Kills.ToString(), "|",
(s.Champion != null && !s.Champion.Deleted) ? "1" : "0", "|",
s.RestartTime.Ticks.ToString(), "|",
s.ExpireTime.Ticks.ToString());
}
private static string WriteChampion(ChampionSpawn s)
{
var status = StatusOf(s);
var bossUp = s.Champion != null && !s.Champion.Deleted;
// Prefer a staff-set display name, then the group, then the spawn type.
string name = !String.IsNullOrEmpty(s.SpawnName) ? s.SpawnName
: !String.IsNullOrEmpty(s.GroupName) ? s.GroupName
: s.Type.ToString();
var sb = BridgeJson.Begin("champ.update")
.Ser("serial", s.Serial)
.Str("category", "champion")
.Str("type", s.Type.ToString())
.Str("name", name)
.Str("status", status)
.Bool("active", s.Active)
.Num("level", s.Level)
.Num("rank", s.Rank)
.Num("kills", s.Kills)
.Num("maxKills", s.MaxKills)
.Bool("bossUp", bossUp)
.Bool("autoRestart", s.AutoRestart)
.Str("map", s.Map == null ? null : s.Map.Name)
.Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
if (bossUp)
sb.Str("boss", String.IsNullOrEmpty(s.Champion.Name) ? s.Champion.GetType().Name : s.Champion.Name);
// Cooldown ETA: when the spawn will auto-restart. Only meaningful while on cooldown.
if (status == "cooldown")
sb.Str("restartAt", s.RestartTime.ToUniversalTime().ToString("o"));
// Level-expiry ETA: when the current level times out if kills stall. Only while active.
if (s.Active)
sb.Str("expireAt", s.ExpireTime.ToUniversalTime().ToString("o"));
return sb.End();
}
// ---- MiniChamp (TerMur mini-champs) ----
// MiniChamp exposes no kills, no boss handle, and no restart-time getter. When inactive it has
// always re-armed a restart, so inactive folds to "cooldown" (there is no dormant state and no
// ETA to report).
private static string SigMini(MiniChamp s)
{
return String.Concat(
"mini|", (s.Active ? "active" : "cooldown"), "|", s.Level.ToString());
}
private static string WriteMini(MiniChamp s)
{
var status = s.Active ? "active" : "cooldown";
var info = MiniChampInfo.GetInfo(s.Type);
var sb = BridgeJson.Begin("champ.update")
.Ser("serial", s.Serial)
.Str("category", "mini")
.Str("type", s.Type.ToString())
.Str("name", s.Type.ToString())
.Str("status", status)
.Bool("active", s.Active)
.Num("level", s.Level)
.Bool("bossUp", false)
.Bool("autoRestart", true)
.Str("map", s.Map == null ? null : s.Map.Name)
.Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
if (info != null)
sb.Num("maxLevel", info.MaxLevel);
return sb.End();
}
// ---- BaseSeaChampion (High Seas world boss) ----
// A sea champion is a Mobile, not a controller: it exists only while summoned and alive, so it
// is always "active" on the board and leaves via champ.remove when slain. Position and health
// are tracked so the board can show a live "world boss here, N% hp".
private static string SigSea(BaseSeaChampion b)
{
return String.Concat(
"sea|", b.Hits.ToString(), "|", b.X.ToString(), "|", b.Y.ToString());
}
private static string WriteSea(BaseSeaChampion b)
{
string name = String.IsNullOrEmpty(b.Name) ? b.GetType().Name : b.Name;
return BridgeJson.Begin("champ.update")
.Ser("serial", b.Serial)
.Str("category", "sea")
.Str("type", b.GetType().Name)
.Str("name", name)
.Str("status", "active")
.Bool("active", true)
.Bool("bossUp", true)
.Str("boss", name)
.Num("hits", b.Hits)
.Num("hitsMax", b.HitsMax)
.Str("map", b.Map == null ? null : b.Map.Name)
.Num("x", b.X).Num("y", b.Y).Num("z", b.Z)
.End();
}
}
}

View File

@@ -18,6 +18,7 @@ namespace Server.Custom.Bridge
public static int DecaySweepSeconds { get; private set; }
public static int EconomySweepSeconds { get; private set; }
public static int PageSweepSeconds { get; private set; }
public static int ChampSweepSeconds { get; private set; }
public static string LinkUrl { get; private set; }
@@ -55,6 +56,10 @@ namespace Server.Custom.Bridge
if (PageSweepSeconds < 1)
PageSweepSeconds = 1;
ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
if (ChampSweepSeconds < 1)
ChampSweepSeconds = 1;
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
@@ -90,9 +95,9 @@ namespace Server.Custom.Bridge
public static string Describe()
{
return String.Format(
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s) adminWrite={7}(floor={8})",
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})",
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
AdminWriteEnabled, AdminAccessFloor);
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor);
}
}
}

View File

@@ -142,7 +142,10 @@ fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
let text = fs::read_to_string(path)?;
let line = format!("auth_token = \"{token}\"");
if text.lines().any(|l| l.trim_start().starts_with("auth_token")) {
if text
.lines()
.any(|l| l.trim_start().starts_with("auth_token"))
{
let out: String = text
.lines()
.map(|l| {

View File

@@ -96,11 +96,44 @@ async fn main() -> anyhow::Result<()> {
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
if ev.kind != "pong" {
let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms);
let t = ev
.value
.get("t")
.and_then(|v| v.as_i64())
.unwrap_or_else(now_ms);
let text = ev.value.to_string();
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
tracing::warn!(error = %e, "failed to persist event");
}
// The champ board is a live projection: champ.update folds in the latest state (one
// row per spawn), champ.remove drops a spawn that despawned or was slain.
match ev.kind.as_str() {
"champ.update" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store
.upsert_champ(
serial,
ev.value.get("status").and_then(|s| s.as_str()),
ev.value.get("name").and_then(|n| n.as_str()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert champ board");
}
}
}
"champ.remove" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store.delete_champ(serial).await {
tracing::warn!(error = %e, "failed to remove champ board row");
}
}
}
_ => {}
}
}
let _ = feed_tx.send(ev.value.to_string());

View File

@@ -56,10 +56,7 @@ impl Rpc {
) -> Result<Value, RpcError> {
let (tx, rx) = oneshot::channel();
self.pending
.lock()
.await
.insert(corr_val.to_string(), tx);
self.pending.lock().await.insert(corr_val.to_string(), tx);
if !shard.send(command.to_string()).await {
self.pending.lock().await.remove(corr_val);

View File

@@ -21,8 +21,8 @@ pub struct Store {
impl Store {
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
pub async fn open(path: &str) -> anyhow::Result<Self> {
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?
.create_if_missing(true);
let opts =
SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4)
@@ -78,7 +78,12 @@ impl Store {
self.recent(Some("economy.supply"), limit).await
}
pub async fn record_link(&self, account: &str, website_user_id: &str, t: i64) -> anyhow::Result<()> {
pub async fn record_link(
&self,
account: &str,
website_user_id: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?)
ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t",
@@ -128,6 +133,50 @@ impl Store {
.await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
}
/// Upserts one champion-spawn's latest state, keyed by serial. Fed from the `champ.update`
/// stream; this table is the live board the website reads, so there is exactly one row per
/// spawn and it always holds the most recent snapshot.
pub async fn upsert_champ(
&self,
serial: &str,
status: Option<&str>,
name: Option<&str>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO champs (serial, status, name, json, updated_t) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(serial) DO UPDATE SET status = excluded.status, name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(serial)
.bind(status)
.bind(name)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Drops one spawn from the board. Fed from the `champ.remove` stream: a controller that was
/// deleted, or a transient sea boss that was slain, leaves the board this way.
pub async fn delete_champ(&self, serial: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM champs WHERE serial = ?")
.bind(serial)
.execute(&self.pool)
.await?;
Ok(())
}
/// The full champion-spawn board: every spawn's latest snapshot. Ordered by name so the site
/// gets a stable list.
pub async fn champs_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM champs ORDER BY name, serial")
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
}
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
@@ -158,4 +207,12 @@ CREATE TABLE IF NOT EXISTS profiles (
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS champs (
serial TEXT PRIMARY KEY,
status TEXT,
name TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
"#;

View File

@@ -70,6 +70,7 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// History, read from SQLite rather than the shard.
.route("/history", get(history))
.route("/economy", get(economy))
.route("/champs", get(champs))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new()
@@ -123,7 +124,8 @@ fn iso_ms(ms: i64) -> Option<String> {
if ms <= 0 {
return None;
}
chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
chrono::DateTime::from_timestamp_millis(ms)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
}
// ---- gate: protocol check + auth ----
@@ -174,8 +176,15 @@ async fn gate(State(st): State<AppState>, req: Request, next: Next) -> Response
fn extract_token(req: &Request) -> Option<String> {
// Authorization: Bearer <token>
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) {
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) {
if let Some(v) = req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok())
{
if let Some(rest) = v
.strip_prefix("Bearer ")
.or_else(|| v.strip_prefix("bearer "))
{
return Some(rest.trim().to_string());
}
}
@@ -349,7 +358,10 @@ async fn page_respond(
Path(id): Path<String>,
Json(body): Json<Value>,
) -> impl IntoResponse {
let message = body.get("message").and_then(|m| m.as_str()).unwrap_or_default();
let message = body
.get("message")
.and_then(|m| m.as_str())
.unwrap_or_default();
if message.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
@@ -439,7 +451,10 @@ async fn vendors(State(st): State<AppState>, Path(account): Path<String>) -> imp
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
let code = body.get("code").and_then(|c| c.as_str()).unwrap_or_default();
let code = body
.get("code")
.and_then(|c| c.as_str())
.unwrap_or_default();
let web_id = body
.get("websiteUserId")
.and_then(|w| w.as_str())
@@ -501,10 +516,7 @@ async fn towncrier_add(State(st): State<AppState>, Json(body): Json<Value>) -> i
respond(st.rpc.call(&st.shard, cmd, &id).await)
}
async fn towncrier_remove(
State(st): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
let cmd = json!({"kind":"towncrier.remove","id":id});
respond(st.rpc.call(&st.shard, cmd, &id).await)
}
@@ -541,6 +553,19 @@ async fn economy(State(st): State<AppState>, Query(q): Query<HistoryQuery>) -> i
}
}
/// The champion-spawn board: every spawn's latest state (status/level/kills/boss/location and, when
/// relevant, the cooldown ETA). Served from the local board table, so it answers without touching
/// the shard and survives a shard outage with the last-known snapshot.
async fn champs(State(st): State<AppState>) -> impl IntoResponse {
match st.store.champs_all().await {
Ok(spawns) => (StatusCode::OK, Json(json!({"spawns": spawns}))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
// ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {