Compare commits
16 Commits
11169c52a6
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d21ef0b63 | |||
| 0550129f8e | |||
| 09c59b256e | |||
| 45227b1a74 | |||
| 78fcb7effb | |||
| ddf14bdab0 | |||
| 4badd15f91 | |||
| e30eec5e4d | |||
| b96a867691 | |||
| fa7f1f786b | |||
| b258ee3e60 | |||
| 9df337e186 | |||
| 359bb937b2 | |||
| 5562e09fb0 | |||
| c515a86a87 | |||
| 17e1c91fb3 |
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
|
||||||
@@ -140,13 +140,15 @@ It turns "a staff member must be logged into the game to see the queue" into "th
|
|||||||
> - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event.
|
> - *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.
|
> - *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 1 + the bidirectional-audit slice are complete.** Remaining is downstream (website UI + moderation log) and the later Phase 2 (help-page queue) / Phase 3 work.
|
> **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:**
|
**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.
|
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.
|
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. Add as the web moderation panel matures.
|
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).
|
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).
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,56 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|
|||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| `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. |
|
| `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
|
## 5. REST — read queries
|
||||||
@@ -351,6 +401,31 @@ Each applied action also emits an unsolicited **`admin.audit`** frame on the Web
|
|||||||
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
|
`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"`.
|
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)
|
### History (from the sidecar's database)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -368,6 +443,29 @@ GET /economy?limit=200
|
|||||||
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
→ { "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
|
## 7. Status codes
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ StatSweepSeconds=30
|
|||||||
DecaySweepSeconds=60
|
DecaySweepSeconds=60
|
||||||
EconomySweepSeconds=300
|
EconomySweepSeconds=300
|
||||||
|
|
||||||
|
# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
|
||||||
|
# this interval to emit champ.update on any status/level/kills/boss change. The world holds
|
||||||
|
# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
|
||||||
|
ChampSweepSeconds=10
|
||||||
|
|
||||||
|
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
|
||||||
|
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
|
||||||
|
# support queue; the full open queue is also available on demand via pages.snapshot.
|
||||||
|
PageSweepSeconds=5
|
||||||
|
|
||||||
# Shown to a player when they run [link. The website page where they enter the code.
|
# Shown to a player when they run [link. The website page where they enter the code.
|
||||||
LinkUrl=https://yoursite/link
|
LinkUrl=https://yoursite/link
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ namespace Server.Custom.Bridge
|
|||||||
case "reload":
|
case "reload":
|
||||||
BridgeConfig.Load();
|
BridgeConfig.Load();
|
||||||
BridgeSweeps.Rearm();
|
BridgeSweeps.Rearm();
|
||||||
|
BridgePages.Rearm();
|
||||||
|
BridgeChamps.Rearm();
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||||
break;
|
break;
|
||||||
@@ -170,8 +172,10 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
case "sweepnow":
|
case "sweepnow":
|
||||||
BridgeSweeps.SweepOnce();
|
BridgeSweeps.SweepOnce();
|
||||||
|
BridgeChamps.SweepOnce();
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -181,6 +185,8 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
||||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
287
overlay/Scripts/Custom/Bridge/BridgeChamps.cs
Normal file
287
overlay/Scripts/Custom/Bridge/BridgeChamps.cs
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using Server.Engines.CannedEvil;
|
||||||
|
using Server.Engines.MiniChamps;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The champion-spawn stream. Like the streams in <see cref="BridgeSweeps"/>, this is polled:
|
||||||
|
/// none of the three champion families expose an EventSink, so their whole lifecycle lives
|
||||||
|
/// inside a per-second SliceTimer and is invisible to a subscriber. Instead we enumerate them
|
||||||
|
/// each tick, fold each to a small record, and emit `champ.update` only when that record
|
||||||
|
/// changes. A 5-10s sweep is well within the site's tolerance and the world holds only a
|
||||||
|
/// handful of spawns, so the pass is trivially cheap.
|
||||||
|
///
|
||||||
|
/// Three families, distinguished by the `category` field:
|
||||||
|
/// champion - ChampionSpawn: the classic Felucca-style altar (type/level/kills/boss/cooldown)
|
||||||
|
/// mini - MiniChamp: the TerMur mini-champ controller (type/level, auto-restarts)
|
||||||
|
/// sea - BaseSeaChampion: a High Seas world boss Mobile, alive only while summoned
|
||||||
|
///
|
||||||
|
/// Status folds public fields into three values (no core patch needed):
|
||||||
|
/// active - running / alive
|
||||||
|
/// cooldown - stopped but a restart is pending (ChampionSpawn: RestartTime ahead; MiniChamp:
|
||||||
|
/// inactive, since it always re-arms a restart)
|
||||||
|
/// dormant - stopped with nothing scheduled (ChampionSpawn only; a GM must turn it on)
|
||||||
|
///
|
||||||
|
/// The sidecar keeps the latest record per serial as a live board. A permanent controller's
|
||||||
|
/// row lives as long as the item; a transient sea boss is removed with `champ.remove` when it
|
||||||
|
/// dies or despawns. A (re)connection clears the diff cache (see OnConnected) so the next
|
||||||
|
/// sweep re-emits every spawn in full, rebuilding a sidecar that restarted on its own.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeChamps
|
||||||
|
{
|
||||||
|
private static Timer _timer;
|
||||||
|
|
||||||
|
// Last-emitted signature per tracked serial. A serial absent from this map has never been
|
||||||
|
// emitted (or the cache was cleared on reconnect), so its next sweep counts as a change.
|
||||||
|
// Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
|
||||||
|
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
||||||
|
|
||||||
|
private static long _sweeps, _emitted, _removed;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EventSink.ServerStarted += OnServerStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnServerStarted()
|
||||||
|
{
|
||||||
|
// Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
|
||||||
|
// independently of the shard rebuilds its state within one sweep.
|
||||||
|
BridgeLink.Connected_Core += OnConnected;
|
||||||
|
Rearm();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnConnected()
|
||||||
|
{
|
||||||
|
_last.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||||
|
public static void Rearm()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
_timer = Timer.DelayCall(
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds),
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds),
|
||||||
|
ChampSweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop()
|
||||||
|
{
|
||||||
|
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})",
|
||||||
|
_sweeps, _emitted, _removed, _last.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
|
public static void SweepOnce()
|
||||||
|
{
|
||||||
|
ChampSweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ChampSweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sweeps++;
|
||||||
|
|
||||||
|
if (!BridgeLink.Connected)
|
||||||
|
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||||
|
|
||||||
|
var seen = new HashSet<Serial>();
|
||||||
|
|
||||||
|
foreach (var s in World.Items.Values.OfType<ChampionSpawn>())
|
||||||
|
{
|
||||||
|
if (s.Deleted)
|
||||||
|
continue;
|
||||||
|
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var s in World.Items.Values.OfType<MiniChamp>())
|
||||||
|
{
|
||||||
|
if (s.Deleted)
|
||||||
|
continue;
|
||||||
|
Track(seen, s.Serial, SigMini(s), WriteMini(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var b in World.Mobiles.Values.OfType<BaseSeaChampion>())
|
||||||
|
{
|
||||||
|
if (b.Deleted || !b.Alive)
|
||||||
|
continue;
|
||||||
|
Track(seen, b.Serial, SigSea(b), WriteSea(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything tracked last sweep but not seen now has gone away (a controller deleted, a
|
||||||
|
// sea boss slain). Tell the sidecar to drop its board row.
|
||||||
|
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||||
|
foreach (var serial in gone)
|
||||||
|
{
|
||||||
|
_last.Remove(serial);
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
|
||||||
|
_removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] champ sweep threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a spawn as seen and emits it only if its signature changed since last sweep.</summary>
|
||||||
|
private static void Track(HashSet<Serial> seen, Serial serial, string sig, string line)
|
||||||
|
{
|
||||||
|
seen.Add(serial);
|
||||||
|
|
||||||
|
string prior;
|
||||||
|
if (_last.TryGetValue(serial, out prior) && prior == sig)
|
||||||
|
return; // unchanged since last emit
|
||||||
|
|
||||||
|
_last[serial] = sig;
|
||||||
|
BridgeLink.Emit(line);
|
||||||
|
_emitted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ChampionSpawn (classic) ----
|
||||||
|
|
||||||
|
private static string StatusOf(ChampionSpawn s)
|
||||||
|
{
|
||||||
|
if (s.Active)
|
||||||
|
return "active";
|
||||||
|
if (s.RestartTime > DateTime.UtcNow)
|
||||||
|
return "cooldown";
|
||||||
|
return "dormant";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The volatile fields that define a meaningful change. Kept in sync with WriteChampion so the
|
||||||
|
// site never misses a level, a kill-count tick, a boss pop, or a status/cooldown transition.
|
||||||
|
private static string SigChampion(ChampionSpawn s)
|
||||||
|
{
|
||||||
|
return String.Concat(
|
||||||
|
"champion|", StatusOf(s), "|",
|
||||||
|
s.Level.ToString(), "|",
|
||||||
|
s.Kills.ToString(), "|",
|
||||||
|
(s.Champion != null && !s.Champion.Deleted) ? "1" : "0", "|",
|
||||||
|
s.RestartTime.Ticks.ToString(), "|",
|
||||||
|
s.ExpireTime.Ticks.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteChampion(ChampionSpawn s)
|
||||||
|
{
|
||||||
|
var status = StatusOf(s);
|
||||||
|
var bossUp = s.Champion != null && !s.Champion.Deleted;
|
||||||
|
|
||||||
|
// Prefer a staff-set display name, then the group, then the spawn type.
|
||||||
|
string name = !String.IsNullOrEmpty(s.SpawnName) ? s.SpawnName
|
||||||
|
: !String.IsNullOrEmpty(s.GroupName) ? s.GroupName
|
||||||
|
: s.Type.ToString();
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("champ.update")
|
||||||
|
.Ser("serial", s.Serial)
|
||||||
|
.Str("category", "champion")
|
||||||
|
.Str("type", s.Type.ToString())
|
||||||
|
.Str("name", name)
|
||||||
|
.Str("status", status)
|
||||||
|
.Bool("active", s.Active)
|
||||||
|
.Num("level", s.Level)
|
||||||
|
.Num("rank", s.Rank)
|
||||||
|
.Num("kills", s.Kills)
|
||||||
|
.Num("maxKills", s.MaxKills)
|
||||||
|
.Bool("bossUp", bossUp)
|
||||||
|
.Bool("autoRestart", s.AutoRestart)
|
||||||
|
.Str("map", s.Map == null ? null : s.Map.Name)
|
||||||
|
.Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
|
||||||
|
|
||||||
|
if (bossUp)
|
||||||
|
sb.Str("boss", String.IsNullOrEmpty(s.Champion.Name) ? s.Champion.GetType().Name : s.Champion.Name);
|
||||||
|
|
||||||
|
// Cooldown ETA: when the spawn will auto-restart. Only meaningful while on cooldown.
|
||||||
|
if (status == "cooldown")
|
||||||
|
sb.Str("restartAt", s.RestartTime.ToUniversalTime().ToString("o"));
|
||||||
|
|
||||||
|
// Level-expiry ETA: when the current level times out if kills stall. Only while active.
|
||||||
|
if (s.Active)
|
||||||
|
sb.Str("expireAt", s.ExpireTime.ToUniversalTime().ToString("o"));
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MiniChamp (TerMur mini-champs) ----
|
||||||
|
|
||||||
|
// MiniChamp exposes no kills, no boss handle, and no restart-time getter. When inactive it has
|
||||||
|
// always re-armed a restart, so inactive folds to "cooldown" (there is no dormant state and no
|
||||||
|
// ETA to report).
|
||||||
|
private static string SigMini(MiniChamp s)
|
||||||
|
{
|
||||||
|
return String.Concat(
|
||||||
|
"mini|", (s.Active ? "active" : "cooldown"), "|", s.Level.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteMini(MiniChamp s)
|
||||||
|
{
|
||||||
|
var status = s.Active ? "active" : "cooldown";
|
||||||
|
var info = MiniChampInfo.GetInfo(s.Type);
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("champ.update")
|
||||||
|
.Ser("serial", s.Serial)
|
||||||
|
.Str("category", "mini")
|
||||||
|
.Str("type", s.Type.ToString())
|
||||||
|
.Str("name", s.Type.ToString())
|
||||||
|
.Str("status", status)
|
||||||
|
.Bool("active", s.Active)
|
||||||
|
.Num("level", s.Level)
|
||||||
|
.Bool("bossUp", false)
|
||||||
|
.Bool("autoRestart", true)
|
||||||
|
.Str("map", s.Map == null ? null : s.Map.Name)
|
||||||
|
.Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
|
||||||
|
|
||||||
|
if (info != null)
|
||||||
|
sb.Num("maxLevel", info.MaxLevel);
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BaseSeaChampion (High Seas world boss) ----
|
||||||
|
|
||||||
|
// A sea champion is a Mobile, not a controller: it exists only while summoned and alive, so it
|
||||||
|
// is always "active" on the board and leaves via champ.remove when slain. Position and health
|
||||||
|
// are tracked so the board can show a live "world boss here, N% hp".
|
||||||
|
private static string SigSea(BaseSeaChampion b)
|
||||||
|
{
|
||||||
|
return String.Concat(
|
||||||
|
"sea|", b.Hits.ToString(), "|", b.X.ToString(), "|", b.Y.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string WriteSea(BaseSeaChampion b)
|
||||||
|
{
|
||||||
|
string name = String.IsNullOrEmpty(b.Name) ? b.GetType().Name : b.Name;
|
||||||
|
|
||||||
|
return BridgeJson.Begin("champ.update")
|
||||||
|
.Ser("serial", b.Serial)
|
||||||
|
.Str("category", "sea")
|
||||||
|
.Str("type", b.GetType().Name)
|
||||||
|
.Str("name", name)
|
||||||
|
.Str("status", "active")
|
||||||
|
.Bool("active", true)
|
||||||
|
.Bool("bossUp", true)
|
||||||
|
.Str("boss", name)
|
||||||
|
.Num("hits", b.Hits)
|
||||||
|
.Num("hitsMax", b.HitsMax)
|
||||||
|
.Str("map", b.Map == null ? null : b.Map.Name)
|
||||||
|
.Num("x", b.X).Num("y", b.Y).Num("z", b.Z)
|
||||||
|
.End();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ namespace Server.Custom.Bridge
|
|||||||
public static int StatSweepSeconds { get; private set; }
|
public static int StatSweepSeconds { get; private set; }
|
||||||
public static int DecaySweepSeconds { get; private set; }
|
public static int DecaySweepSeconds { get; private set; }
|
||||||
public static int EconomySweepSeconds { get; private set; }
|
public static int EconomySweepSeconds { get; private set; }
|
||||||
|
public static int PageSweepSeconds { get; private set; }
|
||||||
|
public static int ChampSweepSeconds { get; private set; }
|
||||||
|
|
||||||
public static string LinkUrl { get; private set; }
|
public static string LinkUrl { get; private set; }
|
||||||
|
|
||||||
@@ -50,6 +52,13 @@ namespace Server.Custom.Bridge
|
|||||||
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
||||||
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||||
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
||||||
|
PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
|
||||||
|
if (PageSweepSeconds < 1)
|
||||||
|
PageSweepSeconds = 1;
|
||||||
|
|
||||||
|
ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
|
||||||
|
if (ChampSweepSeconds < 1)
|
||||||
|
ChampSweepSeconds = 1;
|
||||||
|
|
||||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||||
|
|
||||||
@@ -86,9 +95,9 @@ namespace Server.Custom.Bridge
|
|||||||
public static string Describe()
|
public static string Describe()
|
||||||
{
|
{
|
||||||
return String.Format(
|
return String.Format(
|
||||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s) adminWrite={7}(floor={8})",
|
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})",
|
||||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||||
AdminWriteEnabled, AdminAccessFloor);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,10 @@ fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
|
|||||||
let text = fs::read_to_string(path)?;
|
let text = fs::read_to_string(path)?;
|
||||||
let line = format!("auth_token = \"{token}\"");
|
let line = format!("auth_token = \"{token}\"");
|
||||||
|
|
||||||
if text.lines().any(|l| l.trim_start().starts_with("auth_token")) {
|
if text
|
||||||
|
.lines()
|
||||||
|
.any(|l| l.trim_start().starts_with("auth_token"))
|
||||||
|
{
|
||||||
let out: String = text
|
let out: String = text
|
||||||
.lines()
|
.lines()
|
||||||
.map(|l| {
|
.map(|l| {
|
||||||
|
|||||||
@@ -96,11 +96,44 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
|
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
|
||||||
if ev.kind != "pong" {
|
if ev.kind != "pong" {
|
||||||
let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms);
|
let t = ev
|
||||||
|
.value
|
||||||
|
.get("t")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or_else(now_ms);
|
||||||
let text = ev.value.to_string();
|
let text = ev.value.to_string();
|
||||||
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
||||||
tracing::warn!(error = %e, "failed to persist event");
|
tracing::warn!(error = %e, "failed to persist event");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The champ board is a live projection: champ.update folds in the latest state (one
|
||||||
|
// row per spawn), champ.remove drops a spawn that despawned or was slain.
|
||||||
|
match ev.kind.as_str() {
|
||||||
|
"champ.update" => {
|
||||||
|
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||||
|
if let Err(e) = event_store
|
||||||
|
.upsert_champ(
|
||||||
|
serial,
|
||||||
|
ev.value.get("status").and_then(|s| s.as_str()),
|
||||||
|
ev.value.get("name").and_then(|n| n.as_str()),
|
||||||
|
&text,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to upsert champ board");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"champ.remove" => {
|
||||||
|
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||||
|
if let Err(e) = event_store.delete_champ(serial).await {
|
||||||
|
tracing::warn!(error = %e, "failed to remove champ board row");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = feed_tx.send(ev.value.to_string());
|
let _ = feed_tx.send(ev.value.to_string());
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ impl Rpc {
|
|||||||
) -> Result<Value, RpcError> {
|
) -> Result<Value, RpcError> {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
self.pending
|
self.pending.lock().await.insert(corr_val.to_string(), tx);
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(corr_val.to_string(), tx);
|
|
||||||
|
|
||||||
if !shard.send(command.to_string()).await {
|
if !shard.send(command.to_string()).await {
|
||||||
self.pending.lock().await.remove(corr_val);
|
self.pending.lock().await.remove(corr_val);
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ pub struct Store {
|
|||||||
impl Store {
|
impl Store {
|
||||||
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
|
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
|
||||||
pub async fn open(path: &str) -> anyhow::Result<Self> {
|
pub async fn open(path: &str) -> anyhow::Result<Self> {
|
||||||
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?
|
let opts =
|
||||||
.create_if_missing(true);
|
SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?.create_if_missing(true);
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(4)
|
.max_connections(4)
|
||||||
@@ -78,7 +78,12 @@ impl Store {
|
|||||||
self.recent(Some("economy.supply"), limit).await
|
self.recent(Some("economy.supply"), limit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn record_link(&self, account: &str, website_user_id: &str, t: i64) -> anyhow::Result<()> {
|
pub async fn record_link(
|
||||||
|
&self,
|
||||||
|
account: &str,
|
||||||
|
website_user_id: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?)
|
"INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t",
|
ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t",
|
||||||
@@ -128,6 +133,50 @@ impl Store {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upserts one champion-spawn's latest state, keyed by serial. Fed from the `champ.update`
|
||||||
|
/// stream; this table is the live board the website reads, so there is exactly one row per
|
||||||
|
/// spawn and it always holds the most recent snapshot.
|
||||||
|
pub async fn upsert_champ(
|
||||||
|
&self,
|
||||||
|
serial: &str,
|
||||||
|
status: Option<&str>,
|
||||||
|
name: Option<&str>,
|
||||||
|
json: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO champs (serial, status, name, json, updated_t) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(serial) DO UPDATE SET status = excluded.status, name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(serial)
|
||||||
|
.bind(status)
|
||||||
|
.bind(name)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops one spawn from the board. Fed from the `champ.remove` stream: a controller that was
|
||||||
|
/// deleted, or a transient sea boss that was slain, leaves the board this way.
|
||||||
|
pub async fn delete_champ(&self, serial: &str) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM champs WHERE serial = ?")
|
||||||
|
.bind(serial)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full champion-spawn board: every spawn's latest snapshot. Ordered by name so the site
|
||||||
|
/// gets a stable list.
|
||||||
|
pub async fn champs_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||||
|
let rows = sqlx::query("SELECT json FROM champs ORDER BY name, serial")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(parse_json_column(rows))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
|
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,
|
json TEXT NOT NULL,
|
||||||
updated_t INTEGER NOT NULL
|
updated_t INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS champs (
|
||||||
|
serial TEXT PRIMARY KEY,
|
||||||
|
status TEXT,
|
||||||
|
name TEXT,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -63,9 +63,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
.route("/admin/ban", post(admin_ban))
|
.route("/admin/ban", post(admin_ban))
|
||||||
.route("/admin/unban", post(admin_unban))
|
.route("/admin/unban", post(admin_unban))
|
||||||
.route("/admin/broadcast", post(admin_broadcast))
|
.route("/admin/broadcast", post(admin_broadcast))
|
||||||
|
// Help-page (support) queue: snapshot the open queue, respond to / close a page.
|
||||||
|
.route("/pages", get(pages_list))
|
||||||
|
.route("/pages/:id/respond", post(page_respond))
|
||||||
|
.route("/pages/:id/close", post(page_close))
|
||||||
// History, read from SQLite rather than the shard.
|
// History, read from SQLite rather than the shard.
|
||||||
.route("/history", get(history))
|
.route("/history", get(history))
|
||||||
.route("/economy", get(economy))
|
.route("/economy", get(economy))
|
||||||
|
.route("/champs", get(champs))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -119,7 +124,8 @@ fn iso_ms(ms: i64) -> Option<String> {
|
|||||||
if ms <= 0 {
|
if ms <= 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
chrono::DateTime::from_timestamp_millis(ms)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- gate: protocol check + auth ----
|
// ---- gate: protocol check + auth ----
|
||||||
@@ -170,8 +176,15 @@ async fn gate(State(st): State<AppState>, req: Request, next: Next) -> Response
|
|||||||
|
|
||||||
fn extract_token(req: &Request) -> Option<String> {
|
fn extract_token(req: &Request) -> Option<String> {
|
||||||
// Authorization: Bearer <token>
|
// Authorization: Bearer <token>
|
||||||
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) {
|
if let Some(v) = req
|
||||||
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) {
|
.headers()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
{
|
||||||
|
if let Some(rest) = v
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| v.strip_prefix("bearer "))
|
||||||
|
{
|
||||||
return Some(rest.trim().to_string());
|
return Some(rest.trim().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,6 +343,48 @@ async fn admin_broadcast(State(st): State<AppState>, Json(body): Json<Value>) ->
|
|||||||
admin_call(&st, "admin.broadcast", body).await
|
admin_call(&st, "admin.broadcast", body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- help-page queue handlers ----
|
||||||
|
|
||||||
|
/// The open help-page queue, correlated on reqId. Returns a pages.list.
|
||||||
|
async fn pages_list(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({"kind": "pages.snapshot", "reqId": req_id});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"message":"...","close":<bool, optional>}. Delivers a staff response to the player.
|
||||||
|
async fn page_respond(
|
||||||
|
State(st): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let message = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if message.trim().is_empty() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "message is required"})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let close = body.get("close").and_then(|c| c.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({
|
||||||
|
"kind": "page.respond", "reqId": req_id,
|
||||||
|
"pageId": id, "message": message, "close": close
|
||||||
|
});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a page from the queue.
|
||||||
|
async fn page_close(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({"kind": "page.close", "reqId": req_id, "pageId": id});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- query handlers ----
|
// ---- query handlers ----
|
||||||
|
|
||||||
async fn char_by_slot(
|
async fn char_by_slot(
|
||||||
@@ -396,7 +451,10 @@ async fn vendors(State(st): State<AppState>, Path(account): Path<String>) -> imp
|
|||||||
|
|
||||||
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
|
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
|
||||||
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
let code = body.get("code").and_then(|c| c.as_str()).unwrap_or_default();
|
let code = body
|
||||||
|
.get("code")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
let web_id = body
|
let web_id = body
|
||||||
.get("websiteUserId")
|
.get("websiteUserId")
|
||||||
.and_then(|w| w.as_str())
|
.and_then(|w| w.as_str())
|
||||||
@@ -458,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)
|
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn towncrier_remove(
|
async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||||
State(st): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let cmd = json!({"kind":"towncrier.remove","id":id});
|
let cmd = json!({"kind":"towncrier.remove","id":id});
|
||||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||||
}
|
}
|
||||||
@@ -498,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 ----
|
// ---- websocket ----
|
||||||
|
|
||||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user