Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||
@@ -178,12 +178,63 @@ 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
|
||||
| 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. |
|
||||
|
||||
#### 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.
|
||||
|
||||
---
|
||||
|
||||
## 5. REST — read queries
|
||||
@@ -301,6 +352,80 @@ DELETE /towncrier/{id}
|
||||
|
||||
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
|
||||
|
||||
### 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 +443,29 @@ GET /economy?limit=200
|
||||
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
||||
```
|
||||
|
||||
### Champion-spawn board
|
||||
|
||||
```
|
||||
GET /champs
|
||||
```
|
||||
|
||||
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
|
||||
|
||||
```
|
||||
GET /champs
|
||||
→ { "spawns": [
|
||||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
||||
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
|
||||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
|
||||
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
|
||||
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
|
||||
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
|
||||
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
|
||||
] }
|
||||
```
|
||||
|
||||
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
|
||||
|
||||
---
|
||||
|
||||
## 7. Status codes
|
||||
|
||||
@@ -19,6 +19,16 @@ 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
|
||||
|
||||
# Shown to a player when they run [link. The website page where they enter the code.
|
||||
LinkUrl=https://yoursite/link
|
||||
|
||||
@@ -29,6 +39,24 @@ TownCrierMaxLineLength=200
|
||||
TownCrierMaxActive=20
|
||||
TownCrierMaxDurationSec=86400
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
362
overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
Normal file
362
overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
Normal file
@@ -0,0 +1,362 @@
|
||||
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.
|
||||
/// </summary>
|
||||
private 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.
|
||||
/// </summary>
|
||||
private 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,8 @@ namespace Server.Custom.Bridge
|
||||
case "reload":
|
||||
BridgeConfig.Load();
|
||||
BridgeSweeps.Rearm();
|
||||
BridgePages.Rearm();
|
||||
BridgeChamps.Rearm();
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||
break;
|
||||
@@ -170,8 +172,10 @@ namespace Server.Custom.Bridge
|
||||
|
||||
case "sweepnow":
|
||||
BridgeSweeps.SweepOnce();
|
||||
BridgeChamps.SweepOnce();
|
||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -181,6 +185,8 @@ namespace Server.Custom.Bridge
|
||||
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ 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 string LinkUrl { get; private set; }
|
||||
|
||||
@@ -25,6 +27,12 @@ namespace Server.Custom.Bridge
|
||||
public static int TownCrierMaxActive { get; private set; }
|
||||
public static int TownCrierMaxDurationSec { 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; }
|
||||
|
||||
public static bool Enabled { get; private set; }
|
||||
|
||||
public static void Configure()
|
||||
@@ -44,6 +52,13 @@ 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;
|
||||
|
||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||
|
||||
@@ -52,15 +67,37 @@ namespace Server.Custom.Bridge
|
||||
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
||||
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
|
||||
|
||||
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);
|
||||
|
||||
if (QueueCap < 16)
|
||||
QueueCap = 16;
|
||||
}
|
||||
|
||||
/// <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})",
|
||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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| {
|
||||
|
||||
@@ -96,11 +96,44 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
|
||||
if ev.kind != "pong" {
|
||||
let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms);
|
||||
let t = ev
|
||||
.value
|
||||
.get("t")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or_else(now_ms);
|
||||
let text = ev.value.to_string();
|
||||
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
||||
tracing::warn!(error = %e, "failed to persist event");
|
||||
}
|
||||
|
||||
// The champ board is a live projection: champ.update folds in the latest state (one
|
||||
// row per spawn), champ.remove drops a spawn that despawned or was slain.
|
||||
match ev.kind.as_str() {
|
||||
"champ.update" => {
|
||||
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||
if let Err(e) = event_store
|
||||
.upsert_champ(
|
||||
serial,
|
||||
ev.value.get("status").and_then(|s| s.as_str()),
|
||||
ev.value.get("name").and_then(|n| n.as_str()),
|
||||
&text,
|
||||
t,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to upsert champ board");
|
||||
}
|
||||
}
|
||||
}
|
||||
"champ.remove" => {
|
||||
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||
if let Err(e) = event_store.delete_champ(serial).await {
|
||||
tracing::warn!(error = %e, "failed to remove champ board row");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = feed_tx.send(ev.value.to_string());
|
||||
|
||||
@@ -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",
|
||||
@@ -128,6 +133,50 @@ impl Store {
|
||||
.await?;
|
||||
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
||||
}
|
||||
|
||||
/// Upserts one champion-spawn's latest state, keyed by serial. Fed from the `champ.update`
|
||||
/// stream; this table is the live board the website reads, so there is exactly one row per
|
||||
/// spawn and it always holds the most recent snapshot.
|
||||
pub async fn upsert_champ(
|
||||
&self,
|
||||
serial: &str,
|
||||
status: Option<&str>,
|
||||
name: Option<&str>,
|
||||
json: &str,
|
||||
t: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO champs (serial, status, name, json, updated_t) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(serial) DO UPDATE SET status = excluded.status, name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||
)
|
||||
.bind(serial)
|
||||
.bind(status)
|
||||
.bind(name)
|
||||
.bind(json)
|
||||
.bind(t)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops one spawn from the board. Fed from the `champ.remove` stream: a controller that was
|
||||
/// deleted, or a transient sea boss that was slain, leaves the board this way.
|
||||
pub async fn delete_champ(&self, serial: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM champs WHERE serial = ?")
|
||||
.bind(serial)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The full champion-spawn board: every spawn's latest snapshot. Ordered by name so the site
|
||||
/// gets a stable list.
|
||||
pub async fn champs_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||
let rows = sqlx::query("SELECT json FROM champs ORDER BY name, serial")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(parse_json_column(rows))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
|
||||
@@ -158,4 +207,12 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS champs (
|
||||
serial TEXT PRIMARY KEY,
|
||||
status TEXT,
|
||||
name TEXT,
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
@@ -57,9 +57,20 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
.route("/link/:account", get(link_lookup))
|
||||
.route("/towncrier", post(towncrier_add))
|
||||
.route("/towncrier/:id", axum::routing::delete(towncrier_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))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||
|
||||
let app = Router::new()
|
||||
@@ -113,7 +124,8 @@ fn iso_ms(ms: i64) -> Option<String> {
|
||||
if ms <= 0 {
|
||||
return None;
|
||||
}
|
||||
chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
chrono::DateTime::from_timestamp_millis(ms)
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
}
|
||||
|
||||
// ---- gate: protocol check + auth ----
|
||||
@@ -164,8 +176,15 @@ async fn gate(State(st): State<AppState>, req: Request, next: Next) -> Response
|
||||
|
||||
fn extract_token(req: &Request) -> Option<String> {
|
||||
// Authorization: Bearer <token>
|
||||
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) {
|
||||
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) {
|
||||
if let Some(v) = req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
if let Some(rest) = v
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| v.strip_prefix("bearer "))
|
||||
{
|
||||
return Some(rest.trim().to_string());
|
||||
}
|
||||
}
|
||||
@@ -231,6 +250,141 @@ 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"})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 +451,10 @@ async fn vendors(State(st): State<AppState>, Path(account): Path<String>) -> imp
|
||||
|
||||
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
|
||||
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
let code = body.get("code").and_then(|c| c.as_str()).unwrap_or_default();
|
||||
let code = body
|
||||
.get("code")
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or_default();
|
||||
let web_id = body
|
||||
.get("websiteUserId")
|
||||
.and_then(|w| w.as_str())
|
||||
@@ -359,10 +516,7 @@ async fn towncrier_add(State(st): State<AppState>, Json(body): Json<Value>) -> i
|
||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||
}
|
||||
|
||||
async fn towncrier_remove(
|
||||
State(st): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||
let cmd = json!({"kind":"towncrier.remove","id":id});
|
||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||
}
|
||||
@@ -399,6 +553,19 @@ async fn economy(State(st): State<AppState>, Query(q): Query<HistoryQuery>) -> i
|
||||
}
|
||||
}
|
||||
|
||||
/// The champion-spawn board: every spawn's latest state (status/level/kills/boss/location and, when
|
||||
/// relevant, the cooldown ETA). Served from the local board table, so it answers without touching
|
||||
/// the shard and survives a shard outage with the last-known snapshot.
|
||||
async fn champs(State(st): State<AppState>) -> impl IntoResponse {
|
||||
match st.store.champs_all().await {
|
||||
Ok(spawns) => (StatusCode::OK, Json(json!({"spawns": spawns}))),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- websocket ----
|
||||
|
||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
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