Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 UOM/link):
|
||||
# REGISTRY_USER — Gitea username the token below belongs to
|
||||
# REGISTRY_TOKEN — Gitea access token. For image builds it needed
|
||||
# write:package; THIS workflow additionally needs
|
||||
# `write:repository` so it can push the bump commit + tag
|
||||
# and create the release. Grant that scope to the token.
|
||||
# Also: `main` must accept a direct push from that user (disable branch
|
||||
# protection for it, or add it as an exception) — the bump commit lands on main.
|
||||
#
|
||||
# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
|
||||
|
||||
name: Release sidecar
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: release-sidecar
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: UOM/link
|
||||
WORKDIR: sidecar
|
||||
BIN: uo-link-sidecar
|
||||
LINUX_TARGET: x86_64-unknown-linux-gnu
|
||||
WINDOWS_TARGET: x86_64-pc-windows-gnu
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
|
||||
# Quoted because the expression contains a colon (`chore(release):`), which an
|
||||
# unquoted YAML scalar would misparse as a mapping value.
|
||||
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
|
||||
steps:
|
||||
- name: Check out full history (need tags + commit log for the bump)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
|
||||
- name: Plan the release (version + changelog)
|
||||
id: plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
CARGO_VERSION="$(grep -m1 '^version' "${WORKDIR}/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
|
||||
|
||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||
|
||||
BUMP=none
|
||||
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
|
||||
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
|
||||
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
|
||||
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
|
||||
|
||||
bump() { # <x.y.z> <major|minor|patch> -> bumped
|
||||
IFS=. read -r MA MI PA <<< "$1"
|
||||
case "$2" in
|
||||
major) echo "$((MA+1)).0.0" ;;
|
||||
minor) echo "${MA}.$((MI+1)).0" ;;
|
||||
patch) echo "${MA}.${MI}.$((PA+1))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
RELEASE=true
|
||||
if [ -z "$LAST_TAG" ]; then
|
||||
VERSION="$CARGO_VERSION" # first release: ship what's in Cargo.toml
|
||||
elif [ "$BUMP" = none ]; then
|
||||
RELEASE=false # no feat/fix/breaking since last tag
|
||||
VERSION="${LAST_TAG#v}"
|
||||
else
|
||||
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
echo "Tag v${VERSION} already exists — nothing to release."
|
||||
RELEASE=false
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## ${BIN} v${VERSION}"
|
||||
echo
|
||||
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
||||
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||
echo "### All changes"
|
||||
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||
echo "$SUBJECTS" | sed 's/^/- /'
|
||||
} > dist/CHANGELOG.md
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
|
||||
|
||||
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
|
||||
- name: Install Rust toolchain, Windows target, and MinGW linker
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal --default-toolchain stable
|
||||
fi
|
||||
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
rustup component add rustfmt
|
||||
rustup target add "${WINDOWS_TARGET}"
|
||||
|
||||
- name: Set the crate version to match the release
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
# Replace only the [package] version (the first `version = "..."`).
|
||||
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml"
|
||||
grep -m1 '^version' "${WORKDIR}/Cargo.toml"
|
||||
# Bumping the manifest version desyncs this crate's own entry in
|
||||
# Cargo.lock, which would make the `--locked` fmt/test/build steps below
|
||||
# fail ("cannot update the lock file ... --locked was passed"). Sync just
|
||||
# the workspace member(s) into the lock — dependency pins are untouched.
|
||||
cargo update --manifest-path "${WORKDIR}/Cargo.toml" --workspace
|
||||
|
||||
# ── RUST ADAPTER: gates ──────────────────────────────────────────────
|
||||
- name: cargo fmt --check
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
working-directory: sidecar
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: cargo test
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
working-directory: sidecar
|
||||
run: cargo test --locked
|
||||
|
||||
# ── RUST ADAPTER: build both targets ─────────────────────────────────
|
||||
- name: cargo build --release (Linux)
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
working-directory: sidecar
|
||||
run: cargo build --release --locked --target "${LINUX_TARGET}"
|
||||
|
||||
- name: cargo build --release (Windows, cross via MinGW)
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
working-directory: sidecar
|
||||
env:
|
||||
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
|
||||
CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
|
||||
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
|
||||
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
|
||||
|
||||
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
|
||||
- name: Package artifacts and SHA256SUMS
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
|
||||
cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
|
||||
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
|
||||
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
||||
|
||||
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
|
||||
- name: Commit version bump and push tag
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
# Secrets can arrive with a trailing newline (depending on how they were
|
||||
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
|
||||
# be parsed"). Strip line breaks before building the URL. Passing them via
|
||||
# env (not inline ${{ }}) also keeps a newline from breaking this script.
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
git config user.name "uo-link-ci"
|
||||
git config user.email "ci@whitlocktech.com"
|
||||
git remote set-url origin \
|
||||
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||
|
||||
git add "${WORKDIR}/Cargo.toml" "${WORKDIR}/Cargo.lock"
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
|
||||
git push origin "HEAD:main"
|
||||
else
|
||||
echo "Version unchanged (first release) — no bump commit needed."
|
||||
fi
|
||||
git tag "${TAG}"
|
||||
git push origin "${TAG}"
|
||||
|
||||
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
|
||||
- name: Create Gitea release and upload assets
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
BODY="$(cat dist/CHANGELOG.md)"
|
||||
# Same newline hygiene as the push step: a stray CR/LF in the token would
|
||||
# corrupt the Authorization header.
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ obj/
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
*.log
|
||||
|
||||
308
docs/ADMIN_CONTROLS.md
Normal file
308
docs/ADMIN_CONTROLS.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Administrative Controls — Research & Integration Plan
|
||||
|
||||
**Status:** Research + design. No code written yet.
|
||||
**Date:** 2026-07-12
|
||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
||||
**Companion to** [`PLAN.md`](PLAN.md) (the read/event plane) and [`INTEGRATION.md`](INTEGRATION.md) (the website API). This document covers the **write plane**: staff actions the website should be able to take against the live shard.
|
||||
|
||||
---
|
||||
|
||||
## 1. The question
|
||||
|
||||
The bridge today is almost entirely *outbound*. It streams events and answers read queries. Its entire inbound (website → shard) surface is three verbs:
|
||||
|
||||
| Verb | File | What it does |
|
||||
|------|------|--------------|
|
||||
| `ping` | `BridgeBoot.cs:139` | Liveness echo. |
|
||||
| `link.confirm` | `BridgeAccountLink.cs` | Ties a game account to a website user. |
|
||||
| `towncrier.add` / `towncrier.remove` | `BridgeTownCrier.cs` | Publishes news to the in-game criers. |
|
||||
|
||||
None of these are *moderation*. A staff member who wants to kick a cheater, ban an account, answer a help page, or teleport a stuck player still has to be logged into the game client. This document surveys what in-game administrative controls exist, decides which are worth exposing over the bridge, and specifies the protocol and safety model for doing it.
|
||||
|
||||
**The thesis up front:** a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation (`[add`, `[set`, `[dupe`, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form.
|
||||
|
||||
---
|
||||
|
||||
## 2. How ServUO admin controls actually work
|
||||
|
||||
Four mechanisms, all of which the bridge must respect or reuse.
|
||||
|
||||
### 2.1 The AccessLevel ladder
|
||||
|
||||
`Server/Mobile.cs:431`:
|
||||
|
||||
```
|
||||
Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer, Administrator, Developer, CoOwner, Owner
|
||||
```
|
||||
|
||||
Every command is gated on a minimum level (`CommandSystem.Register(name, level, handler)`). This ladder is the shard's whole authorization model. **The bridge has no Mobile and therefore no natural place on this ladder** — see §5, the attribution problem.
|
||||
|
||||
### 2.2 The command system
|
||||
|
||||
Two registration styles:
|
||||
|
||||
- **Simple commands** — `CommandSystem.Register("Save", AccessLevel.Administrator, handler)`. The bridge already uses this for `[bridge` (`BridgeBoot.cs:44`, Administrator-gated).
|
||||
- **Generic/target commands** — `BaseCommand` subclasses in `Commands/Generic/Commands/Commands.cs`, registered as objects (`KillCommand`, `KickCommand`, `FirewallCommand`, …). These are built to be *targeted* in-game (click a mobile). Their **logic** is reusable from the bridge; their **targeting/gump plumbing** is not.
|
||||
|
||||
### 2.3 Command logging (the existing audit trail)
|
||||
|
||||
Staff actions call `CommandLogging.WriteLine(from, ...)`, which writes `Logs/Commands/*.log` **and** is the source of the bridge's own `audit.command` / `audit.set` events (`INTEGRATION.md` §4). Any web-initiated action **must** feed this same trail, or the in-game audit log develops blind spots exactly where remote power is exercised.
|
||||
|
||||
### 2.4 Account model (the moderation state)
|
||||
|
||||
`Scripts/Accounting/Account.cs`. The durable, offline-capable levers live here:
|
||||
|
||||
| Lever | API | Notes |
|
||||
|-------|-----|-------|
|
||||
| Ban (indefinite) | `acct.Banned = true; acct.SetUnspecifiedBan(from)` | `Account.cs:440`, `:1098` |
|
||||
| Ban (timed) | `acct.SetBanTags(from, DateTime.UtcNow, TimeSpan)` then `acct.Banned = true` | `:1103`; `Banned` getter auto-clears when the window lapses (`:454`) |
|
||||
| Unban | `acct.Banned = false; acct.SetUnspecifiedBan(null)` | clears the tags |
|
||||
| Read ban | `acct.GetBanTags(out when, out dur)` | `:1133` |
|
||||
| Staff level | `acct.AccessLevel = …` | `:557` — promotes/demotes a whole account |
|
||||
| Young status | `acct.Young` | `:471` |
|
||||
|
||||
Account-level state persists and applies whether or not the player is online. Per-*mobile* state (below) generally requires the target resident.
|
||||
|
||||
---
|
||||
|
||||
## 3. Candidate controls
|
||||
|
||||
Grouped by subsystem. **Tier**: **A** = wire in first, **B** = second wave, **N** = never expose remotely. **~~H~~ = excluded.** The former "hold" items (firewall, kill/res, jail, item/gold grants, set-access-level) were reviewed and **cut from the roadmap entirely** per the 2026-07-12 decision — their rows are kept below for the record but will **not** be built. The write plane is deliberately account/session moderation + support, nothing that manipulates the world or the object graph.
|
||||
|
||||
### 3.1 Session control (target online)
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Kick** | `[Kick` → `KickCommand`, `Commands.cs:1170` | `targ.NetState?.Dispose()` | **A** | Pure disconnect. Reversible (they reconnect). Lowest blast radius of any real moderation action. |
|
||||
| **Firewall (IP block)** | `[Firewall`, `Commands.cs:1125` | `Firewall.Add(state.Address)` | **H** | Blocks an IP, not an account. Collateral damage on shared IPs/CGNAT; hard to reverse from the same UI. Powerful but sharp. |
|
||||
| **Locate / who** | `[Where`, `[Client` | already have `char.vitals`/`mob.login` | — | Effectively already covered by the event plane. |
|
||||
|
||||
### 3.2 Account moderation (works offline)
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Ban (indefinite)** | `[Ban` → `KickCommand(ban:true)`, `Commands.cs:1225` | `Banned=true; SetUnspecifiedBan` + kick live sessions | **A** | The headline verb. Note the in-game path *also* opens `BanDurationGump` — we replace that with an explicit duration in the request. |
|
||||
| **Ban (timed)** | (gump) | `SetBanTags(actor, now, dur); Banned=true` | **A** | Duration in the request body; auto-expires. |
|
||||
| **Unban** | property edit | `Banned=false; SetUnspecifiedBan(null)` | **A** | |
|
||||
| **Mute / squelch** | property `Squelched` | `mob.Squelched = true` (`Mobile.cs:5807`) | **B** | Per-**character**, not per-account. **Persists** across relog + restart (serialized, `Mobile.cs:6489`/`:6013`); works on offline chars too. Mute an account = squelch each resident character (§7.1). |
|
||||
| **Page-mute** | `PagingSquelched` | set on `PlayerMobile` | **B** | Stops help-page spam without a full mute. |
|
||||
| **Set access level** | property `AccessLevel` | `acct.AccessLevel = …` | **H** | Promoting staff from a web UI is a serious privilege path. Gate hard, or omit. |
|
||||
| **Comments / notes** | account comments | `acct.Comments` | **B** | A staff notes field — pairs naturally with a web moderation panel. |
|
||||
|
||||
### 3.3 Player actions (target online)
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Kill / Resurrect** | `[Kill` / `[Res`, `Commands.cs:966` | `mob.Kill()` / `mob.Resurrect()` | **H** | Legitimate for stuck/exploit cleanup; also the most "griefable" verb if the web authz ever leaks. |
|
||||
| **Teleport / Bring** | `[Go`, `[Move`, `[Tele` | `mob.MoveToWorld(p, map)` | **B** | "Bring to me" has no meaning without a staff mobile; "send to coordinates / named location" does. |
|
||||
| **Jail** | region only — `Regions/Jail.cs`, **no stock command** | custom: move to jail point (+ flag) | **H** | Needs us to *build* the action (pick a jail location, decide on release). Region exists; the verb does not. |
|
||||
| **Hide / Unhide** | `[Hide`, `Commands.cs:1066` | `mob.Hidden = bool` | **N** | No remote use case. |
|
||||
| **Set/Get property** | `[Set` / `[Get` / `[Props` | reflection | **N** | Arbitrary property writes = arbitrary power. Keep in-client. |
|
||||
| **Give item / gold** | `[Add`, `Bank` | construct + place | **H** | Compensation flows are real but this is a duplication/economy risk; if wanted, expose *specific* curated grants, never `[add` by type. |
|
||||
|
||||
### 3.4 Support: the help-page queue ★
|
||||
|
||||
`Scripts/Services/Help/PageQueue.cs`. When a player uses the in-game Help button they create a `PageEntry` (`Bug`, `Stuck`, `Account`, `Question`, `Suggestion`, `Harassment`, …) carrying **sender, message, type, location/map, timestamp, and assigned handler**. `PageQueue.List` is the live queue; `PageQueue.Enqueue/Remove` mutate it; a staff reply reaches the player via `ResponseEntry` → `MessageSentGump`.
|
||||
|
||||
This is the single **best** tie-in and deserves its own slice of work:
|
||||
|
||||
- **Stream** new pages as a `page.new` event and removals as `page.closed`.
|
||||
- **Snapshot** the open queue over REST (`GET /pages`).
|
||||
- **Respond** from the website (`POST /pages/{id}/respond`) → delivers a message to the player in-game, exactly like a staff member typing a response.
|
||||
- **Close / assign** a page.
|
||||
|
||||
It turns "a staff member must be logged into the game to see the queue" into "the queue is a page on the site." Tier **A**, but scoped as its own phase (§6, Phase 2) because it is read+write+stream, not a single verb.
|
||||
|
||||
### 3.5 Broadcast & messaging
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Server broadcast** | `[BCast`, `Handlers.cs` | `World.Broadcast(hue, ascii, text)` | **A** | Overlaps town-crier but different UX (instant system message vs. crier loop). Cheap, high-value. |
|
||||
| **Staff message (SMsg)** | `[SMsg`, `Handlers.cs` | send to online staff | **B** | "Post to staff channel" from the site. |
|
||||
| **Tell / private msg** | `[Tell` | `mob.SendMessage` | **B** | Message one player from the web (e.g. auto-reply to a page). |
|
||||
|
||||
### 3.6 World / server operations
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Save** | `[Save`, `Handlers.cs` (Administrator) | `AutoSave.Save()` | **B** | Trigger a world save from a deploy/admin panel. Emits `world.save.*` we already stream. |
|
||||
| **Background save** | `[BGSave` | | **B** | Non-blocking variant. |
|
||||
| **Shutdown / restart** | console | process-level | **N** | Do this at the process/host layer, not through a game plugin. |
|
||||
| **Freeze / Wipe / DecorateDelete / TelGen** | various | — | **N** | Destructive world-building. In-client only. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Roadmap (decided)
|
||||
|
||||
> **Build status (2026-07-13):** Phase 1 is **built and live-verified end-to-end**, branch `feature/admin-controls`.
|
||||
> - *Plugin* (`BridgeAdmin.cs` + config): all four verbs, `web:<actor>` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner was refused) confirmed against a booted ServUO.
|
||||
> - *Sidecar* (`sidecar/src/web.rs`): `POST /admin/{kick,ban,unban,broadcast}` routes with the status mapping in §6. Verified with the real sidecar + shard: 200 on success, **403** on the Owner floor, **404** unknown target, **400** missing actor, **401** no token.
|
||||
> - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event.
|
||||
> - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored.
|
||||
>
|
||||
> **Phase 2 — help-page queue: built and live-verified.** `BridgePages.cs` polls the queue (`PageSweepSeconds`, default 5s) → `page.new`/`page.updated`/`page.closed`; inbound `pages.snapshot`/`page.respond`/`page.close`; sidecar `GET /pages` + `POST /pages/{id}/respond|close`; `INTEGRATION.md` §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both `page.new` emits, respond, close (→ page removed), `page.closed` emit, and 404 on an unknown page.
|
||||
>
|
||||
> **Phase 1 + bidirectional audit + Phase 2 are complete — this is the shipped scope.** Phase 3 (below) is **not planned** (owner decision, 2026-07-13). Remaining work is downstream and website-side only: the admin/mod UI (moderation log + support-queue view).
|
||||
|
||||
**Wire in, in order:**
|
||||
|
||||
1. **Phase 1 — Account & session moderation (Tier A).** `admin.kick`, `admin.ban` (timed + indefinite), `admin.unban`, plus `admin.broadcast`. These are the actions a staff member most often wishes they could do from a phone. Ban/unban work offline and are the highest-value; kick and broadcast are trivial and safe.
|
||||
2. **Phase 2 — Help-page queue (Tier A, own phase).** Stream + snapshot + respond/close. The biggest single quality-of-life win, but it is a read/write/stream subsystem, not one verb.
|
||||
3. ~~**Phase 3 — Second wave (Tier B).** Mute/page-mute, account comments, teleport-to-location, staff message, manual save.~~ **Not planned** (owner decision, 2026-07-13). The Tier-B candidates catalogued in §3 stay documented for the record, but the shipped scope is Phase 1 + Phase 2.
|
||||
|
||||
Cross-cutting, lands alongside Phase 1: **bidirectional audit** — in-game use of any of these moderation verbs is forwarded to the website in the same shape as web-initiated ones, so the site has a complete moderation picture (§5.5).
|
||||
|
||||
**Excluded — will not be built:** firewall, set-access-level, kill/res, jail, item/gold grants (the former Tier H), and the Tier-N set — arbitrary `[set`/`[get`, `[add`, hide, freeze, wipe, decorate, shutdown. Sharp, privilege-escalating, or catastrophic; all stay in the game client.
|
||||
|
||||
---
|
||||
|
||||
## 5. Authorization & attribution (decided)
|
||||
|
||||
Every in-game moderation command carries a `Mobile from` — the staff member — used for two things the bridge has no natural source for:
|
||||
|
||||
1. **Audit** — `CommandLogging.WriteLine(from, …)` and the `SetBanTags(from, …)` "BanDealer" tag record *who did it*.
|
||||
2. **Authorization** — e.g. `KickCommand` refuses unless `from.AccessLevel > targ.AccessLevel` (`Commands.cs:1200`), so a GM can't ban an Admin.
|
||||
|
||||
The resolved model:
|
||||
|
||||
**Authorization lives on the website.** The website gates these commands behind its own **admin-only** roles (and moderator ability levels). The shard does not — cannot — re-derive per-user permission; it trusts the loopback socket + auth token exactly as it already trusts town-crier. The sidecar is the trust boundary.
|
||||
|
||||
**Sidecar commands carry `CoOwner`-level authority on the shard.** Because the website has already authenticated and authorized the staff user, an inbound `admin.*` is applied as if issued by a synthetic `CoOwner` — the second-highest rung (`Server/Mobile.cs:431`: only `Owner` is above it). This cleanly satisfies the `from.AccessLevel > targ.AccessLevel` guard for every ordinary target.
|
||||
|
||||
**The one shard-side floor: never touch the Owner.** Even at CoOwner authority, an `admin.*` command **refuses any target account whose `AccessLevel >= CoOwner`.** That is the whole defense-in-depth on the plugin side: a compromised or buggy sidecar can moderate players and staff below CoOwner, but can never ban, kick, or demote the Owner (or another CoOwner). *Note the consequence, plainly:* this is a permissive posture — it deliberately lets the web plane act on Administrator/Seer/GM-level accounts, on the assumption that reaching the web admin panel already means near-total trust. If that assumption ever weakens, raise the floor in `Bridge.cfg` (`AdminAccessFloor`).
|
||||
|
||||
**Attribution is an explicit `web:<actor>` string.** Every `admin.*` request carries a required `actor` field — the website username/id of the staff member. The shard:
|
||||
- logs it to the **server console** as `[Bridge][admin] web:<actor> <action> …`. *(Note, corrected during implementation: `CommandLogging.WriteLine` cannot be reused for web actions — it dereferences `from.NetState`/`from.Account`/`from.AccessLevel` (`Scripts/Commands/Logging.cs:93-103`) and there is no staff `Mobile`. So web actions do **not** land in `Logs/Commands/`; the console line plus the `admin.audit` stream plus the website's own log are their durable record. `Logs/Commands/` remains the record for **in-game** staff actions, which §5.5 forwards to the site — so the complete picture lives on the website, by design.)*
|
||||
- stores `web:<actor>` in the ban "BanDealer" tag (`SetBanTags` wants a `Mobile from`; we pass `null` for the Mobile and set the tag ourselves — no core edit),
|
||||
- echoes it back in an `admin.audit` event (§5.5) so the website's own moderation record and the game's audit agree.
|
||||
|
||||
**The website keeps its own durable record.** Independently of the shard, the website persists every moderation action to its own log (who/what/when/why), mirroring the existing admin-activity-log pattern. The shard's `CommandLogging` + `admin.audit` are the game-side truth; the website log is the site-side truth; §5.5 keeps them in sync in both directions.
|
||||
|
||||
### 5.5 Bidirectional audit — one moderation picture, both origins
|
||||
|
||||
The website must see moderation actions **whether they originate on the site or in the game client**, in one consistent schema. Two directions:
|
||||
|
||||
- **Web → game (already in the request path).** Each applied `admin.*` emits an unsolicited `admin.audit` broadcast frame to every connected dashboard, tagged `"origin":"web"`, `"actor":"web:<user>"`.
|
||||
- **Game → web (the "full picture" requirement).** When a staff member runs one of these same verbs *in the game client* — `[ban`, `[kick`, `[bcast`, a page-queue response, a mute — the plugin forwards it to the website as the **same** `admin.audit` shape, tagged `"origin":"in-game"`, `"actor":"<staff account/name>"`.
|
||||
|
||||
The raw hook already exists: `BridgeEvents.OnStaffCommand` subscribes to `EventSink.Command` and emits `audit.command` for every staff command (`BridgeEvents.cs:404`), and `OnStaffPropertySet` emits `audit.set`. Those stay as the low-level firehose. On top of them we add a **normalizer** that emits a structured `admin.audit` for the specific moderation verbs, so the website's moderation log has one shape to store, not a freeform command string to parse.
|
||||
|
||||
```json
|
||||
{ "kind": "admin.audit", "origin": "in-game", "action": "ban",
|
||||
"actor": "GreyBeard", "target": "griefer42", "reason": null,
|
||||
"durationSec": 604800, "t": 1783720195626 }
|
||||
```
|
||||
|
||||
**The dispatch path — traced and settled (no longer an open question).** `[ban` and `[kick` *are* registered directly in the command table: `SingleCommandImplementor.Register` calls `CommandSystem.Register(name, level, Redirect)` for each command name (`SingleCommandImplementor.cs:22`), so they sit in `m_Entries` and `EventSink.InvokeCommand(e)` fires for them (`Server/Commands.cs:259`). **So the existing `audit.command` hook already sees them** — the earlier worry that generic commands bypass `EventSink.Command` is wrong.
|
||||
|
||||
The genuine subtlety is *when* it fires and *with what*:
|
||||
|
||||
| Verb shape | Example | What `EventSink.Command` carries | Complete? |
|
||||
|------------|---------|----------------------------------|-----------|
|
||||
| Arg-bearing, no target | `[bcast Server down in 5` | verb **+ full args** | ✅ fully captured |
|
||||
| **Target-cursor** | `[ban` → click victim | verb only, **empty args** | ⚠️ **verb but not the victim** |
|
||||
|
||||
For target-cursor verbs, `Handle` runs `entry.Handler(e)` (→ `Redirect` → `Process` → `from.BeginTarget(...)`, which arms the cursor and returns) and *then* `InvokeCommand(e)` (`Commands.cs:255-259`). The event therefore fires the moment `[ban` is **typed**, before the staff clicks anyone. The resolved action — the actual target and `Account.Banned = true` — happens later inside `KickCommand.Execute`, which calls `CommandLogging.WriteLine(from, "… banning {target}")` **with** the victim (`Commands.cs:1211`).
|
||||
|
||||
**Conclusion:** the reliable choke point for a *resolved* in-game moderation action (verb **and** victim) is `CommandLogging.WriteLine` (`Scripts/Commands/Logging.cs:86`), which is where every command already records its outcome — but it has **no event to subscribe to** today. So the "full picture" needs one small hook:
|
||||
|
||||
- **Add a `WriteLine` event to `Scripts/Commands/Logging.cs`** (a 1-line `Action<Mobile,string>` raised in `WriteLine`). This is a stock file, so it ships as a **`patches/` diff** — the same mechanism Phase 7's `PlayerVendorSale` already established, and arguably the *correct* universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emits `admin.audit`.
|
||||
- Broadcasts and other arg-bearing simple commands need **no** patch — the existing `EventSink.Command` hook already carries their full payload; the normalizer just reshapes them.
|
||||
|
||||
---
|
||||
|
||||
## 6. Protocol design
|
||||
|
||||
Reuse the existing inbound machinery verbatim — `BridgeBoot.RegisterHandler(kind, handler)`, Core-thread dispatch via `Timer.DelayCall`, `reqId` echo, and `*.ok` / `*.error` replies — exactly as `BridgeRequests` and `BridgeTownCrier` already do. A new `BridgeAdmin.cs` registers the `admin.*` handlers.
|
||||
|
||||
### Request shape (website → sidecar → shard)
|
||||
|
||||
```json
|
||||
{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech",
|
||||
"account": "griefer42", "durationSec": 604800, "reason": "harassment" }
|
||||
```
|
||||
|
||||
- `reqId` — correlation id, echoed on the reply (as in `BridgeRequests`).
|
||||
- `actor` — **required.** The website staff user. Rejected if absent.
|
||||
- Target — `account` (offline-capable verbs) or `serial` (online mobiles), resolved with the same `ResolveSerial` / `Accounts.GetAccount` helpers `BridgeRequests` uses.
|
||||
- `reason` — recorded in the audit trail.
|
||||
|
||||
### Reply shape (shard → sidecar → website)
|
||||
|
||||
```json
|
||||
{ "kind": "admin.ok", "reqId": "a1b2", "action": "ban", "target": "griefer42" }
|
||||
{ "kind": "admin.error", "reqId": "a1b2", "reason": "target is staff; refused" }
|
||||
```
|
||||
|
||||
Map to REST like the rest of `INTEGRATION.md`: `admin.ok` → 200, unknown target → 404, floor-violation/`actor` missing → 403, malformed → 400.
|
||||
|
||||
### Audit event (shard → website, unsolicited)
|
||||
|
||||
Every applied `admin.*` also emits a broadcast audit frame so *all* connected dashboards see it, not just the caller — parallel to the existing `audit.command`, and (per §5.5) emitted for **in-game** uses of the same verbs too:
|
||||
|
||||
```json
|
||||
{ "kind": "admin.audit", "origin": "web", "action": "ban", "actor": "web:whitlocktech",
|
||||
"target": "griefer42", "reason": "harassment", "durationSec": 604800, "t": 1783720195626 }
|
||||
```
|
||||
|
||||
`origin` is `"web"` for sidecar-initiated actions or `"in-game"` for actions a staff member took in the game client.
|
||||
|
||||
### Verbs for Phase 1
|
||||
|
||||
| kind | target | required fields | shard action |
|
||||
|------|--------|-----------------|--------------|
|
||||
| `admin.kick` | `serial` or `account` | `actor` | dispose live NetState(s) |
|
||||
| `admin.ban` | `account` | `actor` (+ `durationSec` optional) | set ban tags/flag, then kick live sessions |
|
||||
| `admin.unban` | `account` | `actor` | clear ban |
|
||||
| `admin.broadcast` | — | `actor`, `text` (+ `hue`) | `World.Broadcast` |
|
||||
|
||||
Every one: enforce the **Owner floor** on the target (refuse `AccessLevel >= CoOwner`), apply on the Core thread as a synthetic CoOwner, `CommandLogging.WriteLine("web:<actor> …")`, emit `admin.audit` (`origin:"web"`), reply `admin.ok`/`admin.error`.
|
||||
|
||||
### Caps / defense-in-depth (mirroring town-crier)
|
||||
|
||||
- `actor` required and non-empty.
|
||||
- Target floor: refuse any target with `AccessLevel >= CoOwner` (`AdminAccessFloor` in `Bridge.cfg`, default `CoOwner` → only the Owner/CoOwners are shielded).
|
||||
- `reason` length cap; `durationSec` clamp (min/max); `broadcast` text length cap.
|
||||
- Master switch `AdminWriteEnabled` in `Bridge.cfg` (default **off**) so the whole write plane is opt-in per shard.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification log (all resolved)
|
||||
|
||||
All resolved by source inspection (ServUO checkout at `C:\Users\colby\Desktop\servuo`). No live-shard run was needed — every path below is unambiguous in the code, and a mute smoke-test would in any case require a real UO client to log in and speak.
|
||||
|
||||
1. **`Mobile.Squelched` persists — confirmed durable.** Serialized unconditionally (`Server/Mobile.cs:6489` write) and read back in the version ladder at case 9 (`:6013`), so it survives relog **and** a full server restart; no need to persist it ourselves. It gates `OnSaid` (`:7591` → *"You can not say anything, you have been muted."*). Two consequences for the plan: (a) it is **per-Mobile (per-character), not per-account** — "mute the account" means squelch each resident character; (b) it works on **offline** characters too, since logged-off mobiles stay resident in `World`. Phase 3 mute is therefore durable and offline-capable out of the box.
|
||||
2. **Kicking all sessions — settled.** Enumerate `NetState.Instances` (`Server/Network/NetState.cs:583`, a `ReadOnlyCollection<NetState>`), filter on `ns.Account == acct` (`:574`), and `Dispose()` each. This is **strictly better than walking the account's characters' `NetState`**: a client sitting at character-select has a `NetState` with an `Account` but *no* mobile, and only the `Instances` sweep catches it. `admin.kick` and the live-session cleanup in `admin.ban` both use this.
|
||||
3. **In-game capture of resolved bans/kicks (was the ★ risk).** Traced through the dispatch path — settled in §5.5. `[ban`/`[kick` *do* raise `EventSink.Command`, but at type-time without the target. The complete capture point is a **1-line event added to `Scripts/Commands/Logging.cs:86`**, shipped as a `patches/` diff. Broadcasts need no patch.
|
||||
4. **Ban attribution** — pass `null` for the `Mobile from` and set `web:<actor>` as the `BanDealer` tag ourselves. No core edit.
|
||||
5. **Broadcast + town-crier** — keep both; they differ (instant system line vs. looping crier) and both are cheap.
|
||||
6. **Access floor** — `CoOwner` (Owner-only shield). See §5.
|
||||
|
||||
**Nothing in §7 remains open — the plan is implementation-ready.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisions — locked 2026-07-12
|
||||
|
||||
- **Scope:** Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. **The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely** — not now, not later.
|
||||
- **Authorization:** enforced on the **website** (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as **CoOwner-level** authority, with a hard floor that refuses any target at `AccessLevel >= CoOwner` (Owner-only shield). Write plane defaults **off** in `Bridge.cfg`.
|
||||
- **Attribution:** `web:<actor>` in `CommandLogging` and the `BanDealer` tag; no core edits.
|
||||
- **Logging:** the **website keeps its own durable moderation record**; the plugin **forwards in-game uses** of these same verbs to the site as `admin.audit` (`origin:"in-game"`) so the picture is complete from both sides (§5.5).
|
||||
- **Help-page queue:** confirmed, lands as **Phase 2**.
|
||||
|
||||
---
|
||||
|
||||
## 9. Where the code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeAdmin.cs` | New. Registers `admin.*` handlers; the CoOwner-authority application + Owner floor; `web` `admin.audit` emission. Mirrors `BridgeTownCrier.cs` structure. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeEvents.cs` | Extend: normalize in-game moderation verbs into `admin.audit` (`origin:"in-game"`). Broadcasts reshape from the existing `EventSink.Command` hook; ban/kick subscribe to the new `CommandLogging` event (§5.5). |
|
||||
| `patches/commandlogging-event.patch` | New. Adds a 1-line `Action<Mobile,string>` event to `Scripts/Commands/Logging.cs:86` so resolved staff actions (verb **+ target**) are observable. Stock file → ships as a patch, per the Phase-7 precedent. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgePages.cs` | New (Phase 2). Streams/snapshots/answers the `PageQueue`. |
|
||||
| `overlay/Config/Bridge.cfg` | Add `AdminWriteEnabled` (default off), `AdminAccessFloor` (default `CoOwner`), and the caps. |
|
||||
| `sidecar/src/web.rs` | New REST routes (`POST /admin/*`, `/pages/*`) → inbound lines; map replies to status codes. |
|
||||
| `docs/INTEGRATION.md` | Document the new endpoints + the `admin.audit` / `page.*` events. |
|
||||
| *(website, separate repo)* | Admin/moderator-gated UI + a durable moderation log that records both its own actions and inbound `admin.audit` frames. |
|
||||
|
||||
The Phase-1 **verbs** need no core or stock edit — every web-initiated action is an existing script-layer API called from the new `BridgeAdmin.cs` overlay. The only non-overlay change is the **one-line `CommandLogging` event** (`patches/commandlogging-event.patch`), needed solely so *in-game* bans/kicks forward their resolved target to the website (§5.5); it reuses the Phase-7 `patches/` mechanism and touches nothing else.
|
||||
@@ -31,16 +31,18 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`.
|
||||
|
||||
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**:
|
||||
- Every response carries an **`X-UOLink-Version: 2`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
|
||||
- **Optionally**, send `X-UOLink-Version: 2` 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" }
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
|
||||
```
|
||||
|
||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
||||
|
||||
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
|
||||
|
||||
---
|
||||
|
||||
## 3. Health
|
||||
@@ -178,11 +180,140 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|
||||
| `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. |
|
||||
| `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:<user>"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. |
|
||||
|
||||
#### Account linking
|
||||
#### Account linking & provisioning
|
||||
| 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. |
|
||||
| `account.audit` | `origin`, `action`, `actor`, `target`, `websiteUserId` | A provisioning action was applied from the site (`origin:"web"`, `actor:"web:<user>"`). `action` is `create` or `unlink`; `target` is the account. Broadcast to every dashboard. **Never carries the password.** Emitted alongside the `account.ok` reply; see §6. |
|
||||
| `account.unlinked` | `origin`, `account`, `websiteUserId`, `char` | A player ran `[unlink` **in game** (`origin:"in-game"`), severing the tie themselves. Drop the link from any roster you cache and reconcile your own record. |
|
||||
|
||||
#### Help-page (support) queue
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). |
|
||||
| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). |
|
||||
| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). |
|
||||
|
||||
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
|
||||
|
||||
#### Champion spawns
|
||||
|
||||
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
|
||||
|
||||
| `category` | source | what it is |
|
||||
|------------|--------|-----------|
|
||||
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
|
||||
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
|
||||
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
|
||||
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
|
||||
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
|
||||
|
||||
`status` is one of:
|
||||
- **`active`** — running (or, for `sea`, the boss is alive).
|
||||
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
|
||||
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
|
||||
|
||||
Category-specific fields on `champ.update`:
|
||||
|
||||
| category | extra fields |
|
||||
|----------|--------------|
|
||||
| `champion` | `level` (0–16), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
|
||||
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
|
||||
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
|
||||
|
||||
```json
|
||||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
||||
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
|
||||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
|
||||
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
|
||||
|
||||
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
|
||||
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
|
||||
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
|
||||
```
|
||||
|
||||
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
|
||||
|
||||
#### Guilds (Protocol 2.0)
|
||||
|
||||
Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board.
|
||||
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. |
|
||||
| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
|
||||
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
|
||||
|
||||
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user.
|
||||
|
||||
```json
|
||||
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
|
||||
"online":3,"alliance":"Britannian Pact",
|
||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||||
"t":1752489280000}
|
||||
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
||||
"who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}
|
||||
```
|
||||
|
||||
Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events.
|
||||
|
||||
#### Town governors (Protocol 2.0)
|
||||
|
||||
In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set of cities is polled (`CitySweepSeconds`, default 300s); each city emits `city.update` (full-state upsert) only when its governor, governor-elect, or election phase changes. **No events at all unless the shard runs the City Loyalty system.**
|
||||
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `city.update` | `city`, `governor` (actor or null), `governorElect` (actor or null), `electionPhase`, `candidates`, `autoPickAt` (ISO-8601 UTC, when an election is ongoing) | A city's governance changed. Derive "the governor changed" by comparing to your stored board. |
|
||||
|
||||
`electionPhase` is one of `none` / `nominate` / `vote` / `pending`. Cities: Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia.
|
||||
|
||||
```json
|
||||
{"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
|
||||
"governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||||
"governorElect":null,"t":1752489280000}
|
||||
```
|
||||
|
||||
Render the current board from `GET /governors` (§6) on connect, then keep it live with these events.
|
||||
|
||||
#### Presence (Protocol 2.0)
|
||||
|
||||
Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, default 30s) and emitted **only when it changes**; region transitions arrive in real time.
|
||||
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `presence.online` | `count`, `byFacet` `{map: n}`, `byRegion` `{region: n}` | The current online population. Emitted when the count or any breakdown changes. `GET /online` gives the latest; `GET /history?kind=presence.online` the time series. |
|
||||
| `region.enter` | `from` (or null), `to` (or null), `map`, `who` (actor object) | A player crossed into a new named region. `from`/`to` are region names (`Wilderness` is unnamed). Cheap "who's where" feed. |
|
||||
|
||||
```json
|
||||
{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
|
||||
"byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000}
|
||||
{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca",
|
||||
"who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...}
|
||||
```
|
||||
|
||||
#### Houses (Protocol 2.0)
|
||||
|
||||
The house registry — one row per house, complementing the `house.decay` *transition* feed (§ above). Polled (`HousingSweepSeconds`, default 300s) and diffed like the other boards.
|
||||
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `house.update` | `serial`, `name`, `owner` (actor or null), `coOwners`, `friends`, `region`, `map`, `x`,`y`,`z`, `decay`, `price`, `builtOn`, `lastRefreshed` | A house's owner/region/decay/co-owners changed, or first sight this connection. `decay` is the level name (e.g. `LikeNew`). `price` is the placement value — **stock ServUO has no "for sale" flag**, so this is not a listing. |
|
||||
| `house.remove` | `serial` | The house was demolished or no longer exists. Drop the row. |
|
||||
|
||||
```json
|
||||
{"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil","decay":"LikeNew",
|
||||
"price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain",
|
||||
"owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
||||
"coOwners":2,"friends":5,"builtOn":"2026-01-02T00:00:00Z","lastRefreshed":"2026-07-10T00:00:00Z",
|
||||
"t":1752489280000}
|
||||
```
|
||||
|
||||
Render from `GET /houses` (§6) on connect, then keep live with these events.
|
||||
|
||||
---
|
||||
|
||||
@@ -214,7 +345,9 @@ Full character sheet: stats, all trained skills, worn equipment with flattened i
|
||||
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
|
||||
"weapon":{"minDamage":16,"maxDamage":18},
|
||||
"mods":{"WeaponDamage":50,"HitLightning":40} }
|
||||
]
|
||||
],
|
||||
"titles": { "selected": 0, "fameKarma": "Lord", "skill": "Grandmaster Swordsman",
|
||||
"reward": ["1154060", "The Bold"] }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -222,6 +355,7 @@ 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.
|
||||
- `titles` (Protocol 2.0): `selected` is the index into `reward` currently displayed (`-1` if none). `fameKarma`/`skill` are computed display titles, omitted when the character has none. `reward` entries may be a **cliloc number as a string** or a literal string — resolve numeric ones against your cliloc table, same as item names.
|
||||
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
|
||||
|
||||
### Account roster
|
||||
@@ -284,6 +418,48 @@ GET /link/{account}
|
||||
|
||||
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
|
||||
|
||||
### Create a game account (Protocol 2.0)
|
||||
|
||||
Provision a game account from your signup form and link it to the website user in one step. Requires a **v2** sidecar. Whether this is honored depends on the shard's signup mode (`website`/`hybrid` accept it; `game` refuses).
|
||||
|
||||
```
|
||||
POST /accounts/create
|
||||
{ "actor": "whitlocktech", "account": "bob", "password": "hunter2",
|
||||
"websiteUserId": "9931", "ip": "203.0.113.7" }
|
||||
```
|
||||
|
||||
- `actor` — the website user/staff id, recorded in the audit. Required.
|
||||
- `account`, `password` — the game-client credentials the player chose. The password is hashed on the shard and **never** appears in any reply, event, or log.
|
||||
- `websiteUserId` — the site user to auto-link.
|
||||
- `ip` — **the end user's browser IP**, which you read from your own request context (remote-addr, or a trusted `X-Forwarded-For`). The shard enforces its per-IP account cap with this, exactly as it does for in-game signups. The sidecar cannot see the browser's IP (it only sees your server), so you must send it.
|
||||
|
||||
Responses:
|
||||
|
||||
- Success → **200** `{"kind":"account.ok","action":"create","account":"bob","websiteUserId":"9931"}`. The account exists and is linked; subsequent `mob.login` events carry `webId`.
|
||||
- Name already taken → **409** `{"kind":"account.error","reason":"account already exists"}`.
|
||||
- Per-IP cap hit → **429** `{"kind":"account.error","reason":"ip account limit reached"}`.
|
||||
- Signups disabled for this mode → **403** `{"kind":"account.error","reason":"signups disabled for this mode"}`.
|
||||
- Missing browser IP (when the shard requires it) → **400** `{"kind":"account.error","reason":"client ip required"}`.
|
||||
- Bad username/password, or a missing field → **400**.
|
||||
|
||||
Abuse control beyond the per-IP cap (captcha, email verification, signup rate) is your site's responsibility.
|
||||
|
||||
### Unlink an account (Protocol 2.0)
|
||||
|
||||
Sever a game account's tie to its website user, from the site side. Requires a **v2** sidecar.
|
||||
|
||||
```
|
||||
DELETE /link/{account}
|
||||
{ "actor": "whitlocktech" }
|
||||
```
|
||||
|
||||
- Success → **200** `{"kind":"account.ok","action":"unlink","account":"bob"}`. The `WebsiteUserId` tag is cleared on the shard and the sidecar's link mirror is dropped, so attribution stops immediately.
|
||||
- Not linked → **404** `{"kind":"account.error","reason":"not linked"}`.
|
||||
- Protected staff account → **403** `{"kind":"account.error","reason":"target is protected staff; refused"}`.
|
||||
- Missing `actor` → **400**.
|
||||
|
||||
A player can also unlink themselves in game with `[unlink`; that emits an `account.unlinked` event (see §4) so you can reconcile your record.
|
||||
|
||||
### Publish / remove town-crier news
|
||||
|
||||
Push a message that every in-game town crier announces until it expires.
|
||||
@@ -301,6 +477,102 @@ DELETE /towncrier/{id}
|
||||
|
||||
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
|
||||
|
||||
### Publish / remove Town Cryer **news** (Protocol 2.1)
|
||||
|
||||
Distinct from the scrolling-crier lines above: this puts a full article — title, HTML body, image, and a "more info" URL — into the in-game **Town Cryer News gump**, and (by default) has the criers proclaim the **title** in-world.
|
||||
|
||||
```
|
||||
POST /news
|
||||
{ "id": "42", "title": "Double XP Weekend",
|
||||
"body": "<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.",
|
||||
"image": 1614, "url": "https://yoursite/news/42" }
|
||||
```
|
||||
→ **200** `{"kind":"news.ok","id":"42"}`. Re-posting the same `id` **replaces** the prior article in place.
|
||||
|
||||
- `id`, `title` required. `body` (HTML supported), `image` (a UO gump id; a neutral scroll if omitted), `url` (a browser button in the gump) optional.
|
||||
- `announce` defaults to **true** — the criers proclaim the title. Send `"announce": false` to post silently (e.g. a correction).
|
||||
|
||||
```
|
||||
DELETE /news/{id}
|
||||
```
|
||||
→ **200** `{"kind":"news.ok","id":"42"}`, or **404** `{"kind":"news.error","reason":"unknown id"}`.
|
||||
|
||||
Caps apply (title/body length, max active articles). The **website is the source of truth**: the shard rebuilds its news list on restart and does not persist yours, so the sidecar automatically re-pushes your articles (silently) whenever the shard reconnects. Stock ServUO news is left intact — your articles are tracked separately.
|
||||
|
||||
### Staff moderation — the write plane
|
||||
|
||||
Account and session moderation against the live shard. **These are privileged.** The sidecar does
|
||||
not model per-user roles — **your site must authenticate the staff user and check their permission
|
||||
before calling.** The shard trusts the loopback socket and applies each command with CoOwner-level
|
||||
authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner
|
||||
account) is refused (**403**). The whole plane is **opt-in on the shard** (`AdminWriteEnabled` in
|
||||
`Bridge.cfg`); when it's off, every call returns **403** `"admin write plane disabled"`.
|
||||
|
||||
Every request requires an **`actor`** — the website username/id of the staff member taking the
|
||||
action. It is recorded in the shard console log, the ban's `BanDealer` tag, and the `admin.audit`
|
||||
event, so actions are always attributable. A missing `actor` is **400**.
|
||||
|
||||
```
|
||||
POST /admin/kick { "actor":"jane", "account":"griefer42" } # or "serial":"0x2E0"
|
||||
POST /admin/ban { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
|
||||
POST /admin/unban { "actor":"jane", "account":"griefer42" }
|
||||
POST /admin/broadcast { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
|
||||
```
|
||||
|
||||
- **kick** — disconnects every live session of the target account (including one parked at
|
||||
character-select). Target by `account` or `serial`. Reply carries `sessions` (how many were cut).
|
||||
- **ban** — bans the account (works offline) and disconnects any live sessions. `durationSec > 0`
|
||||
is a timed ban that auto-expires; `0`/absent is indefinite. Clamped to the shard's
|
||||
`AdminBanMaxDurationSec`.
|
||||
- **unban** — clears the ban.
|
||||
- **broadcast** — a system message to everyone online. `hue` optional (default `53`, staff green).
|
||||
Length-capped by the shard.
|
||||
|
||||
Success → **200** with an `admin.ok`:
|
||||
|
||||
```json
|
||||
{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }
|
||||
```
|
||||
|
||||
Failure → an `admin.error` with a mapped status:
|
||||
|
||||
| Status | When |
|
||||
|--------|------|
|
||||
| 400 | missing `actor`, malformed body, or bad parameter |
|
||||
| 401 | missing/invalid auth token |
|
||||
| 403 | target is protected (at/above the floor), or the write plane is disabled on the shard |
|
||||
| 404 | unknown or accountless target |
|
||||
| 503 / 504 | shard not connected / didn't reply in time |
|
||||
|
||||
Each applied action also emits an unsolicited **`admin.audit`** frame on the WebSocket (§4) with
|
||||
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
|
||||
by staff in the game client surfaces the same way with `origin:"in-game"`.
|
||||
|
||||
### Help-page (support) queue
|
||||
|
||||
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
|
||||
roles, like the moderation endpoints above.
|
||||
|
||||
```
|
||||
GET /pages # the open queue, newest state
|
||||
POST /pages/{pageId}/respond { "message":"...", "close": false }
|
||||
POST /pages/{pageId}/close
|
||||
```
|
||||
|
||||
- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new`
|
||||
event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live
|
||||
with the `page.new` / `page.updated` / `page.closed` events.
|
||||
- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if
|
||||
they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass
|
||||
`"close": true` to resolve the page in the same call. → **200** `page.ok`.
|
||||
- **close** removes the page from the queue. → **200** `page.ok`.
|
||||
- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**.
|
||||
|
||||
```json
|
||||
POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true }
|
||||
→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true }
|
||||
```
|
||||
|
||||
### History (from the sidecar's database)
|
||||
|
||||
```
|
||||
@@ -318,6 +590,74 @@ GET /economy?limit=200
|
||||
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
||||
```
|
||||
|
||||
### Champion-spawn board
|
||||
|
||||
```
|
||||
GET /champs
|
||||
```
|
||||
|
||||
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
|
||||
|
||||
```
|
||||
GET /champs
|
||||
→ { "spawns": [
|
||||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
||||
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
|
||||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
|
||||
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
|
||||
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
|
||||
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
|
||||
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
|
||||
] }
|
||||
```
|
||||
|
||||
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
|
||||
|
||||
### Guild board (Protocol 2.0)
|
||||
|
||||
```
|
||||
GET /guilds
|
||||
→ { "guilds": [ {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
||||
"members":14,"online":3,"alliance":"Britannian Pact",
|
||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||||
"t":1752489280000}, ... ] }
|
||||
```
|
||||
|
||||
Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart.
|
||||
|
||||
### Governor board (Protocol 2.0)
|
||||
|
||||
```
|
||||
GET /governors
|
||||
→ { "cities": [ {"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
|
||||
"governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
||||
"governorElect":null,"t":1752489280000}, ... ] }
|
||||
```
|
||||
|
||||
Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city.
|
||||
|
||||
### Online population (Protocol 2.0)
|
||||
|
||||
```
|
||||
GET /online
|
||||
→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
|
||||
"byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000}
|
||||
```
|
||||
|
||||
The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`.
|
||||
|
||||
### House registry (Protocol 2.0)
|
||||
|
||||
```
|
||||
GET /houses
|
||||
→ { "houses": [ {"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil",
|
||||
"decay":"LikeNew","price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain",
|
||||
"owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
||||
"coOwners":2,"friends":5,"builtOn":"...","lastRefreshed":"...","t":...}, ... ] }
|
||||
```
|
||||
|
||||
Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the `house.*` stream (§4). Ordered by name. Survives a sidecar restart.
|
||||
|
||||
---
|
||||
|
||||
## 7. Status codes
|
||||
@@ -327,8 +667,9 @@ GET /economy?limit=200
|
||||
| 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) |
|
||||
| 404 | Not found (unknown account / character / id, or a not-linked account) |
|
||||
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
|
||||
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
|
||||
| 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 |
|
||||
@@ -342,7 +683,7 @@ GET /economy?limit=200
|
||||
A typical character page:
|
||||
|
||||
```js
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" };
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
|
||||
|
||||
// 1. render the roster
|
||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||
|
||||
548
docs/PROTOCOL_2.md
Normal file
548
docs/PROTOCOL_2.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# Protocol 2.0 — Provisioning & World-State Streams
|
||||
|
||||
**Status:** Parts A + B (phases 1–4) **built and smoke-tested live** on branch `feat/protocol2-account-provisioning` (2026-07-17) — booted ServUO + the real sidecar and exercised every endpoint (see §15). Part B phase 5 (Factions/VvV) deferred by owner decision.
|
||||
**Date:** 2026-07-17
|
||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
||||
**Companion to** [`PLAN.md`](PLAN.md) (read/event plane), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), and [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
||||
|
||||
Protocol 1.0 shipped the read/event plane, the request/reply plane, `[link` account linking, town-crier, the player-vendor-sale core edit, the admin write plane, and the help-page queue.
|
||||
|
||||
2.0 has **two scope areas**:
|
||||
|
||||
- **A — Account provisioning & unlinking (§1–§9).** The website can **create** accounts, **unlink** them, and the shard runs in one of three **signup modes** that decide which side may mint accounts. (1.0 could only *link* an account that already existed, and a link could never be undone.)
|
||||
- **B — Social & political world-state streams (§10–§11).** Guilds, town governors ("mayors"), factions/VvV, and player titles — the standings a website community page wants. §10 specs the requested streams; §11 is a menu of further integration points to pick from.
|
||||
|
||||
---
|
||||
|
||||
# Part A — Account provisioning & unlinking
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today, and the gap
|
||||
|
||||
| Capability | 1.0 | 2.0 |
|
||||
|------------|-----|-----|
|
||||
| Create account in-game (first-login auto-create) | ✔ `AccountHandler.cs:281` | unchanged |
|
||||
| Link an **existing** game account to a website user | ✔ `[link` → `link.confirm` | unchanged |
|
||||
| **Create** a game account from the website | ✗ | **new** `account.create` |
|
||||
| **Unlink** a game account from its website user | ✗ | **new** `account.unlink` + `[unlink` |
|
||||
| Choose which side may create accounts | ✗ (always in-game) | **new** signup mode |
|
||||
|
||||
**The linking flow is not changing.** `[link`, the one-time code, `link.confirm`, and the `WebsiteUserId` tag all stay exactly as they are (`BridgeAccountLink.cs`). 2.0 only *adds* verbs alongside them.
|
||||
|
||||
### The account-creation facts that shape this
|
||||
|
||||
- `new Account(username, password)` self-registers — its constructor calls `Accounts.Add(this)` (`Account.cs:186`) and `SetPassword` hashes per the shard's `AccountHandler.ProtectPasswords` (`Account.cs:174`). So creating an account from the bridge is `new Account(un, pw)` plus the link tag — no extra persistence layer, same as the `[link` tag reaching disk on the next world save.
|
||||
- ServUO's in-game auto-create is gated on the **core** config `Accounts.AutoCreateAccounts` (default `true`, read once in `AccountHandler`'s static init, `AccountHandler.cs:29`). The bridge cannot intercept that path without a core edit, so the signup mode governs the **bridge's** `account.create` verb; the in-game side is controlled by pairing it with the matching core config (see §3).
|
||||
- The core `CreateAccount` path (`AccountHandler.cs:494`) validates the username/password character set (printable ASCII `0x20–0x7F`, no forbidden chars) and enforces `MaxAccountsPerIP` (`Accounts.AccountsPerIp`, default **1**). The website path has **no `NetState`**, so the browser IP must be **passed through explicitly** to enforce that same cap (§3.1); and it must reuse the **character-safety** validation before `new Account`, or it can mint an account no client can log into (or that corrupts serialization).
|
||||
- There is **no `EventSink.AccountCreated`**. The create path is silent. This is why in-game→website creation sync is an open item, not committed scope (§7).
|
||||
|
||||
---
|
||||
|
||||
## 2. Signup modes — the model
|
||||
|
||||
A single shard-wide setting, `Bridge.SignupMode`, with three values. **Default `hybrid`.**
|
||||
|
||||
| Mode | `account.create` from website | In-game first-login auto-create | Who is the account authority |
|
||||
|------|:-----------------------------:|:-------------------------------:|------------------------------|
|
||||
| `website` | **accepted** | should be **off** | the website |
|
||||
| `game` | **rejected** (`account.error`) | **on** | the game server |
|
||||
| `hybrid` *(default)* | **accepted** | **on** | either side |
|
||||
|
||||
The bridge enforces exactly one half of this: whether it **honors `account.create`**. The other half — in-game auto-create — is the core `Accounts.AutoCreateAccounts` config, which the operator sets to match:
|
||||
|
||||
| `Bridge.SignupMode` | pair with `Accounts.AutoCreateAccounts` |
|
||||
|---------------------|-----------------------------------------|
|
||||
| `website` | `false` — otherwise any client that types a new name still mints an account, defeating website-only |
|
||||
| `game` | `true` |
|
||||
| `hybrid` | `true` |
|
||||
|
||||
On boot the bridge **reads `Accounts.AutoCreateAccounts` and warns** if it contradicts the selected mode (e.g. `SignupMode=website` while auto-create is still on), so a half-configured shard is loud, not silently permissive. The bridge does not try to flip the core setting — it only detects and reports the mismatch, the same defensive posture `BridgeConfig.ParseAccessLevel` already takes.
|
||||
|
||||
**Reconciliation in `hybrid`.** Both paths can race for the same username. `account.create` resolves it the only correct way: `Accounts.GetAccount(un) != null` → refuse with `account.error "account already exists"`. First writer wins; the loser gets a clean error, never a duplicate.
|
||||
|
||||
---
|
||||
|
||||
## 3. `account.create` — website-driven provisioning
|
||||
|
||||
The website has already authenticated and authorized the user (its own signup form). It hands the shard a username, the password the player chose, and the website user id, and asks for an account that is created **and linked in one step** — no code exchange, because the website *is* the authority here (unlike `[link`, where the game side proves ownership with a code).
|
||||
|
||||
### Request (website → sidecar → shard)
|
||||
|
||||
```json
|
||||
{ "kind": "account.create", "reqId": "c1", "actor": "whitlocktech",
|
||||
"account": "bob", "password": "hunter2", "websiteUserId": "9931",
|
||||
"ip": "203.0.113.7" }
|
||||
```
|
||||
|
||||
- `reqId` — correlation id, echoed on the reply (as everywhere else).
|
||||
- `actor` — the website user/staff id, for the audit line. Required, non-empty (mirrors the admin plane).
|
||||
- `account` — desired username.
|
||||
- `password` — the game-client password the player chose on the site. Plaintext over the **loopback + token** socket, the same trust boundary every inbound verb already relies on; the shard hashes it via `SetPassword` immediately.
|
||||
- `websiteUserId` — the site user to auto-link.
|
||||
- `ip` — the **end user's browser IP**, so the shard can enforce `MaxAccountsPerIP` on website signups exactly as it does on in-game first-login. The website reads this from its own request context (remote-addr, or a trusted `X-Forwarded-For`); the **sidecar cannot derive it** — the sidecar only sees the website's connection IP, not the browser's, so this must be an explicit field. See §3.1.
|
||||
|
||||
### Shard behavior (Core thread, in `BridgeAccounts.cs`)
|
||||
|
||||
1. **Gate.** `SignupMode == game` → `account.error "signups disabled for this mode"`. Master switch `Bridge.AccountCreateEnabled` (default follows mode) must be on.
|
||||
2. **Validate `actor`** present (as admin plane does).
|
||||
3. **Validate username/password** with the same character-safety rules as `AccountHandler.CreateAccount` (printable ASCII, no leading/trailing space, no trailing dot, no forbidden chars). Enforce length caps from config.
|
||||
4. **Collision check.** `Accounts.GetAccount(account) != null` → `account.error "account already exists"`.
|
||||
5. **IP cap.** Parse `ip` → `IPAddress`. If `RequireIpForCreate` and it is missing/unparseable/loopback → `account.error "client ip required"` (**fail closed** — a missing IP must never silently bypass the cap; loopback is exempt in `IPLimiter`, so accepting it *is* a bypass). Then `AccountHandler.CanCreate(ip) == false` → `account.error "ip account limit reached"`. This is the same read-side check the in-game path runs at `AccountHandler.cs:510`.
|
||||
6. **Create + link atomically.** `var a = new Account(account, password); a.LogAccess(ip); a.SetTag("WebsiteUserId", websiteUserId);` — `LogAccess` bumps `AccountHandler.IPTable[ip]` and records the IP into `LoginIPs` (`Account.cs:1251`), which is exactly what an in-game first-login does, so the per-IP count is both live-accurate and durable (it rebuilds from `LoginIPs[0]` on reboot). The `WebsiteUserId` tag persists to `accounts.xml` on the next world save, identical to the `[link` path.
|
||||
7. **Reply** `account.ok` and **emit** an unsolicited `account.audit` (`origin:"web"`, `action:"create"`) to every dashboard, parallel to `admin.audit`.
|
||||
|
||||
### Reply
|
||||
|
||||
```json
|
||||
{ "kind": "account.ok", "reqId": "c1", "action": "create",
|
||||
"account": "bob", "websiteUserId": "9931" }
|
||||
{ "kind": "account.error", "reqId": "c1", "reason": "account already exists" }
|
||||
```
|
||||
|
||||
### 3.1 The IP flow — who sees what
|
||||
|
||||
```
|
||||
browser ──HTTP signup──► website ──POST /accounts/create──► sidecar ──account.create──► shard
|
||||
(real IP) (sees browser IP) (sees WEBSITE's IP, not browser's) (enforces cap)
|
||||
```
|
||||
|
||||
The chain hops hosts, so the only party that sees the **end user's** IP is the website, at the edge. By the time the request reaches the sidecar, the socket's peer address is the *website*, not the player — which is why `ip` is a body field, not something the sidecar reads off the connection. The website populates it from its request context (remote-addr, or `X-Forwarded-For` from a proxy it trusts).
|
||||
|
||||
Two consequences to state plainly:
|
||||
|
||||
- **The IP is only as trustworthy as the website's proxy handling.** A compromised or misconfigured website could send a spoofed or wrong IP. That is already inside the 2.0 trust boundary (the website is trusted via loopback + token), but it means the per-IP cap is an *honesty* control against ordinary multi-account signups, not a hard security boundary against a hostile website.
|
||||
- **IPv4/IPv6 skew.** A browser may present IPv6 while the UO client connects over IPv4; the two are different `IPAddress` keys, so a website account and a later in-game account from the "same" person may not share an `IPTable` bucket. Inherent to keying on raw IP — noted, not solved.
|
||||
|
||||
The sidecar itself does **not** validate or transform `ip`; it forwards the field and lets the shard (which owns `IPTable`) decide. If a shard wants the sidecar to reject obviously-bad input early, that is a later refinement, not required for correctness — the shard fails closed regardless.
|
||||
|
||||
### Sidecar route
|
||||
|
||||
`POST /accounts/create`, body `{actor, account, password, websiteUserId, ip}` → the inbound line, correlated on a fresh `reqId`. Status mapping (new `respond_account`, modeled on `respond_admin`):
|
||||
|
||||
| Reply / reason | HTTP |
|
||||
|----------------|------|
|
||||
| `account.ok` | 200 |
|
||||
| `"account already exists"` | 409 Conflict |
|
||||
| `"ip account limit reached"` | 429 Too Many Requests |
|
||||
| `"signups disabled…"` | 403 |
|
||||
| `"client ip required"`, `"invalid username/password"`, missing field | 400 |
|
||||
| shard down / timeout | 503 / 504 |
|
||||
|
||||
> ⚠️ The password is a secret in a request body and a shard reply. Keep it off the WebSocket broadcast entirely: `account.audit`/`account.ok` **never carry the password**, and the console/audit log records only `account` + `actor`. This is the same discipline `BridgeEvents` already applies to the plaintext `AccountLoginEventArgs.Password` it deliberately never forwards (`PLAN.md` §12).
|
||||
|
||||
---
|
||||
|
||||
## 4. Unlinking
|
||||
|
||||
Symmetric with `[link`: either side can sever the tie. Both paths do the same one thing — remove the `WebsiteUserId` account tag (`acct.RemoveTag("WebsiteUserId")`) — and both persist on the next world save.
|
||||
|
||||
### 4.1 Website → `account.unlink`
|
||||
|
||||
```json
|
||||
{ "kind": "account.unlink", "reqId": "u1", "actor": "whitlocktech", "account": "bob" }
|
||||
```
|
||||
|
||||
- Resolve by `account` (username) or `serial` (a player mobile's account), reusing `BridgeAdmin.ResolveTargetAccount`.
|
||||
- Not linked → `account.error "not linked"` (a no-op is reported honestly, not faked as success).
|
||||
- Apply the **Owner floor** (`BridgeAdmin.Protected`): refuse to unlink an account at/above `AdminAccessFloor`, same defense-in-depth as the admin verbs.
|
||||
- Reply `account.ok action:"unlink"`; emit `account.audit action:"unlink"`.
|
||||
|
||||
Sidecar: `DELETE /link/{account}` (the existing `/link/:account` GET already looks a link up; this adds the delete verb next to it) → also clears the sidecar's mirrored link row (`store.record_unlink`), so event attribution stops immediately without waiting on the shard.
|
||||
|
||||
### 4.2 In-game → `[unlink`
|
||||
|
||||
`CommandSystem.Register("unlink", AccessLevel.Player, …)` in `BridgeAccountLink.cs`, next to `[link`:
|
||||
|
||||
- Reads the caller's own account, clears the tag, emits `account.unlinked` (so the site learns of a player-initiated unlink and can reconcile its own record).
|
||||
- Player-scoped: a player can only unlink **their own** account (no target argument), so it needs no floor.
|
||||
- Symmetric UX with `[link`: `"Your account is no longer linked."`
|
||||
|
||||
> **Note — `[link` behavior is unchanged.** `[link` still refuses when a tag already exists (`BridgeAccountLink.cs:96`). `[unlink` is what clears it; after unlinking, `[link` works again. That is the whole interaction, and it needs no change to the existing link code — only the new command beside it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Trust & attribution
|
||||
|
||||
Identical model to the admin write plane (`ADMIN_CONTROLS.md` §5), because these are the same shape of action (website-authorized, applied on the loopback socket):
|
||||
|
||||
- **Authorization lives on the website.** `account.create`/`unlink` are gated behind the site's own roles (self-service signup for create; admin/self for unlink). The shard trusts the loopback + token socket and the required `actor` field.
|
||||
- **Owner floor** applies to `account.unlink` (never unlink a protected staff account from the web).
|
||||
- **Attribution** is the `web:<actor>` string in the console line and the `account.audit` frame; the website keeps its own durable record, as it already does for `admin.audit`.
|
||||
- **`account.create` now enforces `MaxAccountsPerIP`** using the browser IP the website forwards (§3.1), via the same `CanCreate` / `LogAccess` path as in-game first-login. But the cap is only as honest as the website's IP reporting, and it fails **closed** on a missing/loopback IP when `RequireIpForCreate` is on. Higher-order abuse control (captcha, email verification, per-account-per-day) remains the website's job — the shard cap is a floor, not the whole defense.
|
||||
|
||||
---
|
||||
|
||||
## 6. Config keys (`Config/Bridge.cfg`)
|
||||
|
||||
```ini
|
||||
SignupMode=hybrid # website | game | hybrid (default hybrid)
|
||||
AccountCreateEnabled=true # master switch for account.create; auto-off when SignupMode=game
|
||||
RequireIpForCreate=true # fail closed if account.create omits a usable browser IP
|
||||
AccountNameMaxLength=16
|
||||
AccountPasswordMaxLength=30
|
||||
```
|
||||
|
||||
Read in `BridgeConfig.Load()`, re-readable via `[bridge reload`. `SignupMode` parses like `AdminAccessFloor` — unrecognized value falls back to the safest option (`game`, i.e. no website creation) with a console warning, so a typo can never accidentally open provisioning. `RequireIpForCreate` defaults **on**: the per-IP cap only means something if a missing IP is refused rather than waved through. Turn it off only for a deployment that deliberately does not cap website signups by IP (and then `MaxAccountsPerIP` still applies in-game as before).
|
||||
|
||||
---
|
||||
|
||||
## 7. Open item (not committed) — in-game → website creation sync
|
||||
|
||||
Per the 2026-07-17 decision, **this is not in 2.0's committed scope.** When an account is created *in-game* (first-login auto-create, or staff `[AddAccount`), the website is **not** notified today, and 2.0 does not change that. Recorded here so the tradeoff is explicit, not forgotten:
|
||||
|
||||
- **Why it's hard cleanly:** there is no `EventSink.AccountCreated`. The only faithful tap is a core edit — an `Action<Account>` raised in the `Account(string, string)` ctor (safe: the load path is a *separate* ctor, `Account.cs:189`, so it won't fire during world load), shipped as a `patches/` diff exactly like `PlayerVendorSale` and the `CommandLogging` event.
|
||||
- **Why it may not be needed:** in `website`-mode the website already knows every account (it created them). Sync only matters for `hybrid`/`game` modes where the website wants a roster of game-born accounts — and even then the sidecar can approximate "new account" from the `mob.login` `acct` field it already receives (first-seen = new), lossy but zero core edits.
|
||||
- **If we do it later:** it becomes an `account.created` event stream (`origin:"in-game"`), the natural mirror of the `account.audit` (`origin:"web"`) that `account.create` emits — the same bidirectional-audit shape §5.5 of `ADMIN_CONTROLS.md` established. Revisit if a shard chooses `hybrid`/`game` and wants a complete website roster.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where the code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeAccounts.cs` | **New.** Registers `account.create` and `account.unlink`; the create+link, char-safety validation, collision check, Owner floor on unlink, `account.audit` emission. Mirrors `BridgeAdmin.cs` structure. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs` | **Extend.** Add the `[unlink` player command beside `[link`. No change to existing link behavior. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeConfig.cs` | **Extend.** `SignupMode` (parsed, safe fallback), `AccountCreateEnabled`, `RequireIpForCreate`, name/password length caps; read `Accounts.AutoCreateAccounts` and warn on mode mismatch. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeBoot.cs` | **Extend.** Wire `BridgeAccounts.Initialize()` into `Initialize()` (one line, beside the other subsystems). |
|
||||
| `overlay/Config/Bridge.cfg` + `.example` | **Extend.** The §6 keys, defaults documented. |
|
||||
| `sidecar/src/web.rs` | **Extend.** `POST /accounts/create` (forwards `ip` from the body untouched), `DELETE /link/:account`; `respond_account` status mapping (409 on collision, 429 on IP cap, 400 on missing IP); scrub password from any logged/broadcast value. |
|
||||
| `sidecar/src/store.rs` | **Extend.** `record_unlink` (clear the mirrored link row) beside the existing `record_link`. |
|
||||
| `docs/INTEGRATION.md` | **Extend.** Document `POST /accounts/create`, `DELETE /link/{account}`, and the `account.audit` event. |
|
||||
| *(website, separate repo)* | Signup form → `POST /accounts/create`; unlink control → `DELETE /link/{account}`; consume `account.audit`. |
|
||||
|
||||
No new core/stock edits in committed scope — `account.create`/`unlink` are all script-layer (`new Account`, `SetTag`/`RemoveTag`) called from the new overlay. The only core edit contemplated (the `AccountCreated` event, §7) is explicitly deferred.
|
||||
|
||||
---
|
||||
|
||||
## 9. Phasing
|
||||
|
||||
1. ~~**Config + modes.**~~ **Done.** `BridgeConfig` gains `SignupMode` (parsed, unrecognized → `game`), `AccountCreateEnabled` (mode-following default), `RequireIpForCreate`, name/password caps, and the boot-time `Accounts.AutoCreateAccounts` mismatch warning. `[bridge status` shows `signup=…(create=…)`.
|
||||
2. ~~**`account.create`.**~~ **Done.** `BridgeAccounts.cs` + `POST /accounts/create` + `respond_account`. Gate on mode, `actor` required, char-safety mirrored from `AccountHandler`, collision → 409, IP cap via `CanCreate`/`LogAccess` (fail-closed on missing/loopback IP when `RequireIpForCreate`), create + link, `account.audit`, password never logged/echoed. *Acceptance below is written for a live run — not yet exercised end-to-end.*
|
||||
3. ~~**Unlink — both surfaces.**~~ **Done.** `account.unlink` + `DELETE /link/:account` + `store.record_unlink`, and the in-game `[unlink`. Owner floor reuses `BridgeAdmin.Protected`; `[unlink` emits `account.unlinked`.
|
||||
4. ~~**Docs.**~~ **Done.** `INTEGRATION.md` §2 (protocol bumped to **2**), §4 (`account.audit`/`account.unlinked`), §6 (`POST /accounts/create`, `DELETE /link/{account}`), §7 (409/429).
|
||||
|
||||
**Build verification (2026-07-17):** sidecar `cargo check` clean; overlay compiled in the full ServUO Scripts tree — **0 errors, 0 warnings**. **Live end-to-end run still pending** (needs a booted shard + sidecar): create+link in website/hybrid, game-mode refusal, duplicate 409, the per-IP cap holding (second create same `ip` → 429, different IP succeeds, omitted IP → 400 while `RequireIpForCreate`), `LoginIPs[0]`/`IPTable` incremented, and unlink clearing tag+mirror with the Owner floor refusing a protected target.
|
||||
|
||||
Deferred (revisit only if a shard needs it): the §7 in-game→website `account.created` sync; the §12.3 `account.setpassword`/`account.exists` siblings; §12.5 credential-verb rate limiting.
|
||||
|
||||
---
|
||||
|
||||
# Part B — Social & political world-state streams
|
||||
|
||||
These are **outbound** streams (shard → website), the natural extension of `PLAN.md`'s event/sweep plane. None needs a write plane; all reuse the transport, the bounded queue, and the sweep/emit-on-change discipline `BridgeSweeps` already established. Each entry below states its **grounded hook situation** so nothing rides an event that doesn't fire.
|
||||
|
||||
## 10. The requested streams
|
||||
|
||||
### 10.1 Guilds
|
||||
|
||||
**Hook reality (verified):**
|
||||
|
||||
- `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`.
|
||||
- `EventSink.CreateGuild` is **not** a creation notification. It is the load-time deserialization factory: raised only from `Server/World.cs:517` while reading the guild index at boot, where the handler's job is to *construct* the guild instance (`Guild.cs:775` → `new Guild(args.Id)`). Player guild creation (`new Guild(pm, name, abbrev)` at `Create Guild Gump.cs:83`, `GuildDeed.cs:127`) raises **no event**. **Do not use `CreateGuild` for "a guild was created"** — it would fire once per guild at every boot and never on an actual new guild.
|
||||
- Leave, disband, leader change, alliance change, rename: **no events.**
|
||||
|
||||
**Delivery — a guild sweep + diff, exactly like house decay (`PLAN.md` §5.4).** `BaseGuild.List` is a `Dictionary<int, BaseGuild>` (`Server/Guild.cs:54`) — the whole registry, enumerable on the Core thread. Hold a `Dictionary<int, GuildSnapshot>` (name, abbreviation, leader serial, member count, alliance name, member-serial set hash). On each sweep, diff:
|
||||
|
||||
- id present now, absent before → `guild.created`
|
||||
- id absent now, present before → `guild.disbanded`
|
||||
- leader / alliance / name / abbreviation changed → `guild.updated`
|
||||
- member set grew/shrank → `guild.join` / `guild.leave` (the sweep is the reliable source for leaves; `EventSink.JoinGuild` can *also* emit an immediate `guild.join` for joins, with the sweep as the backstop)
|
||||
|
||||
Take a **silent baseline** on `ServerStarted` (populate without emitting), same as decay, or every guild re-announces on every boot. Cost is trivial — a shard has tens to low-hundreds of guilds, and reading `Members.Count` + `Leader` is a handful of field reads each.
|
||||
|
||||
```jsonc
|
||||
{"kind":"guild.created","id":1234,"name":"The Silver Hand","abbr":"TSH",
|
||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"},"members":14,"alliance":null}
|
||||
{"kind":"guild.leave","id":1234,"who":{"serial":"0x77","name":"Bran"},"members":13}
|
||||
{"kind":"guild.disbanded","id":1234,"name":"The Silver Hand"}
|
||||
```
|
||||
|
||||
> If real-time (not next-sweep) leave/disband ever matters, the clean tap is a one-line `patches/` hook in `Scripts/Misc/Guild.cs` `RemoveMember`/`OnDelete` — the Phase-7 `patches/` precedent. Start with the sweep; add the patch only if latency is a real complaint. Guild membership does not move fast enough to justify it up front.
|
||||
|
||||
### 10.2 Town governors ("mayors")
|
||||
|
||||
In modern ServUO the "mayor of a town" is the **Governor** in the City Loyalty System (King Blackthorn's governance). Each `City` (enum, `CityLoyaltySystem.cs:15`) has a `CityLoyaltySystem` instance carrying `Governor` (Mobile), `GovernorElect`, an `Election`, a `Citizens` count, and a herald. The `Governor` setter already broadcasts a herald message on change (`CityLoyaltySystem.cs:193`), confirming a governor transition is a first-class in-game event — there just isn't an `EventSink` for it.
|
||||
|
||||
**Delivery — a city sweep, emit-on-change.** `CityLoyaltySystem.Cities` (static `List<CityLoyaltySystem>`, `CityLoyaltySystem.cs:680`) is the full set, one per city. Sweep, hold `Dictionary<City, governorSerial>`, emit on transition. Governors change on the order of weeks — a slow sweep (e.g. 5 min, or fold into the economy sweep cadence) is ample. Also emit election open/close and, optionally, the standing.
|
||||
|
||||
```jsonc
|
||||
{"kind":"city.governor","city":"Britain","from":{"serial":"0x55","name":"Old Mayor"},
|
||||
"to":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"}}
|
||||
{"kind":"city.election","city":"Moonglow","phase":"nominate","candidates":3,"endsAt":"2026-07-24T…"}
|
||||
```
|
||||
|
||||
> **Gate on `CityLoyaltySystem.Enabled`** (`CityLoyalty.Enabled`, default true). If a shard runs its own custom town-ownership system instead, this sweep should no-op — detect and log, don't assume.
|
||||
|
||||
### 10.3 Player titles
|
||||
|
||||
There is **no title-change event.** Titles are read-model state, best delivered two ways, not as a stream:
|
||||
|
||||
- **Enrich `char.profile`** (`BridgeProfile`) with a `titles` block. Sources on a `PlayerMobile`: the reward-title list `m_RewardTitles` (`List<object>`) + the selected index `m_SelectedTitle` (`PlayerMobile.cs:4194,4595`), the champion title `m_CurrentChampTitle`, plus the computed titles from `Titles.ComputeTitle` / `ComputeFameTitle` / `GetSkillTitle` / veteran titles (`Scripts/Misc/Titles.cs`). `char.profile` already carries all-skills, so titles slot in beside it at near-zero extra cost, and it is a *read* — no hook needed.
|
||||
- **Optional `title.change`** only if the community page wants a live "so-and-so is now *Grandmaster Blacksmith*" feed — and then it comes from a **profile-diff in the sidecar**, not a shard event (the shard has nothing to subscribe to). Recommend starting with profile enrichment; add the diff feed only if there is demand.
|
||||
|
||||
City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`, `MerchantTitles.cs`) fold into the same `titles` block.
|
||||
|
||||
### 10.4 Factions / Vice vs Virtue
|
||||
|
||||
**Which system is live is a shard decision — verify before building.** Two exist:
|
||||
|
||||
- **Old Factions** (`Scripts/Services/Factions`): `Faction.Commander` (leader, `Faction.cs:160`), `Faction.Election`, `Faction.Members` (`List<PlayerState>`), and faction-controlled **Towns** (`Town.cs` — each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, **off**.
|
||||
- **Vice vs Virtue** (`Scripts/Services/ViceVsVirtue`): the modern replacement. `ViceVsVirtueSystem.Enabled` (`VvV.Enabled`, default **true**), a singleton `Instance`, an active `Battle`, and per-player `VvVPlayerEntry` (score, kills, assists). City control in VvV rides the same city-loyalty/governor rails as §10.2.
|
||||
|
||||
**Delivery — a sweep, gated on whichever is enabled.** Neither system raises membership/leadership `EventSink`s, so it is the same sweep+diff pattern:
|
||||
|
||||
- VvV (recommended default): standings per side, active-battle status (`Battle.OnGoing`, current city), and the top `VvVPlayerEntry` scores → a `vvv.standings` snapshot on change + a `vvv.battle` open/close event.
|
||||
- Old Factions (only if a shard runs it): `faction.control` (town → owning faction on change), `faction.commander` (leader change from the `Election`).
|
||||
|
||||
```jsonc
|
||||
{"kind":"vvv.battle","phase":"start","city":"Britain","map":"Felucca","endsAt":"2026-07-17T…"}
|
||||
{"kind":"vvv.standings","order":142000,"chaos":138500,"leaderSide":"Order"}
|
||||
```
|
||||
|
||||
> Start by detecting which system is enabled at boot and streaming only that one; emit a one-time `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing.
|
||||
|
||||
## 11. Further integration points — a menu to pick from
|
||||
|
||||
Everything below is grounded in a hook or a cheap sweep in *this* server. Ranked roughly by value-to-effort. **Pick the ones you want and I'll fold them into the phasing.** (✔ = a real `EventSink` exists; ⟳ = sweep/diff; ⚑ = needs a small `patches/` core tap.)
|
||||
|
||||
| # | Stream | Source | Effort | Why it's worth it |
|
||||
|---|--------|--------|:------:|-------------------|
|
||||
| 1 | **Who's-online / population** | ⟳ online sweep over `NetState.Instances` | low | A live "N players online", per-facet population, and a history series. The single most-asked-for website widget. |
|
||||
| 2 | **Region presence** | ✔ `EventSink.OnEnterRegion` (`Region.cs:1160`, player-filtered) | low | Cheap location stream → town population heatmap, "who's in Despise" — `PLAN.md` §5.6 already flags it as the right answer over `Movement`. |
|
||||
| 3 | **Crafting feed** | ✔ `EventSink.CraftSuccess` | low | Who crafted what, exceptional/runic — a crafting economy + "notable crafts" feed. |
|
||||
| 4 | **Taming feed** | ✔ `EventSink.TameCreature` | low | New tames, esp. rares/greaters — high community interest. |
|
||||
| 5 | **Resource harvesting** | ✔ `EventSink.ResourceHarvestSuccess` | low-med | Mining/lumber/fishing volume → the raw-material side of the economy (pairs with the vendor/gold streams already shipped). |
|
||||
| 6 | **Virtue progression** | ✔ `EventSink.VirtueLevelChange` | low | Knight/Seeker/etc. virtue ranks — a progression badge system. |
|
||||
| 7 | **Bulk Order Deeds** | ✔ `EventSink.BODOffered` / `BODUsed` | low | BOD turn-ins and rewards — a crafting-endgame feed and reward-title source. |
|
||||
| 8 | **Guild wars** | ⟳ from the §10.1 guild sweep (war state on `Guild`) | low | Declared/active/ended wars between guilds — a PvP politics board, nearly free once guilds sweep. |
|
||||
| 9 | **Player housing registry** | ⟳ extend the existing decay sweep to a full house list | med | Owner → houses map, "houses for sale" (via vendor data already streamed), a housing map. Reuses `PLAN.md` §5.4 machinery. |
|
||||
| 10 | **Peerless / boss / rare drops** | ⚑ virtual-override or drop-system tap (no `EventSink`) | med | An "epic loot" feed. Honest cost: no clean event (same gap as per-hit damage, `PLAN.md` §5.9) — needs a targeted `patches/` hook, so it is a deliberate pick, not a freebie. |
|
||||
| 11 | **Secure player trades** | ⚑ `SecureTrade` completion has no `EventSink` | med | Player-to-player item/gold transfers → economy + fraud signal, complements the vendor-sale core edit. Needs a core tap. |
|
||||
| 12 | **Champion spawn *board*** | already shipped (`BridgeChamps`) — extend, don't rebuild | — | Champs are done in 1.0. Listed so it is not re-proposed; any gap is an extension of the existing sweep. |
|
||||
|
||||
**Selected for Part B (owner pick, 2026-07-17):** guilds (§10.1) + governors (§10.2) + who's-online (#1) + region presence (#2) + **housing registry (#9)** + titles (§10.3, free as profile enrichment). All reuse the sweep pattern and need no core edit; together they give a website its "living world" page — population, guild politics, town leadership, and a housing map. Factions/VvV (§10.4) is deferred until you confirm which system your shard runs. The phasing is §13.
|
||||
|
||||
### Where the Part B code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeSocial.cs` | **New.** The guild sweep+diff and the `EventSink.JoinGuild` subscription → `guild.*`. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeGovernance.cs` | **New.** The city sweep → `city.governor`/`city.election`; the VvV/faction standings sweep (gated on enabled) → `vvv.*` / `faction.*`; the one-time `world.systems` frame. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeProfile.cs` | **Extend.** Add the `titles` block to `char.profile`. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeSweeps.cs` | **Extend / mirror.** New sweep timers (guild, city, presence), re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`, counters in `[bridge status` — same shape as the existing sweeps. |
|
||||
| `overlay/Config/Bridge.cfg` | **Extend.** `GuildSweepSeconds`, `CitySweepSeconds`, `PresenceSweepSeconds` (+ enable flags). |
|
||||
| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the snapshots that back boards (guild roster, governors, population history); `GET /guilds`, `/governors`, `/online` served from the store so they survive a shard outage, exactly like `/champs` and `/economy` do today. |
|
||||
| `docs/INTEGRATION.md` | **Extend.** New event catalog entries + the read endpoints. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Cross-cutting additions (recommended)
|
||||
|
||||
Five things that are not new *streams* but make 2.0 correct and complete. The first two I consider **essential**; the rest are high-value companions to what's already specced.
|
||||
|
||||
### 12.1 Bump the protocol version to 2 — **essential**
|
||||
|
||||
The sidecar is `PROTOCOL_VERSION = 1` (`sidecar/src/main.rs:23`), and every response carries `X-UOLink-Version`; the gate 409s a client that declares a different one (`web.rs:146`). 2.0 adds inbound verbs (`account.create`, `account.unlink`, …) and event kinds, so it must bump to `2`.
|
||||
|
||||
The compatibility rule to write down: **new outbound event kinds are additive** — a 1.x website ignores unknown kinds and keeps working, so the live feed stays backward-compatible. What is *not* compatible is a client that calls a **new inbound verb** against an old sidecar, or a new sidecar that a strict old client rejects on the version header. So: bump to `2`, keep the feed additive, and document that the new *verbs/endpoints* require a v2 sidecar while the *event feed* degrades gracefully.
|
||||
|
||||
### 12.2 Every diff stream needs a REST snapshot companion — **essential**
|
||||
|
||||
The Part B streams are **diff-based**: `guild.created`/`disbanded`, `city.governor`, housing changes emit only on transition (like house decay). That means a website that connects fresh — or a **sidecar that restarts** — has seen *no* deltas yet and therefore has **no current state**. The live feed alone can never answer "what are the guilds *right now*."
|
||||
|
||||
So every board-backed stream ships with a REST snapshot served from the sidecar's store, exactly as `/champs` and `/economy` already are (`web.rs`): `GET /guilds`, `/governors`, `/online`, `/houses`. The shard emits deltas; the sidecar persists the latest snapshot; the website hydrates from REST on load and then live-updates from the feed. This is the single most important robustness rule for Part B — without it, a sidecar restart silently blanks the community page until the next guild happens to change.
|
||||
|
||||
> Concretely: the sidecar keeps a `guilds` / `governors` / `population` table updated from the stream (upsert on each delta, plus a periodic full snapshot the shard can push), and the REST route reads that table. The shard should also support an on-demand full re-emit (a `snapshot.request` inbound, or just re-run the sweep with baseline suppression off) so a sidecar that lost its store can rebuild.
|
||||
|
||||
### 12.3 Round out the provisioning surface — password reset, existence check
|
||||
|
||||
`account.create` sets the account's **initial** password (§3) — that part is done. What it does not cover is the rest of the credential lifecycle. Two small siblings close it, both trivially grounded:
|
||||
|
||||
- **`account.setpassword`** — the **later** password *change/reset* for an account that already exists (a player who forgot theirs), distinct from the initial password `account.create` sets. `acct.SetPassword(newpw)` (`Account.cs:676`) is public; the verb takes `{actor, account, password}`, applies the Owner floor, emits `account.audit action:"setpassword"`, and — like create — **never echoes the password**. `POST /accounts/{account}/password`. Only worth building if the site will offer a "forgot password" flow.
|
||||
- **`account.exists`** — the signup form wants to say "that name is taken" before submit. A read: `Accounts.GetAccount(un) != null`. `GET /accounts/{account}` → `{exists: true|false, linked: bool}`. Cheap, and it prevents the worse UX of finding out via a 409 on submit.
|
||||
|
||||
Both reuse the `account.*` machinery from Part A verbatim. `account.setpassword` is the higher-value of the two.
|
||||
|
||||
### 12.4 Mirror hygiene — deletion & link teardown
|
||||
|
||||
If the website mirrors rosters/links (it does — `store.record_link`), it must learn when the game side removes things, or the mirror rots:
|
||||
|
||||
- **Character deletion.** `EventSink.DeleteRequest` (`EventSink.cs:1754`) fires when a player deletes a character at the select screen. Emit `char.deleted` so the website drops it from any roster it caches. (`PLAN.md` §5.1 already lists this hook as a roster-honesty signal — 2.0 is where it earns its place, now that the website keeps rosters.)
|
||||
- **Account deletion.** `Account.Delete()` exists (`Account.cs:642`); an optional `account.delete` verb (Owner-floor-guarded, `origin:"web"` audit) closes the lifecycle. Lower priority — most shards ban rather than delete — but list it so the option is on record.
|
||||
- On any unlink **or** account delete, the sidecar clears its link mirror (the `record_unlink` already specced in §4.1), so event attribution stops immediately.
|
||||
|
||||
### 12.5 Rate-limit the credential verbs
|
||||
|
||||
`account.create` and `account.setpassword` mint/change persistent credentials. The per-IP cap (§3.1) blocks multi-accounting from one IP, but a compromised or buggy website could still hammer distinct IPs. Add a **sidecar-side rate limit** on the credential verbs — a global create-per-minute ceiling and a per-`actor` cooldown — mirroring the caps philosophy town-crier and the admin plane already follow (`BridgeConfig.TownCrier*`, `Admin*`). Cheap insurance; the shard stays the last line of defense (collision + IP cap), the sidecar is the first.
|
||||
|
||||
---
|
||||
|
||||
## 13. Part B phasing
|
||||
|
||||
1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.*
|
||||
2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.*
|
||||
3. ~~**Housing registry.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeHousing.cs`: a house sweep over `BaseHouse.AllHouses` → `house.update`/`house.remove` (owner, region, location, decay, co-owners, friends, price), complementing the existing `house.decay` transition feed. `HousingSweepSeconds` (300s), wired into `[bridge`. Sidecar `houses` board + `GET /houses`. (Stock ServUO has no "for sale" flag, so this is owner→houses; `price` is the placement value, not a listing.) *Live run pending.*
|
||||
4. ~~**Titles.**~~ **Built (2026-07-17), compiles clean.** `char.profile` gains a `titles` block (`selected`, `fameKarma`, `skill`, `reward[]`) from `PlayerMobile` accessors — no new stream, folds into `BridgeProfile`. *Live run pending.*
|
||||
5. **Factions/VvV** — **deferred** (owner decision): only after confirming which system the shard runs; stream just the enabled one.
|
||||
|
||||
Cross-cutting, lands with Phase 1: the **protocol bump to 2** (§12.1) and the **snapshot-companion rule** (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the `account.*` surface.
|
||||
|
||||
---
|
||||
|
||||
## 14. Built-in reports — replace the FTP/HTML path with JSON over the sidecar
|
||||
|
||||
ServUO ships a **Reports engine** (`Server.Engines.Reports`, `Scripts/Services/Reports/`) that already compiles exactly the dashboard data a website wants — it just delivers it the way RunUO did in 2004: render static HTML and **FTP it to your website**. The bridge can tap the *compiled data* directly and ship JSON, retiring the file/FTP path entirely. **No core edit** — the compile methods are `public static`.
|
||||
|
||||
### 14.1 What the engine produces (verified)
|
||||
|
||||
`Reports.Generate()` runs hourly on the Core thread and builds a `Snapshot` from public static compile methods (`Reports.cs`):
|
||||
|
||||
| Method | Returns | Content |
|
||||
|--------|---------|---------|
|
||||
| `CompileGeneralStats()` | `Report` | NPCs, Players, Clients, Accounts, Items |
|
||||
| `CompileStatChart()` | `Chart` | population over time |
|
||||
| `CompileSkillReports()` | `PersistableObject[]` | **skill distribution — GM count per skill** |
|
||||
| `CompileFactionReports()` | `PersistableObject[]` | faction membership / stats |
|
||||
| `Reports.StaffHistory` | `StaffHistory` | staff activity per account (`StaffInfo`/`UserInfo` hashtables), **help-page-queue length over time** (`QueueStats`), page history |
|
||||
|
||||
Each `Report` is structured (`Columns` + `Items`), so it serializes to JSON cleanly with the hand-rolled `BridgeJson` writers — no reflection serializer. The engine also persists an hourly **`SnapshotHistory`** series to disk, so a backfill of historical points is available if wanted.
|
||||
|
||||
### 14.2 How it's delivered today (the file path you flagged)
|
||||
|
||||
- **HTML + FTP.** `UpdateOutput` (`Reports.cs:406`, on a ThreadPool thread) runs `HtmlRenderer` into `<BaseDir>/reports/stats/` and `reports/staff/` (`Reports.Path`, default `reports`), then `Upload()` writes an `upload.ftp` job to FTP the HTML to a website. Gated on `Reports.AutoGenerate` (**default off**).
|
||||
- **WebStatus.** A *separate* mechanism (`Scripts/Misc/WebStatus.cs`): an in-process `HttpListener` on `:80/status/` serving a live status HTML page. Default `Enabled = false`.
|
||||
|
||||
Both are the "report goes to a file / gets pushed out-of-band" pattern. The sidecar already replaces the second one (`/health` + the live feed cover what `WebStatus` served); §14 replaces the first.
|
||||
|
||||
### 14.3 The tap — a report sweep, JSON out
|
||||
|
||||
`BridgeReports.cs` runs a **Core-thread timer** (`ReportSweepSeconds`, e.g. hourly to match stock, or faster) that calls the same public compile methods, serializes the `Report`/`Chart` objects to JSON, emits `report.*`, and hands the sidecar a snapshot to persist and serve over REST:
|
||||
|
||||
```jsonc
|
||||
{"kind":"report.skills","t":1752…,"skills":[
|
||||
{"skill":"Swordsmanship","gms":42},{"skill":"Magery","gms":88}, …]}
|
||||
{"kind":"report.general","players":142,"npcs":42826,"clients":150,"accounts":51,"items":206467}
|
||||
{"kind":"report.staff","window":"7d","staff":[
|
||||
{"account":"GreyBeard","actions":318}],"pageQueue":[{"t":…,"open":4}, …]}
|
||||
```
|
||||
|
||||
Served for hydration (the §12.2 snapshot rule): `GET /reports/skills`, `/reports/general`, `/reports/staff`.
|
||||
|
||||
Key points, all grounded:
|
||||
|
||||
- **No core edit, no HTML, no FTP.** Calling `Compile*` directly skips `HtmlRenderer`/`Upload` entirely. Leave `Reports.AutoGenerate` **off** (no HTML files written) and run the bridge tap instead. The FTP `upload.ftp` path and `Reports.Path` become dead weight for a bridge-connected shard.
|
||||
- **Threading.** `Compile*` read `World.Mobiles`/`Skills`, so they must run on the Core thread — which the bridge's sweep timers already are (`PLAN.md` non-negotiables). Stock only offloaded the *HTML rendering* (slow string work) to a ThreadPool; the bridge skips that step, so there's nothing to offload. Skill distribution walks all mobiles once — treat it like the profile-bulk warning in `PLAN.md` §1: run it on a slow cadence (hourly is plenty), never in a fast sweep.
|
||||
- **Dedupe against Part B.** `report.general` and the population chart overlap with who's-online (§11 #1); faction reports overlap with §10.4. The **unique** wins here are **skill distribution** (a GM-per-skill leaderboard available nowhere else in the bridge) and the **staff-activity + page-queue-length history** (aggregates that complement the per-action `admin.audit` we already stream). Prioritize those two; treat the rest as "already covered, don't double-emit."
|
||||
- **Optional backfill.** On first connect the sidecar could ingest the engine's persisted `SnapshotHistory` (`Reports.StaffHistory`/stats history) to seed the historical series instead of starting empty. Nice-to-have, not required.
|
||||
|
||||
### 14.4 Where the code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeReports.cs` | **New.** Core-thread report sweep calling `Reports.Compile*` + `Reports.StaffHistory`; serialize to `report.*`; re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`. |
|
||||
| `overlay/Config/Bridge.cfg` | **Extend.** `ReportSweepSeconds` (+ enable flag). |
|
||||
| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the report snapshots; `GET /reports/{skills,general,staff}` served from the store (survives shard outage, like `/champs`). |
|
||||
| `docs/INTEGRATION.md` | **Extend.** `report.*` events + endpoints; note they supersede the stock FTP/HTML reports and `WebStatus`. |
|
||||
|
||||
> **Recommendation:** fold this in as **Part B, Phase 6** (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all.
|
||||
|
||||
---
|
||||
|
||||
## 15. Smoke test — live run (2026-07-17)
|
||||
|
||||
Deployed the overlay to the ServUO checkout, booted the shard and the real sidecar (protocol 2, `plugin_connected: true`), and exercised every new surface over REST against the live game. All green.
|
||||
|
||||
**Part A — provisioning (through the real shard):**
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `POST /accounts/create` (fresh IP) | **200** `account.ok`, account created + linked |
|
||||
| duplicate name | **409** `account already exists` |
|
||||
| per-IP cap | shard's real `AccountsPerIp=3` enforced: 3rd from one IP allowed, **4th → 429** `ip account limit reached` |
|
||||
| loopback IP with `RequireIpForCreate` | **400** `client ip required` (fails closed) |
|
||||
| `GET /link/{acct}` after create | **200**, linked to the website id |
|
||||
| `DELETE /link/{acct}` | **200** `unlink`; lookup then **404** |
|
||||
|
||||
**Part B — world-state boards (through the real shard):**
|
||||
|
||||
| Endpoint | Result |
|
||||
|----------|--------|
|
||||
| `GET /houses` | **28 houses**, full owner/decay/co-owner/built-on data (shard → `house.update` → board → REST) |
|
||||
| `GET /governors` | **9 cities**, `governor: null`/`electionPhase: none` on this unseeded world |
|
||||
| `GET /guilds` | `[]` — no guilds on this world; the sweep ran without error |
|
||||
| `GET /online` | `count: 0` — headless (no UO client), snapshot emitted and stored |
|
||||
| `GET /char/{acct}/0` | full profile incl. the new `titles` block |
|
||||
|
||||
**Not exercised (needs a live UO client, not a headless boot):** `presence.online` with real players, `region.enter`, real-time `guild.join`, and `char.vitals`. And `guild.join`/guild board content needs a guild to exist. These are inherent to a clientless smoke test — the board *plumbing* is proven by `/houses`, which uses the identical path.
|
||||
|
||||
**One operational note surfaced:** the boot-time `Dynamic` script recompile **cannot replace `Scripts.dll` while the server is running**, because the Scripts build tries to copy the locked `ServUO.exe` and fails (the `PLAN.md §3` trap). The fix used here: build `Scripts/Scripts.csproj` once with the server **stopped**, then boot — the offline build produces a fresh `Scripts.dll` the boot then loads. Rely on this, not the in-process rebuild, when deploying new bridge code. The shard's world save was left untouched (hard-kill, no autosave), so the test accounts did not persist.
|
||||
|
||||
---
|
||||
|
||||
## 16. Town Cryer news — website articles into the news gump (Protocol 2.1)
|
||||
|
||||
**Status:** **Built and smoke-tested live** (2026-07-17). `BridgeNews.cs` (pure overlay, no stock edit) + `POST /news` / `DELETE /news/{id}` + reconnect replay. Verified against a booted shard: `news.add` (full + title-only) → `news.ok`, missing title → 400, idempotent replace, `news.remove` → `news.ok`, unknown id → `news.error`, no shard exceptions, and the **reconnect replay** confirmed (after a shard restart the stored article was re-pushed with `announce:false` and re-accepted). The gump rendering itself is verified by source inspection (needs a UO client to view).
|
||||
|
||||
There are **two** distinct town-crier surfaces in ServUO, and 2.0 has so far touched only the first:
|
||||
|
||||
1. **The scrolling crier** (`GlobalTownCrierEntryList`) — the wandering Town Crier NPC that *says* short announcement lines. Protocol 1.0 phase 6 (`BridgeTownCrier.cs`, `towncrier.add`/`remove`) already drives this.
|
||||
2. **The Town Cryer News gump** (`TownCryerSystem.NewsEntries`) — the paged news UI with title + body + image + a "more info" URL per article. **Nothing drives this yet.** This section adds it.
|
||||
|
||||
The ask: a website news article should land as a full article in the **news gump** (2), and the crier should also *say* just the **title** through the existing say feature (1) — so players get the audible "Hear ye!" proclamation while the full write-up lives in the gump.
|
||||
|
||||
### 16.1 The hook (verified in the shard's source)
|
||||
|
||||
- **`TownCryerSystem.NewsEntries`** (`TownCryerSystem.cs:40`) — `public static List<TownCryerNewsEntry>`. The setter is private, but the **list is public and mutable**, so it can be inserted into and removed from directly.
|
||||
- **`TownCryerNewsEntry(TextDefinition title, TextDefinition body, int gumpImage, Type questType, string url)`** (`TownCryerNewsEntry.cs`) — public ctor. Pass `questType: null` for website news.
|
||||
- **The display gumps already handle string content**, so no gump edits are needed:
|
||||
- List view (`TownCryerGump.cs:97-103`): `if (entry.Title.Number > 0) AddHtmlLocalized(...) else AddLabelCropped(..., entry.Title)`.
|
||||
- Detail view (`TownCryerNewsGump.cs:27-42`): `if (Entry.Body.Number > 0) AddHtmlLocalized(...) else AddHtml(..., Entry.Body.String, ..., true)` — a **string body renders as HTML** (so `<CENTER>…</CENTER><BR><BR>…` works), `AddImage(..., Entry.GumpImage)`, and `InfoUrl` becomes a `LaunchBrowser` button.
|
||||
- **Stock news is live on this shard.** `TownCryerSystem.Initialize()` adds ~18 hardcoded `uo.com` entries whenever `TownCryerSystem.Enabled` (`TownCryerSystem.cs:93-120`) — *not* gated by `UsePreloadedMessages` (that only gates a reload command). So the list is not empty, and our sync must not clobber it (see §16.3).
|
||||
|
||||
### 16.2 Evaluating the pasted guidance
|
||||
|
||||
The pasted analysis is **substantially correct** and useful — it identifies the right hook (`NewsEntries`), the right constructor, the cliloc-vs-string branching the gump already does, the image/url fields, and the important instinct to keep stock news separate. Two adjustments for *this* architecture:
|
||||
|
||||
- **No stock patch is needed.** The pasted plan adds `AddNewsEntry` / `ClearExternalNews` methods to the stock `TownCryerSystem.cs`. That file is stock ServUO, so editing it would ship as a `patches/` diff (like `PlayerVendorSale`). We can avoid that entirely: because `NewsEntries` is a **public mutable list**, the bridge overlay inserts and removes directly — `TownCryerSystem.NewsEntries.Insert(0, entry)` / `.Remove(entry)` — and keeps the "which entries are ours" bookkeeping in an **overlay-side list**, not in a new field on the stock class. This is exactly how `BridgeTownCrier` already mutates `GlobalTownCrierEntryList` from the overlay. Pure overlay, zero stock edits.
|
||||
- **Track our entries to keep stock intact.** Rather than the pasted `ExternalNewsEntries` field on the stock class, the overlay holds `List<TownCryerNewsEntry> _ours`. On a sync we `Remove` our previous entries from `NewsEntries` and insert the new set — the stock `uo.com` articles are never touched. `MaxNewsEntries` is 100 (`TownCryerSystem.cs:26`); the overlay caps its own contribution well under that.
|
||||
|
||||
Everything else in the pasted note stands, and the "this is one of the easier integrations — you're replacing the content provider" framing is right.
|
||||
|
||||
### 16.3 The two surfaces, tied together
|
||||
|
||||
On an inbound article the bridge does two things on the Core thread:
|
||||
|
||||
1. **News gump** — build `new TownCryerNewsEntry(new TextDefinition(title), new TextDefinition(body), image, null, url)` and `Insert(0, …)` at the top of `TownCryerSystem.NewsEntries`, tracking it in `_ours`; trim `_ours` past the cap by removing the oldest (from both `_ours` and `NewsEntries`).
|
||||
2. **Say the title** — reuse the scrolling-crier path (`GlobalTownCrierEntryList`, as `BridgeTownCrier` does) to announce a single line, the **title only**, for a short duration, so the crier proclaims it in-world. **On by default**; set `announce: false` on an article to suppress it (e.g. a silent correction that should not re-proclaim).
|
||||
|
||||
### 16.4 Protocol
|
||||
|
||||
```jsonc
|
||||
// website → sidecar → shard
|
||||
{"kind":"news.add","id":"42","title":"Double XP Weekend",
|
||||
"body":"<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.",
|
||||
"image":1614,"url":"https://uomysticmoon.com/news/42"}
|
||||
// announce defaults to true; add "announce":false to suppress the crier proclamation
|
||||
{"kind":"news.remove","id":"42"}
|
||||
```
|
||||
|
||||
- Correlated by `id` (echoed on the reply), like town-crier. Re-adding an `id` **replaces** the prior entry (find-by-id in `_ours`, remove, re-insert) — idempotent.
|
||||
- `title` required; `body`/`image`/`url` optional (a title-only blurb is valid). `image` defaults to a neutral scroll gump id when absent.
|
||||
- Caps (defense in depth, mirroring `TownCrier*`): title/body length, max external entries. Replies `news.ok` / `news.error`.
|
||||
- Sidecar: `POST /news` (add/replace), `DELETE /news/{id}`. Same `respond`-style status mapping as town-crier.
|
||||
|
||||
### 16.5 Restart & re-sync (the source-of-truth rule)
|
||||
|
||||
`NewsEntries` is **not persisted** by ServUO — it is rebuilt at every boot from stock `Initialize()` plus whatever we have inserted since. So our external articles vanish on a shard restart until re-pushed. The **website is the source of truth**: the sidecar re-sends the current external news set on every shard (re)connect, the same discipline §12.2 uses for the diff boards. (The sidecar persists the external set in its store so it can replay it without the website being up.)
|
||||
|
||||
### 16.6 Where the code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeNews.cs` | **New.** `news.add` / `news.remove`: insert/remove `TownCryerNewsEntry` in the public `NewsEntries` list, track `_ours`, cap; optional title announcement via `GlobalTownCrierEntryList`; replies + caps. No stock edit. |
|
||||
| `overlay/Config/Bridge.cfg` | **Extend.** `NewsMaxTitleLength`, `NewsMaxBodyLength`, `NewsMaxExternal`, default announce duration. |
|
||||
| `sidecar/src/web.rs` + `store.rs` | **Extend.** `POST /news`, `DELETE /news/{id}`; persist the external-news set; replay it on shard (re)connect. |
|
||||
| `docs/INTEGRATION.md` | **Extend.** The `news.*` verbs + endpoints. |
|
||||
|
||||
No core or stock ServUO change — the whole integration rides the public `TownCryerSystem.NewsEntries` list and the existing crier say path.
|
||||
@@ -19,6 +19,35 @@ StatSweepSeconds=30
|
||||
DecaySweepSeconds=60
|
||||
EconomySweepSeconds=300
|
||||
|
||||
# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
|
||||
# this interval to emit champ.update on any status/level/kills/boss change. The world holds
|
||||
# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
|
||||
ChampSweepSeconds=10
|
||||
|
||||
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
|
||||
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
|
||||
# support queue; the full open queue is also available on demand via pages.snapshot.
|
||||
PageSweepSeconds=5
|
||||
|
||||
# Guild roster poll (docs/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.
|
||||
LinkUrl=https://yoursite/link
|
||||
|
||||
@@ -29,6 +58,58 @@ TownCrierMaxLineLength=200
|
||||
TownCrierMaxActive=20
|
||||
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 (docs/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
|
||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
|
||||
return;
|
||||
|
||||
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
|
||||
CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
|
||||
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ---- [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 ----
|
||||
|
||||
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 (docs/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 (docs/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 (docs/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 docs/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":
|
||||
BridgeConfig.Load();
|
||||
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: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||
break;
|
||||
@@ -170,8 +176,18 @@ namespace Server.Custom.Bridge
|
||||
|
||||
case "sweepnow":
|
||||
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: {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;
|
||||
|
||||
default:
|
||||
@@ -181,6 +197,12 @@ namespace Server.Custom.Bridge
|
||||
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", 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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
/// <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 (docs/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>
|
||||
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
|
||||
/// reads as "Bridge.Port" here.
|
||||
@@ -17,6 +29,12 @@ namespace Server.Custom.Bridge
|
||||
public static int StatSweepSeconds { get; private set; }
|
||||
public static int DecaySweepSeconds { get; private set; }
|
||||
public static int EconomySweepSeconds { get; private set; }
|
||||
public static int PageSweepSeconds { get; private set; }
|
||||
public static int ChampSweepSeconds { get; private set; }
|
||||
public static 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; }
|
||||
|
||||
@@ -25,6 +43,25 @@ namespace Server.Custom.Bridge
|
||||
public static int TownCrierMaxActive { get; private set; }
|
||||
public static int TownCrierMaxDurationSec { get; private set; }
|
||||
|
||||
// Town Cryer news gump (docs/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 (docs/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 void Configure()
|
||||
@@ -44,6 +81,31 @@ namespace Server.Custom.Bridge
|
||||
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
||||
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||
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 (docs/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");
|
||||
|
||||
@@ -52,15 +114,100 @@ namespace Server.Custom.Bridge
|
||||
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
||||
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)
|
||||
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()
|
||||
{
|
||||
return String.Format(
|
||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s)",
|
||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds);
|
||||
"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,
|
||||
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 (docs/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 (docs/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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,46 @@ namespace Server.Custom.Bridge
|
||||
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>
|
||||
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
|
||||
/// (docs/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 (docs/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,55 @@ namespace Server.Custom.Bridge
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
WriteTitles(sb, m);
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The titles a character holds (docs/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)
|
||||
{
|
||||
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 (docs/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 docs/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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,24 @@ 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`.
|
||||
|
||||
## 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`
|
||||
|
||||
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.
|
||||
|
||||
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;
|
||||
|
||||
@@ -142,7 +142,10 @@ fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
|
||||
let text = fs::read_to_string(path)?;
|
||||
let line = format!("auth_token = \"{token}\"");
|
||||
|
||||
if text.lines().any(|l| l.trim_start().starts_with("auth_token")) {
|
||||
if text
|
||||
.lines()
|
||||
.any(|l| l.trim_start().starts_with("auth_token"))
|
||||
{
|
||||
let out: String = text
|
||||
.lines()
|
||||
.map(|l| {
|
||||
|
||||
@@ -20,7 +20,11 @@ use tracing_subscriber::EnvFilter;
|
||||
/// 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
|
||||
/// 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]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -75,6 +79,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let route_rpc = rpc.clone();
|
||||
let event_store = store.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;
|
||||
tokio::spawn(async move {
|
||||
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.
|
||||
if ev.kind != "pong" {
|
||||
let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms);
|
||||
let t = ev
|
||||
.value
|
||||
.get("t")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or_else(now_ms);
|
||||
let text = ev.value.to_string();
|
||||
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
||||
tracing::warn!(error = %e, "failed to persist event");
|
||||
}
|
||||
|
||||
// The champ board is a live projection: champ.update folds in the latest state (one
|
||||
// row per spawn), champ.remove drops a spawn that despawned or was slain.
|
||||
match ev.kind.as_str() {
|
||||
"champ.update" => {
|
||||
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||
if let Err(e) = event_store
|
||||
.upsert_champ(
|
||||
serial,
|
||||
ev.value.get("status").and_then(|s| s.as_str()),
|
||||
ev.value.get("name").and_then(|n| n.as_str()),
|
||||
&text,
|
||||
t,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to upsert champ board");
|
||||
}
|
||||
}
|
||||
}
|
||||
"champ.remove" => {
|
||||
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||
if let Err(e) = event_store.delete_champ(serial).await {
|
||||
tracing::warn!(error = %e, "failed to remove champ board row");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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());
|
||||
|
||||
@@ -56,10 +56,7 @@ impl Rpc {
|
||||
) -> Result<Value, RpcError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
self.pending
|
||||
.lock()
|
||||
.await
|
||||
.insert(corr_val.to_string(), tx);
|
||||
self.pending.lock().await.insert(corr_val.to_string(), tx);
|
||||
|
||||
if !shard.send(command.to_string()).await {
|
||||
self.pending.lock().await.remove(corr_val);
|
||||
|
||||
@@ -21,8 +21,8 @@ pub struct Store {
|
||||
impl Store {
|
||||
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
|
||||
pub async fn open(path: &str) -> anyhow::Result<Self> {
|
||||
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?
|
||||
.create_if_missing(true);
|
||||
let opts =
|
||||
SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(4)
|
||||
@@ -78,7 +78,12 @@ impl Store {
|
||||
self.recent(Some("economy.supply"), limit).await
|
||||
}
|
||||
|
||||
pub async fn record_link(&self, account: &str, website_user_id: &str, t: i64) -> anyhow::Result<()> {
|
||||
pub async fn record_link(
|
||||
&self,
|
||||
account: &str,
|
||||
website_user_id: &str,
|
||||
t: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?)
|
||||
ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t",
|
||||
@@ -99,6 +104,16 @@ impl Store {
|
||||
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(
|
||||
&self,
|
||||
serial: &str,
|
||||
@@ -128,6 +143,191 @@ impl Store {
|
||||
.await?;
|
||||
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
||||
}
|
||||
|
||||
/// Upserts one champion-spawn's latest state, keyed by serial. Fed from the `champ.update`
|
||||
/// stream; this table is the live board the website reads, so there is exactly one row per
|
||||
/// spawn and it always holds the most recent snapshot.
|
||||
pub async fn upsert_champ(
|
||||
&self,
|
||||
serial: &str,
|
||||
status: Option<&str>,
|
||||
name: Option<&str>,
|
||||
json: &str,
|
||||
t: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO champs (serial, status, name, json, updated_t) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(serial) DO UPDATE SET status = excluded.status, name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||
)
|
||||
.bind(serial)
|
||||
.bind(status)
|
||||
.bind(name)
|
||||
.bind(json)
|
||||
.bind(t)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops one spawn from the board. Fed from the `champ.remove` stream: a controller that was
|
||||
/// deleted, or a transient sea boss that was slain, leaves the board this way.
|
||||
pub async fn delete_champ(&self, serial: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM champs WHERE serial = ?")
|
||||
.bind(serial)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The full champion-spawn board: every spawn's latest snapshot. Ordered by name so the site
|
||||
/// gets a stable list.
|
||||
pub async fn champs_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||
let rows = sqlx::query("SELECT json FROM champs ORDER BY name, serial")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(parse_json_column(rows))
|
||||
}
|
||||
|
||||
// ---- 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> {
|
||||
@@ -158,4 +358,38 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS champs (
|
||||
serial TEXT PRIMARY KEY,
|
||||
status TEXT,
|
||||
name TEXT,
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
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))
|
||||
// Inbound commands (correlated by code / id).
|
||||
.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/: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.
|
||||
.route("/history", get(history))
|
||||
.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 (docs/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));
|
||||
|
||||
let app = Router::new()
|
||||
@@ -113,7 +135,8 @@ fn iso_ms(ms: i64) -> Option<String> {
|
||||
if ms <= 0 {
|
||||
return None;
|
||||
}
|
||||
chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
chrono::DateTime::from_timestamp_millis(ms)
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
}
|
||||
|
||||
// ---- gate: protocol check + auth ----
|
||||
@@ -164,8 +187,15 @@ async fn gate(State(st): State<AppState>, req: Request, next: Next) -> Response
|
||||
|
||||
fn extract_token(req: &Request) -> Option<String> {
|
||||
// Authorization: Bearer <token>
|
||||
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) {
|
||||
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) {
|
||||
if let Some(v) = req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
if let Some(rest) = v
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| v.strip_prefix("bearer "))
|
||||
{
|
||||
return Some(rest.trim().to_string());
|
||||
}
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
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`.
|
||||
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
let code = body.get("code").and_then(|c| c.as_str()).unwrap_or_default();
|
||||
let code = body
|
||||
.get("code")
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or_default();
|
||||
let web_id = body
|
||||
.get("websiteUserId")
|
||||
.and_then(|w| w.as_str())
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
async fn towncrier_remove(
|
||||
State(st): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||
let cmd = json!({"kind":"towncrier.remove","id":id});
|
||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||
}
|
||||
|
||||
/// 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) ----
|
||||
|
||||
#[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 ----
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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