Compare commits
34 Commits
v0.1.0
...
2b0d6635bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b0d6635bb | |||
| 8751151abc | |||
| 6542282ffb | |||
| 957f5701d4 | |||
| 813ff52059 | |||
| ed8d24bb1d | |||
| 484bc33706 | |||
| fd9c9fd96a | |||
| 6f76a8d35f | |||
| 92374ba15c | |||
| e0445d3f94 | |||
| b858d526b8 | |||
| d47170581d | |||
| 2ed0b0bd00 | |||
| 808f6ab68b | |||
| 4d21ef0b63 | |||
| 0550129f8e | |||
| 09c59b256e | |||
| 45227b1a74 | |||
| 78fcb7effb | |||
| ddf14bdab0 | |||
| 4badd15f91 | |||
| e30eec5e4d | |||
| b96a867691 | |||
| fa7f1f786b | |||
| b258ee3e60 | |||
| 9df337e186 | |||
| 359bb937b2 | |||
| 5562e09fb0 | |||
| c515a86a87 | |||
| 17e1c91fb3 | |||
| 11169c52a6 | |||
| a0dbb80e1e | |||
| 213f3fa2ac |
258
.gitea/workflows/release.yml
Normal file
258
.gitea/workflows/release.yml
Normal 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 RunicGateway/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: RunicGateway/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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ obj/
|
|||||||
*.dll
|
*.dll
|
||||||
*.exe
|
*.exe
|
||||||
*.pdb
|
*.pdb
|
||||||
|
*.log
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -9,6 +9,10 @@ ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidec
|
|||||||
|
|
||||||
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
|
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
All project documentation now lives in the central **[RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs)** repo, under [`link/`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link) (design docs, integration guide, protocol spec, research — with full history preserved). Individual docs are linked inline below and referenced throughout the source.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
@@ -17,10 +21,10 @@ The shard never speaks WebSocket. Every world read happens on the Core thread; t
|
|||||||
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
||||||
| `sidecar/` | The Rust sidecar: terminates the loopback link to the shard, exposes WS + REST to the website. See `sidecar/README.md`. |
|
| `sidecar/` | The Rust sidecar: terminates the loopback link to the shard, exposes WS + REST to the website. See `sidecar/README.md`. |
|
||||||
| `tools/` | Never deployed. Test scaffolding and anything else that must not reach a server. |
|
| `tools/` | Never deployed. Test scaffolding and anything else that must not reach a server. |
|
||||||
| `docs/INTEGRATION.md` | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. Start here to build the front end. |
|
| [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. Start here to build the front end. |
|
||||||
| `docs/PLAN.md` | Implementation plan, measured performance budget, and the full data catalog. |
|
| [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) | Implementation plan, measured performance budget, and the full data catalog. |
|
||||||
| `docs/RESEARCH.md` | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
|
| [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. |
|
||||||
| `docs/SHARD_PREREQS.md` | Repairs the target shard needed before any of this could load. |
|
| [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) | Repairs the target shard needed before any of this could load. |
|
||||||
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
|
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
|
||||||
|
|
||||||
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
||||||
@@ -37,13 +41,13 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
|
|||||||
| Phase | State |
|
| Phase | State |
|
||||||
|------:|-------|
|
|------:|-------|
|
||||||
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
||||||
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** |
|
| 1 — transport (`BridgeLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §11** |
|
||||||
| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
|
| 2 — event streams (`BridgeEvents`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §12** |
|
||||||
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** |
|
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §13** |
|
||||||
| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** |
|
| 4 — request/response (`BridgeRequests`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §14** |
|
||||||
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** |
|
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §15** |
|
||||||
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in `docs/PLAN.md` §16** |
|
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §16** |
|
||||||
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in `docs/PLAN.md` §17** |
|
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §17** |
|
||||||
|
|
||||||
Every phase on the ServUO side is complete. Phases 0–6 are drop-in (`overlay/`); Phase 7 is the one core change, shipped as `patches/`. Remaining work is the Rust sidecar.
|
Every phase on the ServUO side is complete. Phases 0–6 are drop-in (`overlay/`); Phase 7 is the one core change, shipped as `patches/`. Remaining work is the Rust sidecar.
|
||||||
|
|
||||||
@@ -91,4 +95,4 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S
|
|||||||
|
|
||||||
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
|
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
|
||||||
|
|
||||||
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in `docs/PLAN.md` §1. See `tools/scaffolding/README.md`.
|
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. See `tools/scaffolding/README.md`.
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
# uo-link Sidecar — Website Integration Guide
|
|
||||||
|
|
||||||
This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly.
|
|
||||||
|
|
||||||
```
|
|
||||||
website ──WebSocket (live feed) + REST (queries/commands)──► sidecar ──loopback──► shard
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Base URL** — default `http://127.0.0.1:8080` (WebSocket: `ws://127.0.0.1:8080`). Configurable in `sidecar.toml` (`web.bind`) or `UOLINK_WEB_BIND`. If you serve the site from another host, bind the sidecar to `0.0.0.0:8080` and put it behind TLS.
|
|
||||||
- **Content type** — all request and response bodies are JSON (`application/json`).
|
|
||||||
- **Timestamps** — every `t` field is **epoch milliseconds** (UTC). Human-readable timestamps (e.g. `house.decay.builtOn`, `/health.last_event`) are ISO-8601 UTC.
|
|
||||||
- **Serials** — game object ids are hex strings like `"0x24C"` (mobiles) or `"0x40013AAD"` (items). Treat them as opaque keys.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Authentication
|
|
||||||
|
|
||||||
Every route **except `GET /health`** requires the shared token from `sidecar.toml` (`web.auth_token`). Present it any of these ways:
|
|
||||||
|
|
||||||
| Transport | How |
|
|
||||||
|-----------|-----|
|
|
||||||
| REST | `Authorization: Bearer <token>` |
|
|
||||||
| REST | `X-Api-Key: <token>` |
|
|
||||||
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
|
|
||||||
|
|
||||||
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Protocol version
|
|
||||||
|
|
||||||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
|
||||||
|
|
||||||
- Every response carries an **`X-UOLink-Version: 1`** header.
|
|
||||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 1`.
|
|
||||||
- **Optionally**, send `X-UOLink-Version: 1` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Health
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /health (no auth)
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
|
|
||||||
"protocol": 1,
|
|
||||||
"plugin_connected": true, // is the shard link up right now?
|
|
||||||
"database": "ok", // "ok" | "error"
|
|
||||||
"uptime": "3d 12h",
|
|
||||||
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Always returns HTTP 200 (read `status`/`plugin_connected` for real state). Use it for liveness checks and to detect when the shard has dropped (`plugin_connected: false`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. WebSocket live feed
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /ws?token=<token> (WebSocket upgrade)
|
|
||||||
```
|
|
||||||
|
|
||||||
A push-only stream of game events as they happen. You do **not** send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.
|
|
||||||
|
|
||||||
**On connect**, the first frame is:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "ws.hello", "protocol": 1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- **Live-only, no replay.** A client that connects now sees events from now on. For history/backfill, use `GET /history`.
|
|
||||||
- The sidecar sends WebSocket **ping** frames every ~30s for keepalive; browser clients answer automatically.
|
|
||||||
- You may occasionally see a `{"kind":"pong",...}` frame (the sidecar's internal heartbeat to the shard). Ignore any `kind` you don't handle.
|
|
||||||
- A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.
|
|
||||||
|
|
||||||
### Minimal browser client
|
|
||||||
|
|
||||||
```js
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
|
|
||||||
ws.onmessage = (m) => {
|
|
||||||
const ev = JSON.parse(m.data);
|
|
||||||
switch (ev.kind) {
|
|
||||||
case "ws.hello": /* check ev.protocol === 1 */ break;
|
|
||||||
case "mob.login": onLogin(ev); break;
|
|
||||||
case "vendor.sale": onSale(ev); break;
|
|
||||||
case "house.decay": onIdoc(ev); break;
|
|
||||||
// ...handle the kinds you care about; ignore the rest
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event catalog
|
|
||||||
|
|
||||||
Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"serial","name","acct","player"}` (`acct` present only for player-owned mobiles).
|
|
||||||
|
|
||||||
#### Lifecycle
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `server.hello` | `shard`, `bootId`, `connects`, `items`, `mobiles`, `accounts` | Sent to the sidecar on every shard (re)connect. `bootId` changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". |
|
|
||||||
| `server.shutdown` | — | Clean shutdown. |
|
|
||||||
| `server.crashed` | `error` | Not always sent (a hard crash may skip it). |
|
|
||||||
| `world.save.before` / `world.save.after` | (`after` adds `items`, `mobiles`) | Save-cycle boundaries; a natural consistency checkpoint. |
|
|
||||||
|
|
||||||
#### Sessions & identity
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
|
|
||||||
| `mob.logout` | `who` |
|
|
||||||
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
|
|
||||||
|
|
||||||
#### Economy & commerce
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `gold.change` | `acct`, `old`, `new`, `delta` | AccountGold flow (gold in bank/account, not physical coins). |
|
|
||||||
| `vendor.buy` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor purchase (validation stage). |
|
|
||||||
| `vendor.sell` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor sale. |
|
|
||||||
| `vendor.sale` | `buyerSerial`, `buyerAcct`, `ownerSerial`, `ownerAcct`, `vendorSerial`, `itemType`, `itemSerial`, `itemId`, `amount`, `price`, `commission`, `committed:true` | **Player** vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. |
|
|
||||||
| `vendor.placed` | `owner`, `vendor` | A player vendor was placed. |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
|
|
||||||
"ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
|
|
||||||
"itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
|
|
||||||
"price":100,"commission":0,"t":1783720195626}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Character progression & vitals
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `char.vitals` | `serial`, `hits`,`hitsMax`, `mana`,`manaMax`, `stam`,`stamMax`, `str`,`dex`,`int`, `map`, `x`,`y` | Periodic snapshot of each **online** player (~every 30s; configurable). Diff successive snapshots to detect change. |
|
|
||||||
| `skill.gain` | `who`, `skill`, `gained`, `base`, `cap` | Player skill gains only (NPC gains are filtered out). |
|
|
||||||
| `fame.change` / `karma.change` | `who`, `old`, `new` | Player only. |
|
|
||||||
| `quest.complete` | `who`, `quest` | |
|
|
||||||
|
|
||||||
#### Death & PvP
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `player.death` | `who`, `killer` |
|
|
||||||
| `player.murdered` | `victim`, `murderer` |
|
|
||||||
| `mob.killed` | `killed`, `killer` — only kills that involve a player |
|
|
||||||
|
|
||||||
#### Housing / IDOC
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
|
|
||||||
|
|
||||||
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
|
||||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
|
||||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
|
||||||
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Economy supply (periodic)
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `economy.supply` | `accounts`, `gold` — total money supply across all accounts (~every 5 min; configurable) |
|
|
||||||
|
|
||||||
#### Cheat detection & staff audit
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. |
|
|
||||||
| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. |
|
|
||||||
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. |
|
|
||||||
|
|
||||||
#### Account linking
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. REST — read queries
|
|
||||||
|
|
||||||
These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning **504**.
|
|
||||||
|
|
||||||
### Character profile
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /char/{account}/{slot} # by account + character slot (0-based)
|
|
||||||
GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C
|
|
||||||
```
|
|
||||||
|
|
||||||
Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for **offline** characters too. `GET /char/serial/...` falls back to the last **cached** profile if the shard is unreachable (so a page still renders during a shard restart).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
|
|
||||||
"body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
|
|
||||||
"stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
|
|
||||||
"mana":123,"manaMax":123, "stam":120,"stamMax":120,
|
|
||||||
"fame":0,"karma":0,"luck":0,
|
|
||||||
"resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
|
|
||||||
"skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
|
|
||||||
"equipment": [
|
|
||||||
{ "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
|
|
||||||
"cliloc":1027933,"mods":{} },
|
|
||||||
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
|
|
||||||
"weapon":{"minDamage":16,"maxDamage":18},
|
|
||||||
"mods":{"WeaponDamage":50,"HitLightning":40} }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Field notes:
|
|
||||||
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
|
|
||||||
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
|
|
||||||
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site.
|
|
||||||
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
|
|
||||||
|
|
||||||
### Account roster
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /roster/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
Lightweight list of an account's characters (up to 5–7), including offline ones. Use this for a character-picker, then fetch the full profile on demand.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind":"account.roster", "acct":"whitlocktech",
|
|
||||||
"chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Player vendors
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /vendors/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every player vendor owned by any character on the account, with held gold and current listings.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind":"vendor.snapshot", "acct":"seed_000",
|
|
||||||
"vendors":[
|
|
||||||
{ "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
|
|
||||||
"ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
|
|
||||||
"listings":[
|
|
||||||
{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
|
|
||||||
] } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. REST — commands & history
|
|
||||||
|
|
||||||
### Confirm an account link
|
|
||||||
|
|
||||||
The in-game `[link` flow: the player runs `[link`, the shard emits a `link.request` event (over the WebSocket) carrying a one-time `code`. Your site shows the logged-in website user a box to enter that code, then:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /link/confirm
|
|
||||||
{ "code": "AB12CD", "websiteUserId": "9931" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Success → **200** `{"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}`. The game account is now permanently tagged with your `websiteUserId` (persisted on the shard); subsequent `mob.login` events for that account carry `webId`.
|
|
||||||
- Bad/expired code → **404** `{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}`.
|
|
||||||
|
|
||||||
Codes are one-time and expire (default 5 min).
|
|
||||||
|
|
||||||
### Look up an existing link
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /link/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **200** `{"account":"PerryAdimn","websiteUserId":"9931"}` if linked.
|
|
||||||
- **404** `{"account":"PerryAdimn","linked":false}` if not.
|
|
||||||
|
|
||||||
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
|
|
||||||
|
|
||||||
### Publish / remove town-crier news
|
|
||||||
|
|
||||||
Push a message that every in-game town crier announces until it expires.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /towncrier
|
|
||||||
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`. Re-posting the same `id` replaces the prior entry.
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /towncrier/{id}
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`, or **404** `{"kind":"towncrier.error","reason":"unknown id"}`.
|
|
||||||
|
|
||||||
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
|
|
||||||
|
|
||||||
### History (from the sidecar's database)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /history?kind={kind}&limit={n} # kind optional, limit default 100 (max 1000)
|
|
||||||
GET /economy?limit={n} # the money-supply series (economy.supply events)
|
|
||||||
```
|
|
||||||
|
|
||||||
Recent events, **newest first**, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC".
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /history?kind=vendor.sale&limit=50
|
|
||||||
→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] }
|
|
||||||
|
|
||||||
GET /economy?limit=200
|
|
||||||
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Status codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| 200 | OK |
|
|
||||||
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
|
||||||
| 401 | Missing or invalid auth token |
|
|
||||||
| 404 | Not found (unknown account / character / id) |
|
|
||||||
| 409 | Protocol version mismatch (you sent `X-UOLink-Version` and it disagreed) |
|
|
||||||
| 500 | Internal error (e.g. database) |
|
|
||||||
| 503 | Shard not connected — the query needs the live game and it's down |
|
|
||||||
| 504 | Shard connected but didn't reply within 10s |
|
|
||||||
|
|
||||||
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Putting it together
|
|
||||||
|
|
||||||
A typical character page:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" };
|
|
||||||
|
|
||||||
// 1. render the roster
|
|
||||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
|
||||||
|
|
||||||
// 2. full sheet for the selected character
|
|
||||||
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
|
|
||||||
if (res.status === 503) showBanner("Game server is restarting…");
|
|
||||||
else renderProfile(await res.json());
|
|
||||||
|
|
||||||
// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
|
|
||||||
// (see the WebSocket client in §4)
|
|
||||||
|
|
||||||
// 4. recent sales widget
|
|
||||||
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
|
|
||||||
.then(r => r.json());
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Caveats & current limits
|
|
||||||
|
|
||||||
- **No rate limiting yet.** The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
|
|
||||||
- **WebSocket is push-only and live-only.** No client→server messages, no replay. Backfill via `/history`.
|
|
||||||
- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
|
|
||||||
- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted.
|
|
||||||
- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape.
|
|
||||||
508
docs/PLAN.md
508
docs/PLAN.md
@@ -1,508 +0,0 @@
|
|||||||
# ServUO Bridge Plugin — Implementation Plan & Data Catalog
|
|
||||||
|
|
||||||
**Status:** Design, grounded in **measurements taken on this shard**, not estimates.
|
|
||||||
**Date:** 2026-07-10
|
|
||||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
|
||||||
**Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) for where that document is wrong.
|
|
||||||
|
|
||||||
Test scaffolding used to produce this plan lives in `Scripts/Custom/BridgeSeeder.cs` (world population) and `Scripts/Custom/BridgeProbe.cs` (timing). Both are gated behind `Config/Bridge.cfg` flags and default to off. **Neither is part of the bridge.** Delete before production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Measured budget
|
|
||||||
|
|
||||||
Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the **Core thread** — the probe printed `thread: Core Thread (id 1)`, which empirically confirms the threading model that `BRIDGE_FINDINGS.md` could only infer from a crash log.
|
|
||||||
|
|
||||||
| Read | Cost | Payload | Per-unit |
|
|
||||||
|------|------|---------|----------|
|
|
||||||
| Full character profile | **0.069 ms/char** | 2,386 B JSON | — |
|
|
||||||
| Vitals sweep (150 chars) | 0.223 ms | ~180 B/char | 0.0015 ms/char |
|
|
||||||
| House decay sweep (35 houses) | 0.007 ms | — | 0.0002 ms/house |
|
|
||||||
| Economy supply sweep (51 accounts) | 0.001 ms | — | ~0.00002 ms/acct |
|
|
||||||
| Vendor snapshot (30 vendors, 1200 listings) | 0.343 ms | — | 0.0003 ms/listing |
|
|
||||||
|
|
||||||
Linear extrapolation at the same gear complexity:
|
|
||||||
|
|
||||||
| Scenario | Cost | Verdict |
|
|
||||||
|----------|------|---------|
|
|
||||||
| Vitals sweep @ 200 online | 0.30 ms | free |
|
|
||||||
| Vitals sweep @ 1000 online | 1.49 ms | free |
|
|
||||||
| Decay sweep @ 2000 houses | 0.38 ms | free |
|
|
||||||
| Economy @ 5000 accounts | 0.06 ms | free |
|
|
||||||
| **Profiles for 1000 chars** | **69.4 ms** | **stall — never in a sweep** |
|
|
||||||
|
|
||||||
**The headline result inverts the original doc's anxiety.** `BRIDGE_FINDINGS.md` treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is *not* free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. **Tier by volatility and serve profiles on demand.** That conclusion survives; the reasoning behind it changes.
|
|
||||||
|
|
||||||
### Caveat on these numbers
|
|
||||||
|
|
||||||
Seeded characters carry **8 equipped items with ~6 non-zero mods each and ~12 trained skills**. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore **understated, plausibly by 2–4×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~6–8 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars.
|
|
||||||
|
|
||||||
Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Architecture (confirmed, unchanged)
|
|
||||||
|
|
||||||
```
|
|
||||||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
|
||||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
|
||||||
```
|
|
||||||
|
|
||||||
ServUO does **not** speak WebSocket. It writes `{...}\n` lines to `127.0.0.1`. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust.
|
|
||||||
|
|
||||||
Non-negotiable rules, all of which the measurements support:
|
|
||||||
|
|
||||||
- **Every world read happens on the Core thread.** Verified: probe reported `Core Thread (id 1)`.
|
|
||||||
- **The Core thread never touches the socket.** Producer formats a line, enqueues to a bounded `ConcurrentQueue`, returns. A dedicated writer thread drains it.
|
|
||||||
- **Inbound commands marshal back via `Timer.DelayCall(TimeSpan.Zero, ...)`**, which is lock-protected and cross-thread safe (`Server/Timer.cs:243-251`). The read thread touches no `World`/`Mobile`/`Item` API.
|
|
||||||
- **Bound the outbound queue** (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard.
|
|
||||||
- **Never block or throw inside an EventSink handler.** Several are veto hooks sitting in a transaction path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Prerequisite: fix the build, or the plugin will not load
|
|
||||||
|
|
||||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) runs `dotnet build Scripts/Scripts.csproj -c Release`, **prints the output, never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Two consequences:
|
|
||||||
|
|
||||||
1. A failing script build is **silently ignored** and the previous `Scripts.dll` reloads. (`BRIDGE_FINDINGS.md` §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.)
|
|
||||||
2. The build passes no `Platform`, so it defaults to `AnyCPU`. `OutputPath` is only set under the `Release|x64` condition, so the DLL lands in `Scripts/bin/Release/` while the server loads `Scripts.dll` from the repo root. **Script edits currently never take effect.**
|
|
||||||
|
|
||||||
**Fix before writing any bridge code.** Either add a default `<Platform>x64</Platform>` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Plugin layout
|
|
||||||
|
|
||||||
All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug.
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. |
|
|
||||||
| `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. |
|
|
||||||
| `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. |
|
|
||||||
| `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. |
|
|
||||||
| `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. |
|
|
||||||
| `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. |
|
|
||||||
| `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. |
|
|
||||||
|
|
||||||
**Lifecycle** (`Server/Main.cs:544-562`, all Core thread):
|
|
||||||
`Configure()` → `World.Load()` → `Initialize()` → `EventSink.ServerStarted`.
|
|
||||||
|
|
||||||
Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Data catalog — everything the shard can give you
|
|
||||||
|
|
||||||
91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified.
|
|
||||||
|
|
||||||
### 5.1 Session & identity
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
|
||||||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
|
||||||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
|
||||||
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
|
|
||||||
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
|
||||||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
|
||||||
|
|
||||||
### 5.2 Character state
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. |
|
|
||||||
| **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. |
|
|
||||||
| Skill progression | `SkillGain` | medium | High-signal. Ship it. |
|
|
||||||
| Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. |
|
|
||||||
| Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. |
|
|
||||||
| Hunger | `HungerChanged` | low | Cosmetic; optional. |
|
|
||||||
|
|
||||||
> ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free.
|
|
||||||
>
|
|
||||||
> ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7.
|
|
||||||
|
|
||||||
### 5.3 Economy & commerce
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). |
|
|
||||||
| **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. |
|
|
||||||
| NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** |
|
|
||||||
| NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. |
|
|
||||||
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
|
|
||||||
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
|
|
||||||
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
|
|
||||||
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
|
|
||||||
|
|
||||||
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
|
|
||||||
|
|
||||||
Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold.
|
|
||||||
|
|
||||||
### 5.4 Housing / IDOC
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
|
||||||
|
|
||||||
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
|
|
||||||
|
|
||||||
**The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected:
|
|
||||||
|
|
||||||
- `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 h random** (`DynamicDecay.cs:18`).
|
|
||||||
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
|
|
||||||
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
|
|
||||||
|
|
||||||
Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
|
|
||||||
|
|
||||||
### 5.5 Combat, death, PvP
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Player death | `PlayerDeath` | low | |
|
|
||||||
| Murder | `PlayerMurdered` | low | High-signal for the website. |
|
|
||||||
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
|
|
||||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
|
||||||
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
|
|
||||||
|
|
||||||
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
|
|
||||||
|
|
||||||
### 5.6 Progression & activity
|
|
||||||
|
|
||||||
`QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`.
|
|
||||||
|
|
||||||
`OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`.
|
|
||||||
|
|
||||||
> ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`.
|
|
||||||
>
|
|
||||||
> Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object.
|
|
||||||
|
|
||||||
### 5.7 Cheat detection & staff audit
|
|
||||||
|
|
||||||
This is where the catalog earns its keep, and it is thin in the original doc.
|
|
||||||
|
|
||||||
| Signal | Hook | Why |
|
|
||||||
|--------|------|-----|
|
|
||||||
| **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. |
|
|
||||||
| **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. |
|
|
||||||
| Staff commands | `EventSink.Command` | Every command invocation. |
|
|
||||||
| **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. |
|
|
||||||
| Gold flow | `AccountGoldChange` | Reconcile against sale stream. |
|
|
||||||
|
|
||||||
### 5.8 Lifecycle
|
|
||||||
|
|
||||||
`ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`.
|
|
||||||
|
|
||||||
`AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.**
|
|
||||||
|
|
||||||
### 5.9 Known gaps (no clean hook)
|
|
||||||
|
|
||||||
- **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.**
|
|
||||||
- **Per-hit combat damage.** Virtual overrides only.
|
|
||||||
- **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only.
|
|
||||||
- **Stat/vital deltas.** Sweep. (Cheap — see §5.2.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. The one core edit: `PlayerVendorSale`
|
|
||||||
|
|
||||||
Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// PlayerVendorGumps.cs:84-96
|
|
||||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
|
||||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
|
||||||
int commission = 0;
|
|
||||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
|
||||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed
|
|
||||||
```
|
|
||||||
|
|
||||||
At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale.
|
|
||||||
|
|
||||||
Three edits, then the bridge stays pure-subscription:
|
|
||||||
|
|
||||||
1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape).
|
|
||||||
2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96.
|
|
||||||
3. Bridge subscribes in `Initialize` like any other event.
|
|
||||||
|
|
||||||
~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Wire protocol
|
|
||||||
|
|
||||||
Newline-delimited JSON, one object per line, `serial` as the primary key.
|
|
||||||
|
|
||||||
### Outbound (shard → sidecar)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"t":1752…,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2,
|
|
||||||
"items":206467,"mobiles":42826,"accounts":51}
|
|
||||||
{"t":1752…,"kind":"server.shutdown"}
|
|
||||||
{"t":1752…,"kind":"server.crashed","error":"…"}
|
|
||||||
{"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
|
|
||||||
{"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
|
|
||||||
"str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
|
|
||||||
{"t":1752…,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
|
|
||||||
{"t":1752…,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
|
||||||
"owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
|
|
||||||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
|
|
||||||
{"t":1752…,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC",
|
|
||||||
"map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0},
|
|
||||||
"region":"Britain","name":"The Silver Anvil",
|
|
||||||
"owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[],
|
|
||||||
"builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
|
|
||||||
{"t":1752…,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
|
|
||||||
{"t":1752…,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
|
|
||||||
{"t":1752…,"kind":"economy.supply","accounts":51,"gold":110478209}
|
|
||||||
```
|
|
||||||
|
|
||||||
`char.profile` follows the shape in `BRIDGE_FINDINGS.md` §IV.3 — it was correct — with `mods` a flattened union of non-zero entries across `AosAttributes`, `AosWeaponAttributes`, `AosArmorAttributes`, produced by iterating each enum through the bag's indexer (`Scripts/Misc/AOS.cs:924,1464,2238`). No hardcoded property names.
|
|
||||||
|
|
||||||
### Inbound (sidecar → shard)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"char.request","account":"PerryAdimn","slot":0}
|
|
||||||
{"kind":"account.roster","account":"PerryAdimn"}
|
|
||||||
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
|
|
||||||
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
|
|
||||||
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
|
|
||||||
{"kind":"towncrier.remove","id":"n123"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every inbound handler marshals to the Core thread before touching world state.
|
|
||||||
|
|
||||||
### `server.hello` is per-connection, not per-boot
|
|
||||||
|
|
||||||
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.
|
|
||||||
|
|
||||||
`bootId` is a GUID generated at `ServerStarted`. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes *"I reconnected"* (keep cached state) from *"the shard restarted"* (discard it). `connects` is the shard's count of successful connections, so the first `hello` of a run carries `connects:1`.
|
|
||||||
|
|
||||||
Counts in `hello` are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning.
|
|
||||||
|
|
||||||
### Item names are clilocs
|
|
||||||
|
|
||||||
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Corrections to `BRIDGE_FINDINGS.md`
|
|
||||||
|
|
||||||
| § | Claim | Reality |
|
|
||||||
|---|-------|---------|
|
|
||||||
| §1 | "A compile error in your bridge file takes the whole shard down at boot." | **False.** `Compile()` ignores the build exit code; a failing build silently reloads the stale `Scripts.dll`. Worse: your plugin would appear absent, not broken. See §3. |
|
|
||||||
| §III.3 | IDOC = 95.0–99.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 12–24 h. Also: `CanDecay` is true only for `Condemned`/`ManualRefresh`, so an active owner's newest house never decays. |
|
|
||||||
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
|
|
||||||
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
|
|
||||||
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
|
|
||||||
| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. |
|
|
||||||
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Implementation phases
|
|
||||||
|
|
||||||
0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.`
|
|
||||||
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
|
|
||||||
2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
|
|
||||||
3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
|
|
||||||
4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests.
|
|
||||||
5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15.
|
|
||||||
6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16.
|
|
||||||
7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17.
|
|
||||||
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
|
|
||||||
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
|
|
||||||
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
|
|
||||||
8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar.
|
|
||||||
|
|
||||||
### Config keys (`Config/Bridge.cfg`)
|
|
||||||
|
|
||||||
```ini
|
|
||||||
Host=127.0.0.1
|
|
||||||
Port=7788
|
|
||||||
QueueCap=10000
|
|
||||||
StatSweepSeconds=30
|
|
||||||
DecaySweepSeconds=60
|
|
||||||
EconomySweepSeconds=300
|
|
||||||
```
|
|
||||||
|
|
||||||
Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Phase 1 acceptance
|
|
||||||
|
|
||||||
Run against the seeded shard with `tools/stub_sidecar.ps1`. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed.
|
|
||||||
|
|
||||||
| Claim | Evidence |
|
|
||||||
|-------|----------|
|
|
||||||
| The shard boots normally with **no sidecar listening**. | World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat. |
|
|
||||||
| Events emitted while disconnected are **buffered and delivered on connect**. | `server.hello` carried `t=…070312` (boot) but arrived at `…114209`, 44 s later, when the sidecar first appeared. |
|
|
||||||
| Inbound commands execute on the **Core thread**. | `{"kind":"ping","id":"t1"}` → `{"kind":"pong","id":"t1"}`. |
|
|
||||||
| An **unknown kind** is ignored, not fatal. | `[Bridge] no handler for inbound kind 'nonsense.kind'` |
|
|
||||||
| **Malformed JSON** does not kill the reader. | `[Bridge] malformed inbound line, ignoring`, connection stayed up. |
|
|
||||||
| Killing the sidecar **does not disturb the shard**. | Shard stayed up, CPU unchanged, no exception, no log spam. |
|
|
||||||
| The shard **reconnects unattended**. | Second `[Bridge] connected`, `hello` re-sent with `connects:2` and the same `bootId`. |
|
|
||||||
|
|
||||||
Two defects were found this way and fixed:
|
|
||||||
|
|
||||||
- **Backoff ceiling was 30 s**, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s.
|
|
||||||
- **A stale reader could kill a fresh connection.** `reader.Join(1s)` can time out, and the old reader's `finally` then set the shared `_dead` flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Phase 7 acceptance
|
|
||||||
|
|
||||||
The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`.
|
|
||||||
|
|
||||||
Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so.
|
|
||||||
|
|
||||||
Verified end to end with a probe that fired the event using **real seeded-vendor data**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"vendor.sale","committed":true,
|
|
||||||
"buyerSerial":"0x1F8","buyerAcct":"seed_001",
|
|
||||||
"ownerSerial":"0x1F5","ownerAcct":"seed_000",
|
|
||||||
"vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword",
|
|
||||||
"itemId":3937,"amount":1,"price":69819,"commission":0}
|
|
||||||
```
|
|
||||||
|
|
||||||
Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events.
|
|
||||||
|
|
||||||
**Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Phase 6 acceptance
|
|
||||||
|
|
||||||
`BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary.
|
|
||||||
|
|
||||||
Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree:
|
|
||||||
|
|
||||||
| Sent | Reply | Crier list |
|
|
||||||
|------|-------|------------|
|
|
||||||
| `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines |
|
|
||||||
| `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list |
|
|
||||||
| `remove n1` | `towncrier.ok` | entry gone |
|
|
||||||
| `remove does-not-exist` | `towncrier.error "unknown id"` | no change |
|
|
||||||
|
|
||||||
The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged.
|
|
||||||
|
|
||||||
Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs.
|
|
||||||
|
|
||||||
No core changes; this closes the pure-plugin inbound work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Phase 5 acceptance
|
|
||||||
|
|
||||||
`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`.
|
|
||||||
|
|
||||||
Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it):
|
|
||||||
|
|
||||||
```
|
|
||||||
<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300
|
|
||||||
-> link.confirm code=77M9TK websiteUserId=web-9931
|
|
||||||
<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931
|
|
||||||
-> link.confirm code=BADCOD ...
|
|
||||||
<- link.error code=BADCOD reason="unknown or expired code"
|
|
||||||
```
|
|
||||||
|
|
||||||
**The tag persists.** After a `World.Save()`, `accounts.xml` contained:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<tags>
|
|
||||||
<tag name="WebsiteUserId">web-9931</tag>
|
|
||||||
</tags>
|
|
||||||
```
|
|
||||||
|
|
||||||
This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised.
|
|
||||||
|
|
||||||
Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup.
|
|
||||||
|
|
||||||
Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Phase 4 acceptance
|
|
||||||
|
|
||||||
`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence.
|
|
||||||
|
|
||||||
Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread:
|
|
||||||
|
|
||||||
- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline.
|
|
||||||
- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed.
|
|
||||||
- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree.
|
|
||||||
- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each.
|
|
||||||
- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`.
|
|
||||||
|
|
||||||
Two things the real character surfaced that the seeded dummies could not:
|
|
||||||
|
|
||||||
- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully.
|
|
||||||
- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking.
|
|
||||||
|
|
||||||
Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Phase 3 acceptance
|
|
||||||
|
|
||||||
`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters.
|
|
||||||
|
|
||||||
Verified on the seeded world with intervals cut to 8 s:
|
|
||||||
|
|
||||||
- **Decay is transition-only.** Baseline recorded 29 houses **silently** on `ServerStarted`. A probe bumped one house `Somewhat → Fairly` with `SetDynamicDecay`; the next sweep emitted **exactly one** `house.decay`, none for the other 28:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
|
||||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
|
||||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
|
||||||
"builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Economy supply** emitted a snapshot each interval: `{"kind":"economy.supply","accounts":51,"gold":…}`.
|
|
||||||
- **Vitals** correctly emitted nothing — the seeded characters are all offline (`NetState == null`). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client.
|
|
||||||
|
|
||||||
Notes from the run:
|
|
||||||
|
|
||||||
- **`region` is null** for the seeded houses — they sit outside any named region. The handler guards `Region`, `Sign`, and `Owner` for null; all three can be absent on abandoned or oddly-placed houses.
|
|
||||||
- The sweeps **skip emitting when the sidecar is disconnected** (`BridgeLink.Connected`), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless.
|
|
||||||
- **Config duplicate keys: last write wins** (`Config.cs` does `_Entries[key] = e`), which is why the scaffolding appends test overrides to the end of `Bridge.cfg`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Phase 2 acceptance
|
|
||||||
|
|
||||||
The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar:
|
|
||||||
|
|
||||||
```
|
|
||||||
{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
|
|
||||||
{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
|
|
||||||
{"kind":"karma.change",...,"old":7903,"new":7853}
|
|
||||||
{"kind":"world.save.before"}
|
|
||||||
{"kind":"world.save.after","items":206312,"mobiles":42826}
|
|
||||||
```
|
|
||||||
|
|
||||||
`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.
|
|
||||||
|
|
||||||
### The finding: `SkillGain` fires for NPCs, hard
|
|
||||||
|
|
||||||
The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events.
|
|
||||||
|
|
||||||
This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.
|
|
||||||
|
|
||||||
### Safety facts baked into the handlers
|
|
||||||
|
|
||||||
- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process.
|
|
||||||
- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these.
|
|
||||||
- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer.
|
|
||||||
- The property is `FastWalkEventArgs.NetState`, not `.State`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Operational notes
|
|
||||||
|
|
||||||
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
|
|
||||||
- **Pending link codes are in-memory** and lost on crash. Acceptable — the player re-runs `[link`.
|
|
||||||
- **`zlibwapi64` `DllNotFoundException`** already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
|
||||||
- The bridge should carry the resolved `websiteUserId` on every player event once the account tag is read at `Login` and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.
|
|
||||||
544
docs/RESEARCH.md
544
docs/RESEARCH.md
@@ -1,544 +0,0 @@
|
|||||||
# ServUO ⇄ External Service Bridge — Research Findings
|
|
||||||
|
|
||||||
**Status:** Research only, no implementation.
|
|
||||||
**Architecture:** Rust sidecar owns a bidirectional WebSocket + JSON endpoint for the website; ServUO links to it over a **local loopback socket**. Tracking players/stats/gold/economy/NPC+player-vendor sales, IDOC/house decay, in-game **`[link`** account linking, and website→game town-crier news. See **Part II** (design/transport/tracking/link), **Part III** (player-vendor, IDOC, town crier, config), and **Part IV** (full character profiles — gear/skills/stats, online & offline, up to 5/account).
|
|
||||||
**Date:** 2026-07-07
|
|
||||||
**Codebase:** ServUO 57.4 (this repo, `C:\Users\colby\Desktop\servuo`), target framework **.NET Framework 4.8 / x64**.
|
|
||||||
**Method:** Grounded in this repo's source. Where the running server would normally be used to confirm behavior, see the note in [§0](#0-note-on-empirical-verification) — the shard was **not running** at research time, so live-boot verification was deliberately skipped and replaced with source-level proof plus evidence from this repo's own crash logs. A ready-to-run empirical probe is included in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Note on empirical verification
|
|
||||||
|
|
||||||
You said the shard was running and to verify against it. At research time **no `ServUO.exe` / `dotnet` process was live** (`Get-Process` returned nothing; `Logs/Console.log` absent). I chose **not** to boot it myself because a cold boot on this machine would:
|
|
||||||
|
|
||||||
- shell out to `dotnet build Scripts.csproj` (per `ScriptCompiler.Compile`, `Compiler.Dynamic=true` by default),
|
|
||||||
- **bind the live game port** and load/write your actual `Saves/` world (117 mobiles / 2469 items per the last crash report),
|
|
||||||
- run `EventSink.ServerStarted` and AutoSave against real state.
|
|
||||||
|
|
||||||
That's outward-facing and hard to reverse, so it needs your go-ahead. **It turned out not to be necessary for the core threading claims**, because:
|
|
||||||
|
|
||||||
1. The source pins the threading model exactly (call sites shown below), and
|
|
||||||
2. **Your own crash log is live evidence.** `Crash 6-5-2026-22-38-3.log` contains this stack:
|
|
||||||
|
|
||||||
```
|
|
||||||
Server.EventSink.InvokeClientVersionReceived(...)
|
|
||||||
Server.Network.MessagePump.HandleReceive(NetState ns)
|
|
||||||
Server.Network.MessagePump.Slice()
|
|
||||||
Server.Core.Main(String[] args)
|
|
||||||
```
|
|
||||||
|
|
||||||
That is a network-triggered EventSink handler executing **inside `MessagePump.Slice()`, called directly from `Core.Main`** — i.e. on the Core (main) thread, synchronously in the game loop. This is exactly the thread-identity fact item 3/5 hinges on, captured from this instance at runtime.
|
|
||||||
|
|
||||||
If you want the live thread-ID trace anyway (Timer + ServerStarted, no client needed), drop in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself) and start the shard, or tell me to boot it.
|
|
||||||
|
|
||||||
> ⚠️ Unrelated but worth flagging: that crash was `DllNotFoundException: zlibwapi64`. The DLL **is** present in the repo root, so this is a working-directory / native-load-path issue that has already crashed your shard once when sending a packed gump. Not a bridge concern, but it will bite the bridge too if the bridge ever triggers gump sends. Track separately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART II — Re-evaluation for the Rust WebSocket sidecar (READ FIRST)
|
|
||||||
|
|
||||||
**Confirmed architecture (from you):** a **Rust sidecar** holds a bidirectional **WebSocket** connection and exposes a **JSON endpoint the website consumes**. Goals: track players + stats, gold, overall economy, vendor sales; and an in-game **`[link`** command that ties a game account to a website account.
|
|
||||||
|
|
||||||
The §1–§5 findings below are unchanged and still govern (lifecycle, events, timers, threading). This part maps them onto *your* design and supersedes the old §6/§7.
|
|
||||||
|
|
||||||
### II.1 Transport: put the WebSocket in Rust, keep the C# side dumb
|
|
||||||
|
|
||||||
```
|
|
||||||
ServUO plugin (C#, net48) ──local loopback, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
|
||||||
(main-thread events) ◄──inbound commands (link, etc.)──┘ (owns WS, buffering, auth, fan-out)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation: ServUO ↔ sidecar = a plain local TCP loopback socket (`127.0.0.1`), newline-delimited JSON, bidirectional. Do NOT make ServUO speak WebSocket.**
|
|
||||||
|
|
||||||
- `System.Net.WebSockets.ClientWebSocket` *does* exist on net48 + Windows 11 and would work, but it's the wrong place for WS complexity. The sidecar already terminates WS for the website; a second WS hop inside the shard buys nothing and adds a heavier, blockier client on the one thread you must never block (§5). A raw `TcpClient` with `\n`-framed JSON is ~30 lines of C#, trivially non-blocking, and lets the **sidecar restart independently** without touching the shard.
|
|
||||||
- Named pipes (old §6) also work and are fine if you prefer them; loopback TCP is marginally simpler cross-process and cross-language (Rust `tokio::net::TcpListener` ↔ C# `TcpClient`).
|
|
||||||
- **This split is exactly what §5 demands.** All backpressure, reconnect, retry, website fan-out, and schema validation live in **Rust**. ServUO only ever does: (outbound) format a small JSON line → enqueue → a background writer thread drains to the socket; (inbound) a background read loop parses a line → `Timer.DelayCall` to the main thread. A slow or absent website can never stall the shard, because the Rust side owns the buffer and the socket write from C# is to loopback with a bounded local queue in front of it.
|
|
||||||
|
|
||||||
**Framing:** newline-delimited JSON objects (`{...}\n`), `PipeTransmissionMode`/message-mode not needed. One writer thread on the C# side keeps event ordering intact. Bound the outbound queue (drop-oldest + a dropped-counter) so a stalled sidecar can't OOM the shard.
|
|
||||||
|
|
||||||
### II.2 Tracking targets → concrete hooks (and the gaps)
|
|
||||||
|
|
||||||
| Target | Hook | Freq | Notes / caveats |
|
|
||||||
|--------|------|------|-----------------|
|
|
||||||
| **Player online / identity** | `EventSink.Login` / `Logout` | Low | Snapshot `Account.Username`, char name, `Mobile.Serial`, `Map`, `Location`. Best per-player anchor. |
|
|
||||||
| **Player stats** (Str/Dex/Int, Hits/Mana/Stam, skills, Fame/Karma) | ⚑ **No per-change EventSink** | — | Strategy: full snapshot on `Login`, then a **periodic sweep** (every 15–30 s) of online `PlayerMobile`s pushed as-is; let the **sidecar diff** and forward only changes. Add `FameChange`/`KarmaChange`/`SkillGain` for high-signal jumps. Don't try to hook the per-stat delta system — it's invasive and firehose-y. |
|
|
||||||
| **Gold (per player)** | `EventSink.AccountGoldChange` | Low–Med | ✔ **AccountGold is ENABLED on this shard** (expansion EJ ≥ TOL, `CurrentExpansion.cs:20`). Args give `IAccount` + `OldAmount`/`NewAmount` (`TotalCurrency`, a `double`). Most gold flow fires this. Caveat: physical coins/checks sitting in a bankbox aren't fully reflected here — see economy row. |
|
|
||||||
| **Overall economy / money supply** | Periodic account sweep + flow events | Low | Money **supply** = periodic sum of `TotalCurrency` across all `Accounts` (+ optionally bankbox coin/check items) on the main thread, pushed as a snapshot. Money **velocity/flow** = the `AccountGoldChange` + vendor-sale event stream. Sidecar aggregates both. |
|
|
||||||
| **NPC vendor — player buys** | `EventSink.ValidVendorPurchase` | Med | Args: `Mobile` (buyer), `Vendor`, `Bought` (IEntity/item), `AmountPerUnit`. **Total = AmountPerUnit × stack `Amount`.** Raised from `GenericBuy.cs:379`. |
|
|
||||||
| **NPC vendor — player sells** | `EventSink.ValidVendorSell` | Med | Args mirror above (`Sold`, `AmountPerUnit`). Raised from `BaseVendor.cs:2209`. |
|
|
||||||
| **Player vendor sales** | ⚑ **No EventSink (gap)** | Med | Player-vendor buys go through `PlayerVendor.TryToBuy` (`PlayerVendor.cs:447`), not the Valid* events. To capture these you must override/patch the PlayerVendor buy completion. Flag if the spec counts player-vendor commerce as "vendor sales." |
|
|
||||||
| **Account ↔ website link** | `Account.Username` + `Account.SetTag/GetTag` | — | `SetTag("WebsiteUserId", id)` persists to `accounts.xml` across restarts (`Account.cs:1078,1093`). No schema/DB work needed on the C# side. |
|
|
||||||
|
|
||||||
> ⚠️ The `Valid*` vendor events are **validation-stage veto hooks**, not "sale committed" callbacks. They fire when the purchase is being validated; in rare cases a sale could still fail afterward. For coarse economy metrics that's fine; if you need exact ledger accuracy, treat them as "sale attempted" and reconcile against `AccountGoldChange`, or hook the actual completion path. **Never block or throw in these handlers** — you're inside the transaction path.
|
|
||||||
|
|
||||||
### II.3 The `[link` command flow
|
|
||||||
|
|
||||||
Prefix is `[` (`Commands.cs:131`), so `[link` is registered directly. Everything below runs on the main thread except the socket I/O.
|
|
||||||
|
|
||||||
1. **Register** in your plugin's `Initialize()`:
|
|
||||||
`CommandSystem.Register("link", AccessLevel.Player, OnLink);`
|
|
||||||
2. **`[link` handler** (`e.Mobile`): read `e.Mobile.Account as Account`. If already tagged (`GetTag("WebsiteUserId") != null`), tell them so. Otherwise generate a **short, one-time, expiring code** (e.g. 6–8 chars, 5-min TTL), store `code → {accountUsername, expiry}` in an in-memory dict (main thread), and:
|
|
||||||
- push `{"kind":"link.request","code":"AB12CD","account":"PerryAdimn","char":"Thunderheat"}` to the sidecar, and
|
|
||||||
- `e.Mobile.SendMessage("Enter code AB12CD at https://yoursite/link to connect your account.")`
|
|
||||||
3. **Website** (user logged in there) submits the code → sidecar → ServUO inbound line `{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}`.
|
|
||||||
4. **Inbound handler** marshals to main thread (`Timer.DelayCall`), validates code + TTL, then `account.SetTag("WebsiteUserId","9931")`, drops the code, and replies `{"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}`. Optionally `SendMessage` the player if still online.
|
|
||||||
5. **Thereafter**, every player event you emit can carry the resolved `websiteUserId` (read the tag on Login and cache account→id in the sidecar), so the website can attribute stats/gold/sales to a site user.
|
|
||||||
|
|
||||||
Security notes: codes one-time + short-TTL; the link socket is **loopback-only** (bind `127.0.0.1`, never `0.0.0.0`); the account write happens on the main thread; rate-limit `[link` per account to avoid code spam. Treat `websiteUserId` from the sidecar as trusted only because the socket is local — if the sidecar is ever exposed, add a shared secret.
|
|
||||||
|
|
||||||
### II.4 Revised flags for THIS architecture
|
|
||||||
|
|
||||||
1. **✔ Threading is a solved problem given the split.** Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget + `Timer.DelayCall` inbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO.
|
|
||||||
2. **⚑ Player stats have no change-event** → sweep-and-diff in the sidecar (II.2). Budget for a 15–30 s snapshot of online players; don't expect push-on-change.
|
|
||||||
3. **⚑ Player-vendor sales aren't covered by any EventSink** (II.2) — **RESOLVED in §III.1.** You've confirmed this stream is critical (economy + cheat detection), so add the small `PlayerVendorSale` EventSink (~15 lines of core instrumentation). It's the one non-drop-in piece.
|
|
||||||
4. **⚑ "Economy" needs both a periodic supply snapshot and the flow stream.** `AccountGoldChange` alone is flow, not total; physical bank coins/checks aren't in it. Do a periodic `Accounts` `TotalCurrency` sum for money supply.
|
|
||||||
5. **✔ Linking needs no new persistence layer** — account tags serialize to `accounts.xml` for free (II.3). Survives restarts and saves.
|
|
||||||
6. **⚑ Commands/inbound don't apply during world saves** (§5 pitfall 3, ~every 5 min). A `[link.confirm` arriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly.
|
|
||||||
7. **⚑ Crash path skips `Shutdown`** (§1): the sidecar must treat socket EOF as normal and reconnect; don't rely on a clean goodbye frame. Pending link codes are in-memory and lost on crash — acceptable (user re-runs `[link`).
|
|
||||||
8. **⚑ (unchanged) Item pickup/drop and per-hit combat have no EventSink** (§2 gap) — only relevant if the tracking scope grows beyond stats/gold/economy/vendors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART III — Player-vendor tracking, IDOC, town-crier news, config
|
|
||||||
|
|
||||||
Follow-ups you added: **(1)** player-vendor tracking is *critical* (economy balance + admin cheat detection); **(2)** the 30 s stat sweep must be config-editable; **(3)** hook **IDOC / house decay**; **(4)** town criers receive **news pushed from the website**.
|
|
||||||
|
|
||||||
### III.1 Player-vendor sales — the one place you need a small core touch
|
|
||||||
|
|
||||||
There is genuinely **no EventSink** on the player-vendor buy path (confirmed). The purchase *completes* in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), specifically at the gold transfer:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// PlayerVendorGumps.cs ~line 81-96 (existing code)
|
|
||||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
|
||||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
|
||||||
...
|
|
||||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
|
||||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited ◄── sale is now committed
|
|
||||||
```
|
|
||||||
|
|
||||||
At that point every field cheat-detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the real player who profits), **item** (`m_VI.Item`, incl. `Serial`, type, `Amount`), **price** (`m_VI.Price`), and **commission**. This is *better* data than the NPC-vendor `Valid*` events (which lack owner + commission), and unlike them it fires on a **committed** sale, not a validation stage.
|
|
||||||
|
|
||||||
**Recommendation (idiomatic, minimal): add a first-class EventSink event, mirroring the existing vendor events.** Three tiny edits, then the bridge stays pure-subscription like everything else:
|
|
||||||
|
|
||||||
1. In `Server/EventSink.cs`: declare `public static event PlayerVendorSaleEventHandler PlayerVendorSale;`, an `InvokePlayerVendorSale`, and a `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape at `EventSink.cs:1508`).
|
|
||||||
2. In `PlayerVendorGumps.cs`, one line right after the `HoldGold +=` at ~line 96:
|
|
||||||
`EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));`
|
|
||||||
3. Bridge subscribes in `Initialize` like any other event.
|
|
||||||
|
|
||||||
This is **the single spot where the bridge can't be pure drop-in** — worth calling out explicitly since I'd earlier listed player vendors as a "gap." It's a ~15-line core instrumentation, not a rework. (Alternative if you refuse to touch core scripts: a periodic diff of every `PlayerVendor`'s inventory + `HoldGold` — but that can't attribute the *buyer*, which is exactly what cheat detection needs, so it's a poor substitute.)
|
|
||||||
|
|
||||||
**For cheat detection specifically**, emit per sale: buyer serial+account, owner serial+account, item type/serial/amount, price, commission, vendor serial, house/region, timestamp. The sidecar can then flag e.g. same-account buyer≈owner (gold laundering), wildly off-market prices, or burst patterns. Note `m_Vendor.Owner` + `from.Account` are the two identities that matter; both are readable synchronously in the handler (main thread).
|
|
||||||
|
|
||||||
### III.2 Config-editable sweep interval (and other tunables)
|
|
||||||
|
|
||||||
Use ServUO's own config system (`Server/Config.cs`), which reads `Config/*.cfg`. Read tunables in `Configure()` (runs before world load):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
StatSweep = Config.Get("Bridge.StatSweepSeconds", 30);
|
|
||||||
DecaySweep = Config.Get("Bridge.DecaySweepSeconds", 60);
|
|
||||||
```
|
|
||||||
|
|
||||||
Drop a `Config/Bridge.cfg` with `Bridge.StatSweepSeconds=30` etc. `Config.Get<T>` handles `int`/`TimeSpan`/`bool`. Make the sweep timer re-readable on demand (a `[bridge reload` admin command that re-reads config and re-arms the `Timer`) so you can retune without a restart. Store all bridge knobs (sweep intervals, which event streams are enabled, sidecar host/port, queue cap) in that one cfg.
|
|
||||||
|
|
||||||
### III.3 IDOC / house decay — sweep `BaseHouse.AllHouses`, emit on transition
|
|
||||||
|
|
||||||
Also **no EventSink** here. The model (`Scripts/Multis/BaseHouse.cs`):
|
|
||||||
|
|
||||||
- `DecayLevel` enum (`BaseHouse.cs:4341`): `Ageless, LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, DemolitionPending`. **IDOC = 95.0–99.9%** of the decay period elapsed (`GetOldDecayLevel`, `BaseHouse.cs:211-213`); `Collapsed` = 100%.
|
|
||||||
- `BaseHouse.AllHouses` is a static list of every house; `Decay_OnTick` (`BaseHouse.cs:59`) already periodically calls `CheckDecay()` on all of them.
|
|
||||||
- The `DecayLevel` getter has internal transition detection (`m_LastDecayLevel`, `BaseHouse.cs:193`) but it's private and only invalidates the sign — **not** exposed as an event.
|
|
||||||
|
|
||||||
**Decision: emit on transition only, tracked plugin-side.** A low-frequency **sweep** (30–60 s, config per III.2) over `BaseHouse.AllHouses` reads `house.DecayLevel` on the main thread. The plugin holds a `Dictionary<Serial, DecayLevel>` of last-known levels and emits **only when a house's level changes** — no per-sweep spam, one message per real transition. Houses number in the hundreds/thousands (not the mobile firehose), so the sweep is cheap even though we scan all of them each pass.
|
|
||||||
|
|
||||||
**State & re-baseline (important, since the plugin now holds state):**
|
|
||||||
- The last-known map is **in-memory and resets on restart**. On `ServerStarted` (§1), do a **silent baseline pass**: populate the dictionary from the current `DecayLevel` of every house **without emitting** — otherwise every house re-announces its current stage on every boot. Optionally emit a single `idoc.snapshot` of all houses already at IDOC/Collapsed so the website/admin panel is correct immediately after a restart, clearly flagged as a snapshot (not a transition).
|
|
||||||
- Emit direction matters for cheat/economy signals: include both `from`/`to` levels so the consumer can tell decay progression from a **refresh** (owner logged in → level jumps back toward `LikeNew`; `RefreshDecay`, `BaseHouse.cs`). A house leaving IDOC because someone refreshed it is itself a useful signal.
|
|
||||||
- `house.DecayLevel` is a computed property — read it **once per house per sweep** into a local, don't call it repeatedly.
|
|
||||||
|
|
||||||
**Payload (home location state you asked for — all readable synchronously in the sweep):** `BaseHouse` is a `BaseMulti` (an item), so it has `Serial`, `Location`/`X`/`Y`/`Z`, `Map`. Plus:
|
|
||||||
|
|
||||||
| Field | Source |
|
|
||||||
|-------|--------|
|
|
||||||
| house serial | `house.Serial` |
|
|
||||||
| decay from → to | tracked dict → `house.DecayLevel` |
|
|
||||||
| coords + facet | `house.X/Y/Z`, `house.Map` |
|
|
||||||
| stable landmark (where a player stands) | `house.BanLocation` (`BaseHouse.cs:3637`) |
|
|
||||||
| region / area name | `house.Region` (`:3672`) → `Region.Name` |
|
|
||||||
| house name | `house.Sign?.GetName()` (`:2108`) |
|
|
||||||
| owner | `house.Owner` (`:3564`) → serial + `Owner.Account.Username` (may be null if abandoned) |
|
|
||||||
| co-owners / friends | `house.CoOwners`, `house.Friends` (`:3679-3680`) — serials/accounts |
|
|
||||||
| built / last refreshed | `house.BuiltOn`, `house.LastRefreshed` (`:3786,:66`) |
|
|
||||||
| time-to-collapse | `house.NextDecayStage` and/or derive from `LastRefreshed + DecayPeriod` |
|
|
||||||
|
|
||||||
Example emit:
|
|
||||||
```jsonc
|
|
||||||
{ "kind":"house.decay", "serial":"0x40001234", "from":"Greatly", "to":"IDOC",
|
|
||||||
"map":"Felucca", "x":1420, "y":1631, "z":0, "ban":{"x":1422,"y":1635,"z":0},
|
|
||||||
"region":"Britain", "name":"The Silver Anvil",
|
|
||||||
"owner":{"serial":"0x1A2B","account":"PerryAdimn"},
|
|
||||||
"coOwners":[], "builtOn":"2026-01-02T...", "lastRefreshed":"2026-06-30T...",
|
|
||||||
"collapseEta":"2026-07-08T..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
This gives the website a live IDOC feed with exact map pins and the admin side an owner-attributed decay timeline. Guard against `Owner`/`Sign`/`Region` being null (abandoned or mid-demolition houses).
|
|
||||||
|
|
||||||
### III.4 Town-crier news pushed from the website (inbound → main thread)
|
|
||||||
|
|
||||||
Clean API, no core changes needed: `GlobalTownCrierEntryList.Instance.AddEntry(string[] lines, TimeSpan duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`) posts a **global** entry that *every* town crier announces until it expires; `RemoveEntry(entry)` pulls it early. `AddEntry` returns the `TownCrierEntry`.
|
|
||||||
|
|
||||||
**Flow:** website publishes news → sidecar → ServUO inbound `{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","The market tax is now 5%."],"durationSec":3600}` → **marshal to main thread** (`Timer.DelayCall`) → `var e = GlobalTownCrierEntryList.Instance.AddEntry(lines, TimeSpan.FromSeconds(durationSec));` and stash `id → e` so a later `{"kind":"towncrier.remove","id":"n123"}` can call `RemoveEntry(e)`.
|
|
||||||
|
|
||||||
Must run on the main thread (mutates a shared list and sends packets to crier NPCs) — same marshaling rule as `[link` (§II.3 / §5). Guard against abuse: cap line length/count and active-entry count in the handler; the socket being loopback-only is your trust boundary. Note the crier speaks lines on its own timer, so there's a natural delay before players hear it — fine for news.
|
|
||||||
|
|
||||||
### III.5 Updated capability map
|
|
||||||
|
|
||||||
| Capability | Mechanism | Core touch? | Runs on |
|
|
||||||
|-----------|-----------|:-----------:|---------|
|
|
||||||
| Player online/stats/gold | EventSink + 30 s sweep (§II.2) | No | main thread |
|
|
||||||
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | main thread |
|
|
||||||
| **Player-vendor sales** | **new `PlayerVendorSale` EventSink** (§III.1) | **Yes, ~15 lines** | main thread |
|
|
||||||
| `[link` account linking | `CommandSystem.Register` + account tags (§II.3) | No | main thread |
|
|
||||||
| IDOC / house decay | sweep `BaseHouse.AllHouses` on transition (§III.3) | No | main thread |
|
|
||||||
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | main thread (marshaled) |
|
|
||||||
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
|
|
||||||
|
|
||||||
**Net:** everything you listed is doable, and **only player-vendor sales requires a (small, idiomatic) core edit** — which is justified because it's your critical/cheat-detection stream and reflection-based alternatives can't identify the buyer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART IV — Full character profiles (armor / weapons / skills / everything)
|
|
||||||
|
|
||||||
You want the site's **player endpoint** to show a whole character — worn gear, weapon/armor detail, every skill, all stats — for **up to 5 characters per account**, online *or* offline, and eventually their vendor stats. The object model supports all of it; the design question is *how to ship it without turning the 30 s sweep into a firehose.*
|
|
||||||
|
|
||||||
### IV.1 It's all on the live `Mobile` — and offline chars stay resident
|
|
||||||
|
|
||||||
- **Account → characters:** `Account` holds `Mobile[] m_Mobiles` with `account.Length` slots and `account[index]` (`Account.cs:592,598`); non-null slots are the characters (max 5, engine allows up to 7). Iterate them to enumerate an account's roster.
|
|
||||||
- **Offline = still in memory.** Mobiles are removed from `World.Mobiles` **only on `Delete()`, never on logout.** A logged-off character is a live `Mobile` with `NetState == null`; all its gear/skills/stats are intact. **→ the bridge can build a full profile for any character at any time, online or offline** — exactly what "see my characters from the website" needs. `m.NetState != null` (or `m.Player && online`) is your online flag.
|
|
||||||
- **Stats/vitals** (`Server/Mobile.cs`): `Str/Dex/Int` (`:8276+`), `Hits/HitsMax`, `Mana/ManaMax`, `Stam/StamMax` (`:8554+`), the five resists `PhysicalResistance…EnergyResistance` (`:931+`), `VirtualArmor`, plus `Fame`, `Karma`, `Luck`, `TotalWeight`, `Title`, `Body`, `Hue`, `Name`.
|
|
||||||
- **Skills** (`Server/Skills.cs`): `m.Skills` is `IEnumerable<Skill>` (`:1099`) with `Length` + indexer. Each `Skill`: `SkillName`, `Base`, `Value` (base + item/temp bonuses), `Cap`, `Lock` (`Skills.cs:259,322,373,350,269`). Emit all ~58.
|
|
||||||
- **Worn equipment:** `m.Items` (`Mobile.cs:6695`) is the list of *equipped* items (one per `Layer`); `FindItemOnLayer(Layer)` (`:10545`) fetches a slot. `Layer` enum (`Item.cs:25`) covers the ~25 wearable slots (OneHanded, TwoHanded, Helm, Gloves, Ring, Neck, Arms, InnerTorso, Talisman, …). Filter out non-gear layers (Backpack, Bank, Mount, Hair/FacialHair) unless you want them.
|
|
||||||
- **Weapon/armor detail** (`BaseWeapon.cs`, `BaseArmor.cs`): rich AOS attribute objects — `Attributes` (`AosAttributes`), `WeaponAttributes`, `ArmorAttributes`, `AosElementDamages`, `ExtendedWeaponAttributes`, `NegativeAttributes`, plus `MinDamage/MaxDamage/StrRequirement` (weapon) and `BaseArmorRating`/resists (armor). **Each attribute bag exposes an enum indexer** — `AosAttributes[AosAttribute]`, `AosWeaponAttributes[AosWeaponAttribute]`, `AosArmorAttributes[AosArmorAttribute]` (`Scripts/Misc/AOS.cs:924,1464,2238`) — so you can **flatten every mod generically** by iterating the enum and emitting non-zero entries, without hardcoding 30+ property names.
|
|
||||||
|
|
||||||
### IV.2 Ship it tiered + on-demand (don't stream heavy profiles blindly)
|
|
||||||
|
|
||||||
A full profile ≈ 58 skills + ~15 gear items each with a mod table. Pushing that for every character every 30 s (× N accounts, most idle/offline, most unviewed) is wasteful. Split by volatility:
|
|
||||||
|
|
||||||
| Tier | Contents | When emitted |
|
|
||||||
|------|----------|--------------|
|
|
||||||
| **Vitals** (small, volatile) | hits/mana/stam, current str/dex/int, gold, location, online flag | 30 s sweep of **online** players + events |
|
|
||||||
| **Profile** (large, semi-static) | all skills, worn equipment + item mods, resists, caps, fame/karma/luck | on `Login`, on equip/skill change, and **on demand** |
|
|
||||||
|
|
||||||
**On-demand request/response drives the website player endpoint.** When the site opens a character page: website → sidecar → ServUO `{"kind":"char.request","account":"PerryAdimn","slot":0}` (or by serial) → marshal to main thread → build the full profile → reply `{"kind":"char.profile", …}`. The **sidecar caches** the last profile so the page renders instantly and the game only rebuilds on request or on change. This scales: you never pay to serialize characters nobody is looking at. (For a "roster" view, a light `{"kind":"account.roster"}` returning name/body/slot/online per character is enough; fetch the heavy profile only when a specific char is opened.)
|
|
||||||
|
|
||||||
### IV.3 Character-profile schema (sketch)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"kind": "char.profile",
|
|
||||||
"account": "PerryAdimn", "slot": 0,
|
|
||||||
"serial": "0x0075", "name": "Thunderheat", "title": "the Legendary",
|
|
||||||
"body": 400, "hue": 33770, "online": true,
|
|
||||||
"stats": { "str":100,"dex":90,"int":45, "hits":95,"hitsMax":100,
|
|
||||||
"mana":40,"manaMax":45,"stam":88,"stamMax":90,
|
|
||||||
"resist":{"phys":70,"fire":68,"cold":55,"pois":60,"energy":62},
|
|
||||||
"gold":124500, "fame":12000,"karma":-4000,"luck":140,"weight":320 },
|
|
||||||
"skills": [ {"name":"Swords","base":100.0,"value":120.0,"cap":120.0,"lock":"Up"},
|
|
||||||
{"name":"Tactics","base":100.0,"value":110.0,"cap":120.0,"lock":"Locked"} /* …all */ ],
|
|
||||||
"equipment": [
|
|
||||||
{ "serial":"0x4001A2","layer":"TwoHanded","itemId":5046,"hue":0,
|
|
||||||
"name":null,"cliloc":1023721, // resolve name via cliloc (IV.4)
|
|
||||||
"weapon":{"minDamage":16,"maxDamage":18,"strReq":40},
|
|
||||||
"mods":{"WeaponDamage":50,"HitLightning":40,"SwingSpeedIncrement":30,"DefendChance":15} },
|
|
||||||
{ "serial":"0x4002B3","layer":"InnerTorso","itemId":7168,"hue":1157,
|
|
||||||
"name":"Ancient Plate","armor":{"baseRating":45},
|
|
||||||
"mods":{"ResistFireBonus":15,"LowerManaCost":8,"BonusHits":5} }
|
|
||||||
],
|
|
||||||
"vendorsOwned": 3 // future (IV.5)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Locks/enum values serialize as their names. `mods` is the flattened non-zero union across the item's attribute bags.
|
|
||||||
|
|
||||||
### IV.4 Gotchas for the profile export
|
|
||||||
|
|
||||||
- **⚑ Item names are usually clilocs, not strings.** `Item.Name` (`Item.cs:4860`) is frequently `null`; the real display name is `LabelNumber` (`:3771`), a cliloc ID resolved against `Data/Cliloc.enu`. For the website either (a) resolve cliloc → text server-side from the cliloc file and send the string, or (b) send the number and resolve on the site with a cliloc map. Crafted/renamed items *do* carry a plain `Name`. Send both (`name` + `cliloc`) and prefer `name` when present.
|
|
||||||
- **⚑ Don't recurse the whole backpack/bank by default.** A pack can hold hundreds of nested items — that's a different (huge) payload than "what they're wearing." Ship **worn equipment** fully; expose backpack/bank as an opt-in or a summarized count, not a default deep dump.
|
|
||||||
- **Building a profile allocates** (skill list + per-item mod scans). Keep it on-demand / on-change, **not** in the 30 s vitals sweep. A burst of `char.request`s should be fine (main-thread, fast) but rate-limit at the sidecar.
|
|
||||||
- **`Value` vs `Base` for skills:** `Base` is the trained number; `Value` includes item/temp bonuses (what the client shows in combat). Send both — the site likely wants `Base` for "character sheet" and `Value` for "effective."
|
|
||||||
- **Read on the main thread only.** Everything above touches live `Mobile`/`Item` state (§5). Build the DTO synchronously in the request handler / sweep, hand the finished JSON to the writer thread.
|
|
||||||
|
|
||||||
### IV.5 Vendor stats per player (the "eventually")
|
|
||||||
|
|
||||||
Ties into §III.1. A character/account can own player vendors; each `PlayerVendor` has `Owner`, an inventory of `VendorItem`s (item, `Price`, description), `HoldGold`, `BankAccount`, and commission. For a player-facing "my vendors" view, enumerate `PlayerVendor`s whose `Owner` is one of the account's mobiles and emit: vendor serial, house/location, held gold, and inventory (item, price, sold-state). Combined with the §III.1 `PlayerVendorSale` stream, the site can show both **current listings** and **sales history**. Same tiered/on-demand rule — fetch on request, refresh on sale.
|
|
||||||
|
|
||||||
### IV.6 Updated capability map (supersedes III.5)
|
|
||||||
|
|
||||||
| Capability | Mechanism | Core touch? | Cadence |
|
|
||||||
|-----------|-----------|:-----------:|---------|
|
|
||||||
| Player vitals (hp/mana/stam/gold/loc) | 30 s sweep of online + events | No | periodic/event |
|
|
||||||
| **Full character profile** (stats/skills/gear/mods) | build from live `Mobile`, **on-demand + on-change** (§IV) | No | request/response + on change |
|
|
||||||
| Account roster (up to 5 chars) | `account[0..Length]`, incl. offline (§IV.1) | No | on request |
|
|
||||||
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | event |
|
|
||||||
| Player-vendor sales | new `PlayerVendorSale` EventSink (§III.1) | **Yes, ~15 lines** | event |
|
|
||||||
| Player-owned vendor stats | enumerate `PlayerVendor` by owner (§IV.5) | No | on request |
|
|
||||||
| `[link` account linking | `CommandSystem` + account tags (§II.3) | No | event |
|
|
||||||
| IDOC / house decay | sweep `AllHouses`, transition-only (§III.3) | No | 30–60 s sweep |
|
|
||||||
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | inbound |
|
|
||||||
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
|
|
||||||
|
|
||||||
**Net:** the full-character requirement adds **no** new core touches — it's all readable off live objects. The only structural addition it implies is an **inbound request/response channel** (already needed for `[link` and town-crier), used here as `char.request` / `account.roster`, with the sidecar caching profiles for the website.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Script lifecycle — how `Scripts/Custom` loads and hooks startup/shutdown
|
|
||||||
|
|
||||||
**Compilation model (this is a *modern* ServUO, not the old CodeDom one).**
|
|
||||||
`Server/ScriptCompiler.cs:18` → when `Compiler.Dynamic` is true (default), the core literally runs:
|
|
||||||
|
|
||||||
```
|
|
||||||
dotnet build "Scripts/Scripts.csproj" -c Release (or Debug)
|
|
||||||
```
|
|
||||||
|
|
||||||
then `Assembly.LoadFrom("Scripts.dll")` (`ScriptCompiler.cs:63`). `Scripts.csproj` is SDK-style (`Microsoft.NET.Sdk`) with **default globbing**, so **every `.cs` anywhere under `Scripts/` — including `Scripts/Custom/` — is compiled automatically**. There is no per-file registration. A new plugin = drop a `.cs` file in `Scripts/Custom/` and restart (or rebuild `Scripts.dll`).
|
|
||||||
|
|
||||||
- If `dotnet build` fails, the core loops asking to retry (`Main.cs:525`); under `-service` it just returns/exits. So **a compile error in your bridge file takes the whole shard down at boot** — keep the plugin minimal and defensive.
|
|
||||||
- `-service`/non-interactive suppresses the console prompt (`Main.cs:386`).
|
|
||||||
|
|
||||||
**Lifecycle entry points (in boot order, all on the Core thread — `Main.cs:544-562`):**
|
|
||||||
|
|
||||||
| Order | Mechanism | How you hook it |
|
|
||||||
|------:|-----------|-----------------|
|
|
||||||
| 1 | `ScriptCompiler.Invoke("Configure")` | Any `public static void Configure()` in any script type |
|
|
||||||
| 2 | `World.Load()` | (world state restored from `Saves/`) |
|
|
||||||
| 3 | `ScriptCompiler.Invoke("Initialize")` | Any `public static void Initialize()` in any script type |
|
|
||||||
| 4 | `EventSink.InvokeServerStarted()` | `EventSink.ServerStarted += ...` |
|
|
||||||
|
|
||||||
`Invoke()` (`ScriptCompiler.cs:87`) reflects over **all** loaded types, finds the named `public static` method, sorts by `[CallPriority(n)]` (`Server/Attributes.cs:27`), and calls them. **`Configure` runs *before* `World.Load`; `Initialize` runs *after*.** → Register EventSink handlers in `Initialize` (or `Configure`); read config in `Configure`. Canonical example already in-tree: `Scripts/Misc/WeightOverloading.cs:15` subscribes to `EventSink.Movement` inside `Initialize()`.
|
|
||||||
|
|
||||||
**Shutdown.** Two clean hooks, both fire on the Core thread:
|
|
||||||
- `EventSink.Shutdown` — invoked from `Core.HandleClosed()` (`Main.cs:313`) on normal exit, *after* `World.WaitForWriteCompletion()`. **Not** invoked if `_Crashed`.
|
|
||||||
- `EventSink.Crashed` — invoked from the unhandled-exception handler (`Main.cs:198`); gives you an `args.Close` vote.
|
|
||||||
- Windows console-close / Ctrl-C routes through `OnConsoleEvent` → `Kill()` → `HandleClosed()` (`Main.cs:254`), so `Shutdown` normally still fires.
|
|
||||||
|
|
||||||
**Bridge implication:** your named-pipe writer/listener should be **created in `Initialize` (or on `ServerStarted`) and torn down in `Shutdown`**. Don't assume `Shutdown` runs on a crash — the pipe handle may be abandoned; the external service must tolerate an abrupt EOF.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. EventSink — available events, subscription, and frequency
|
|
||||||
|
|
||||||
**Subscription pattern:** `EventSink.<Name> += handler;` (static multicast delegates, declared `Server/EventSink.cs:1692-1784`). Handlers are plain delegates invoked synchronously via `EventSink.Invoke<Name>(args)` from the code path that raises them. **Every handler runs on whatever thread raised the event — in practice always the Core thread** (movement, speech, combat, login all originate from packet handling in `MessagePump.Slice()` or from the main-loop delta processing).
|
|
||||||
|
|
||||||
### Events relevant to a state-export bridge
|
|
||||||
|
|
||||||
| Event | Fires when | Frequency | Notes for export |
|
|
||||||
|-------|-----------|-----------|------------------|
|
|
||||||
| `Login` | Player fully in-world | Low | Best "player online" signal; gives `Mobile`. |
|
|
||||||
| `Logout` | Player disconnect (in-world) | Low | Pair with Login. |
|
|
||||||
| `Connected` / `Disconnected` | Socket up/down | Low | Lower-level than Login/Logout (fires for char-select too). |
|
|
||||||
| `PlayerDeath` | Player dies | Low | `PlayerDeathEventArgs` (mobile, corpse-ish context). |
|
|
||||||
| `CreatureDeath` | NPC/creature dies | **Medium–High** | Fires for *every* mob kill; on a busy shard this is a firehose. Filter/aggregate. |
|
|
||||||
| `Speech` | Player/NPC speech | Medium | `SpeechEventArgs`; raised from `Mobile.cs:5114`. Includes NPC/system speech. |
|
|
||||||
| `Movement` | **Any mobile takes a step** | **Very High** | See ⚠️ below. |
|
|
||||||
| `AggressiveAction` | Combat aggression declared | Medium–High | `AggressiveActionEventArgs` (`EventSink.cs:372`). Not per-swing, per aggression state change. |
|
|
||||||
| `ItemCreated` / `ItemDeleted` | Item constructed/deleted | **Very High** | Fires for *every* item incl. transient/loot/internal. Huge volume. |
|
|
||||||
| `MobileCreated` / `MobileDeleted` | Mobile constructed/deleted | High | Same caveat as items. |
|
|
||||||
| `SkillGain`, `CraftSuccess`, `ResourceHarvestSuccess` | Progression | Medium | Good "interesting player activity" signals. |
|
|
||||||
| `AccountGoldChange`, `FameChange`, `KarmaChange` | Economy/rep deltas | Low–Medium | Naturally diff-shaped. |
|
|
||||||
| `QuestComplete`, `JoinGuild`, `TameCreature`, `PlayerMurdered` | Milestone events | Low | Cheap, high-signal — ideal to export. |
|
|
||||||
| `WorldSave` / `BeforeWorldSave` / `AfterWorldSave` | Save cycle | Low (~5 min) | Natural checkpoint boundary for the bridge. |
|
|
||||||
| `ServerStarted` / `Shutdown` / `Crashed` | Lifecycle | Once | Bridge connect/disconnect signaling. |
|
|
||||||
|
|
||||||
Full list of 70+ events at `EventSink.cs:1692-1784` (context menus, vendor buy/sell, BOD, virtue, targeting macros, etc.).
|
|
||||||
|
|
||||||
> ⚠️ **`Movement` is the single most dangerous event to naively export.** `EventSink.InvokeMovement` is called from `Mobile.InternalOnMove` (`Mobile.cs:3029`), which runs for **every mobile that takes a step — all NPCs, all creatures, not just players.** On a populated shard that's thousands of invocations/second. It is **synchronous and cancellable** (`args.Blocked` gates the move), so your handler sits *inside the movement decision path* — any latency there (a blocking pipe write!) stalls the whole server. Additionally the args object is **pooled and immediately `Free()`d** (see §5). Rules: filter to `PlayerMobile` at the top of the handler, copy out primitives synchronously, never block, never retain the args reference.
|
|
||||||
|
|
||||||
### ⚑ Gap flag — events with *no* clean EventSink hook
|
|
||||||
|
|
||||||
These are things a bridge spec commonly wants to export but that **do not have a first-class `EventSink`**:
|
|
||||||
|
|
||||||
- **Item pickup / drop / "lift".** There is **no `EventSink` for picking up or dropping items.** It's handled by **virtual methods** on the objects: `Item.OnDragLift` / `Item.OnDragDrop` / `Item.OnDroppedInto` (`Item.cs:4647,2157,5060`) and `Mobile.OnDragDrop` / `Mobile.OnDragLift` (`Mobile.cs:10877,10949`). To observe these you must **override them on your own subclasses** or patch base classes — you can't subscribe globally from `Initialize`. Partial coverage exists via `EventSink.OnItemObtained`, `EventSink.ContainerDroppedTo`, and `EventSink.CorpseLoot`, but none of these is a universal "player moved item X from A to B" hook. **This is the biggest event-availability gap for the bridge.**
|
|
||||||
- **Per-hit combat damage.** `AggressiveAction` marks aggression, not each swing/damage tick. For damage numbers you'd hook `Mobile.Damage` / weapon `OnHit` paths (virtual/override), not an EventSink.
|
|
||||||
- **Equip/unequip of items generally.** `CheckEquipItem` exists (a *veto* hook), plus `EquipMacro`/`UnequipMacro` (macro-triggered only). No clean "item equipped" firehose via EventSink.
|
|
||||||
- **Stat/hits/mana/stam changes.** No EventSink; these move through the delta/`ProcessDeltaQueue` system (§4). You'd poll or hook `Mobile` delta handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Timers — mechanism and which thread callbacks run on
|
|
||||||
|
|
||||||
**This is the crux, and the answer is unambiguous.** ServUO splits timers into a *scheduler thread* and *main-thread execution*:
|
|
||||||
|
|
||||||
- **Timer Thread** (`Main.cs:429-434`, named `"Timer Thread"`) runs `Timer.TimerThread.TimerMain` (`Timer.cs:314`). Its *only* job is bookkeeping: walk the priority buckets, decide which timers are due, and **enqueue** them into a shared `m_Queue` (`Timer.cs:354-357`). It **does not execute callbacks.** When anything becomes due it calls `Core.Set()` (`Timer.cs:374`) to wake the main loop.
|
|
||||||
- **Core / main thread** runs `Timer.Slice()` (`Timer.cs:391`, called from `Core.Main` at `Main.cs:580`). This dequeues due timers and calls **`t.OnTick()` on the main thread** (`Timer.cs:409`).
|
|
||||||
|
|
||||||
**→ Every `Timer` / `Timer.DelayCall` callback executes on the Core (main) game thread.** The separate Timer Thread never touches game state; it's a scheduling clock. This is verifiable live via Appendix A (the probe logs `Thread.CurrentThread` from a Timer tick and from `Initialize` — they match, and match the network path shown in your crash log).
|
|
||||||
|
|
||||||
Other properties worth knowing:
|
|
||||||
- Timers are bucketed by `TimerPriority` (`EveryTick`, `TenMS`, … `OneMinute`); priority is auto-computed from delay/interval (`Timer.cs:468`).
|
|
||||||
- `Timer.Slice` has a `BreakCount` (default **20000**, `Timer.cs:383`) — if more than that many timers are due in one slice, the overflow waits for the next slice. Relevant if the bridge ever schedules a flood of one-shot timers.
|
|
||||||
- **Timers do not fire during world save/load.** `TimerMain` early-continues while `World.Loading || World.Saving` (`Timer.cs:322`). See §5 — this directly affects inbound-command latency.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Object model & serialization — and a diff-friendly state shape
|
|
||||||
|
|
||||||
**Identity.** `Serial` (`Server/Serial.cs:7`) is a `struct` wrapping a single `int`. **Mobiles** get serials `< 0x40000000`; **items** start at `0x40000000` (`Serial.cs:11-12`); `IsItem`/`IsMobile` test that boundary. Serials are stable for an object's lifetime and are the natural **primary key** for any external mirror of state. `World.Mobiles` / `World.Items` are `Dictionary<Serial, …>` (`World.cs:19-20`) — O(1) lookup by serial from the main thread.
|
|
||||||
|
|
||||||
**ServUO's own persistence** (`Server/Serialization.cs`, `Server/World.cs`):
|
|
||||||
- Every `Item`/`Mobile`/`SaveData` implements `Serialize(GenericWriter)` / `Deserialize(GenericReader)` plus a serial-taking ctor. `Core.VerifySerialization` (`Main.cs:679`) enforces this at boot.
|
|
||||||
- `GenericWriter`/`GenericReader` are a **versioned, positional binary stream** of primitives (`ReadInt`, `ReadString`, `ReadMobile`, `ReadPoint3D`, …; `Serialization.cs:17+`). Each object writes an `int` version first, then fields in a fixed order. It is **compact but *not* diff-friendly**: it's a full positional snapshot with no field names, meaningless without the exact type+version that wrote it, and it encodes the *entire* object every save.
|
|
||||||
- Saves are orchestrated by `World.Save` (`World.cs:1102`) on the main thread; a `SaveStrategy` may flush bytes to disk on a **background thread**, guarded by `m_DiskWriteHandle` (`ManualResetEvent`, `World.cs:29`). During a save `World.Saving` is true and object add/delete is deferred into `_addQueue`/`_deleteQueue` (`World.cs:1247-1280`).
|
|
||||||
|
|
||||||
**Recommendation for a diff-friendly representation (do NOT reuse the save system):**
|
|
||||||
The internal serializer is the wrong tool for the bridge — it's full-snapshot, schema-coupled, and versioned per type. Instead, build an **event-sourced delta keyed by `Serial`**:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// one line per change, main-thread produced, drained by background writer
|
|
||||||
{ "t": 172..., "kind": "mob.move", "serial": "0x1A2B", "x": 1420, "y": 1631, "z": 0, "dir": "North" }
|
|
||||||
{ "t": 172..., "kind": "mob.login", "serial": "0x1A2B", "name": "Thunderheat", "acct": "PerryAdimn" }
|
|
||||||
{ "t": 172..., "kind": "item.gold", "serial": "0x1A2B", "delta": -500, "total": 12000 }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Derive fields from the **EventSink args + the live object** at event time (e.g. `m.X/Y/Z/Map/Serial`), not from `Serialize`.
|
|
||||||
- Keyed by `Serial` so the external service maintains its own mirror and applies deltas.
|
|
||||||
- Emit a periodic/`ServerStarted` **full snapshot** (iterate `World.Mobiles`/`World.Items` on the main thread) as a baseline the deltas layer onto; `AfterWorldSave` is a natural snapshot boundary.
|
|
||||||
- Keep each record to primitives copied out **synchronously on the main thread** (pooled args, live objects mutate — see §5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Thread-safety rules & marshaling onto the main thread
|
|
||||||
|
|
||||||
**Golden rule (RunUO/ServUO-wide):** the world — `World.Mobiles`, `World.Items`, every `Mobile`/`Item`/`Account`, the delta queues, packet sends — is **single-threaded and owned by the Core thread.** None of it is locked for general access. Reading or mutating any of it from another thread is a data race / heisenbug generator. The dictionaries aren't concurrent; `Mobile.ProcessDeltaQueue`/`Item.ProcessDeltaQueue` run on the main loop (`Main.cs:577-578`) with no cross-thread guard.
|
|
||||||
|
|
||||||
**What *is* safe from a non-main thread:**
|
|
||||||
- `Core.Set()` — wake the main loop (`AutoResetEvent`, `Main.cs:324`).
|
|
||||||
- **`Timer.DelayCall(...)`** — verified safe cross-thread. `DelayCall`→`Start`→`TimerThread.AddTimer`→`Change` takes `lock (m_Changed)` and signals the timer thread (`Timer.cs:243-251,883-892`). The scheduling call is lock-protected; the **callback then runs on the main thread.** This is the intended marshaling primitive.
|
|
||||||
- Pushing onto a **`ConcurrentQueue`** you own, then letting the main thread drain it — this is literally how the network stack works: `MessagePump.m_Queue` is a `ConcurrentQueue<NetState>` (`MessagePump.cs:14`) filled by listener threads and drained by `MessagePump.Slice()` on the main thread (`MessagePump.cs:113`).
|
|
||||||
|
|
||||||
**The two marshaling patterns for inbound named-pipe commands** (pick one; pattern A is simplest):
|
|
||||||
|
|
||||||
- **A — `Timer.DelayCall` from the pipe thread.** On each inbound command, from the pipe read-callback thread call `Timer.DelayCall(TimeSpan.Zero, () => ApplyCommand(cmd))`. The lambda executes on the main thread on the next slice. Zero shared mutable state of your own. Caveat: a burst of commands = a burst of one-shot timers (mind `BreakCount`).
|
|
||||||
- **B — your own `ConcurrentQueue` + `Core.Slice`.** Pipe thread enqueues; register a handler on the `Core.Slice` delegate (`Main.cs:41,586`) that drains the queue every loop iteration on the main thread. Mirrors the network design; better for high inbound rates.
|
|
||||||
|
|
||||||
**Pitfalls specific to this codebase:**
|
|
||||||
1. **Pooled event args.** `MovementEventArgs` (and several others) are recycled via a plain `Queue` pool and `Free()`d immediately after the event (`EventSink.cs:802-834`). The pool itself is **not** thread-safe (main-thread-only). **Never** hand an args object to the pipe writer thread; copy primitives out first. Holding the reference = reading fields that belong to an unrelated later mobile.
|
|
||||||
2. **Blocking the main thread = stalling the shard.** EventSink handlers and Timer ticks run on the Core thread. A synchronous named-pipe **write** that blocks (slow/absent reader, full pipe buffer) will freeze movement, combat, saves — everything. The writer *must* be fire-and-forget onto a background queue (see §6).
|
|
||||||
3. **Timers pause during save/load.** Because `TimerMain` skips while `World.Saving`/`World.Loading` (`Timer.cs:322`), **inbound commands marshaled via `Timer.DelayCall` are deferred until the save finishes** (typically seconds; longer with background write). If commands must apply during a save window, prefer pattern B (Core.Slice) — but note the main loop also spends the save inside `World.Save`, so nothing script-side really runs mid-save regardless. Treat "commands don't apply during a save" as a design constraint, and have the external side tolerate the latency spike.
|
|
||||||
4. **Reentrancy / world-mutation during save.** Adding/deleting entities during a save is deferred to safety queues and logs a warning (`World.cs:988,1247`). If a bridge command spawns/deletes, it may silently queue.
|
|
||||||
5. **Crash path skips `Shutdown`.** Don't rely on graceful pipe teardown (§1).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Local ServUO↔sidecar transport (net48) — non-blocking bridge I/O
|
|
||||||
|
|
||||||
> **Superseded by [Part II.1](#ii1-transport-put-the-websocket-in-rust-keep-the-c-side-dumb).** For the Rust WS sidecar design the recommended C↔Rust link is **loopback TCP + newline-JSON**, not a named pipe, and **ServUO should not speak WebSocket**. The non-blocking principles below still apply verbatim to whichever local transport you pick.
|
|
||||||
|
|
||||||
Target is **net48** (`Scripts.csproj:3`), so you have `System.IO.Pipes` / `System.Net.Sockets` with `async`/`await` and `Begin/End` APIs, but **not** the newer `IAsyncEnumerable`/`CancellationToken` niceties of modern .NET. Design around that.
|
|
||||||
|
|
||||||
**Outbound (fire-and-forget writer) — the important one:**
|
|
||||||
- The producer is the Core thread (event handlers). It must **never touch the pipe directly.** Producer does only: format the delta record → `ConcurrentQueue.Enqueue` → return. This is a non-blocking, allocation-only operation.
|
|
||||||
- A **single dedicated background writer thread** (or a long-running `Task`) owns the `NamedPipeServerStream`/`ClientStream` and drains the queue, using `WriteAsync`/`FlushAsync`. One writer = writes stay ordered and you avoid interleaved frames on the pipe.
|
|
||||||
- Use a **length-prefixed or newline-delimited framing** (`PipeTransmissionMode.Byte` is simplest and most portable; `Message` mode has size/OS quirks). Don't rely on message boundaries.
|
|
||||||
- **Bound the queue.** If the external reader stalls, an unbounded queue is a memory leak that eventually OOMs the shard. Drop-oldest or drop-on-full with a dropped-count counter is the safe default for telemetry-style data.
|
|
||||||
- Handle `IOException`/`Broken pipe` by reconnecting in the writer thread; the game keeps running, the queue keeps the newest N records.
|
|
||||||
|
|
||||||
**Inbound (command listener):**
|
|
||||||
- A separate background thread/loop `WaitForConnectionAsync` → `ReadAsync` loop, parse a framed command, then **marshal to the main thread** via pattern A or B from §5. The read thread must not call any `World`/`Mobile`/`Item` API.
|
|
||||||
- Server vs client: making ServUO the **`NamedPipeServerStream`** (external service connects in) is usually cleaner for lifecycle — the shard owns the pipe, survives external restarts, and you control `maxNumberOfServerInstances`. Two half-duplex pipes (one in, one out) are simpler to reason about than one duplex pipe shared across your writer and reader threads.
|
|
||||||
- Set `PipeOptions.Asynchronous` at construction — required for the `*Async` methods to actually overlap I/O rather than block a thread-pool thread.
|
|
||||||
|
|
||||||
**Pitfalls:**
|
|
||||||
- Don't `await` pipe I/O on the Core thread — there's no synchronization context that returns you to the Core thread anyway, and you'd risk resuming world access on a thread-pool thread. Keep all pipe `await`s on your dedicated background threads.
|
|
||||||
- Named-pipe ACLs: if the external service runs as a different user/session, set a `PipeSecurity` explicitly or the connect will `UnauthorizedAccessException`.
|
|
||||||
- First-chance `IOException` on client disconnect is normal; log-and-reconnect, don't crash the writer loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Flags against the bridge architecture
|
|
||||||
|
|
||||||
> **See [Part II.4](#ii4-revised-flags-for-this-architecture) for the flags that matter to the Rust WS sidecar + tracking/link design.** The list below is the original generic set (still valid background).
|
|
||||||
|
|
||||||
1. **⚑ Item pickup/drop has no EventSink (§2 gap).** If the spec assumes "subscribe to item move events" the way you subscribe to login/movement, that assumption is wrong. Pickup/drop/lift live on **virtual methods** (`Item.OnDragLift/OnDragDrop/OnDroppedInto`, `Mobile.OnDragDrop`). Exporting them cleanly requires base-class overrides/patching, not `Initialize`-time subscription. This is the item most likely to change the design.
|
|
||||||
2. **⚑ `Movement` (and `Item/MobileCreated/Deleted`) are firehoses on the main thread (§2, §5).** Any spec that says "export all movement" must add player-filtering + aggregation, and the export path must be non-blocking. `Movement` args are **pooled** — copy-out-synchronously is mandatory, not optional.
|
|
||||||
3. **⚑ Everything you'd export runs on the single Core thread (§3, §5).** The whole bridge stands or falls on the writer being fire-and-forget. If the spec has event handlers writing to the pipe synchronously, that's a shard-wide stall waiting to happen. Confirmed by your own crash log that even packet-triggered handlers run inline on `Core.Main`.
|
|
||||||
4. **✔ Inbound commands *can* be safely marshaled to the main thread** via `Timer.DelayCall` (verified thread-safe) or a `ConcurrentQueue` drained on `Core.Slice`. The named-pipe approach is **not** blocked by threading — but:
|
|
||||||
5. **⚑ Commands don't apply during world saves (§5 pitfall 3).** Timers pause and the main loop is inside `World.Save` (~seconds, every ~5 min by default). If the spec expects sub-second inbound command latency 100% of the time, it needs to tolerate periodic save-window spikes.
|
|
||||||
6. **⚑ Don't mirror state via ServUO's serializer (§4).** If the spec imagined "reuse ServUO's save format to ship state," reconsider — it's full-snapshot, schema-versioned, and unnamed. Use event-derived deltas keyed by `Serial` + periodic snapshots.
|
|
||||||
7. **⚑ Crash path skips graceful shutdown (§1).** The external service must treat pipe EOF as normal and re-handshake; don't assume a clean `Shutdown` teardown.
|
|
||||||
8. **⚑ A compile error in the bridge plugin fails the whole shard boot (§1).** Keep the plugin small, wrap handler bodies in try/catch, and never let a bridge exception escape into a game code path.
|
|
||||||
9. **(Environmental) The `zlibwapi64` native-load crash (§0)** already downed this shard once. Unrelated to the bridge, but resolve it before load-testing or it will confound results.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A — Drop-in empirical probe (run this yourself)
|
|
||||||
|
|
||||||
Save as `Scripts/Custom/BridgeThreadProbe.cs`, start the shard, watch the console. **No game client needed** — it proves the thread identity of `Initialize`, `ServerStarted`, a `Timer` tick, and `Core.Slice`. Delete the file afterward. (This is a throwaway diagnostic, not the bridge.)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using Server;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
public static class BridgeThreadProbe
|
|
||||||
{
|
|
||||||
private static void Log(string where)
|
|
||||||
{
|
|
||||||
var t = Thread.CurrentThread;
|
|
||||||
Console.WriteLine("[PROBE] {0,-16} thread id={1} name=\"{2}\"",
|
|
||||||
where, t.ManagedThreadId, t.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
Log("Initialize"); // expect: Core Thread
|
|
||||||
|
|
||||||
EventSink.ServerStarted += () => Log("ServerStarted"); // expect: Core Thread
|
|
||||||
EventSink.Login += e => Log("Login (client)"); // needs a client login
|
|
||||||
|
|
||||||
// Timer tick — proves callbacks run on the main thread, not the Timer Thread.
|
|
||||||
Timer.DelayCall(TimeSpan.FromSeconds(3), () => Log("Timer.DelayCall")); // expect: Core Thread
|
|
||||||
|
|
||||||
// Cross-thread marshal test: schedule from a raw background thread,
|
|
||||||
// confirm the callback still lands on Core Thread.
|
|
||||||
new Thread(() =>
|
|
||||||
{
|
|
||||||
Log("raw bg thread"); // expect: some worker id, NOT Core Thread
|
|
||||||
Timer.DelayCall(TimeSpan.Zero, () => Log("marshaled->main"));
|
|
||||||
}).Start();
|
|
||||||
|
|
||||||
// Core.Slice runs every main-loop iteration; log once then detach.
|
|
||||||
Slice one = null;
|
|
||||||
one = () => { Log("Core.Slice"); Core.Slice -= one; };
|
|
||||||
Core.Slice += one; // expect: Core Thread
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected result:** every line except `raw bg thread` reports `name="Core Thread"` with the same managed id as `Initialize` — confirming EventSink handlers, Timer ticks, and `Core.Slice` all execute on the one main thread, and that `Timer.DelayCall` from a background thread correctly hops work onto it. If you connect a client, `Login (client)` also reports `Core Thread`, matching the `MessagePump.Slice` evidence in your crash log.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key source references
|
|
||||||
|
|
||||||
| Topic | File:line |
|
|
||||||
|-------|-----------|
|
|
||||||
| Main game loop / thread setup | `Server/Main.cs:329,410-434,573-599` |
|
|
||||||
| `Core.Slice` main-thread hook | `Server/Main.cs:41,586` |
|
|
||||||
| `Core.Set` wake main loop | `Server/Main.cs:322-327` |
|
|
||||||
| Shutdown / Crashed hooks | `Server/Main.cs:198,313` |
|
|
||||||
| Script compile (`dotnet build`) | `Server/ScriptCompiler.cs:18-65` |
|
|
||||||
| `Configure`/`Initialize` invoke + CallPriority | `Server/ScriptCompiler.cs:87-112`, `Server/Attributes.cs:27` |
|
|
||||||
| EventSink event declarations | `Server/EventSink.cs:1692-1784` |
|
|
||||||
| Movement raise (all mobiles, pooled, cancellable) | `Server/Mobile.cs:3020-3036`, `Server/EventSink.cs:792-834` |
|
|
||||||
| Item pickup/drop = virtual, no EventSink | `Server/Item.cs:2157,4647,5060`, `Server/Mobile.cs:10877,10949` |
|
|
||||||
| Timer scheduler thread (enqueue only) | `Server/Timer.cs:314-379` |
|
|
||||||
| Timer execution on main thread | `Server/Timer.cs:391-419`, `Server/Main.cs:580` |
|
|
||||||
| `Timer.DelayCall` cross-thread safety | `Server/Timer.cs:243-251,524-534,883-892` |
|
|
||||||
| Network marshaling (ConcurrentQueue → main) | `Server/Network/MessagePump.cs:14,108,113` |
|
|
||||||
| Serial identity | `Server/Serial.cs:7-33` |
|
|
||||||
| Serialization API | `Server/Serialization.cs:17+` |
|
|
||||||
| World save threading / safety queues | `Server/World.cs:29,1102-1208,1247-1280` |
|
|
||||||
| Runtime evidence: EventSink on Core thread | `Crash 6-5-2026-22-38-3.log` |
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Shard prerequisites
|
|
||||||
|
|
||||||
Repairs the target shard (`C:\Users\colby\Desktop\servuo`, ServUO 57.4) required before the bridge could load. These are **deletions and edits of existing files**, so they cannot be expressed as an overlay copy. They are recorded here, and where practical as diffs under `patches/`.
|
|
||||||
|
|
||||||
Applied 2026-07-10. Backups on the Desktop: `servuo_saves_backup_2026-07-10_032608`, `servuo_bin_backup_2026-07-10_032608`, `servuo_removed_files_2026-07-10`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The symptom
|
|
||||||
|
|
||||||
`Scripts.dll` had not been rebuilt since **2026-05-30 17:01**. Every script change after that — including all of `Scripts/Custom/Named/`, `MyStats.cs`, and `SearchAdd.cs` — had never executed.
|
|
||||||
|
|
||||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) shells out to `dotnet build`, prints the output, ignores the exit code, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. A failing script build is invisible: the stale DLL simply reloads. The retry loop at `Main.cs:525` never trips.
|
|
||||||
|
|
||||||
Four independent breakages, all introduced between 17:14 and 21:55 on 2026-05-30.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Stray `Server/Gumps/Gumps.cs`
|
|
||||||
|
|
||||||
A **byte-identical copy** of `Scripts/Services/Pet Training/Gumps.cs` (75,468 bytes), sitting in the Server project. It declares `namespace Server.Mobiles` and extends `BaseGump`, referencing `BaseCreature`, `PlayerMobile`, `TrainingPoint` — all defined in Scripts. Server cannot reference Scripts, so `Server.csproj` failed with 35 errors.
|
|
||||||
|
|
||||||
**Action:** deleted. The canonical copy under `Scripts/Services/Pet Training/` was edited 10 minutes later and is the one that matters.
|
|
||||||
|
|
||||||
## 2. Eleven duplicate creature classes
|
|
||||||
|
|
||||||
`Scripts/Custom/{Named,Legendary}/` redefined classes already present in `Scripts/Mobiles/Normal/`, producing `CS0111` / `CS0579`.
|
|
||||||
|
|
||||||
**Named** — `Eowmu`, `SkeletalCat`, `Windrunner`. The stock files each define **two** types: the mount *and* an `ICreatureStatuette` item (`EowmuStatue`, …) that `Scripts/Services/UltimaStore/UltimaStore.cs` references. Deleting the stock files outright would have re-broken the build.
|
|
||||||
|
|
||||||
**Action:** removed only the duplicate mount class from each stock file; kept the statues.
|
|
||||||
|
|
||||||
**Legendary** — `FireSteed`, `Kirin`, `Nightmare`, `OsseinRam`, `Phoenix`, `PolarBear`, `ShadowWyrm`, `TsukiWolf`. Clean 1:1 pairs. All custom versions sit in `namespace Server.Mobiles`, so the serialized type name is unchanged, and each `Deserialize` guards on `version` and migrates from 0 (`ShadowWyrm`: `if (version >= 1)`; `FireSteed`: `if (version < 1)` skill-cap migration; `Kirin`: `if (version == 0)` AI fixup).
|
|
||||||
|
|
||||||
**Action:** deleted the eight stock files. Custom wins.
|
|
||||||
|
|
||||||
## 3. `PolarBear` — a base-class change, not a version bump
|
|
||||||
|
|
||||||
Custom `PolarBear : BaseMount`; stock `PolarBear : BaseCreature`. The saved world contained a bear serialized through the `BaseCreature` chain, so loading it as a `BaseMount` misaligned the stream. World load aborted at `Server.Mobiles.PolarBear` serial `0x00000412` with `Delete the object? (y/n)`.
|
|
||||||
|
|
||||||
**Changing a saved type's base class is not version-migratable.** The custom class also carried `[TypeAlias("Server.Mobiles.Polarbear")]`, which would have hijacked the same records.
|
|
||||||
|
|
||||||
**Action:** restored stock `PolarBear : BaseCreature`; renamed the custom mount to `LegendaryPolarBear` and dropped the `TypeAlias`. Stock scripts referencing `typeof(PolarBear)` (`TalismanSlayer`, `SpeedInfo`, `RoyalZooDonationBox`, `SummonCreature`, `PetTrainingHelper`) continue to resolve to the `BaseCreature`.
|
|
||||||
|
|
||||||
Note: `Scripts/Custom/Legendary/PolarBear.cs` was renamed to `LegendaryPolarBear.cs`.
|
|
||||||
|
|
||||||
## 4. `AnimalLore.cs` referenced a package that does not exist
|
|
||||||
|
|
||||||
`Scripts/Skills/AnimalLore.cs` had `using ShrinkSystem;` and two `IShrinkItem` branches. No `ShrinkSystem` namespace exists anywhere in the repo, and `IShrinkItem` appears nowhere in the stale `Scripts.dll` — **the code had never compiled or run.** (`Scripts/Misc/ShrinkTable.cs` is unrelated stock: `namespace Server`, class `ShrinkTable`.)
|
|
||||||
|
|
||||||
**Action:** removed the `using` and collapsed the shrink branches back to the `BaseCreature` path. This restores exactly the behavior the shard was already running.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After the repairs, `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` succeeded with 0 warnings, 0 errors. Rebuilding `ServUO.exe` and `Ultima.dll` from current source produced **byte-identical** binaries (same SHA-256), confirming the core was never stale in content — only `Scripts.dll` was.
|
|
||||||
|
|
||||||
With Phase 0 applied, a plain boot shows:
|
|
||||||
|
|
||||||
```
|
|
||||||
Core: Compiling scripts...
|
|
||||||
Build succeeded.
|
|
||||||
Core: Verified 6023 item and 1385 mobile types
|
|
||||||
World: Loading...
|
|
||||||
...done (206208 items, 42771 mobiles, 0 customs)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Unrelated, still open
|
|
||||||
|
|
||||||
`DllNotFoundException: zlibwapi64` crashed this shard once (`Crash 6-5-2026-22-38-3.log`) while sending a packed gump. `zlibwapi64.dll` is present in the repo root, so this is a working-directory / native-load-path problem. It will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
|
||||||
@@ -14,11 +14,40 @@ Port=7788
|
|||||||
QueueCap=10000
|
QueueCap=10000
|
||||||
|
|
||||||
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs
|
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs
|
||||||
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See docs/PLAN.md §1.
|
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
|
||||||
StatSweepSeconds=30
|
StatSweepSeconds=30
|
||||||
DecaySweepSeconds=60
|
DecaySweepSeconds=60
|
||||||
EconomySweepSeconds=300
|
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.
|
||||||
|
PageSweepSeconds=5
|
||||||
|
|
||||||
|
# Guild roster poll (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
|
||||||
|
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
|
||||||
|
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
|
||||||
|
GuildSweepSeconds=60
|
||||||
|
|
||||||
|
# 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).
|
||||||
|
CitySweepSeconds=300
|
||||||
|
|
||||||
|
# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
|
||||||
|
# interval and emitted as presence.online only when it changes. Region transitions come
|
||||||
|
# through separately in real time as region.enter (EventSink.OnEnterRegion).
|
||||||
|
PresenceSweepSeconds=30
|
||||||
|
|
||||||
|
# Housing registry poll. Every house is diffed on this interval to emit house.update /
|
||||||
|
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
|
||||||
|
HousingSweepSeconds=300
|
||||||
|
|
||||||
# Shown to a player when they run [link. The website page where they enter the code.
|
# Shown to a player when they run [link. The website page where they enter the code.
|
||||||
LinkUrl=https://yoursite/link
|
LinkUrl=https://yoursite/link
|
||||||
|
|
||||||
@@ -29,6 +58,58 @@ TownCrierMaxLineLength=200
|
|||||||
TownCrierMaxActive=20
|
TownCrierMaxActive=20
|
||||||
TownCrierMaxDurationSec=86400
|
TownCrierMaxDurationSec=86400
|
||||||
|
|
||||||
|
# Town Cryer news gump. Website articles (news.add) become entries in the modern Town
|
||||||
|
# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines
|
||||||
|
# above. The article title is also proclaimed by the criers (announce defaults on). Caps
|
||||||
|
# are defense in depth on top of the loopback trust boundary.
|
||||||
|
NewsMaxTitleLength=100
|
||||||
|
NewsMaxBodyLength=2000
|
||||||
|
NewsMaxExternal=20
|
||||||
|
NewsAnnounceDurationSec=300
|
||||||
|
|
||||||
|
# Admin write plane (staff moderation from the website). OFF by default: the whole
|
||||||
|
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
|
||||||
|
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
|
||||||
|
# loopback socket and applies a hard floor below.
|
||||||
|
AdminWriteEnabled=false
|
||||||
|
|
||||||
|
# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
|
||||||
|
# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
|
||||||
|
# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
|
||||||
|
# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
|
||||||
|
AdminAccessFloor=CoOwner
|
||||||
|
|
||||||
|
# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
|
||||||
|
AdminBroadcastMaxLength=300
|
||||||
|
AdminReasonMaxLength=400
|
||||||
|
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
|
||||||
|
AdminBanMaxDurationSec=31536000
|
||||||
|
|
||||||
|
# Account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A). Which side may mint game accounts:
|
||||||
|
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
|
||||||
|
# (else an in-game login of any new name still mints an account).
|
||||||
|
# game — the game server is the authority; website account.create is refused.
|
||||||
|
# hybrid — either side may create (the default).
|
||||||
|
# The bridge governs only the account.create verb; the in-game first-login auto-create is
|
||||||
|
# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
|
||||||
|
# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
|
||||||
|
# (the safest — no website creation).
|
||||||
|
SignupMode=hybrid
|
||||||
|
|
||||||
|
# Master switch for the account.create verb. Absent, it follows the mode (on unless
|
||||||
|
# SignupMode=game). Set explicitly to force it on or off regardless of mode.
|
||||||
|
AccountCreateEnabled=true
|
||||||
|
|
||||||
|
# Fail closed if account.create omits a usable browser IP. The per-IP cap
|
||||||
|
# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
|
||||||
|
# than waved through. Turn off only for a deployment that deliberately does not cap website
|
||||||
|
# signups by IP (MaxAccountsPerIP still applies in-game either way).
|
||||||
|
RequireIpForCreate=true
|
||||||
|
|
||||||
|
# Length caps on a website-supplied username / password, checked before the account is made.
|
||||||
|
AccountNameMaxLength=16
|
||||||
|
AccountPasswordMaxLength=30
|
||||||
|
|
||||||
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// and replies link.ok. The tag persists to accounts.xml across restarts.
|
/// and replies link.ok. The tag persists to accounts.xml across restarts.
|
||||||
///
|
///
|
||||||
/// The code table and the account write both live on the Core thread. The websiteUserId in
|
/// The code table and the account write both live on the Core thread. The websiteUserId in
|
||||||
/// link.confirm is trusted only because the socket is loopback-only (docs/PLAN.md §2); if the
|
/// link.confirm is trusted only because the socket is loopback-only (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2); if the
|
||||||
/// sidecar ever moves off-host, gate it behind a shared secret.
|
/// sidecar ever moves off-host, gate it behind a shared secret.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BridgeAccountLink
|
public static class BridgeAccountLink
|
||||||
@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
|
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
|
||||||
|
CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
|
||||||
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
|
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
|
||||||
|
|
||||||
// Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
|
// Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
|
||||||
@@ -127,6 +128,53 @@ namespace Server.Custom.Bridge
|
|||||||
url, (int)CodeTtl.TotalMinutes);
|
url, (int)CodeTtl.TotalMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- [unlink ----
|
||||||
|
|
||||||
|
[Usage("unlink")]
|
||||||
|
[Description("Unlinks this game account from your website account.")]
|
||||||
|
private static void OnUnlinkCommand(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
Unlink(e.Mobile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so
|
||||||
|
/// the website can reconcile a player-initiated unlink. Player-scoped (own account only),
|
||||||
|
/// so it needs no access floor. After unlinking, [link works again.
|
||||||
|
/// </summary>
|
||||||
|
public static void Unlink(Mobile m)
|
||||||
|
{
|
||||||
|
if (m == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var acct = m.Account as Account;
|
||||||
|
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
m.SendMessage("Bridge: no account on this character.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = acct.GetTag(Tag);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
m.SendMessage("Your account is not linked to a website account.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acct.RemoveTag(Tag);
|
||||||
|
DropCodesFor(acct.Username); // drop any pending codes so nothing dangles
|
||||||
|
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("account.unlinked")
|
||||||
|
.Str("origin", "in-game")
|
||||||
|
.Str("account", acct.Username)
|
||||||
|
.Str("websiteUserId", existing)
|
||||||
|
.Str("char", m.Name)
|
||||||
|
.End());
|
||||||
|
|
||||||
|
m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- inbound link.confirm ----
|
// ---- inbound link.confirm ----
|
||||||
|
|
||||||
private static void OnLinkConfirm(Dictionary<string, object> o)
|
private static void OnLinkConfirm(Dictionary<string, object> o)
|
||||||
|
|||||||
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Misc;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The account provisioning plane (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A): website-driven account
|
||||||
|
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
|
||||||
|
/// is unchanged.
|
||||||
|
///
|
||||||
|
/// account.create — mint a game account and link it to a website user in one step.
|
||||||
|
/// account.unlink — sever the WebsiteUserId tie from the website side.
|
||||||
|
///
|
||||||
|
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
|
||||||
|
/// Timer.DelayCall first), so they touch accounts freely.
|
||||||
|
///
|
||||||
|
/// Trust model matches the admin plane (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization lives on
|
||||||
|
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
|
||||||
|
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
|
||||||
|
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
|
||||||
|
/// AccountCreateEnabled.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeAccounts
|
||||||
|
{
|
||||||
|
private const string Tag = "WebsiteUserId";
|
||||||
|
|
||||||
|
// Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an
|
||||||
|
// in-game one (AccountHandler.cs). Kept local because that array is private.
|
||||||
|
private static readonly char[] ForbiddenChars =
|
||||||
|
{
|
||||||
|
'<', '>', ':', '"', '/', '\\', '|', '?', '*', ' '
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeBoot.RegisterHandler("account.create", OnCreate);
|
||||||
|
BridgeBoot.RegisterHandler("account.unlink", OnUnlink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account.create ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a game account and links it to the given website user. Refused unless the
|
||||||
|
/// signup mode allows website creation. Enforces the same username/password character
|
||||||
|
/// safety and per-IP cap as ServUO's in-game create path; the password never leaves the
|
||||||
|
/// process in any reply, audit, or log.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnCreate(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
var actor = BridgeJson.GetString(o, "actor");
|
||||||
|
const string action = "create";
|
||||||
|
|
||||||
|
if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "signups disabled for this mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "missing actor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var account = BridgeJson.GetString(o, "account");
|
||||||
|
var password = BridgeJson.GetString(o, "password");
|
||||||
|
var webId = BridgeJson.GetString(o, "websiteUserId");
|
||||||
|
var ipStr = BridgeJson.GetString(o, "ip");
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(account))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "missing account");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "missing password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(webId))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "missing websiteUserId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.Length > BridgeConfig.AccountNameMaxLength ||
|
||||||
|
password.Length > BridgeConfig.AccountPasswordMaxLength)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "username or password too long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsSafeUsername(account) || !IsSafePassword(password))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "invalid username/password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision: the only correct resolution of a website/in-game race for a name.
|
||||||
|
if (Accounts.GetAccount(account) != null)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "account already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is
|
||||||
|
// exempt in IPLimiter, so accepting it would silently bypass the cap.
|
||||||
|
IPAddress ip;
|
||||||
|
bool haveIp = TryParseIp(ipStr, out ip);
|
||||||
|
|
||||||
|
if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip)))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "client ip required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haveIp && !AccountHandler.CanCreate(ip))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "ip account limit reached");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create + link. new Account self-registers (Accounts.Add) and hashes the password per
|
||||||
|
// the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an
|
||||||
|
// in-game first-login does; the tag persists on the next world save.
|
||||||
|
var acct = new Account(account, password);
|
||||||
|
|
||||||
|
if (haveIp)
|
||||||
|
acct.LogAccess(ip);
|
||||||
|
|
||||||
|
acct.SetTag(Tag, webId);
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}",
|
||||||
|
actor, account, webId, haveIp ? ip.ToString() : "-");
|
||||||
|
|
||||||
|
BridgeLink.Emit(AuditBegin(action, actor, account)
|
||||||
|
.Str("websiteUserId", webId)
|
||||||
|
.End());
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("account.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action).Str("account", account).Str("websiteUserId", webId);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account.unlink ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the
|
||||||
|
/// Owner floor keeps a protected staff account unreachable from the web.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnUnlink(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
var actor = BridgeJson.GetString(o, "actor");
|
||||||
|
const string action = "unlink";
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "missing actor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var acct = BridgeAdmin.ResolveTargetAccount(o);
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "unknown or accountless target");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BridgeAdmin.Protected(acct))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "target is protected staff; refused");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = acct.GetTag(Tag);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "not linked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acct.RemoveTag(Tag);
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})",
|
||||||
|
actor, acct.Username, existing);
|
||||||
|
|
||||||
|
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
||||||
|
.Str("websiteUserId", existing)
|
||||||
|
.End());
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("account.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action).Str("account", acct.Username);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static void Err(string reqId, string action, string reason)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("account.error");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
if (action != null) sb.Str("action", action);
|
||||||
|
sb.Str("reason", reason);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
|
||||||
|
/// admin.audit. Never carries the password.
|
||||||
|
/// </summary>
|
||||||
|
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
||||||
|
{
|
||||||
|
return BridgeJson.Begin("account.audit")
|
||||||
|
.Str("origin", "web")
|
||||||
|
.Str("action", action)
|
||||||
|
.Str("actor", "web:" + actor)
|
||||||
|
.Str("target", target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors the username safety rules in AccountHandler.CreateAccount.</summary>
|
||||||
|
private static bool IsSafeUsername(string un)
|
||||||
|
{
|
||||||
|
if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith("."))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (int i = 0; i < un.Length; i++)
|
||||||
|
{
|
||||||
|
char c = un[i];
|
||||||
|
if (c < 0x20 || c >= 0x7F || IsForbidden(c))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors the password safety rules in AccountHandler.CreateAccount.</summary>
|
||||||
|
private static bool IsSafePassword(string pw)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < pw.Length; i++)
|
||||||
|
{
|
||||||
|
char c = pw[i];
|
||||||
|
if (c < 0x20 || c >= 0x7F)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsForbidden(char c)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < ForbiddenChars.Length; i++)
|
||||||
|
if (c == ForbiddenChars[i])
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseIp(string s, out IPAddress ip)
|
||||||
|
{
|
||||||
|
ip = null;
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(s))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return IPAddress.TryParse(s.Trim(), out ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
364
overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
Normal file
364
overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Network;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The staff write plane: moderation actions the website drives against the live shard.
|
||||||
|
/// Phase 1 verbs are admin.kick, admin.ban, admin.unban, admin.broadcast.
|
||||||
|
///
|
||||||
|
/// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
|
||||||
|
/// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
|
||||||
|
///
|
||||||
|
/// Trust model (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
|
||||||
|
/// these commands are gated there behind admin/moderator roles. The shard trusts the
|
||||||
|
/// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
|
||||||
|
/// CoOwner authority. Its one hard floor is <see cref="Protected"/>: a command refuses any
|
||||||
|
/// target at or above BridgeConfig.AdminAccessFloor (default CoOwner), so a compromised or
|
||||||
|
/// buggy sidecar can never ban, kick, or otherwise touch the Owner.
|
||||||
|
///
|
||||||
|
/// The whole plane is opt-in: nothing here acts unless BridgeConfig.AdminWriteEnabled is set.
|
||||||
|
/// Attribution rides on a required "actor" field (the website staff user); every applied
|
||||||
|
/// action logs to the console and emits an admin.audit event the website persists.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeAdmin
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeBoot.RegisterHandler("admin.kick", OnKick);
|
||||||
|
BridgeBoot.RegisterHandler("admin.ban", OnBan);
|
||||||
|
BridgeBoot.RegisterHandler("admin.unban", OnUnban);
|
||||||
|
BridgeBoot.RegisterHandler("admin.broadcast", OnBroadcast);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admin.kick ----
|
||||||
|
|
||||||
|
/// <summary>Disconnects every live session of the target account. Target by serial or account.</summary>
|
||||||
|
private static void OnKick(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
var actor = BridgeJson.GetString(o, "actor");
|
||||||
|
const string action = "kick";
|
||||||
|
|
||||||
|
if (!Ready(reqId, action, actor))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var acct = ResolveTargetAccount(o);
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "unknown or accountless target");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Protected(acct))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "target is protected staff; refused");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int kicked = KickAccountSessions(acct);
|
||||||
|
var reason = Reason(o);
|
||||||
|
|
||||||
|
Log(actor, action, acct.Username, reason);
|
||||||
|
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
||||||
|
.Num("sessions", kicked)
|
||||||
|
.Str("reason", reason)
|
||||||
|
.End());
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("admin.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action).Str("target", acct.Username).Num("sessions", kicked);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admin.ban ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bans an account (offline-capable) and disconnects any live sessions. A positive
|
||||||
|
/// durationSec makes it a timed ban that auto-expires; zero/absent is indefinite. Mirrors
|
||||||
|
/// the in-game [ban path (KickCommand), but takes the duration explicitly instead of a gump.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnBan(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
var actor = BridgeJson.GetString(o, "actor");
|
||||||
|
const string action = "ban";
|
||||||
|
|
||||||
|
if (!Ready(reqId, action, actor))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var acct = ResolveTargetAccount(o);
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "unknown or accountless target");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Protected(acct))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "target is protected staff; refused");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int durationSec = BridgeJson.GetInt(o, "durationSec", 0);
|
||||||
|
if (durationSec < 0)
|
||||||
|
durationSec = 0;
|
||||||
|
if (durationSec > BridgeConfig.AdminBanMaxDurationSec)
|
||||||
|
durationSec = BridgeConfig.AdminBanMaxDurationSec;
|
||||||
|
|
||||||
|
if (durationSec > 0)
|
||||||
|
acct.SetBanTags(null, DateTime.UtcNow, TimeSpan.FromSeconds(durationSec));
|
||||||
|
else
|
||||||
|
acct.SetUnspecifiedBan(null); // clears any prior duration tags -> indefinite
|
||||||
|
|
||||||
|
// SetBanTags/SetUnspecifiedBan(null) clear the BanDealer tag; set our own attribution.
|
||||||
|
acct.SetTag("BanDealer", WebActor(actor));
|
||||||
|
acct.Banned = true;
|
||||||
|
|
||||||
|
int kicked = KickAccountSessions(acct);
|
||||||
|
var reason = Reason(o);
|
||||||
|
|
||||||
|
Log(actor, action, acct.Username, reason);
|
||||||
|
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
||||||
|
.Num("durationSec", durationSec)
|
||||||
|
.Num("sessions", kicked)
|
||||||
|
.Str("reason", reason)
|
||||||
|
.End());
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("admin.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action).Str("target", acct.Username).Num("durationSec", durationSec).Num("sessions", kicked);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admin.unban ----
|
||||||
|
|
||||||
|
private static void OnUnban(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
var actor = BridgeJson.GetString(o, "actor");
|
||||||
|
const string action = "unban";
|
||||||
|
|
||||||
|
if (!Ready(reqId, action, actor))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var acct = ResolveTargetAccount(o);
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "unknown or accountless target");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acct.Banned = false;
|
||||||
|
acct.SetUnspecifiedBan(null); // clears BanTime/BanDuration/BanDealer tags
|
||||||
|
|
||||||
|
var reason = Reason(o);
|
||||||
|
|
||||||
|
Log(actor, action, acct.Username, reason);
|
||||||
|
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
||||||
|
.Str("reason", reason)
|
||||||
|
.End());
|
||||||
|
|
||||||
|
Ok(reqId, action, acct.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admin.broadcast ----
|
||||||
|
|
||||||
|
private static void OnBroadcast(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
var actor = BridgeJson.GetString(o, "actor");
|
||||||
|
const string action = "broadcast";
|
||||||
|
|
||||||
|
if (!Ready(reqId, action, actor))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text = BridgeJson.GetString(o, "text");
|
||||||
|
if (String.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
Err(reqId, action, "missing text");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.Length > BridgeConfig.AdminBroadcastMaxLength)
|
||||||
|
text = text.Substring(0, BridgeConfig.AdminBroadcastMaxLength);
|
||||||
|
|
||||||
|
// Default to the staff-broadcast green; callers may override.
|
||||||
|
int hue = BridgeJson.GetInt(o, "hue", 0x35);
|
||||||
|
|
||||||
|
World.Broadcast(hue, false, text);
|
||||||
|
|
||||||
|
Log(actor, action, null, text);
|
||||||
|
BridgeLink.Emit(AuditBegin(action, actor, null)
|
||||||
|
.Num("hue", hue)
|
||||||
|
.Str("text", text)
|
||||||
|
.End());
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("admin.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shared prologue / replies ----
|
||||||
|
|
||||||
|
/// <summary>Common gate: the write plane must be enabled and an actor must be present.</summary>
|
||||||
|
private static bool Ready(string reqId, string action, string actor)
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.AdminWriteEnabled)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "admin write plane disabled");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "missing actor");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Ok(string reqId, string action, string target)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("admin.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action);
|
||||||
|
if (target != null) sb.Str("target", target);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Err(string reqId, string action, string reason)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("admin.error");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
if (action != null) sb.Str("action", action);
|
||||||
|
sb.Str("reason", reason);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
|
||||||
|
/// connected dashboard so the website's moderation log stays complete regardless of which
|
||||||
|
/// client issued the action. The in-game counterpart (origin=in-game) is emitted from
|
||||||
|
/// BridgeEvents; see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
|
||||||
|
/// </summary>
|
||||||
|
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
||||||
|
{
|
||||||
|
return BridgeJson.Begin("admin.audit")
|
||||||
|
.Str("origin", "web")
|
||||||
|
.Str("action", action)
|
||||||
|
.Str("actor", WebActor(actor))
|
||||||
|
.Str("target", target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WebActor(string actor)
|
||||||
|
{
|
||||||
|
return "web:" + actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads and length-clamps the optional reason string.</summary>
|
||||||
|
private static string Reason(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reason = BridgeJson.GetString(o, "reason");
|
||||||
|
if (reason != null && reason.Length > BridgeConfig.AdminReasonMaxLength)
|
||||||
|
reason = reason.Substring(0, BridgeConfig.AdminReasonMaxLength);
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Log(string actor, string action, string target, string detail)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge][admin] {0} {1} target={2} detail={3}",
|
||||||
|
WebActor(actor), action, target ?? "-", detail ?? "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- target resolution & floor ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the command's target account, by "serial" (a player mobile's account) or by
|
||||||
|
/// "account" (username). Returns null if neither resolves to a real account. Public so the
|
||||||
|
/// account plane (unlink) resolves targets the same way the moderation plane does.
|
||||||
|
/// </summary>
|
||||||
|
public static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var serialStr = BridgeJson.GetString(o, "serial");
|
||||||
|
if (serialStr != null)
|
||||||
|
{
|
||||||
|
var m = ResolveSerial(serialStr);
|
||||||
|
return m == null ? null : m.Account as Account;
|
||||||
|
}
|
||||||
|
|
||||||
|
var acctName = BridgeJson.GetString(o, "account");
|
||||||
|
return acctName == null ? null : Accounts.GetAccount(acctName) as Account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one shard-side safety floor. Protects any account whose effective access level —
|
||||||
|
/// the account's own or the highest of its characters' — is at or above the configured
|
||||||
|
/// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
|
||||||
|
/// so the account plane (unlink) enforces the identical floor.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Protected(Account acct)
|
||||||
|
{
|
||||||
|
var lvl = acct.AccessLevel;
|
||||||
|
|
||||||
|
for (int i = 0; i < acct.Length; i++)
|
||||||
|
{
|
||||||
|
var m = acct[i];
|
||||||
|
if (m != null && m.AccessLevel > lvl)
|
||||||
|
lvl = m.AccessLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lvl >= BridgeConfig.AdminAccessFloor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disconnects every live NetState bound to this account. Enumerating NetState.Instances
|
||||||
|
/// (rather than walking the account's characters) also catches a session parked at
|
||||||
|
/// character-select, which has an account but no mobile yet. Snapshot first, since Dispose
|
||||||
|
/// mutates the instance set.
|
||||||
|
/// </summary>
|
||||||
|
private static int KickAccountSessions(Account acct)
|
||||||
|
{
|
||||||
|
var doomed = new List<NetState>();
|
||||||
|
|
||||||
|
foreach (var ns in NetState.Instances)
|
||||||
|
{
|
||||||
|
if (ns != null && ns.Account == acct)
|
||||||
|
doomed.Add(ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var ns in doomed)
|
||||||
|
ns.Dispose();
|
||||||
|
|
||||||
|
return doomed.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mobile ResolveSerial(string serialStr)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var s = serialStr.Trim();
|
||||||
|
int value;
|
||||||
|
|
||||||
|
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
value = Convert.ToInt32(s.Substring(2), 16);
|
||||||
|
else
|
||||||
|
value = Convert.ToInt32(s, 10);
|
||||||
|
|
||||||
|
return World.FindMobile(value);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,12 @@ namespace Server.Custom.Bridge
|
|||||||
case "reload":
|
case "reload":
|
||||||
BridgeConfig.Load();
|
BridgeConfig.Load();
|
||||||
BridgeSweeps.Rearm();
|
BridgeSweeps.Rearm();
|
||||||
|
BridgePages.Rearm();
|
||||||
|
BridgeChamps.Rearm();
|
||||||
|
BridgeSocial.Rearm();
|
||||||
|
BridgeGovernance.Rearm();
|
||||||
|
BridgePresence.Rearm();
|
||||||
|
BridgeHousing.Rearm();
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||||
break;
|
break;
|
||||||
@@ -170,8 +176,18 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
case "sweepnow":
|
case "sweepnow":
|
||||||
BridgeSweeps.SweepOnce();
|
BridgeSweeps.SweepOnce();
|
||||||
|
BridgeChamps.SweepOnce();
|
||||||
|
BridgeSocial.SweepOnce();
|
||||||
|
BridgeGovernance.SweepOnce();
|
||||||
|
BridgePresence.SweepOnce();
|
||||||
|
BridgeHousing.SweepOnce();
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -181,6 +197,12 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
||||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
287
overlay/Scripts/Custom/Bridge/BridgeChamps.cs
Normal file
287
overlay/Scripts/Custom/Bridge/BridgeChamps.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,18 @@ using System;
|
|||||||
|
|
||||||
namespace Server.Custom.Bridge
|
namespace Server.Custom.Bridge
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
|
||||||
|
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
|
||||||
|
/// the operator pairs with this (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §2).
|
||||||
|
/// </summary>
|
||||||
|
public enum SignupMode
|
||||||
|
{
|
||||||
|
Website, // website is the account authority; in-game auto-create should be off
|
||||||
|
Game, // game server is the authority; account.create is refused
|
||||||
|
Hybrid // either side may create
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
|
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
|
||||||
/// reads as "Bridge.Port" here.
|
/// reads as "Bridge.Port" here.
|
||||||
@@ -17,6 +29,12 @@ namespace Server.Custom.Bridge
|
|||||||
public static int StatSweepSeconds { get; private set; }
|
public static int StatSweepSeconds { get; private set; }
|
||||||
public static int DecaySweepSeconds { get; private set; }
|
public static int DecaySweepSeconds { get; private set; }
|
||||||
public static int EconomySweepSeconds { 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 int GuildSweepSeconds { get; private set; }
|
||||||
|
public static int CitySweepSeconds { get; private set; }
|
||||||
|
public static int PresenceSweepSeconds { get; private set; }
|
||||||
|
public static int HousingSweepSeconds { get; private set; }
|
||||||
|
|
||||||
public static string LinkUrl { get; private set; }
|
public static string LinkUrl { get; private set; }
|
||||||
|
|
||||||
@@ -25,6 +43,25 @@ namespace Server.Custom.Bridge
|
|||||||
public static int TownCrierMaxActive { get; private set; }
|
public static int TownCrierMaxActive { get; private set; }
|
||||||
public static int TownCrierMaxDurationSec { get; private set; }
|
public static int TownCrierMaxDurationSec { get; private set; }
|
||||||
|
|
||||||
|
// Town Cryer news gump (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16).
|
||||||
|
public static int NewsMaxTitleLength { get; private set; }
|
||||||
|
public static int NewsMaxBodyLength { get; private set; }
|
||||||
|
public static int NewsMaxExternal { get; private set; }
|
||||||
|
public static int NewsAnnounceDurationSec { get; private set; }
|
||||||
|
|
||||||
|
public static bool AdminWriteEnabled { get; private set; }
|
||||||
|
public static AccessLevel AdminAccessFloor { get; private set; }
|
||||||
|
public static int AdminBroadcastMaxLength { get; private set; }
|
||||||
|
public static int AdminReasonMaxLength { get; private set; }
|
||||||
|
public static int AdminBanMaxDurationSec { get; private set; }
|
||||||
|
|
||||||
|
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
|
||||||
|
public static SignupMode Signup { get; private set; }
|
||||||
|
public static bool AccountCreateEnabled { get; private set; }
|
||||||
|
public static bool RequireIpForCreate { get; private set; }
|
||||||
|
public static int AccountNameMaxLength { get; private set; }
|
||||||
|
public static int AccountPasswordMaxLength { get; private set; }
|
||||||
|
|
||||||
public static bool Enabled { get; private set; }
|
public static bool Enabled { get; private set; }
|
||||||
|
|
||||||
public static void Configure()
|
public static void Configure()
|
||||||
@@ -44,6 +81,31 @@ namespace Server.Custom.Bridge
|
|||||||
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
||||||
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||||
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
||||||
|
PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
|
||||||
|
if (PageSweepSeconds < 1)
|
||||||
|
PageSweepSeconds = 1;
|
||||||
|
|
||||||
|
ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
|
||||||
|
if (ChampSweepSeconds < 1)
|
||||||
|
ChampSweepSeconds = 1;
|
||||||
|
|
||||||
|
// Social/political sweeps (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Both change slowly, so the
|
||||||
|
// defaults are unhurried; the pass is a handful of field reads over a small set.
|
||||||
|
GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
|
||||||
|
if (GuildSweepSeconds < 1)
|
||||||
|
GuildSweepSeconds = 1;
|
||||||
|
|
||||||
|
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
|
||||||
|
if (CitySweepSeconds < 1)
|
||||||
|
CitySweepSeconds = 1;
|
||||||
|
|
||||||
|
PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
|
||||||
|
if (PresenceSweepSeconds < 1)
|
||||||
|
PresenceSweepSeconds = 1;
|
||||||
|
|
||||||
|
HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
|
||||||
|
if (HousingSweepSeconds < 1)
|
||||||
|
HousingSweepSeconds = 1;
|
||||||
|
|
||||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||||
|
|
||||||
@@ -52,15 +114,100 @@ namespace Server.Custom.Bridge
|
|||||||
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
||||||
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
|
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
|
||||||
|
|
||||||
|
NewsMaxTitleLength = Config.Get("Bridge.NewsMaxTitleLength", 100);
|
||||||
|
NewsMaxBodyLength = Config.Get("Bridge.NewsMaxBodyLength", 2000);
|
||||||
|
NewsMaxExternal = Config.Get("Bridge.NewsMaxExternal", 20);
|
||||||
|
NewsAnnounceDurationSec = Config.Get("Bridge.NewsAnnounceDurationSec", 300);
|
||||||
|
if (NewsAnnounceDurationSec < 1)
|
||||||
|
NewsAnnounceDurationSec = 1;
|
||||||
|
|
||||||
|
AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
|
||||||
|
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
|
||||||
|
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
|
||||||
|
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
||||||
|
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
||||||
|
|
||||||
|
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
|
||||||
|
// unrecognized* value falls back to Game (the safest — no website creation), so a
|
||||||
|
// typo can never accidentally open provisioning.
|
||||||
|
Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game);
|
||||||
|
// Default follows the mode: creation is on unless the shard is game-authority.
|
||||||
|
AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game);
|
||||||
|
RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true);
|
||||||
|
AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16);
|
||||||
|
AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30);
|
||||||
|
if (AccountNameMaxLength < 1)
|
||||||
|
AccountNameMaxLength = 1;
|
||||||
|
if (AccountPasswordMaxLength < 1)
|
||||||
|
AccountPasswordMaxLength = 1;
|
||||||
|
|
||||||
if (QueueCap < 16)
|
if (QueueCap < 16)
|
||||||
QueueCap = 16;
|
QueueCap = 16;
|
||||||
|
|
||||||
|
WarnOnSignupMismatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bridge governs only the account.create verb; ServUO's in-game first-login
|
||||||
|
/// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves
|
||||||
|
/// disagree is quietly broken (website-only that still auto-creates in game, or a mode
|
||||||
|
/// that expects in-game creation with it switched off), so surface the contradiction
|
||||||
|
/// loudly rather than silently doing the permissive thing.
|
||||||
|
/// </summary>
|
||||||
|
private static void WarnOnSignupMismatch()
|
||||||
|
{
|
||||||
|
var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true);
|
||||||
|
|
||||||
|
if (Signup == SignupMode.Website && autoCreate)
|
||||||
|
Console.WriteLine(
|
||||||
|
"[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; "
|
||||||
|
+ "an in-game login of any new name still mints an account. Set it false for website-only.");
|
||||||
|
else if (Signup == SignupMode.Game && !autoCreate)
|
||||||
|
Console.WriteLine(
|
||||||
|
"[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; "
|
||||||
|
+ "in-game creation is off and account.create is refused, so no account can be created.");
|
||||||
|
else if (Signup == SignupMode.Hybrid && !autoCreate)
|
||||||
|
Console.WriteLine(
|
||||||
|
"[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; "
|
||||||
|
+ "in-game first-login creation is off. Only website account.create will work.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a SignupMode name, case-insensitively, falling back to <paramref name="fallback"/>
|
||||||
|
/// on anything unrecognized so a typo can never open provisioning wider than intended.
|
||||||
|
/// </summary>
|
||||||
|
private static SignupMode ParseSignupMode(string value, SignupMode fallback)
|
||||||
|
{
|
||||||
|
SignupMode parsed;
|
||||||
|
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
||||||
|
Enum.IsDefined(typeof(SignupMode), parsed))
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses an AccessLevel name from config, case-insensitively, falling back to the given
|
||||||
|
/// default on anything unrecognized so a typo can never open the floor wider than intended.
|
||||||
|
/// </summary>
|
||||||
|
private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
|
||||||
|
{
|
||||||
|
AccessLevel parsed;
|
||||||
|
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
||||||
|
Enum.IsDefined(typeof(AccessLevel), parsed))
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Describe()
|
public static string Describe()
|
||||||
{
|
{
|
||||||
return String.Format(
|
return String.Format(
|
||||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s)",
|
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
|
||||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds);
|
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||||
|
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Server.Engines.CityLoyalty;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The town-governor stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a
|
||||||
|
/// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of
|
||||||
|
/// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises
|
||||||
|
/// an EventSink, so — like <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — the set
|
||||||
|
/// is polled and each city emits `city.update` only when its signature changes. Governors turn
|
||||||
|
/// over on the order of weeks, so a slow sweep (default 5 min) is ample.
|
||||||
|
///
|
||||||
|
/// The wire model is uniform with the rest of Part B: a full-state `city.update` upsert, with
|
||||||
|
/// "the governor changed" derived sidecar-side by comparing to the stored board — rather than a
|
||||||
|
/// discrete from→to event, which a sidecar reconnect (cache cleared, full re-emit) would
|
||||||
|
/// otherwise fire spuriously for every city.
|
||||||
|
///
|
||||||
|
/// Gated on CityLoyaltySystem.Enabled: a shard running its own town system emits nothing here.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeGovernance
|
||||||
|
{
|
||||||
|
private static Timer _timer;
|
||||||
|
|
||||||
|
// City enum value -> last-emitted signature.
|
||||||
|
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
||||||
|
|
||||||
|
private static long _sweeps, _emitted;
|
||||||
|
private static bool _warnedDisabled;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EventSink.ServerStarted += OnServerStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnServerStarted()
|
||||||
|
{
|
||||||
|
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.CitySweepSeconds),
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
|
||||||
|
CitySweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop()
|
||||||
|
{
|
||||||
|
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format("cities(enabled={0} sweeps={1} emitted={2} tracked={3})",
|
||||||
|
CityLoyaltySystem.Enabled, _sweeps, _emitted, _last.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
|
public static void SweepOnce()
|
||||||
|
{
|
||||||
|
CitySweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CitySweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sweeps++;
|
||||||
|
|
||||||
|
if (!CityLoyaltySystem.Enabled || CityLoyaltySystem.Cities == null)
|
||||||
|
{
|
||||||
|
if (!_warnedDisabled)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] city loyalty disabled; governor stream idle.");
|
||||||
|
_warnedDisabled = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BridgeLink.Connected)
|
||||||
|
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||||
|
|
||||||
|
foreach (var city in CityLoyaltySystem.Cities)
|
||||||
|
{
|
||||||
|
if (city == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var sig = Signature(city);
|
||||||
|
|
||||||
|
int key = (int)city.City;
|
||||||
|
|
||||||
|
string prior;
|
||||||
|
if (_last.TryGetValue(key, out prior) && prior == sig)
|
||||||
|
continue; // unchanged since last emit
|
||||||
|
|
||||||
|
_last[key] = sig;
|
||||||
|
BridgeLink.Emit(WriteCity(city));
|
||||||
|
_emitted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] city sweep threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The volatile fields: governor, governor-elect, and the election phase / candidate count.
|
||||||
|
private static string Signature(CityLoyaltySystem city)
|
||||||
|
{
|
||||||
|
var gov = city.Governor == null ? 0 : city.Governor.Serial.Value;
|
||||||
|
var elect = city.GovernorElect == null ? 0 : city.GovernorElect.Serial.Value;
|
||||||
|
|
||||||
|
var e = city.Election;
|
||||||
|
var phase = ElectionPhase(e);
|
||||||
|
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
|
||||||
|
|
||||||
|
return String.Concat(
|
||||||
|
gov.ToString(), "|", elect.ToString(), "|", phase, "|", candidates.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteCity(CityLoyaltySystem city)
|
||||||
|
{
|
||||||
|
var e = city.Election;
|
||||||
|
var phase = ElectionPhase(e);
|
||||||
|
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("city.update")
|
||||||
|
.Str("city", city.City.ToString())
|
||||||
|
.Str("electionPhase", phase)
|
||||||
|
.Num("candidates", candidates);
|
||||||
|
|
||||||
|
sb.Actor("governor", city.Governor);
|
||||||
|
sb.Actor("governorElect", city.GovernorElect);
|
||||||
|
|
||||||
|
if (e != null && e.Ongoing)
|
||||||
|
sb.Str("autoPickAt", e.AutoPickGovernor.ToUniversalTime().ToString("o"));
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Folds the election state into one of: none / nominate / vote / pending.</summary>
|
||||||
|
private static string ElectionPhase(CityElection e)
|
||||||
|
{
|
||||||
|
if (e == null)
|
||||||
|
return "none";
|
||||||
|
|
||||||
|
if (e.CanNominate())
|
||||||
|
return "nominate";
|
||||||
|
|
||||||
|
if (e.CanVote())
|
||||||
|
return "vote";
|
||||||
|
|
||||||
|
if (e.Ongoing)
|
||||||
|
return "pending";
|
||||||
|
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
overlay/Scripts/Custom/Bridge/BridgeHousing.cs
Normal file
165
overlay/Scripts/Custom/Bridge/BridgeHousing.cs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using Server.Multis;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The housing registry (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay
|
||||||
|
/// *transitions*; this is the complementary *board*: one row per house with owner, location,
|
||||||
|
/// region, co-owners, value, and current decay level, so the website can render an owner→houses
|
||||||
|
/// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit
|
||||||
|
/// house.update only when a house's signature changes, and house.remove when a house is gone.
|
||||||
|
///
|
||||||
|
/// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the
|
||||||
|
/// registry is owner→houses; `price` is the house's placement value, not a sale listing.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeHousing
|
||||||
|
{
|
||||||
|
private static Timer _timer;
|
||||||
|
|
||||||
|
// house serial -> last-emitted signature.
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
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.HousingSweepSeconds),
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
|
||||||
|
HouseSweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop()
|
||||||
|
{
|
||||||
|
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format("housing(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()
|
||||||
|
{
|
||||||
|
HouseSweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HouseSweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sweeps++;
|
||||||
|
|
||||||
|
if (!BridgeLink.Connected)
|
||||||
|
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||||
|
|
||||||
|
var seen = new HashSet<Serial>();
|
||||||
|
|
||||||
|
foreach (var house in BaseHouse.AllHouses)
|
||||||
|
{
|
||||||
|
if (house == null || house.Deleted)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
seen.Add(house.Serial);
|
||||||
|
|
||||||
|
var level = house.DecayLevel; // computed getter — read once
|
||||||
|
var sig = Signature(house, level);
|
||||||
|
|
||||||
|
string prior;
|
||||||
|
if (_last.TryGetValue(house.Serial, out prior) && prior == sig)
|
||||||
|
continue; // unchanged since last emit
|
||||||
|
|
||||||
|
_last[house.Serial] = sig;
|
||||||
|
BridgeLink.Emit(WriteHouse(house, level));
|
||||||
|
_emitted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||||
|
foreach (var serial in gone)
|
||||||
|
{
|
||||||
|
_last.Remove(serial);
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End());
|
||||||
|
_removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Signature(BaseHouse house, DecayLevel level)
|
||||||
|
{
|
||||||
|
var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value;
|
||||||
|
var region = house.Region;
|
||||||
|
var regionName = region == null ? "" : (region.Name ?? "");
|
||||||
|
var sign = house.Sign;
|
||||||
|
var name = sign == null ? "" : (sign.GetName() ?? "");
|
||||||
|
var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count;
|
||||||
|
|
||||||
|
return String.Concat(
|
||||||
|
ownerSerial.ToString(), "|",
|
||||||
|
level.ToString(), "|",
|
||||||
|
regionName, "|",
|
||||||
|
name, "|",
|
||||||
|
coOwners.ToString(), "|",
|
||||||
|
house.Price.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteHouse(BaseHouse house, DecayLevel level)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("house.update")
|
||||||
|
.Ser("serial", house.Serial)
|
||||||
|
.Str("decay", level.ToString())
|
||||||
|
.Num("price", house.Price)
|
||||||
|
.Str("map", house.Map == null ? null : house.Map.Name)
|
||||||
|
.Num("x", house.X).Num("y", house.Y).Num("z", house.Z);
|
||||||
|
|
||||||
|
var sign = house.Sign;
|
||||||
|
if (sign != null)
|
||||||
|
sb.Str("name", sign.GetName());
|
||||||
|
|
||||||
|
var region = house.Region;
|
||||||
|
if (region != null)
|
||||||
|
sb.Str("region", region.Name);
|
||||||
|
|
||||||
|
sb.Actor("owner", house.Owner);
|
||||||
|
|
||||||
|
sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count);
|
||||||
|
sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count);
|
||||||
|
|
||||||
|
sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o"));
|
||||||
|
sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o"));
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ namespace Server.Custom.Bridge
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
|
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
|
||||||
/// every emitted event, and the measured budget in docs/PLAN.md assumes this cost, not a
|
/// every emitted event, and the measured budget in https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md assumes this cost, not a
|
||||||
/// reflection serializer's.
|
/// reflection serializer's.
|
||||||
///
|
///
|
||||||
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
|
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
|
||||||
@@ -76,6 +76,46 @@ namespace Server.Custom.Bridge
|
|||||||
return sb;
|
return sb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a nested actor object: serial, name, account (when there is one), the linked
|
||||||
|
/// webId (when the account is linked), and the player flag. A `null` mobile writes null.
|
||||||
|
/// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a
|
||||||
|
/// guild leader / joiner / governor can be attributed to a site user without a lookup.
|
||||||
|
/// </summary>
|
||||||
|
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
|
||||||
|
{
|
||||||
|
sb.Append(",\"").Append(name).Append("\":");
|
||||||
|
|
||||||
|
if (m == null)
|
||||||
|
{
|
||||||
|
sb.Append("null");
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||||
|
|
||||||
|
sb.Append(",\"name\":");
|
||||||
|
Escape(sb, m.Name ?? "");
|
||||||
|
|
||||||
|
var acct = m.Account as Accounting.Account;
|
||||||
|
if (acct != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"acct\":");
|
||||||
|
Escape(sb, acct.Username);
|
||||||
|
|
||||||
|
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||||
|
if (webId != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"webId\":");
|
||||||
|
Escape(sb, webId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
|
||||||
|
sb.Append('}');
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
||||||
public static string End(this StringBuilder sb)
|
public static string End(this StringBuilder sb)
|
||||||
{
|
{
|
||||||
|
|||||||
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Server.Services.TownCryer;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Website news articles pushed into the modern Town Cryer News gump
|
||||||
|
/// (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier
|
||||||
|
/// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML),
|
||||||
|
/// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries,
|
||||||
|
/// which the stock news gumps already render (they branch on TextDefinition.Number, so string
|
||||||
|
/// content needs no gump change).
|
||||||
|
///
|
||||||
|
/// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep
|
||||||
|
/// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just
|
||||||
|
/// the title through the existing crier say path (default on), so players hear it in-world.
|
||||||
|
///
|
||||||
|
/// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall),
|
||||||
|
/// which is required to touch the shared news list and to send crier packets.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeNews
|
||||||
|
{
|
||||||
|
// A neutral scroll gump when the website supplies no image.
|
||||||
|
private const int DefaultImage = 0x64E;
|
||||||
|
|
||||||
|
// Website id -> the news entry we created for it, so a later remove/replace can find it.
|
||||||
|
private static readonly Dictionary<string, TownCryerNewsEntry> _ours =
|
||||||
|
new Dictionary<string, TownCryerNewsEntry>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeBoot.RegisterHandler("news.add", OnAdd);
|
||||||
|
BridgeBoot.RegisterHandler("news.remove", OnRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnAdd(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var id = BridgeJson.GetString(o, "id");
|
||||||
|
|
||||||
|
if (id == null)
|
||||||
|
{
|
||||||
|
Reply("news.error", null, "missing id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = TownCryerSystem.NewsEntries;
|
||||||
|
if (list == null)
|
||||||
|
{
|
||||||
|
Reply("news.error", id, "town cryer unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = BridgeJson.GetString(o, "title");
|
||||||
|
if (String.IsNullOrEmpty(title))
|
||||||
|
{
|
||||||
|
Reply("news.error", id, "missing title");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = BridgeJson.GetString(o, "body") ?? "";
|
||||||
|
var url = BridgeJson.GetString(o, "url");
|
||||||
|
int image = BridgeJson.GetInt(o, "image", DefaultImage);
|
||||||
|
|
||||||
|
// announce defaults to true (proclaim the title in-world); "announce":false suppresses it.
|
||||||
|
bool announce = true;
|
||||||
|
object rawAnnounce;
|
||||||
|
if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool)
|
||||||
|
announce = (bool)rawAnnounce;
|
||||||
|
|
||||||
|
if (title.Length > BridgeConfig.NewsMaxTitleLength)
|
||||||
|
title = title.Substring(0, BridgeConfig.NewsMaxTitleLength);
|
||||||
|
if (body.Length > BridgeConfig.NewsMaxBodyLength)
|
||||||
|
body = body.Substring(0, BridgeConfig.NewsMaxBodyLength);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Replace an existing id in place: drop the old entry first.
|
||||||
|
TownCryerNewsEntry old;
|
||||||
|
if (_ours.TryGetValue(id, out old) && old != null)
|
||||||
|
{
|
||||||
|
list.Remove(old);
|
||||||
|
_ours.Remove(id);
|
||||||
|
}
|
||||||
|
else if (_ours.Count >= BridgeConfig.NewsMaxExternal)
|
||||||
|
{
|
||||||
|
Reply("news.error", id, "too many news entries");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new TownCryerNewsEntry(
|
||||||
|
new TextDefinition(title),
|
||||||
|
new TextDefinition(body),
|
||||||
|
image,
|
||||||
|
null,
|
||||||
|
url);
|
||||||
|
|
||||||
|
list.Insert(0, entry); // newest first, as the gump reads top-down
|
||||||
|
_ours[id] = entry;
|
||||||
|
|
||||||
|
if (announce)
|
||||||
|
Announce(title);
|
||||||
|
|
||||||
|
Reply("news.ok", id, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message);
|
||||||
|
Reply("news.error", id, "internal error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnRemove(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var id = BridgeJson.GetString(o, "id");
|
||||||
|
|
||||||
|
if (id == null)
|
||||||
|
{
|
||||||
|
Reply("news.error", null, "missing id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TownCryerNewsEntry entry;
|
||||||
|
if (!_ours.TryGetValue(id, out entry))
|
||||||
|
{
|
||||||
|
Reply("news.error", id, "unknown id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ours.Remove(id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = TownCryerSystem.NewsEntries;
|
||||||
|
if (list != null && entry != null)
|
||||||
|
list.Remove(entry);
|
||||||
|
|
||||||
|
Reply("news.ok", id, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message);
|
||||||
|
Reply("news.error", id, "internal error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Proclaims a single line — the article title — through the town criers.</summary>
|
||||||
|
private static void Announce(string title)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GlobalTownCrierEntryList.Instance.AddEntry(
|
||||||
|
new[] { title },
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A failed proclamation must not fail the news add — the article is already posted.
|
||||||
|
Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Reply(string kind, string id, string reason)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin(kind);
|
||||||
|
if (id != null) sb.Str("id", id);
|
||||||
|
if (reason != null) sb.Str("reason", reason);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
420
overlay/Scripts/Custom/Bridge/BridgePages.cs
Normal file
420
overlay/Scripts/Custom/Bridge/BridgePages.cs
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Engines.Help;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The in-game help-page (support ticket) queue, surfaced to the website.
|
||||||
|
///
|
||||||
|
/// A player who uses the Help button creates a <see cref="PageEntry"/> — sender, message,
|
||||||
|
/// type, location, and (once a staffer claims it) a handler. The queue lives in memory with
|
||||||
|
/// no EventSink, so — like the sweeps in <see cref="BridgeSweeps"/> — it is polled and diffed:
|
||||||
|
/// a page appearing emits <c>page.new</c>, one leaving emits <c>page.closed</c>, and a
|
||||||
|
/// handled-state change emits <c>page.updated</c>. The whole open queue is also available on
|
||||||
|
/// demand via the <c>pages.snapshot</c> request (the backfill a dashboard uses on connect).
|
||||||
|
///
|
||||||
|
/// A page is keyed by its sender's serial: the queue enforces one page per sender
|
||||||
|
/// (PageQueue.Contains), so the sender serial is a stable page id.
|
||||||
|
///
|
||||||
|
/// Inbound <c>page.respond</c> delivers a message to the player exactly as an in-game staff
|
||||||
|
/// response does (online: a gump now; offline: queued for next login), optionally closing the
|
||||||
|
/// page; <c>page.close</c> just removes it. Both run on the Core thread.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgePages
|
||||||
|
{
|
||||||
|
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static Timer _timer;
|
||||||
|
private static long _sweeps, _new, _closed, _updated;
|
||||||
|
|
||||||
|
private struct Seen
|
||||||
|
{
|
||||||
|
public long SentMs;
|
||||||
|
public bool Handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sender serial -> last-seen page identity. Core-thread only.
|
||||||
|
private static readonly Dictionary<int, Seen> _seen = new Dictionary<int, Seen>();
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeBoot.RegisterHandler("pages.snapshot", OnSnapshot);
|
||||||
|
BridgeBoot.RegisterHandler("page.respond", OnRespond);
|
||||||
|
BridgeBoot.RegisterHandler("page.close", OnClose);
|
||||||
|
|
||||||
|
EventSink.ServerStarted += OnServerStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnServerStarted()
|
||||||
|
{
|
||||||
|
Baseline();
|
||||||
|
Rearm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops and recreates the poll timer from current config. Called by `[bridge reload`.</summary>
|
||||||
|
public static void Rearm()
|
||||||
|
{
|
||||||
|
if (_timer != null)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var iv = TimeSpan.FromSeconds(BridgeConfig.PageSweepSeconds);
|
||||||
|
_timer = Timer.DelayCall(iv, iv, Sweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
"pages(sweeps={0} new={1} closed={2} updated={3} open={4})",
|
||||||
|
_sweeps, _new, _closed, _updated, _seen.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeds _seen from the current queue without emitting, so a restart/reload does not
|
||||||
|
/// re-announce pages already open.</summary>
|
||||||
|
private static void Baseline()
|
||||||
|
{
|
||||||
|
_seen.Clear();
|
||||||
|
|
||||||
|
foreach (PageEntry e in PageQueue.List)
|
||||||
|
{
|
||||||
|
if (e == null || e.Sender == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_seen[e.Sender.Serial.Value] = new Seen { SentMs = ToMs(e.Sent), Handled = e.Handler != null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- poll ----
|
||||||
|
|
||||||
|
private static void Sweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sweeps++;
|
||||||
|
|
||||||
|
var cur = new Dictionary<int, PageEntry>();
|
||||||
|
|
||||||
|
foreach (PageEntry e in PageQueue.List)
|
||||||
|
{
|
||||||
|
if (e == null || e.Sender == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
cur[e.Sender.Serial.Value] = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed: keys in _seen no longer present.
|
||||||
|
if (_seen.Count > 0)
|
||||||
|
{
|
||||||
|
List<int> gone = null;
|
||||||
|
|
||||||
|
foreach (var kv in _seen)
|
||||||
|
{
|
||||||
|
if (!cur.ContainsKey(kv.Key))
|
||||||
|
{
|
||||||
|
if (gone == null)
|
||||||
|
gone = new List<int>();
|
||||||
|
gone.Add(kv.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gone != null)
|
||||||
|
{
|
||||||
|
foreach (var id in gone)
|
||||||
|
{
|
||||||
|
EmitClosed(id);
|
||||||
|
_seen.Remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New / replaced / handled-state changed.
|
||||||
|
foreach (var kv in cur)
|
||||||
|
{
|
||||||
|
var e = kv.Value;
|
||||||
|
long sentMs = ToMs(e.Sent);
|
||||||
|
bool handled = e.Handler != null;
|
||||||
|
|
||||||
|
Seen prev;
|
||||||
|
if (!_seen.TryGetValue(kv.Key, out prev))
|
||||||
|
{
|
||||||
|
EmitNew(e);
|
||||||
|
}
|
||||||
|
else if (prev.SentMs != sentMs)
|
||||||
|
{
|
||||||
|
// Same sender, different page (they cancelled and re-paged within a tick).
|
||||||
|
EmitClosed(kv.Key);
|
||||||
|
EmitNew(e);
|
||||||
|
}
|
||||||
|
else if (prev.Handled != handled)
|
||||||
|
{
|
||||||
|
EmitUpdated(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
_seen[kv.Key] = new Seen { SentMs = sentMs, Handled = handled };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] page sweep threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- outbound ----
|
||||||
|
|
||||||
|
private static void EmitNew(PageEntry e)
|
||||||
|
{
|
||||||
|
_new++;
|
||||||
|
var sb = BridgeJson.Begin("page.new").Str("pageId", PageId(e));
|
||||||
|
AppendPageTail(sb, e);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EmitUpdated(PageEntry e)
|
||||||
|
{
|
||||||
|
_updated++;
|
||||||
|
var sb = BridgeJson.Begin("page.updated").Str("pageId", PageId(e));
|
||||||
|
AppendPageTail(sb, e);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EmitClosed(int serial)
|
||||||
|
{
|
||||||
|
_closed++;
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("page.closed")
|
||||||
|
.Str("pageId", "0x" + serial.ToString("X"))
|
||||||
|
.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnSnapshot(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("pages.list");
|
||||||
|
if (reqId != null)
|
||||||
|
sb.Str("reqId", reqId);
|
||||||
|
|
||||||
|
sb.Append(",\"pages\":[");
|
||||||
|
|
||||||
|
bool first = true;
|
||||||
|
foreach (PageEntry e in PageQueue.List)
|
||||||
|
{
|
||||||
|
if (e == null || e.Sender == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!first)
|
||||||
|
sb.Append(',');
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
sb.Append("{\"pageId\":\"0x").Append(e.Sender.Serial.Value.ToString("X")).Append('"');
|
||||||
|
AppendPageTail(sb, e);
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(']');
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends every page field except the opening pageId, each comma-prefixed, so it
|
||||||
|
/// works both after Begin(...) (events) and after a manual `{"pageId":..` (snapshot array).</summary>
|
||||||
|
private static void AppendPageTail(StringBuilder sb, PageEntry e)
|
||||||
|
{
|
||||||
|
sb.Append(",\"sender\":");
|
||||||
|
WriteSender(sb, e.Sender);
|
||||||
|
|
||||||
|
sb.Str("type", e.Type.ToString());
|
||||||
|
sb.Str("message", e.Message ?? "");
|
||||||
|
sb.Str("map", e.PageMap == null ? null : e.PageMap.Name);
|
||||||
|
sb.Num("x", e.PageLocation.X);
|
||||||
|
sb.Num("y", e.PageLocation.Y);
|
||||||
|
sb.Num("z", e.PageLocation.Z);
|
||||||
|
sb.Num("sentMs", ToMs(e.Sent));
|
||||||
|
sb.Bool("handled", e.Handler != null);
|
||||||
|
|
||||||
|
if (e.Handler != null)
|
||||||
|
sb.Str("handler", e.Handler.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteSender(StringBuilder sb, Mobile m)
|
||||||
|
{
|
||||||
|
if (m == null)
|
||||||
|
{
|
||||||
|
sb.Append("null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||||
|
sb.Append(",\"name\":");
|
||||||
|
BridgeJson.Escape(sb, m.Name ?? "");
|
||||||
|
|
||||||
|
var acct = m.Account as Account;
|
||||||
|
if (acct != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"acct\":");
|
||||||
|
BridgeJson.Escape(sb, acct.Username);
|
||||||
|
|
||||||
|
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||||
|
if (webId != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"webId\":");
|
||||||
|
BridgeJson.Escape(sb, webId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- inbound ----
|
||||||
|
|
||||||
|
/// <summary>page.respond {reqId, pageId, message, close?}. Delivers a staff response to the
|
||||||
|
/// player and optionally closes the page.</summary>
|
||||||
|
private static void OnRespond(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
var pageId = BridgeJson.GetString(o, "pageId");
|
||||||
|
var message = BridgeJson.GetString(o, "message");
|
||||||
|
bool close = GetBool(o, "close");
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(message))
|
||||||
|
{
|
||||||
|
Err(reqId, "respond", pageId, "missing message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var e = Find(pageId);
|
||||||
|
if (e == null)
|
||||||
|
{
|
||||||
|
Err(reqId, "respond", pageId, "unknown page");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Same delivery as an in-game staff response: a null handler shows as "Staff".
|
||||||
|
// ResponseEntry queues for an offline sender; SendGump delivers now if online.
|
||||||
|
var re = new ResponseEntry(e.Sender, null, message);
|
||||||
|
re.SendGump();
|
||||||
|
|
||||||
|
if (close)
|
||||||
|
PageQueue.Remove(e);
|
||||||
|
|
||||||
|
Ok(reqId, "respond", pageId, close);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] page.respond threw: {0}", ex.Message);
|
||||||
|
Err(reqId, "respond", pageId, "internal error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>page.close {reqId, pageId}. Removes the page from the queue.</summary>
|
||||||
|
private static void OnClose(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
var pageId = BridgeJson.GetString(o, "pageId");
|
||||||
|
|
||||||
|
var e = Find(pageId);
|
||||||
|
if (e == null)
|
||||||
|
{
|
||||||
|
Err(reqId, "close", pageId, "unknown page");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PageQueue.Remove(e);
|
||||||
|
Ok(reqId, "close", pageId, true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] page.close threw: {0}", ex.Message);
|
||||||
|
Err(reqId, "close", pageId, "internal error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Ok(string reqId, string action, string pageId, bool closed)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("page.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action);
|
||||||
|
if (pageId != null) sb.Str("pageId", pageId);
|
||||||
|
sb.Bool("closed", closed);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Err(string reqId, string action, string pageId, string reason)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("page.error");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action);
|
||||||
|
if (pageId != null) sb.Str("pageId", pageId);
|
||||||
|
sb.Str("reason", reason);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static PageEntry Find(string pageId)
|
||||||
|
{
|
||||||
|
int serial;
|
||||||
|
if (!TryParseSerial(pageId, out serial))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
foreach (PageEntry e in PageQueue.List)
|
||||||
|
{
|
||||||
|
if (e != null && e.Sender != null && e.Sender.Serial.Value == serial)
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PageId(PageEntry e)
|
||||||
|
{
|
||||||
|
return "0x" + e.Sender.Serial.Value.ToString("X");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ToMs(DateTime dt)
|
||||||
|
{
|
||||||
|
return (long)(dt.ToUniversalTime() - Epoch).TotalMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GetBool(Dictionary<string, object> o, string key)
|
||||||
|
{
|
||||||
|
object v;
|
||||||
|
if (o != null && o.TryGetValue(key, out v) && v is bool)
|
||||||
|
return (bool)v;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseSerial(string s, out int value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
if (String.IsNullOrEmpty(s))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
s = s.Trim();
|
||||||
|
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
value = Convert.ToInt32(s.Substring(2), 16);
|
||||||
|
else
|
||||||
|
value = Convert.ToInt32(s, 10);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The presence stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts:
|
||||||
|
///
|
||||||
|
/// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted
|
||||||
|
/// on a sweep but only when it changes, so the site has a live "N online"
|
||||||
|
/// plus a change history without a firehose of identical frames.
|
||||||
|
/// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap
|
||||||
|
/// per-player movement signal PLAN.md §5.6 recommends over Movement.
|
||||||
|
///
|
||||||
|
/// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the
|
||||||
|
/// same population the vitals sweep already walks; counting them by map and region is a handful
|
||||||
|
/// of field reads. region.enter is filtered to players.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgePresence
|
||||||
|
{
|
||||||
|
private static Timer _timer;
|
||||||
|
|
||||||
|
// Signature of the last-emitted snapshot, so an unchanged population emits nothing.
|
||||||
|
private static string _lastSig;
|
||||||
|
|
||||||
|
private static long _sweeps, _emitted, _regionEnters;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EventSink.OnEnterRegion += OnEnterRegion;
|
||||||
|
EventSink.ServerStarted += OnServerStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnServerStarted()
|
||||||
|
{
|
||||||
|
// Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the
|
||||||
|
// current population within one sweep.
|
||||||
|
BridgeLink.Connected_Core += OnConnected;
|
||||||
|
Rearm();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnConnected()
|
||||||
|
{
|
||||||
|
_lastSig = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.PresenceSweepSeconds),
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
|
||||||
|
PresenceSweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop()
|
||||||
|
{
|
||||||
|
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})",
|
||||||
|
_sweeps, _emitted, _regionEnters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
|
public static void SweepOnce()
|
||||||
|
{
|
||||||
|
PresenceSweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PresenceSweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sweeps++;
|
||||||
|
|
||||||
|
if (!BridgeLink.Connected)
|
||||||
|
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
var byFacet = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
var byRegion = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var m in World.Mobiles.Values)
|
||||||
|
{
|
||||||
|
var pm = m as PlayerMobile;
|
||||||
|
|
||||||
|
if (pm == null || pm.NetState == null || pm.Deleted)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
total++;
|
||||||
|
|
||||||
|
var facet = pm.Map == null ? "Internal" : pm.Map.Name;
|
||||||
|
Bump(byFacet, facet);
|
||||||
|
|
||||||
|
var region = pm.Region;
|
||||||
|
var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name;
|
||||||
|
Bump(byRegion, regionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sig = Signature(total, byFacet, byRegion);
|
||||||
|
if (sig == _lastSig)
|
||||||
|
return; // population unchanged since last emit
|
||||||
|
|
||||||
|
_lastSig = sig;
|
||||||
|
BridgeLink.Emit(WriteOnline(total, byFacet, byRegion));
|
||||||
|
_emitted++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Bump(IDictionary<string, int> map, string key)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Signature(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.Append(total);
|
||||||
|
foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
|
||||||
|
sb.Append('#');
|
||||||
|
foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteOnline(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("presence.online").Num("count", total);
|
||||||
|
|
||||||
|
WriteCounts(sb, "byFacet", byFacet);
|
||||||
|
WriteCounts(sb, "byRegion", byRegion);
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes a nested object of {name: count} pairs.</summary>
|
||||||
|
private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary<string, int> counts)
|
||||||
|
{
|
||||||
|
sb.Append(",\"").Append(field).Append("\":{");
|
||||||
|
|
||||||
|
bool first = true;
|
||||||
|
foreach (var kv in counts)
|
||||||
|
{
|
||||||
|
if (!first)
|
||||||
|
sb.Append(',');
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
BridgeJson.Escape(sb, kv.Key);
|
||||||
|
sb.Append(':').Append(kv.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- real-time region transitions ----
|
||||||
|
|
||||||
|
private static void OnEnterRegion(OnEnterRegionEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (e == null || e.From == null || !e.From.Player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var from = e.OldRegion;
|
||||||
|
var to = e.NewRegion;
|
||||||
|
|
||||||
|
// Only meaningful when the named region actually changed.
|
||||||
|
var fromName = from == null ? null : from.Name;
|
||||||
|
var toName = to == null ? null : to.Name;
|
||||||
|
if (String.Equals(fromName, toName, StringComparison.Ordinal))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("region.enter")
|
||||||
|
.Str("from", fromName)
|
||||||
|
.Str("to", toName)
|
||||||
|
.Str("map", e.From.Map == null ? null : e.From.Map.Name);
|
||||||
|
|
||||||
|
sb.Actor("who", e.From);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
_regionEnters++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ namespace Server.Custom.Bridge
|
|||||||
///
|
///
|
||||||
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
|
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
|
||||||
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
|
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
|
||||||
/// a sweep. See docs/PLAN.md §1.
|
/// a sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BridgeProfile
|
public static class BridgeProfile
|
||||||
{
|
{
|
||||||
@@ -83,7 +83,7 @@ namespace Server.Custom.Bridge
|
|||||||
}
|
}
|
||||||
sb.Append(']');
|
sb.Append(']');
|
||||||
|
|
||||||
// worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4)
|
// worn equipment only — not the backpack/bank (see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §IV.4)
|
||||||
sb.Append(",\"equipment\":[");
|
sb.Append(",\"equipment\":[");
|
||||||
first = true;
|
first = true;
|
||||||
foreach (var item in m.Items)
|
foreach (var item in m.Items)
|
||||||
@@ -98,9 +98,55 @@ namespace Server.Custom.Bridge
|
|||||||
}
|
}
|
||||||
sb.Append(']');
|
sb.Append(']');
|
||||||
|
|
||||||
|
WriteTitles(sb, m);
|
||||||
|
|
||||||
return sb.End();
|
return sb.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The titles a character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.3). `selected` is the index into
|
||||||
|
/// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed
|
||||||
|
/// display titles (may be absent). `reward` is the raw reward-title list — an entry may be
|
||||||
|
/// a cliloc number (as a string) or a literal string; resolve clilocs website-side.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteTitles(StringBuilder sb, PlayerMobile m)
|
||||||
|
{
|
||||||
|
sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle);
|
||||||
|
|
||||||
|
var fameKarma = m.FameKarmaTitle;
|
||||||
|
if (!String.IsNullOrEmpty(fameKarma))
|
||||||
|
{
|
||||||
|
sb.Append(",\"fameKarma\":");
|
||||||
|
BridgeJson.Escape(sb, fameKarma);
|
||||||
|
}
|
||||||
|
|
||||||
|
var skill = m.PaperdollSkillTitle;
|
||||||
|
if (!String.IsNullOrEmpty(skill))
|
||||||
|
{
|
||||||
|
sb.Append(",\"skill\":");
|
||||||
|
BridgeJson.Escape(sb, skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(",\"reward\":[");
|
||||||
|
var rewards = m.RewardTitles;
|
||||||
|
if (rewards != null)
|
||||||
|
{
|
||||||
|
bool first = true;
|
||||||
|
for (int i = 0; i < rewards.Count; i++)
|
||||||
|
{
|
||||||
|
var r = rewards[i];
|
||||||
|
if (r == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!first) sb.Append(',');
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append("]}");
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsGearLayer(Layer layer)
|
private static bool IsGearLayer(Layer layer)
|
||||||
{
|
{
|
||||||
switch (layer)
|
switch (layer)
|
||||||
|
|||||||
218
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
218
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using Server.Guilds;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The guild stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
|
||||||
|
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
|
||||||
|
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
|
||||||
|
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
|
||||||
|
/// tick, fold each guild to a small signature, and emit `guild.update` only when it changes.
|
||||||
|
/// 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).
|
||||||
|
///
|
||||||
|
/// "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
|
||||||
|
/// guild, would look like every guild being created at once.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeSocial
|
||||||
|
{
|
||||||
|
private static Timer _timer;
|
||||||
|
|
||||||
|
// guild id -> last-emitted signature. An id absent here has never been emitted (or the cache
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EventSink.JoinGuild += OnJoinGuild;
|
||||||
|
EventSink.ServerStarted += OnServerStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnServerStarted()
|
||||||
|
{
|
||||||
|
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.GuildSweepSeconds),
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
|
||||||
|
GuildSweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop()
|
||||||
|
{
|
||||||
|
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
|
||||||
|
_sweeps, _emitted, _removed, _joins, _last.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
|
public static void SweepOnce()
|
||||||
|
{
|
||||||
|
GuildSweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GuildSweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sweeps++;
|
||||||
|
|
||||||
|
if (!BridgeLink.Connected)
|
||||||
|
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||||
|
|
||||||
|
var seen = new HashSet<int>();
|
||||||
|
|
||||||
|
foreach (var bg in BaseGuild.List.Values)
|
||||||
|
{
|
||||||
|
var g = bg as Guild;
|
||||||
|
|
||||||
|
// Skip disbanded guilds (leader gone): they linger in the list until cleaned up,
|
||||||
|
// and treating them as absent lets the "gone" pass below emit guild.remove.
|
||||||
|
if (g == null || g.Disbanded)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
seen.Add(g.Id);
|
||||||
|
|
||||||
|
var sig = Signature(g);
|
||||||
|
|
||||||
|
string prior;
|
||||||
|
if (_last.TryGetValue(g.Id, out prior) && prior == sig)
|
||||||
|
continue; // unchanged since last emit
|
||||||
|
|
||||||
|
_last[g.Id] = sig;
|
||||||
|
BridgeLink.Emit(WriteGuild(g));
|
||||||
|
_emitted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything tracked last sweep but not seen now has disbanded or been removed.
|
||||||
|
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||||
|
foreach (var id in gone)
|
||||||
|
{
|
||||||
|
_last.Remove(id);
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
|
||||||
|
_removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
long memberSum = 0;
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
var members = g.Members;
|
||||||
|
if (members != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < members.Count; i++)
|
||||||
|
{
|
||||||
|
var m = members[i];
|
||||||
|
if (m == null)
|
||||||
|
continue;
|
||||||
|
count++;
|
||||||
|
unchecked { memberSum += (uint)m.Serial.Value; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value;
|
||||||
|
|
||||||
|
return String.Concat(
|
||||||
|
g.Name ?? "", "|",
|
||||||
|
g.Abbreviation ?? "", "|",
|
||||||
|
leaderSerial.ToString(), "|",
|
||||||
|
count.ToString(), "|",
|
||||||
|
memberSum.ToString(), "|",
|
||||||
|
g.Alliance == null ? "" : (g.AllianceName ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteGuild(Guild g)
|
||||||
|
{
|
||||||
|
int online = 0, count = 0;
|
||||||
|
var members = g.Members;
|
||||||
|
if (members != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < members.Count; i++)
|
||||||
|
{
|
||||||
|
var m = members[i];
|
||||||
|
if (m == null)
|
||||||
|
continue;
|
||||||
|
count++;
|
||||||
|
if (m.NetState != null)
|
||||||
|
online++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("guild.update")
|
||||||
|
.Num("id", g.Id)
|
||||||
|
.Str("name", g.Name)
|
||||||
|
.Str("abbr", g.Abbreviation)
|
||||||
|
.Num("members", count)
|
||||||
|
.Num("online", online)
|
||||||
|
.Str("alliance", g.Alliance == null ? null : g.AllianceName);
|
||||||
|
|
||||||
|
sb.Actor("leader", g.Leader);
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- real-time join ----
|
||||||
|
|
||||||
|
private static void OnJoinGuild(JoinGuildEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (e == null || e.Mobile == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var g = e.Guild as Guild;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("guild.join");
|
||||||
|
if (g != null)
|
||||||
|
sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation);
|
||||||
|
sb.Actor("who", e.Mobile);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
_joins++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The three polled streams, for state that has no EventSink: player vitals, house decay,
|
/// The three polled streams, for state that has no EventSink: player vitals, house decay,
|
||||||
/// and money supply. All three run on the Core thread via repeating Timers, and the
|
/// and money supply. All three run on the Core thread via repeating Timers, and the
|
||||||
/// measured cost (docs/PLAN.md §1) is why they can: at the seeded scale a full pass of all
|
/// measured cost (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1) is why they can: at the seeded scale a full pass of all
|
||||||
/// three is well under a millisecond.
|
/// three is well under a millisecond.
|
||||||
///
|
///
|
||||||
/// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed
|
/// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed
|
||||||
|
|||||||
133
patches/BridgeModerationAudit.cs
Normal file
133
patches/BridgeModerationAudit.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Forwards IN-GAME uses of the write-plane verbs to the website as admin.audit
|
||||||
|
/// (origin=in-game), so the site's moderation log is complete regardless of whether an action
|
||||||
|
/// came from the website or a staff member in the game client. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
|
||||||
|
///
|
||||||
|
/// Two sources, mirroring how the shard records each:
|
||||||
|
/// - ban / kick: resolved with their target inside the stock generic command, which logs a
|
||||||
|
/// line via CommandLogging.WriteLine. We tap the new CommandLogging.OnWrite event and
|
||||||
|
/// parse the "... banning|kicking <target> ('acct')" line for action and target.
|
||||||
|
/// - broadcast: [bcast carries its message as command args and hits no target, so
|
||||||
|
/// EventSink.Command already sees it whole; we reshape it.
|
||||||
|
///
|
||||||
|
/// Not in overlay/: it references CommandLogging.OnWrite, which exists only after
|
||||||
|
/// patches/commandlogging-event.patch is applied. Shipping it in overlay/ would break the
|
||||||
|
/// build on an unpatched install — the same reason BridgeVendorSale.cs lives in patches/.
|
||||||
|
///
|
||||||
|
/// Runs on the Core thread (both sources raise synchronously in the command path). Every body
|
||||||
|
/// is wrapped: a bridge exception must never escape into a staff command.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeModerationAudit
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CommandLogging.OnWrite += OnCommandLog; // ban / kick (resolved, with target)
|
||||||
|
EventSink.Command += OnStaffCommand; // broadcast (carries its message)
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] in-game moderation audit attached");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The stock ban/kick commands log "<level> <from> ('acct') banning|kicking
|
||||||
|
/// <target> ('acct')" (Commands.cs KickCommand). Match the verb, take the target's
|
||||||
|
/// account from the trailing "('acct')", and forward. Non-moderation lines are ignored.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnCommandLog(Mobile from, string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (from == null || text == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string action;
|
||||||
|
int at;
|
||||||
|
|
||||||
|
if ((at = text.IndexOf(" banning ", StringComparison.Ordinal)) >= 0)
|
||||||
|
action = "ban";
|
||||||
|
else if ((at = text.IndexOf(" kicking ", StringComparison.Ordinal)) >= 0)
|
||||||
|
action = "kick";
|
||||||
|
else
|
||||||
|
return;
|
||||||
|
|
||||||
|
var tail = text.Substring(at + 9); // past " banning " / " kicking "
|
||||||
|
Emit(action, from, ExtractAccount(tail), text);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] mod-audit log parse threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>[bcast / [bc / [b — a staff broadcast. Its message is the command args.</summary>
|
||||||
|
private static void OnStaffCommand(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (e == null || e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var cmd = e.Command;
|
||||||
|
if (cmd == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
cmd = cmd.ToLowerInvariant();
|
||||||
|
if (cmd != "bcast" && cmd != "bc" && cmd != "b")
|
||||||
|
return;
|
||||||
|
|
||||||
|
Emit("broadcast", e.Mobile, null, e.ArgString);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] mod-audit command threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pulls the account from a CommandLogging.Format rendering's trailing "('account')".</summary>
|
||||||
|
private static string ExtractAccount(string formatted)
|
||||||
|
{
|
||||||
|
if (formatted == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int open = formatted.LastIndexOf("('", StringComparison.Ordinal);
|
||||||
|
if (open < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int close = formatted.IndexOf("')", open, StringComparison.Ordinal);
|
||||||
|
if (close < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return formatted.Substring(open + 2, close - (open + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits admin.audit with origin=in-game. The actor is the staff member's account name
|
||||||
|
/// (no "web:" prefix — that, plus the origin field, is how the website tells the two
|
||||||
|
/// sources apart). `detail` carries the raw context so nothing is lost if a target could
|
||||||
|
/// not be parsed.
|
||||||
|
/// </summary>
|
||||||
|
private static void Emit(string action, Mobile actor, string target, string detail)
|
||||||
|
{
|
||||||
|
var acct = actor.Account as Account;
|
||||||
|
var actorName = acct != null ? acct.Username : actor.Name;
|
||||||
|
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("admin.audit")
|
||||||
|
.Str("origin", "in-game")
|
||||||
|
.Str("action", action)
|
||||||
|
.Str("actor", actorName)
|
||||||
|
.Str("target", target)
|
||||||
|
.Str("detail", detail)
|
||||||
|
.End());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ git apply patches/<name>.patch
|
|||||||
|
|
||||||
## Phase 7 — player-vendor sale (a coupled unit)
|
## 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 `docs/PLAN.md` §6.
|
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.
|
||||||
|
|
||||||
This is the one non-drop-in piece. Apply all three together:
|
This is the one non-drop-in piece. Apply all three together:
|
||||||
|
|
||||||
@@ -32,10 +32,28 @@ Both patches are `git`-format and verified with `git apply --check` against stoc
|
|||||||
|
|
||||||
Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
|
Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
|
||||||
|
|
||||||
|
## In-game moderation audit (admin controls §5.5)
|
||||||
|
|
||||||
|
So the website's moderation log stays complete, in-game uses of the write-plane verbs are forwarded to it as `admin.audit` (`origin:"in-game"`). Broadcasts already surface through `EventSink.Command`, but resolved bans/kicks only carry their target inside the command's own `CommandLogging.WriteLine` call — which has no event to subscribe to. One small change fixes that:
|
||||||
|
|
||||||
|
| Item | Target | What |
|
||||||
|
|------|--------|------|
|
||||||
|
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | Adds a `public static event Action<Mobile,string> OnWrite`, raised in `WriteLine` **before** the `m_Enabled` guard so it fires even when file logging is off. |
|
||||||
|
| `BridgeModerationAudit.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber: taps `OnWrite` for ban/kick (parsing the target out of the log line) and `EventSink.Command` for `[bcast`, emitting `admin.audit`. **Not** in `overlay/` because it references `CommandLogging.OnWrite`, which does not exist until the patch is applied. |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <servuo root>
|
||||||
|
git apply --check patches/commandlogging-event.patch # dry run
|
||||||
|
git apply patches/commandlogging-event.patch
|
||||||
|
cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/BridgeModerationAudit.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
`Logging.cs` is a **Scripts** file, so this is picked up by the dynamic script build — no core/solution rebuild needed (unlike the Phase 7 `EventSink.cs` patch). Verified end-to-end with `tools/scaffolding/BridgeAuditProbe.cs` (gated by `Bridge.AuditProbeOnStart`): a genuine `[bcast` plus simulated ban/kick log lines produced the expected `admin.audit` frames, target parsed, with non-moderation lines ignored.
|
||||||
|
|
||||||
## Note on `Scripts.csproj`
|
## Note on `Scripts.csproj`
|
||||||
|
|
||||||
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
|
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
|
||||||
|
|
||||||
## Note on shard repairs
|
## Note on shard repairs
|
||||||
|
|
||||||
The deletions and edits described in `docs/SHARD_PREREQS.md` are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
|
The deletions and edits described in [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
|
||||||
|
|||||||
33
patches/commandlogging-event.patch
Normal file
33
patches/commandlogging-event.patch
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
--- a/Scripts/Commands/Logging.cs
|
||||||
|
+++ b/Scripts/Commands/Logging.cs
|
||||||
|
@@ -75,16 +75,27 @@
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ /// <summary>
|
||||||
|
+ /// Raised for every staff command log line — even when file logging is disabled — so an
|
||||||
|
+ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to
|
||||||
|
+ /// forward moderation actions (ban/kick, with the resolved target) to the website.
|
||||||
|
+ /// </summary>
|
||||||
|
+ public static event Action<Mobile, string> OnWrite;
|
||||||
|
+
|
||||||
|
public static void WriteLine(Mobile from, string format, params object[] args)
|
||||||
|
{
|
||||||
|
- if (!m_Enabled)
|
||||||
|
- return;
|
||||||
|
-
|
||||||
|
WriteLine(from, String.Format(format, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteLine(Mobile from, string text)
|
||||||
|
{
|
||||||
|
+ var onWrite = OnWrite;
|
||||||
|
+ if (onWrite != null)
|
||||||
|
+ {
|
||||||
|
+ try { onWrite(from, text); }
|
||||||
|
+ catch { }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
if (!m_Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ website ──WS (live feed) / REST (queries)──► sidecar ──loopback
|
|||||||
(this) newline-JSON, bidirectional
|
(this) newline-JSON, bidirectional
|
||||||
```
|
```
|
||||||
|
|
||||||
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See `../docs/PLAN.md` §2.
|
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §2.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -106,4 +106,4 @@ A shard `*.error` reply maps to HTTP 404 (unknown/not-found) or 400 (bad request
|
|||||||
|
|
||||||
## Wire protocol
|
## Wire protocol
|
||||||
|
|
||||||
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in `../docs/PLAN.md` (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
|
|||||||
let text = fs::read_to_string(path)?;
|
let text = fs::read_to_string(path)?;
|
||||||
let line = format!("auth_token = \"{token}\"");
|
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
|
let out: String = text
|
||||||
.lines()
|
.lines()
|
||||||
.map(|l| {
|
.map(|l| {
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ use tracing_subscriber::EnvFilter;
|
|||||||
/// Wire-protocol version between the website and the sidecar. Bump this whenever an event or
|
/// Wire-protocol version between the website and the sidecar. Bump this whenever an event or
|
||||||
/// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
|
/// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
|
||||||
/// of failing in confusing ways.
|
/// of failing in confusing ways.
|
||||||
pub const PROTOCOL_VERSION: u32 = 1;
|
///
|
||||||
|
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
|
||||||
|
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
|
||||||
|
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 2;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@@ -75,6 +79,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let route_rpc = rpc.clone();
|
let route_rpc = rpc.clone();
|
||||||
let event_store = store.clone();
|
let event_store = store.clone();
|
||||||
let last_event_ts = last_event.clone();
|
let last_event_ts = last_event.clone();
|
||||||
|
let replay_handle = handle.clone(); // re-push external news to the shard on (re)connect
|
||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(ev) = event_rx.recv().await {
|
while let Some(ev) = event_rx.recv().await {
|
||||||
@@ -96,11 +101,121 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
|
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
|
||||||
if ev.kind != "pong" {
|
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();
|
let text = ev.value.to_string();
|
||||||
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
||||||
tracing::warn!(error = %e, "failed to persist event");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Guild board (Protocol 2.0): guild.update folds in the latest roster (one row
|
||||||
|
// per guild id); guild.remove drops a disbanded guild.
|
||||||
|
"guild.update" => {
|
||||||
|
if let Some(id) = ev.value.get("id").and_then(|v| v.as_i64()) {
|
||||||
|
if let Err(e) = event_store
|
||||||
|
.upsert_guild(
|
||||||
|
id,
|
||||||
|
ev.value.get("name").and_then(|n| n.as_str()),
|
||||||
|
&text,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to upsert guild board");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"guild.remove" => {
|
||||||
|
if let Some(id) = ev.value.get("id").and_then(|v| v.as_i64()) {
|
||||||
|
if let Err(e) = event_store.delete_guild(id).await {
|
||||||
|
tracing::warn!(error = %e, "failed to remove guild board row");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Governor board (Protocol 2.0): city.update folds in each city's latest
|
||||||
|
// governance state (one row per city).
|
||||||
|
"city.update" => {
|
||||||
|
if let Some(city) = ev.value.get("city").and_then(|c| c.as_str()) {
|
||||||
|
if let Err(e) = event_store.upsert_governor(city, &text, t).await {
|
||||||
|
tracing::warn!(error = %e, "failed to upsert governor board");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// House registry (Protocol 2.0): house.update folds in each house's latest state
|
||||||
|
// (one row per serial); house.remove drops a demolished/traded house.
|
||||||
|
"house.update" => {
|
||||||
|
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||||
|
if let Err(e) = event_store
|
||||||
|
.upsert_house(
|
||||||
|
serial,
|
||||||
|
ev.value.get("name").and_then(|n| n.as_str()),
|
||||||
|
&text,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to upsert house registry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"house.remove" => {
|
||||||
|
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||||
|
if let Err(e) = event_store.delete_house(serial).await {
|
||||||
|
tracing::warn!(error = %e, "failed to remove house registry row");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a shard (re)connect, re-push the stored external news: the shard rebuilds
|
||||||
|
// TownCryerSystem.NewsEntries from scratch each boot and does not persist ours. Replay
|
||||||
|
// with announce=false so a restart does not re-proclaim every article at once. news.add
|
||||||
|
// is idempotent by id, so replaying to a still-populated shard is harmless.
|
||||||
|
if ev.kind == "server.hello" {
|
||||||
|
match event_store.news_all().await {
|
||||||
|
Ok(items) => {
|
||||||
|
for mut item in items {
|
||||||
|
if let Some(obj) = item.as_object_mut() {
|
||||||
|
obj.insert("announce".to_string(), serde_json::json!(false));
|
||||||
|
}
|
||||||
|
if !replay_handle.send(item.to_string()).await {
|
||||||
|
break; // shard went away mid-replay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "news replay: could not read stored news"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = feed_tx.send(ev.value.to_string());
|
let _ = feed_tx.send(ev.value.to_string());
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ impl Rpc {
|
|||||||
) -> Result<Value, RpcError> {
|
) -> Result<Value, RpcError> {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
self.pending
|
self.pending.lock().await.insert(corr_val.to_string(), tx);
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(corr_val.to_string(), tx);
|
|
||||||
|
|
||||||
if !shard.send(command.to_string()).await {
|
if !shard.send(command.to_string()).await {
|
||||||
self.pending.lock().await.remove(corr_val);
|
self.pending.lock().await.remove(corr_val);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
|
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
|
||||||
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
|
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
|
||||||
//! is never directly reachable from the website — it exposes no port. See docs/PLAN.md §2.
|
//! is never directly reachable from the website — it exposes no port. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2.
|
||||||
//!
|
//!
|
||||||
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
|
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
|
||||||
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ pub struct Store {
|
|||||||
impl Store {
|
impl Store {
|
||||||
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
|
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
|
||||||
pub async fn open(path: &str) -> anyhow::Result<Self> {
|
pub async fn open(path: &str) -> anyhow::Result<Self> {
|
||||||
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?
|
let opts =
|
||||||
.create_if_missing(true);
|
SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?.create_if_missing(true);
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(4)
|
.max_connections(4)
|
||||||
@@ -78,7 +78,12 @@ impl Store {
|
|||||||
self.recent(Some("economy.supply"), limit).await
|
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(
|
sqlx::query(
|
||||||
"INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?)
|
"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",
|
ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t",
|
||||||
@@ -99,6 +104,16 @@ impl Store {
|
|||||||
Ok(row.map(|r| r.get::<String, _>("website_user_id")))
|
Ok(row.map(|r| r.get::<String, _>("website_user_id")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drops the mirrored link row so event attribution stops immediately, without waiting on the
|
||||||
|
/// shard. Returns the number of rows removed (0 if the account was not linked here).
|
||||||
|
pub async fn record_unlink(&self, account: &str) -> anyhow::Result<u64> {
|
||||||
|
let res = sqlx::query("DELETE FROM links WHERE account = ?")
|
||||||
|
.bind(account)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn cache_profile(
|
pub async fn cache_profile(
|
||||||
&self,
|
&self,
|
||||||
serial: &str,
|
serial: &str,
|
||||||
@@ -128,6 +143,191 @@ impl Store {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- guild board (Protocol 2.0) ----
|
||||||
|
|
||||||
|
/// Upserts one guild's latest state, keyed by guild id. Fed from `guild.update`; one row per
|
||||||
|
/// guild, always the most recent snapshot. This is the board the website reads on load.
|
||||||
|
pub async fn upsert_guild(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
name: Option<&str>,
|
||||||
|
json: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO guilds (id, name, json, updated_t) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops one guild from the board. Fed from `guild.remove` (a disband or a removed guild).
|
||||||
|
pub async fn delete_guild(&self, id: i64) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM guilds WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full guild board: every guild's latest snapshot, ordered by name.
|
||||||
|
pub async fn guilds_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||||
|
let rows = sqlx::query("SELECT json FROM guilds ORDER BY name, id")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(parse_json_column(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- governor board (Protocol 2.0) ----
|
||||||
|
|
||||||
|
/// Upserts one city's latest governance state, keyed by city name. Fed from `city.update`.
|
||||||
|
pub async fn upsert_governor(&self, city: &str, json: &str, t: i64) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO governors (city, json, updated_t) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(city) DO UPDATE SET json = excluded.json, updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(city)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full governor board: every city's latest governance snapshot, ordered by city.
|
||||||
|
pub async fn governors_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||||
|
let rows = sqlx::query("SELECT json FROM governors ORDER BY city")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(parse_json_column(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- house registry (Protocol 2.0) ----
|
||||||
|
|
||||||
|
/// Upserts one house's latest state, keyed by serial. Fed from `house.update`.
|
||||||
|
pub async fn upsert_house(
|
||||||
|
&self,
|
||||||
|
serial: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
json: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO houses (serial, name, json, updated_t) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(serial) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(serial)
|
||||||
|
.bind(name)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops one house from the registry. Fed from `house.remove` (demolished / traded away).
|
||||||
|
pub async fn delete_house(&self, serial: &str) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM houses WHERE serial = ?")
|
||||||
|
.bind(serial)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full house registry: every house's latest snapshot, ordered by name then serial.
|
||||||
|
pub async fn houses_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||||
|
let rows = sqlx::query("SELECT json FROM houses ORDER BY name, serial")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(parse_json_column(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Town Cryer news (Protocol 2.1) ----
|
||||||
|
|
||||||
|
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
|
||||||
|
/// website is the source of truth; this lets the sidecar replay the set to the shard on reconnect
|
||||||
|
/// (the shard does not persist NewsEntries across a reboot).
|
||||||
|
pub async fn upsert_news(&self, id: &str, json: &str, t: i64) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO news (id, json, updated_t) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET json = excluded.json, updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes one external news article.
|
||||||
|
pub async fn delete_news(&self, id: &str) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM news WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every stored external news article (as its `news.add` command), oldest first so a replay
|
||||||
|
/// re-inserts them in the same order the website added them.
|
||||||
|
pub async fn news_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||||
|
let rows = sqlx::query("SELECT json FROM news ORDER BY updated_t")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(parse_json_column(rows))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
|
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
|
||||||
@@ -158,4 +358,38 @@ CREATE TABLE IF NOT EXISTS profiles (
|
|||||||
json TEXT NOT NULL,
|
json TEXT NOT NULL,
|
||||||
updated_t INTEGER 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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS guilds (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS governors (
|
||||||
|
city TEXT PRIMARY KEY,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS houses (
|
||||||
|
serial TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS news (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -54,12 +54,34 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
.route("/vendors/:account", get(vendors))
|
.route("/vendors/:account", get(vendors))
|
||||||
// Inbound commands (correlated by code / id).
|
// Inbound commands (correlated by code / id).
|
||||||
.route("/link/confirm", post(link_confirm))
|
.route("/link/confirm", post(link_confirm))
|
||||||
.route("/link/:account", get(link_lookup))
|
// Account provisioning (Protocol 2.0). Create is correlated by reqId; the DELETE unlinks.
|
||||||
|
.route("/accounts/create", post(account_create))
|
||||||
|
.route("/link/:account", get(link_lookup).delete(link_delete))
|
||||||
.route("/towncrier", post(towncrier_add))
|
.route("/towncrier", post(towncrier_add))
|
||||||
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
||||||
|
// Town Cryer news gump (Protocol 2.1). Add/replace an article; delete one.
|
||||||
|
.route("/news", post(news_add))
|
||||||
|
.route("/news/:id", axum::routing::delete(news_remove))
|
||||||
|
// Staff write plane (correlated by reqId). The shard enforces the real authorization;
|
||||||
|
// the website must gate these behind admin/moderator roles before calling.
|
||||||
|
.route("/admin/kick", post(admin_kick))
|
||||||
|
.route("/admin/ban", post(admin_ban))
|
||||||
|
.route("/admin/unban", post(admin_unban))
|
||||||
|
.route("/admin/broadcast", post(admin_broadcast))
|
||||||
|
// Help-page (support) queue: snapshot the open queue, respond to / close a page.
|
||||||
|
.route("/pages", get(pages_list))
|
||||||
|
.route("/pages/:id/respond", post(page_respond))
|
||||||
|
.route("/pages/:id/close", post(page_close))
|
||||||
// History, read from SQLite rather than the shard.
|
// History, read from SQLite rather than the shard.
|
||||||
.route("/history", get(history))
|
.route("/history", get(history))
|
||||||
.route("/economy", get(economy))
|
.route("/economy", get(economy))
|
||||||
|
.route("/champs", get(champs))
|
||||||
|
// World-state boards (Protocol 2.0), served from the store so they answer without the shard
|
||||||
|
// and survive an outage with the last-known snapshot (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §12.2).
|
||||||
|
.route("/guilds", get(guilds))
|
||||||
|
.route("/governors", get(governors))
|
||||||
|
.route("/online", get(online))
|
||||||
|
.route("/houses", get(houses))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -113,7 +135,8 @@ fn iso_ms(ms: i64) -> Option<String> {
|
|||||||
if ms <= 0 {
|
if ms <= 0 {
|
||||||
return None;
|
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 ----
|
// ---- gate: protocol check + auth ----
|
||||||
@@ -164,8 +187,15 @@ async fn gate(State(st): State<AppState>, req: Request, next: Next) -> Response
|
|||||||
|
|
||||||
fn extract_token(req: &Request) -> Option<String> {
|
fn extract_token(req: &Request) -> Option<String> {
|
||||||
// Authorization: Bearer <token>
|
// Authorization: Bearer <token>
|
||||||
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) {
|
if let Some(v) = req
|
||||||
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) {
|
.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());
|
return Some(rest.trim().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +261,273 @@ fn respond(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `respond`, but for the admin write plane, where a rejection is not a not-found. Maps an
|
||||||
|
/// `admin.error` reply to a status by its reason: an unknown target is a 404, a floor/authorization
|
||||||
|
/// refusal (protected target, or the write plane being disabled) is a 403, anything else a 400.
|
||||||
|
fn respond_admin(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||||
|
match result {
|
||||||
|
Ok(value) => {
|
||||||
|
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
|
||||||
|
if kind == "admin.error" {
|
||||||
|
let reason = value
|
||||||
|
.get("reason")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.unwrap_or("request rejected");
|
||||||
|
let code = if reason.contains("unknown") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else if reason.contains("protected")
|
||||||
|
|| reason.contains("refused")
|
||||||
|
|| reason.contains("disabled")
|
||||||
|
{
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
} else {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
};
|
||||||
|
(code, Json(value))
|
||||||
|
} else {
|
||||||
|
(StatusCode::OK, Json(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RpcError::NoShard) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(json!({"error": "shard not connected"})),
|
||||||
|
),
|
||||||
|
Err(RpcError::Timeout) => (
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
Json(json!({"error": "shard did not reply in time"})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `respond`, but for the account-provisioning plane. Maps an `account.error` reply to a
|
||||||
|
/// status by its reason: a name clash is a 409, the per-IP cap is a 429, a disabled/protected/
|
||||||
|
/// refused action is a 403, an unknown target or "not linked" is a 404, anything else a 400.
|
||||||
|
fn respond_account(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||||
|
match result {
|
||||||
|
Ok(value) => {
|
||||||
|
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
|
||||||
|
if kind == "account.error" {
|
||||||
|
let reason = value
|
||||||
|
.get("reason")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.unwrap_or("request rejected");
|
||||||
|
let code = if reason.contains("already exists") {
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
} else if reason.contains("ip account limit") {
|
||||||
|
StatusCode::TOO_MANY_REQUESTS
|
||||||
|
} else if reason.contains("disabled")
|
||||||
|
|| reason.contains("protected")
|
||||||
|
|| reason.contains("refused")
|
||||||
|
{
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
} else if reason.contains("unknown") || reason.contains("not linked") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
};
|
||||||
|
(code, Json(value))
|
||||||
|
} else {
|
||||||
|
(StatusCode::OK, Json(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RpcError::NoShard) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(json!({"error": "shard not connected"})),
|
||||||
|
),
|
||||||
|
Err(RpcError::Timeout) => (
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
Json(json!({"error": "shard did not reply in time"})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account-provisioning handlers ----
|
||||||
|
|
||||||
|
/// Body: {"actor","account","password","websiteUserId","ip"}. Creates and links a game account.
|
||||||
|
/// Correlated on a fresh reqId. The password is forwarded to the shard (loopback) but never logged
|
||||||
|
/// here and never appears in the reply; a successful create mirrors the link into the store.
|
||||||
|
async fn account_create(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
let mut obj = match body {
|
||||||
|
Value::Object(m) => m,
|
||||||
|
_ => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "body must be a JSON object"})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Required, non-empty. `ip` is validated on the shard (which owns the cap), not here.
|
||||||
|
for field in ["actor", "account", "password", "websiteUserId"] {
|
||||||
|
let present = obj
|
||||||
|
.get(field)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !present {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "error": format!("{field} is required") })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
obj.insert("kind".to_string(), json!("account.create"));
|
||||||
|
obj.insert("reqId".to_string(), json!(req_id));
|
||||||
|
|
||||||
|
let result = st.rpc.call(&st.shard, Value::Object(obj), &req_id).await;
|
||||||
|
|
||||||
|
// Mirror a successful create's link into the store, so events are attributable without the
|
||||||
|
// shard (same as link.confirm does).
|
||||||
|
if let Ok(value) = &result {
|
||||||
|
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||||
|
if let (Some(account), Some(web_id)) = (
|
||||||
|
value.get("account").and_then(|a| a.as_str()),
|
||||||
|
value.get("websiteUserId").and_then(|w| w.as_str()),
|
||||||
|
) {
|
||||||
|
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let _ = st.store.record_link(account, web_id, t).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respond_account(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlinks a game account from its website user. Body: {"actor"}. Correlated on reqId; a success
|
||||||
|
/// also clears the sidecar's mirrored link row so attribution stops immediately.
|
||||||
|
async fn link_delete(
|
||||||
|
State(st): State<AppState>,
|
||||||
|
Path(account): Path<String>,
|
||||||
|
body: Option<Json<Value>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let actor = body
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|Json(b)| b.get("actor").and_then(|a| a.as_str()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if actor.is_empty() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "actor is required"})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({
|
||||||
|
"kind": "account.unlink", "reqId": req_id, "actor": actor, "account": account
|
||||||
|
});
|
||||||
|
let result = st.rpc.call(&st.shard, cmd, &req_id).await;
|
||||||
|
|
||||||
|
if let Ok(value) = &result {
|
||||||
|
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||||
|
let _ = st.store.record_unlink(&account).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respond_account(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admin write-plane handlers ----
|
||||||
|
|
||||||
|
/// Forwards a staff moderation command to the shard, correlated on a fresh reqId. Injects `kind`
|
||||||
|
/// and `reqId`, requiring the caller-supplied `actor` up front (the shard enforces it too). The
|
||||||
|
/// body's remaining fields (account/serial/durationSec/reason/text/hue) pass straight through.
|
||||||
|
async fn admin_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json<Value>) {
|
||||||
|
let mut obj = match body {
|
||||||
|
Value::Object(m) => m,
|
||||||
|
_ => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "body must be a JSON object"})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let actor_ok = obj
|
||||||
|
.get("actor")
|
||||||
|
.and_then(|a| a.as_str())
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !actor_ok {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "actor is required"})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
obj.insert("kind".to_string(), json!(kind));
|
||||||
|
obj.insert("reqId".to_string(), json!(req_id));
|
||||||
|
|
||||||
|
respond_admin(st.rpc.call(&st.shard, Value::Object(obj), &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"actor":"...","account":"..."|"serial":"0x.."}. Disconnects the target's live sessions.
|
||||||
|
async fn admin_kick(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
admin_call(&st, "admin.kick", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"actor":"...","account":"...","durationSec":<opt>,"reason":<opt>}. 0/absent = indefinite.
|
||||||
|
async fn admin_ban(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
admin_call(&st, "admin.ban", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"actor":"...","account":"..."}.
|
||||||
|
async fn admin_unban(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
admin_call(&st, "admin.unban", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"actor":"...","text":"...","hue":<opt>}. Announces a system message to everyone online.
|
||||||
|
async fn admin_broadcast(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
admin_call(&st, "admin.broadcast", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- help-page queue handlers ----
|
||||||
|
|
||||||
|
/// The open help-page queue, correlated on reqId. Returns a pages.list.
|
||||||
|
async fn pages_list(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({"kind": "pages.snapshot", "reqId": req_id});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"message":"...","close":<bool, optional>}. Delivers a staff response to the player.
|
||||||
|
async fn page_respond(
|
||||||
|
State(st): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let message = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if message.trim().is_empty() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "message is required"})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let close = body.get("close").and_then(|c| c.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({
|
||||||
|
"kind": "page.respond", "reqId": req_id,
|
||||||
|
"pageId": id, "message": message, "close": close
|
||||||
|
});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a page from the queue.
|
||||||
|
async fn page_close(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({"kind": "page.close", "reqId": req_id, "pageId": id});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- query handlers ----
|
// ---- query handlers ----
|
||||||
|
|
||||||
async fn char_by_slot(
|
async fn char_by_slot(
|
||||||
@@ -297,7 +594,10 @@ async fn vendors(State(st): State<AppState>, Path(account): Path<String>) -> imp
|
|||||||
|
|
||||||
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
|
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
|
||||||
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
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
|
let web_id = body
|
||||||
.get("websiteUserId")
|
.get("websiteUserId")
|
||||||
.and_then(|w| w.as_str())
|
.and_then(|w| w.as_str())
|
||||||
@@ -359,14 +659,55 @@ async fn towncrier_add(State(st): State<AppState>, Json(body): Json<Value>) -> i
|
|||||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn towncrier_remove(
|
async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||||
State(st): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let cmd = json!({"kind":"towncrier.remove","id":id});
|
let cmd = json!({"kind":"towncrier.remove","id":id});
|
||||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Body: {"id":"42","title":"...","body":"<html>","image":1614,"url":"...","announce":true}.
|
||||||
|
/// Adds/replaces a Town Cryer news article. Correlated on `id`. A success is stored so the sidecar
|
||||||
|
/// can replay the article to the shard on reconnect (NewsEntries is not persisted across a reboot).
|
||||||
|
async fn news_add(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
let id = body.get("id").and_then(|i| i.as_str()).unwrap_or_default();
|
||||||
|
let title_ok = body
|
||||||
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if id.is_empty() || !title_ok {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "id and title are required"})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = body.clone();
|
||||||
|
cmd["kind"] = json!("news.add");
|
||||||
|
let id = id.to_string();
|
||||||
|
let result = st.rpc.call(&st.shard, cmd.clone(), &id).await;
|
||||||
|
|
||||||
|
// Persist the article (as its news.add command) so it can be replayed on shard reconnect.
|
||||||
|
if let Ok(value) = &result {
|
||||||
|
if value.get("kind").and_then(|k| k.as_str()) == Some("news.ok") {
|
||||||
|
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let _ = st.store.upsert_news(&id, &cmd.to_string(), t).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn news_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||||
|
let cmd = json!({"kind":"news.remove","id":id});
|
||||||
|
let result = st.rpc.call(&st.shard, cmd, &id).await;
|
||||||
|
|
||||||
|
if let Ok(value) = &result {
|
||||||
|
if value.get("kind").and_then(|k| k.as_str()) == Some("news.ok") {
|
||||||
|
let _ = st.store.delete_news(&id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(result)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- history (from SQLite) ----
|
// ---- history (from SQLite) ----
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -399,6 +740,76 @@ 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()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The guild board: every guild's latest roster snapshot (id/name/abbr/leader/members/alliance).
|
||||||
|
/// Served from the local board table, so it hydrates a fresh page or a restarted sidecar without a
|
||||||
|
/// shard round-trip. The live `guild.*` feed then keeps it current.
|
||||||
|
async fn guilds(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.guilds_all().await {
|
||||||
|
Ok(guilds) => (StatusCode::OK, Json(json!({"guilds": guilds}))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The governor board: each city's latest governance snapshot (governor/elect/election phase).
|
||||||
|
/// Served from the local board table for the same reason as `/guilds`.
|
||||||
|
async fn governors(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.governors_all().await {
|
||||||
|
Ok(cities) => (StatusCode::OK, Json(json!({"cities": cities}))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The house registry: every house's latest snapshot (owner/region/location/decay/value). Served
|
||||||
|
/// from the local board table, so it hydrates without the shard and survives an outage.
|
||||||
|
async fn houses(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.houses_all().await {
|
||||||
|
Ok(houses) => (StatusCode::OK, Json(json!({"houses": houses}))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current online population: total plus per-facet and per-region counts. This is the most
|
||||||
|
/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the
|
||||||
|
/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the
|
||||||
|
/// population time series. Returns `count: 0` if the shard has not reported one yet.
|
||||||
|
async fn online(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.recent(Some("presence.online"), 1).await {
|
||||||
|
Ok(mut events) => match events.pop() {
|
||||||
|
Some(latest) => (StatusCode::OK, Json(latest)),
|
||||||
|
None => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({"kind": "presence.online", "count": 0, "byFacet": {}, "byRegion": {}})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- websocket ----
|
// ---- websocket ----
|
||||||
|
|
||||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
|||||||
71
tools/scaffolding/BridgeAuditProbe.cs
Normal file
71
tools/scaffolding/BridgeAuditProbe.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the in-game moderation-audit forwarding (BridgeModerationAudit) without a game
|
||||||
|
/// client, so the CommandLogging.OnWrite patch and the admin.audit normalizer can be verified
|
||||||
|
/// end-to-end from a stub sidecar.
|
||||||
|
///
|
||||||
|
/// - Broadcast is a *genuine* trigger: CommandSystem.Handle runs [bcast, which raises
|
||||||
|
/// EventSink.Command exactly as a staff keystroke would.
|
||||||
|
/// - Ban/kick can't complete headlessly (they arm a target cursor with no client to click),
|
||||||
|
/// so we call CommandLogging.WriteLine with the stock KickCommand line format — the same
|
||||||
|
/// call that command makes at Commands.cs:1211, which is the point we tap.
|
||||||
|
/// - A non-moderation log line confirms the normalizer ignores everything else.
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Never deployed. Gated behind Bridge.AuditProbeOnStart (absent in a
|
||||||
|
/// shipped Bridge.cfg, so Config.Get returns false and it never runs in production).
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeAuditProbe
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (Config.Get("Bridge.AuditProbeOnStart", false))
|
||||||
|
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var staffAcct = Accounting.Accounts.GetAccount("whitlocktech") as Account;
|
||||||
|
var targetAcct = Accounting.Accounts.GetAccount("seed_010") as Account;
|
||||||
|
|
||||||
|
var from = staffAcct == null ? null : staffAcct[0];
|
||||||
|
var target = targetAcct == null ? null : targetAcct[0];
|
||||||
|
|
||||||
|
if (from == null || target == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[AuditProbe] need whitlocktech + seed_010 chars; seed the world first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] genuine broadcast via [bcast ...");
|
||||||
|
CommandSystem.Handle(from, CommandSystem.Prefix + "bcast in-game audit probe");
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] simulating a resolved ban log line ...");
|
||||||
|
CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
|
||||||
|
from.AccessLevel, CommandLogging.Format(from), "banning", CommandLogging.Format(target));
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] simulating a resolved kick log line ...");
|
||||||
|
CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
|
||||||
|
from.AccessLevel, CommandLogging.Format(from), "kicking", CommandLogging.Format(target));
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] a non-moderation line (should be ignored) ...");
|
||||||
|
CommandLogging.WriteLine(from, "{0} {1} used command '{2}'",
|
||||||
|
from.AccessLevel, CommandLogging.Format(from), "Go 1 1 0");
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] done");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[AuditProbe] FAILED: " + ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
tools/scaffolding/BridgePageProbe.cs
Normal file
61
tools/scaffolding/BridgePageProbe.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Engines.Help;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Puts a couple of genuine PageEntry tickets into the help-page queue so BridgePages
|
||||||
|
/// (poll/stream + snapshot + respond/close) can be verified without a game client.
|
||||||
|
///
|
||||||
|
/// The enqueue is real (PageQueue.Enqueue). The only accommodation for the missing client:
|
||||||
|
/// each entry's InternalTimer would remove the page on its first tick because the sender has
|
||||||
|
/// no NetState (PageQueue.cs:167 treats "no NetState" as a logout), so we call PageEntry.Stop
|
||||||
|
/// to keep the ticket in the queue for the test. Everything the bridge does — detect, snapshot,
|
||||||
|
/// respond, close — then operates on real queue entries.
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Never deployed. Gated behind Bridge.PageProbeOnStart.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgePageProbe
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (Config.Get("Bridge.PageProbeOnStart", false))
|
||||||
|
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Enqueue("seed_030", "My quest is stuck, please help.", PageType.Stuck);
|
||||||
|
Enqueue("seed_031", "Found a bug with a player vendor.", PageType.Bug);
|
||||||
|
Console.WriteLine("[PageProbe] done");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[PageProbe] FAILED: " + ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Enqueue(string account, string message, PageType type)
|
||||||
|
{
|
||||||
|
var acct = Accounting.Accounts.GetAccount(account) as Account;
|
||||||
|
var sender = acct == null ? null : acct[0];
|
||||||
|
|
||||||
|
if (sender == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[PageProbe] {0} has no character in slot 0; seed the world first", account);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new PageEntry(sender, message, type);
|
||||||
|
PageQueue.Enqueue(entry);
|
||||||
|
entry.Stop(); // keep it in the queue despite the offline sender
|
||||||
|
|
||||||
|
Console.WriteLine("[PageProbe] enqueued {0} page for {1} (0x{2:X})",
|
||||||
|
type, account, sender.Serial.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
|
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
|
||||||
|
|
||||||
These two scripts produced the measured budget in `docs/PLAN.md` §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
|
These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
|
||||||
|
|
||||||
| File | Server path when testing | What |
|
| File | Server path when testing | What |
|
||||||
|------|--------------------------|------|
|
|------|--------------------------|------|
|
||||||
|
|||||||
78
tools/stub_sidecar_admin.ps1
Normal file
78
tools/stub_sidecar_admin.ps1
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
param(
|
||||||
|
[int] $Port = 7788,
|
||||||
|
[string] $Log = "$PSScriptRoot\sc_admin.log"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase-1 admin write-plane harness. Connects as the sidecar, waits for the shard,
|
||||||
|
# fires admin.* commands covering the happy paths and every guard, logs the replies.
|
||||||
|
# Requires Bridge.cfg AdminWriteEnabled=true and the seeded world (seed_00x accounts).
|
||||||
|
|
||||||
|
function Say($msg) {
|
||||||
|
for ($i = 0; $i -lt 5; $i++) {
|
||||||
|
try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
|
||||||
|
catch { Start-Sleep -Milliseconds 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"" | Out-File -FilePath $Log -Encoding utf8
|
||||||
|
Say "[admin] starting on 127.0.0.1:$Port"
|
||||||
|
|
||||||
|
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
|
||||||
|
$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
|
||||||
|
|
||||||
|
$bound = $false
|
||||||
|
for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
|
||||||
|
try { $listener.Start(); $bound = $true }
|
||||||
|
catch { Start-Sleep -Seconds 1 }
|
||||||
|
}
|
||||||
|
if (-not $bound) { Say "[admin] could not bind"; exit 1 }
|
||||||
|
|
||||||
|
Say "[admin] listening"
|
||||||
|
$client = $listener.AcceptTcpClient()
|
||||||
|
Say "[admin] === shard connected ==="
|
||||||
|
|
||||||
|
$stream = $client.GetStream()
|
||||||
|
$reader = New-Object System.IO.StreamReader($stream)
|
||||||
|
$writer = New-Object System.IO.StreamWriter($stream)
|
||||||
|
$writer.AutoFlush = $true
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
$requests = @(
|
||||||
|
# happy path, no target needed
|
||||||
|
'{"kind":"admin.broadcast","reqId":"a-bcast","actor":"whitlocktech","text":"uo-link admin test broadcast"}',
|
||||||
|
# ban an offline seed account (timed), then unban
|
||||||
|
'{"kind":"admin.ban","reqId":"a-ban","actor":"whitlocktech","account":"seed_001","durationSec":3600,"reason":"harness test"}',
|
||||||
|
'{"kind":"admin.unban","reqId":"a-unban","actor":"whitlocktech","account":"seed_001"}',
|
||||||
|
# kick an offline account -> should succeed with sessions:0
|
||||||
|
'{"kind":"admin.kick","reqId":"a-kick","actor":"whitlocktech","account":"seed_002"}',
|
||||||
|
# floor: whitlocktech is Owner -> must be refused
|
||||||
|
'{"kind":"admin.ban","reqId":"a-floor","actor":"whitlocktech","account":"whitlocktech"}',
|
||||||
|
# unknown target
|
||||||
|
'{"kind":"admin.ban","reqId":"a-unknown","actor":"whitlocktech","account":"does_not_exist"}',
|
||||||
|
# missing actor -> refused by the shared gate
|
||||||
|
'{"kind":"admin.ban","reqId":"a-noactor","account":"seed_003"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($r in $requests) {
|
||||||
|
$writer.WriteLine($r)
|
||||||
|
Say "[admin] -> $r"
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
|
||||||
|
# Drain greedily: block on ReadLine with an idle timeout so a buffered burst is fully read
|
||||||
|
# (the DataAvailable-gated pattern drops the tail of a burst that a StreamReader pre-buffers).
|
||||||
|
$stream.ReadTimeout = 2500
|
||||||
|
try {
|
||||||
|
while ($true) {
|
||||||
|
$line = $reader.ReadLine()
|
||||||
|
if ($null -eq $line) { break }
|
||||||
|
Say "[admin] <- $line"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Say "[admin] read window closed (idle)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Say "[admin] done"
|
||||||
|
$client.Close()
|
||||||
|
$listener.Stop()
|
||||||
Reference in New Issue
Block a user