Compare commits
25 Commits
v0.3.0
...
f4b71f58fd
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b71f58fd | |||
| 8c4dc0ee93 | |||
| 2301c57768 | |||
| cfe9ec9017 | |||
| 5f50b881ca | |||
| 05e192ca70 | |||
| 480423090a | |||
| 2e386a9d5c | |||
| 21a1462e62 | |||
| 41811d40af | |||
| 8e018a01e9 | |||
| 7e4177c6c0 | |||
| ecdaf9171b | |||
| f23b9030ed | |||
| 31497c38b2 | |||
| 21b9920f80 | |||
| 7224b9b834 | |||
| 45bb8b0de4 | |||
|
|
6138ba8c65 | ||
| a7bf3c7bb1 | |||
| 21d5de9fd4 | |||
| 2b0d6635bb | |||
| 8751151abc | |||
| 6542282ffb | |||
| 957f5701d4 |
41
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
41
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Report something that is broken or behaving unexpectedly
|
||||||
|
title: "[bug] "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- A clear, concise description of the bug. -->
|
||||||
|
|
||||||
|
## Steps to reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
|
||||||
|
<!-- What you expected to happen. -->
|
||||||
|
|
||||||
|
## Actual behavior
|
||||||
|
|
||||||
|
<!-- What actually happened. Include exact error messages and logs if you have them. -->
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Component / repo:
|
||||||
|
- Version or commit:
|
||||||
|
- OS / runtime (Node, Rust, ServUO, browser…):
|
||||||
|
- Deployment (Docker Compose, local dev, bare metal…):
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Screenshots, config (with secrets redacted), anything else that helps. -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Security issue? Do NOT file it here. See SECURITY.md and email
|
||||||
|
whitlocktech@gmail.com instead.
|
||||||
|
-->
|
||||||
5
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
5
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://gitea.whitlocktech.com/RunicGateway/link/src/branch/main/SECURITY.md
|
||||||
|
about: Please do not open a public issue for security problems — report them privately by email instead (see SECURITY.md).
|
||||||
23
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
23
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea, enhancement, or new capability
|
||||||
|
title: "[feature] "
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem / motivation
|
||||||
|
|
||||||
|
<!-- What are you trying to do? What's missing or painful today? -->
|
||||||
|
|
||||||
|
## Proposed solution
|
||||||
|
|
||||||
|
<!-- What you'd like to see happen. -->
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
<!-- Other approaches you thought about, and why you prefer the one above. -->
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Mockups, links, related issues, affected component/repo, etc. -->
|
||||||
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for contributing to Runic Gateway!
|
||||||
|
Please fill out the sections below and check every box before requesting review.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What & why
|
||||||
|
|
||||||
|
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
|
||||||
|
|
||||||
|
## How it was tested
|
||||||
|
|
||||||
|
<!-- Commands you ran, manual steps, screenshots. -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
- [ ] The change builds and existing tests/checks pass locally.
|
||||||
|
- [ ] I have added or updated tests/docs where it makes sense.
|
||||||
|
- [ ] My commits are reasonably scoped with clear messages.
|
||||||
|
|
||||||
|
## AI-assisted contributions (required)
|
||||||
|
|
||||||
|
This project **requires disclosure of AI tool usage**. Please pick one:
|
||||||
|
|
||||||
|
- [ ] No AI tools were used to produce this contribution.
|
||||||
|
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
|
||||||
|
every change, and take responsibility for it. AI-authored commits are
|
||||||
|
marked with a `Co-Authored-By` / `Assisted-By` trailer.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
- [ ] I agree that my contribution is licensed under this project's license
|
||||||
|
(**GNU GPL v3.0 or later**), and I have the right to contribute it.
|
||||||
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
|
||||||
|
|
||||||
|
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
|
||||||
|
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
|
||||||
|
|
||||||
|
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
|
||||||
|
|
||||||
|
Deterministic ordering: directories before files, each group sorted
|
||||||
|
case-insensitively with the raw name as a tiebreak. Output uses the classic
|
||||||
|
`tree(1)` box-drawing style so the result is stable across runs and platforms.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def build(paths):
|
||||||
|
root = {}
|
||||||
|
for p in paths:
|
||||||
|
p = p.strip().replace("\\", "/")
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
node = root
|
||||||
|
for part in p.split("/"):
|
||||||
|
node = node.setdefault(part, {})
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def render(node, prefix, lines):
|
||||||
|
entries = list(node.items())
|
||||||
|
# directories (non-empty children dict) before files, then case-insensitive name
|
||||||
|
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
|
||||||
|
for i, (name, child) in enumerate(entries):
|
||||||
|
last = i == len(entries) - 1
|
||||||
|
branch = "└── " if last else "├── "
|
||||||
|
suffix = "/" if child else ""
|
||||||
|
lines.append(f"{prefix}{branch}{name}{suffix}")
|
||||||
|
if child:
|
||||||
|
render(child, prefix + (" " if last else "│ "), lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||||
|
tree = build(sys.stdin.read().splitlines())
|
||||||
|
lines = [f"{root_label}/"]
|
||||||
|
render(tree, "", lines)
|
||||||
|
sys.stdout.write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
101
.gitea/workflows/pr-checks.yml
Normal file
101
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Gate every pull request into `main` on the same Rust checks the release runs,
|
||||||
|
# so a formatting slip, a lint regression, or a failing test can't reach the
|
||||||
|
# deployable branch.
|
||||||
|
#
|
||||||
|
# Why this exists: release.yml runs only AFTER merge (on push to `main`) and its
|
||||||
|
# FIRST Rust step is `cargo fmt --check`. Before this workflow, an unformatted
|
||||||
|
# commit merged cleanly and then killed the release job before it could build,
|
||||||
|
# tag, or publish anything — the repo had no pull_request workflow at all. These
|
||||||
|
# gates are deliberately a mirror of release.yml's, in the same order, so a green
|
||||||
|
# PR means the release will get past its gates too.
|
||||||
|
#
|
||||||
|
# Enforcement (one-time, in the Gitea UI):
|
||||||
|
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||||
|
# • Enable Status Check
|
||||||
|
# • Status check patterns: PR Checks / *
|
||||||
|
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||||
|
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||||
|
# without needing the dropdown.
|
||||||
|
#
|
||||||
|
# Scope note: this gates PRs into `main` only. Feature work that lands on an
|
||||||
|
# integration branch first (e.g. `edge`) is still caught on the branch's PR into
|
||||||
|
# `main`. To gate that earlier hop too, add the branch to the `branches:` list
|
||||||
|
# below — nothing else needs to change.
|
||||||
|
#
|
||||||
|
# Runner: the same self-hosted `ubuntu-latest` runner release.yml uses. Rust is
|
||||||
|
# not assumed to be preinstalled, so the toolchain step bootstraps it the same
|
||||||
|
# way release.yml does (minus the MinGW cross-compile deps — PRs build for the
|
||||||
|
# host only; the Windows cross-build stays a release-time concern).
|
||||||
|
|
||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# A newer push to the same PR cancels the in-flight run.
|
||||||
|
concurrency:
|
||||||
|
group: pr-checks-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
WORKDIR: sidecar
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust-gates:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# One job runs all three gates on purpose: installing the toolchain costs
|
||||||
|
# far more than the checks themselves, so splitting fmt/clippy/test into
|
||||||
|
# parallel jobs would pay that cost three times for no wall-clock win.
|
||||||
|
- name: Install Rust toolchain (rustfmt + clippy)
|
||||||
|
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 curl ca-certificates git
|
||||||
|
|
||||||
|
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 clippy
|
||||||
|
cargo --version && cargo fmt --version && cargo clippy --version
|
||||||
|
|
||||||
|
# Keyed on Cargo.lock: dependency builds are reused until a dep actually
|
||||||
|
# changes. A cache miss only makes the run slower, never wrong.
|
||||||
|
- name: Cache cargo registry and build dir
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
sidecar/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('sidecar/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
# Cheapest gate first — parses only, no compile, so a formatting slip
|
||||||
|
# fails in seconds instead of after a full build.
|
||||||
|
- name: cargo fmt --check
|
||||||
|
working-directory: sidecar
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
# --all-targets covers tests and examples, not just the binary.
|
||||||
|
# -D warnings makes a lint a failure; the crate is clean at this bar today,
|
||||||
|
# so anything new here is a regression introduced by the PR.
|
||||||
|
- name: cargo clippy
|
||||||
|
working-directory: sidecar
|
||||||
|
run: cargo clippy --locked --all-targets -- -D warnings
|
||||||
|
|
||||||
|
# --locked matches release.yml: it also proves Cargo.lock is in sync with
|
||||||
|
# Cargo.toml, rather than letting the build silently update it.
|
||||||
|
- name: cargo test
|
||||||
|
working-directory: sidecar
|
||||||
|
run: cargo test --locked
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
# nothing releasable -> no release is cut
|
# nothing releasable -> no release is cut
|
||||||
# (first ever run, no tag) -> releases the current Cargo.toml version as-is
|
# (first ever run, no tag) -> releases the current Cargo.toml version as-is
|
||||||
#
|
#
|
||||||
# Prerequisites (Settings → Actions → Secrets on UOM/link):
|
# Prerequisites (Settings → Actions → Secrets on RunicGateway/link):
|
||||||
# REGISTRY_USER — Gitea username the token below belongs to
|
# REGISTRY_USER — Gitea username the token below belongs to
|
||||||
# REGISTRY_TOKEN — Gitea access token. For image builds it needed
|
# REGISTRY_TOKEN — Gitea access token. For image builds it needed
|
||||||
# write:package; THIS workflow additionally needs
|
# write:package; THIS workflow additionally needs
|
||||||
@@ -46,7 +46,7 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_HOST: gitea.whitlocktech.com
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
REPO: UOM/link
|
REPO: RunicGateway/link
|
||||||
WORKDIR: sidecar
|
WORKDIR: sidecar
|
||||||
BIN: uo-link-sidecar
|
BIN: uo-link-sidecar
|
||||||
LINUX_TARGET: x86_64-unknown-linux-gnu
|
LINUX_TARGET: x86_64-unknown-linux-gnu
|
||||||
|
|||||||
52
.gitea/workflows/sonarqube.yml
Normal file
52
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Run SonarQube static analysis against the code that just landed on `main` and
|
||||||
|
# report the results to the self-hosted SonarQube server for review. This is
|
||||||
|
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
|
||||||
|
# not on pull_request, so it never gates a PR. It complements release.yml
|
||||||
|
# (which builds + cuts releases) — this one only feeds the dashboard.
|
||||||
|
#
|
||||||
|
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
|
||||||
|
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
|
||||||
|
# My Account → Security in SonarQube for the
|
||||||
|
# Runic-Gateway-link project (or a global one).
|
||||||
|
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
|
||||||
|
# http://192.168.0.56:9000
|
||||||
|
# (kept as a variable, not committed, so the internal address stays out of git.)
|
||||||
|
#
|
||||||
|
# The runner (self-hosted `ubuntu-latest`, same as release.yml) must be able to
|
||||||
|
# reach SONAR_HOST_URL on your network. Nothing here waits on the SonarQube
|
||||||
|
# Quality Gate, so a failing gate does not fail this job — check the dashboard
|
||||||
|
# when you want to.
|
||||||
|
#
|
||||||
|
# Scope: this analyses the Rust source directly (the Sonar scanner reads
|
||||||
|
# sonar-project.properties). It does NOT build the crate or run Clippy — see the
|
||||||
|
# "Optional enrichment" note in sonar-project.properties for wiring in a Clippy
|
||||||
|
# report if your SonarQube edition supports Rust lint import.
|
||||||
|
|
||||||
|
name: SonarQube
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Allow re-running the analysis on demand from the Actions tab.
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sonarqube-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out (full history for accurate new-code + blame)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# SonarQube uses git history to attribute issues to authors and to
|
||||||
|
# compute "new code". A shallow clone degrades both.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Run SonarQube scan
|
||||||
|
uses: sonarsource/sonarqube-scan-action@v4
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
||||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
name: sync-project-tree
|
||||||
|
|
||||||
|
# Keeps this repo's file-layout snapshot (docs/link/PROJECT_TREE.md in the
|
||||||
|
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
|
||||||
|
# tree from tracked files and, if it changed, opens (or force-updates) a pull
|
||||||
|
# request against the docs repo. It never writes to the docs repo's `main`
|
||||||
|
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
|
||||||
|
# other workflows use (the token needs repo read/write on RunicGateway/docs).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sync-project-tree
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
|
DOCS_REPO: RunicGateway/docs
|
||||||
|
SELF_REPO: RunicGateway/link
|
||||||
|
DOCS_PATH: link/PROJECT_TREE.md
|
||||||
|
TREE_TITLE: uo-link
|
||||||
|
ROOT_LABEL: link
|
||||||
|
PR_BRANCH: chore/sync-link-tree
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out this repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Ensure python3 is available
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
|
||||||
|
|
||||||
|
- name: Render PROJECT_TREE.md from tracked files
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p _sync
|
||||||
|
{
|
||||||
|
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
|
||||||
|
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
|
||||||
|
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
|
||||||
|
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
|
||||||
|
printf '> by hand — changes will be overwritten by the next sync.\n\n'
|
||||||
|
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
|
||||||
|
printf 'git-ignored paths are excluded).\n\n'
|
||||||
|
printf '```text\n'
|
||||||
|
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
|
||||||
|
printf '```\n'
|
||||||
|
} > _sync/PROJECT_TREE.md
|
||||||
|
echo "----- generated ${DOCS_PATH} -----"
|
||||||
|
cat _sync/PROJECT_TREE.md
|
||||||
|
|
||||||
|
- name: Open or update the docs PR if the tree changed
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Secrets can carry a trailing CR/LF depending on how they were pasted;
|
||||||
|
# strip line breaks before they land in a URL or Authorization header.
|
||||||
|
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
|
||||||
|
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
|
||||||
|
|
||||||
|
git clone --depth 1 "${REMOTE}" docs_repo
|
||||||
|
cd docs_repo
|
||||||
|
git config user.name "runic-docs-bot"
|
||||||
|
git config user.email "ci@whitlocktech.com"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${DOCS_PATH}")"
|
||||||
|
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
|
||||||
|
git add "${DOCS_PATH}"
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "PROJECT_TREE.md already up to date — nothing to sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
|
||||||
|
git checkout -B "${PR_BRANCH}"
|
||||||
|
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
|
||||||
|
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
|
||||||
|
|
||||||
|
# Open a PR only if one isn't already open for this branch (a force-push
|
||||||
|
# to an existing open PR's head updates it in place).
|
||||||
|
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${API}/pulls?state=open&limit=50" \
|
||||||
|
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
|
||||||
|
if [ "${OPEN}" = "0" ]; then
|
||||||
|
curl -sSf -X POST "${API}/pulls" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg head "${PR_BRANCH}" \
|
||||||
|
--arg base "main" \
|
||||||
|
--arg title "docs(tree): sync ${DOCS_PATH}" \
|
||||||
|
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
|
||||||
|
'{head: $head, base: $base, title: $title, body: $body}')" \
|
||||||
|
>/dev/null
|
||||||
|
echo "Opened a new docs PR for ${PR_BRANCH}."
|
||||||
|
else
|
||||||
|
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
|
||||||
|
fi
|
||||||
133
CODE_OF_CONDUCT.md
Normal file
133
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||||
|
identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall
|
||||||
|
community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||||
|
any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email address,
|
||||||
|
without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official email address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
**whitlocktech@gmail.com**.
|
||||||
|
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series of
|
||||||
|
actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or permanent
|
||||||
|
ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||||
|
community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||||
|
[https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
89
CONTRIBUTING.md
Normal file
89
CONTRIBUTING.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Contributing to Runic Gateway — uo-link sidecar
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! This repo is the **Rust sidecar** half
|
||||||
|
of the game bridge: it terminates the loopback link from the ServUO shard and
|
||||||
|
exposes the WebSocket + REST API the website consumes.
|
||||||
|
|
||||||
|
By participating you agree to abide by our
|
||||||
|
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## Ways to contribute
|
||||||
|
|
||||||
|
- **Report a bug** or **request a feature** through the
|
||||||
|
[issue tracker](https://gitea.whitlocktech.com/RunicGateway/link/issues)
|
||||||
|
(issue templates are provided).
|
||||||
|
- **Improve the code or docs** by opening a pull request (see below).
|
||||||
|
- **Never** report a security vulnerability in a public issue — see
|
||||||
|
[SECURITY.md](SECURITY.md). The sidecar is the only network-facing part of the
|
||||||
|
bridge, so its security matters.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
**Prerequisites:** a recent stable Rust toolchain (install via
|
||||||
|
[rustup](https://rustup.rs/)).
|
||||||
|
|
||||||
|
The sidecar is a standard cargo crate under `sidecar/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sidecar
|
||||||
|
cp sidecar.toml.example sidecar.toml # then edit
|
||||||
|
cargo build --release # binary at target/release/uo-link-sidecar
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`sidecar/README.md`](sidecar/README.md) for configuration and the wire
|
||||||
|
protocol. The loopback JSON protocol shared with the ServUO plugin
|
||||||
|
([RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins))
|
||||||
|
is a **compatibility contract** — the canonical spec lives in the
|
||||||
|
[docs repo](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link).
|
||||||
|
If you change an event or command, keep both sides and the spec in sync.
|
||||||
|
|
||||||
|
### Checks
|
||||||
|
|
||||||
|
Please make sure the crate builds cleanly and is formatted and lint-clean before
|
||||||
|
opening a PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo test
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branch & PR workflow
|
||||||
|
|
||||||
|
1. Branch from `main` with a descriptive name
|
||||||
|
(`feature/…`, `fix/…`, `docs/…`, `chore/…`).
|
||||||
|
2. Keep changes focused; small PRs are easier to review.
|
||||||
|
3. Push and open a pull request against `main`. Fill out the PR template,
|
||||||
|
including the **AI-assisted contributions** disclosure.
|
||||||
|
4. A maintainer will review; address feedback with follow-up commits.
|
||||||
|
|
||||||
|
### Commit messages
|
||||||
|
|
||||||
|
We use [Conventional Commits](https://www.conventionalcommits.org/) —
|
||||||
|
`type(scope): summary`. Note the release workflow
|
||||||
|
(`.gitea/workflows/release.yml`) derives versions from conventional commits on
|
||||||
|
`main`, so accurate `feat:` / `fix:` prefixes matter here.
|
||||||
|
|
||||||
|
## AI-assisted contributions (disclosure required)
|
||||||
|
|
||||||
|
This project is developed openly with AI assistance, and we ask the same
|
||||||
|
transparency of everyone. **If you used an AI tool** (Claude, Copilot, ChatGPT,
|
||||||
|
Cursor, etc.) to help produce a contribution, you must disclose it:
|
||||||
|
|
||||||
|
- Tick the AI-usage box in the pull-request template and name the tool(s).
|
||||||
|
- Mark AI-authored commits with a trailer, e.g.
|
||||||
|
`Co-Authored-By: Claude <noreply@anthropic.com>` or `Assisted-By: <tool>`.
|
||||||
|
- You remain responsible for every line you submit: review it, understand it,
|
||||||
|
and make sure it is correct and that you have the right to contribute it.
|
||||||
|
|
||||||
|
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are
|
||||||
|
not, and may be closed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Runic Gateway is licensed under the **GNU General Public License v3.0 or later**
|
||||||
|
(see [LICENSE.md](LICENSE.md)). By submitting a contribution you agree that it is
|
||||||
|
licensed under the same terms (inbound = outbound) and that you have the right to
|
||||||
|
contribute it.
|
||||||
31
CONTRIBUTORS.md
Normal file
31
CONTRIBUTORS.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Contributors
|
||||||
|
|
||||||
|
Runic Gateway is built and maintained by the people and tools listed here.
|
||||||
|
Thank you to everyone who has contributed.
|
||||||
|
|
||||||
|
## Maintainers
|
||||||
|
|
||||||
|
- **whitlocktech** <whitlocktech@gmail.com> — project lead and maintainer
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Add yourself here when your contribution is merged — alphabetical by name or
|
||||||
|
handle. One line each:
|
||||||
|
|
||||||
|
- **Name or handle** (optional link) — what you contributed
|
||||||
|
-->
|
||||||
|
|
||||||
|
- _Your name could be here — see [CONTRIBUTING.md](CONTRIBUTING.md)._
|
||||||
|
|
||||||
|
## AI-assisted development
|
||||||
|
|
||||||
|
Parts of Runic Gateway were developed with the assistance of AI coding tools,
|
||||||
|
including **Claude** (Anthropic) via Claude Code. AI-assisted commits are
|
||||||
|
attributed in their commit trailers (e.g. `Co-Authored-By: Claude ...`).
|
||||||
|
|
||||||
|
In keeping with this project's transparency policy, **all contributors must
|
||||||
|
disclose their use of AI tools** on any contribution — see the
|
||||||
|
"AI-assisted contributions" section of [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
Disclosed AI assistance is welcome; undisclosed AI-generated contributions are
|
||||||
|
not.
|
||||||
674
LICENSE.md
Normal file
674
LICENSE.md
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
143
README.md
143
README.md
@@ -1,94 +1,93 @@
|
|||||||
# uo-link
|
# uo-link — Rust sidecar
|
||||||
|
|
||||||
ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
|
The **Rust sidecar** half of the Runic Gateway bridge. The ServUO shard dials out to this sidecar
|
||||||
|
over a loopback TCP socket (newline-delimited JSON); the sidecar owns the WebSocket + REST API the
|
||||||
|
website consumes, along with auth, buffering, and fan-out.
|
||||||
|
|
||||||
```
|
```
|
||||||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
(RunicGateway/servuo-plugins) >>> THIS REPO <<<
|
||||||
```
|
```
|
||||||
|
|
||||||
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
|
The shard never speaks WebSocket and exposes no port of its own — the sidecar is the only
|
||||||
|
network-facing component, which is what keeps the game unreachable from the internet.
|
||||||
|
|
||||||
|
## Related repos
|
||||||
|
|
||||||
|
| Repo | What |
|
||||||
|
|------|------|
|
||||||
|
| **this** — `RunicGateway/link` | The Rust sidecar (`sidecar/`). |
|
||||||
|
| [RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) | The **C# ServUO plugin** — the shard side of the bridge (`overlay/`, `patches/`, `deploy.ps1`, test scaffolding). |
|
||||||
|
| [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation — design docs, protocol spec, integration guide, research. |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
|
| `sidecar/` | The Rust sidecar crate — terminates the loopback link to the shard, exposes WS + REST to the website. See [`sidecar/README.md`](sidecar/README.md). |
|
||||||
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
| `.gitea/workflows/pr-checks.yml` | Gates every PR into `main` on `cargo fmt --check`, `cargo clippy -D warnings`, and `cargo test`. |
|
||||||
| `sidecar/` | The Rust sidecar: terminates the loopback link to the shard, exposes WS + REST to the website. See `sidecar/README.md`. |
|
| `.gitea/workflows/release.yml` | Builds + releases the sidecar binary (Linux + Windows) on every merge to `main`. |
|
||||||
| `tools/` | Never deployed. Test scaffolding and anything else that must not reach a server. |
|
|
||||||
| `docs/INTEGRATION.md` | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. Start here to build the front end. |
|
|
||||||
| `docs/PLAN.md` | Implementation plan, measured performance budget, and the full data catalog. |
|
|
||||||
| `docs/RESEARCH.md` | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
|
|
||||||
| `docs/SHARD_PREREQS.md` | Repairs the target shard needed before any of this could load. |
|
|
||||||
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
|
|
||||||
|
|
||||||
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
## Build & run
|
||||||
|
|
||||||
## Deploy
|
The sidecar is a standard cargo crate:
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify # show what would change
|
cd sidecar
|
||||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo # write
|
cargo build --release # binary at target/release/uo-link-sidecar
|
||||||
|
cp sidecar.toml.example sidecar.toml # then edit
|
||||||
|
cargo run --release
|
||||||
```
|
```
|
||||||
|
|
||||||
## Status
|
`.gitea/workflows/release.yml` cross-compiles Linux + Windows binaries and cuts a Gitea release on
|
||||||
|
every merge to `main` (conventional-commit versioning). See [`sidecar/README.md`](sidecar/README.md)
|
||||||
|
for configuration and the wire protocol.
|
||||||
|
|
||||||
| Phase | State |
|
Before that, `.gitea/workflows/pr-checks.yml` runs the same gates on every pull request into `main` —
|
||||||
|------:|-------|
|
`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, then `cargo test --locked`. Run them
|
||||||
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
locally before pushing and the PR will be green:
|
||||||
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** |
|
|
||||||
| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
|
|
||||||
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** |
|
|
||||||
| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** |
|
|
||||||
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** |
|
|
||||||
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in `docs/PLAN.md` §16** |
|
|
||||||
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in `docs/PLAN.md` §17** |
|
|
||||||
|
|
||||||
Every phase on the ServUO side is complete. Phases 0–6 are drop-in (`overlay/`); Phase 7 is the one core change, shipped as `patches/`. Remaining work is the Rust sidecar.
|
```bash
|
||||||
|
cd sidecar
|
||||||
Cheat-detection signals are not a separate phase — they are folded into the streams above: `cheat.fastwalk`, `audit.set`, `audit.command`, and `vendor.sale` (buyer + owner for laundering detection).
|
cargo fmt # or --check to just report
|
||||||
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
## Phase 0 — what it fixes
|
cargo test --locked
|
||||||
|
|
||||||
`ScriptCompiler.Compile()` runs `dotnet build Scripts/Scripts.csproj -c Release`, prints the output, and **never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Because that build passed no `Platform`, MSBuild defaulted to `AnyCPU`, and `Scripts.csproj` gated both `OutputPath` and `DefineConstants` on `Configuration|Platform == Release|x64`. So:
|
|
||||||
|
|
||||||
- the DLL landed in `Scripts/bin/Release/` while the core loads `Scripts.dll` from the base directory, and
|
|
||||||
- `TRACE;NEWTIMERS;ServUO` went undefined, so XmlSpawner compiled its non-ServUO branches.
|
|
||||||
|
|
||||||
Runtime script compilation therefore had no effect, silently. `overlay/Scripts/Scripts.csproj` conditions both property groups on `Configuration` alone.
|
|
||||||
|
|
||||||
`Server.csproj` is deliberately left alone: nothing under `Server/` uses those symbols, and giving it `OutputPath=..\` would make the boot-time build try to overwrite the running `ServUO.exe`.
|
|
||||||
|
|
||||||
## The plugin (Phase 1)
|
|
||||||
|
|
||||||
`overlay/Scripts/Custom/Bridge/`:
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `BridgeConfig.cs` | Reads `Config/Bridge.cfg` in `Configure()`, before `World.Load`. |
|
|
||||||
| `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. |
|
|
||||||
| `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. |
|
|
||||||
| `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. |
|
|
||||||
| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
|
|
||||||
| `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. |
|
|
||||||
| `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. |
|
|
||||||
| `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. |
|
|
||||||
| `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. |
|
|
||||||
| `BridgeTownCrier.cs` | Town-crier news (Phase 6): inbound `towncrier.add` / `remove` into the global crier list, with abuse caps. |
|
|
||||||
|
|
||||||
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`tools/stub_sidecar_request.ps1` additionally *sends* inbound requests (`char.request`, `account.roster`, `vendor.snapshot`, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4.
|
## Deployment & compatibility
|
||||||
|
|
||||||
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
|
The plugin ([RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins))
|
||||||
|
and this sidecar are deployed **together** but built **independently**:
|
||||||
|
|
||||||
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in `docs/PLAN.md` §1. See `tools/scaffolding/README.md`.
|
- The **plugin** is deployed as source into the ServUO server root and compiled by ServUO at boot —
|
||||||
|
no build artifact, no CI build.
|
||||||
|
- The **sidecar** is a standalone Rust binary released from this repo.
|
||||||
|
|
||||||
|
The only coupling is the **loopback JSON protocol** (the shard dials `127.0.0.1`). Compatibility is a
|
||||||
|
protocol concern, not a build-order one — keep the event/command catalog in sync across the two
|
||||||
|
repos. Canonical spec:
|
||||||
|
[PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §5/§7 and
|
||||||
|
[INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md).
|
||||||
|
Because a wedged or absent sidecar cannot stall the shard, either side can be deployed or restarted
|
||||||
|
independently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Runic Gateway is free software, licensed under the **GNU General Public License
|
||||||
|
v3.0 or later** — see [LICENSE.md](LICENSE.md).
|
||||||
|
|
||||||
|
Copyright (C) 2026 Runic Gateway
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
the terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation, either version 3 of the License, or (at your option) any later
|
||||||
|
version. It is distributed WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
General Public License for more details.
|
||||||
|
|
||||||
|
Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) (note
|
||||||
|
the **AI-usage disclosure** requirement) and our
|
||||||
|
[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately per
|
||||||
|
[SECURITY.md](SECURITY.md).
|
||||||
|
|||||||
50
SECURITY.md
Normal file
50
SECURITY.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Thank you for helping keep Runic Gateway and its users safe.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Please do not report security vulnerabilities through public issues, pull
|
||||||
|
requests, or the wiki.** A public report tips off attackers before a fix is
|
||||||
|
available.
|
||||||
|
|
||||||
|
Instead, report privately by email to:
|
||||||
|
|
||||||
|
**whitlocktech@gmail.com**
|
||||||
|
|
||||||
|
Please include as much of the following as you can:
|
||||||
|
|
||||||
|
- The repository and component affected.
|
||||||
|
- The type of issue (e.g. authentication bypass, injection, secret exposure,
|
||||||
|
remote code execution, denial of service).
|
||||||
|
- Step-by-step instructions to reproduce, and a proof-of-concept if you have one.
|
||||||
|
- The impact — what an attacker could do with it.
|
||||||
|
- Any suggested remediation.
|
||||||
|
|
||||||
|
You will receive an acknowledgement of your report, typically within a few days.
|
||||||
|
We will keep you informed as we investigate and work toward a fix, and we are
|
||||||
|
happy to credit you in the release notes once the issue is resolved (let us know
|
||||||
|
if you would prefer to remain anonymous).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Runic Gateway is a self-hosted platform made up of several components:
|
||||||
|
|
||||||
|
| Component | Repo | Network exposure |
|
||||||
|
|---|---|---|
|
||||||
|
| Website (site + admin + API) | `RunicGateway/website` | Internet-facing (behind a reverse proxy) |
|
||||||
|
| uo-link sidecar | `RunicGateway/link` | The only network-facing part of the game bridge |
|
||||||
|
| ServUO plugin | `RunicGateway/servuo-plugins` | Loopback only — dials the sidecar on `127.0.0.1` |
|
||||||
|
| Documentation | `RunicGateway/docs` | Content only |
|
||||||
|
|
||||||
|
Because instances are self-hosted, the security of any given deployment also
|
||||||
|
depends on how it is configured and operated — strong secrets (`JWT_SECRET`,
|
||||||
|
`SECRET_ENC_KEY`, database and admin passwords), a correctly configured reverse
|
||||||
|
proxy and `TRUST_PROXY`, and keeping the shard itself unreachable from the
|
||||||
|
internet (only the sidecar should be exposed). See each repo's README for the
|
||||||
|
security model.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
This project is developed continuously and does not maintain long-term release
|
||||||
|
branches. Security fixes land on `main`; please run a recent build.
|
||||||
75
deploy.ps1
75
deploy.ps1
@@ -1,75 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Copies overlay/ into a ServUO server root.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
overlay/ mirrors the server root exactly, so deployment is a straight file copy.
|
|
||||||
Nothing is deleted from the server; this only adds or overwrites.
|
|
||||||
|
|
||||||
Run with -Verify first. It reports what would change and touches nothing.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify
|
|
||||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string] $ServerPath,
|
|
||||||
|
|
||||||
[switch] $Verify
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
$overlay = Join-Path $PSScriptRoot 'overlay'
|
|
||||||
|
|
||||||
if (-not (Test-Path $overlay)) { throw "overlay/ not found next to deploy.ps1" }
|
|
||||||
if (-not (Test-Path $ServerPath)) { throw "server path not found: $ServerPath" }
|
|
||||||
|
|
||||||
# ServUO.exe holds a lock on Scripts.dll and writes Saves/ on exit. Never deploy under it.
|
|
||||||
if (Get-Process -Name ServUO -ErrorAction SilentlyContinue) {
|
|
||||||
throw "ServUO is running. Stop it before deploying."
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-Sha([string] $path) {
|
|
||||||
if (-not (Test-Path $path)) { return $null }
|
|
||||||
return (Get-FileHash $path -Algorithm SHA256).Hash
|
|
||||||
}
|
|
||||||
|
|
||||||
$added = 0; $changed = 0; $same = 0
|
|
||||||
|
|
||||||
Get-ChildItem $overlay -Recurse -File | ForEach-Object {
|
|
||||||
$rel = $_.FullName.Substring($overlay.Length + 1)
|
|
||||||
$dst = Join-Path $ServerPath $rel
|
|
||||||
|
|
||||||
$srcHash = Get-Sha $_.FullName
|
|
||||||
$dstHash = Get-Sha $dst
|
|
||||||
|
|
||||||
if ($null -eq $dstHash) {
|
|
||||||
$state = 'ADD '; $added++
|
|
||||||
} elseif ($srcHash -ne $dstHash) {
|
|
||||||
$state = 'CHANGE '; $changed++
|
|
||||||
} else {
|
|
||||||
$state = 'same '; $same++
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($state -ne 'same ') {
|
|
||||||
Write-Output "$state $rel"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $Verify -and $state -ne 'same ') {
|
|
||||||
$parent = Split-Path $dst -Parent
|
|
||||||
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
|
|
||||||
Copy-Item $_.FullName -Destination $dst -Force
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Output ""
|
|
||||||
if ($Verify) {
|
|
||||||
Write-Output "VERIFY only. add=$added change=$changed unchanged=$same (nothing written)"
|
|
||||||
} else {
|
|
||||||
Write-Output "deployed. add=$added change=$changed unchanged=$same"
|
|
||||||
Write-Output ""
|
|
||||||
Write-Output "Scripts.csproj changed => next boot rebuilds Scripts.dll (Compiler.cfg Dynamic=True)."
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,712 +0,0 @@
|
|||||||
# uo-link Sidecar — Website Integration Guide
|
|
||||||
|
|
||||||
This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly.
|
|
||||||
|
|
||||||
```
|
|
||||||
website ──WebSocket (live feed) + REST (queries/commands)──► sidecar ──loopback──► shard
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Base URL** — default `http://127.0.0.1:8080` (WebSocket: `ws://127.0.0.1:8080`). Configurable in `sidecar.toml` (`web.bind`) or `UOLINK_WEB_BIND`. If you serve the site from another host, bind the sidecar to `0.0.0.0:8080` and put it behind TLS.
|
|
||||||
- **Content type** — all request and response bodies are JSON (`application/json`).
|
|
||||||
- **Timestamps** — every `t` field is **epoch milliseconds** (UTC). Human-readable timestamps (e.g. `house.decay.builtOn`, `/health.last_event`) are ISO-8601 UTC.
|
|
||||||
- **Serials** — game object ids are hex strings like `"0x24C"` (mobiles) or `"0x40013AAD"` (items). Treat them as opaque keys.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Authentication
|
|
||||||
|
|
||||||
Every route **except `GET /health`** requires the shared token from `sidecar.toml` (`web.auth_token`). Present it any of these ways:
|
|
||||||
|
|
||||||
| Transport | How |
|
|
||||||
|-----------|-----|
|
|
||||||
| REST | `Authorization: Bearer <token>` |
|
|
||||||
| REST | `X-Api-Key: <token>` |
|
|
||||||
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
|
|
||||||
|
|
||||||
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Protocol version
|
|
||||||
|
|
||||||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
|
||||||
|
|
||||||
- Every response carries an **`X-UOLink-Version: 2`** header.
|
|
||||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
|
|
||||||
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
|
||||||
|
|
||||||
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Health
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /health (no auth)
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
|
|
||||||
"protocol": 1,
|
|
||||||
"plugin_connected": true, // is the shard link up right now?
|
|
||||||
"database": "ok", // "ok" | "error"
|
|
||||||
"uptime": "3d 12h",
|
|
||||||
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Always returns HTTP 200 (read `status`/`plugin_connected` for real state). Use it for liveness checks and to detect when the shard has dropped (`plugin_connected: false`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. WebSocket live feed
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /ws?token=<token> (WebSocket upgrade)
|
|
||||||
```
|
|
||||||
|
|
||||||
A push-only stream of game events as they happen. You do **not** send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.
|
|
||||||
|
|
||||||
**On connect**, the first frame is:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "ws.hello", "protocol": 1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- **Live-only, no replay.** A client that connects now sees events from now on. For history/backfill, use `GET /history`.
|
|
||||||
- The sidecar sends WebSocket **ping** frames every ~30s for keepalive; browser clients answer automatically.
|
|
||||||
- You may occasionally see a `{"kind":"pong",...}` frame (the sidecar's internal heartbeat to the shard). Ignore any `kind` you don't handle.
|
|
||||||
- A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.
|
|
||||||
|
|
||||||
### Minimal browser client
|
|
||||||
|
|
||||||
```js
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
|
|
||||||
ws.onmessage = (m) => {
|
|
||||||
const ev = JSON.parse(m.data);
|
|
||||||
switch (ev.kind) {
|
|
||||||
case "ws.hello": /* check ev.protocol === 1 */ break;
|
|
||||||
case "mob.login": onLogin(ev); break;
|
|
||||||
case "vendor.sale": onSale(ev); break;
|
|
||||||
case "house.decay": onIdoc(ev); break;
|
|
||||||
// ...handle the kinds you care about; ignore the rest
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event catalog
|
|
||||||
|
|
||||||
Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"serial","name","acct","player"}` (`acct` present only for player-owned mobiles).
|
|
||||||
|
|
||||||
#### Lifecycle
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `server.hello` | `shard`, `bootId`, `connects`, `items`, `mobiles`, `accounts` | Sent to the sidecar on every shard (re)connect. `bootId` changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". |
|
|
||||||
| `server.shutdown` | — | Clean shutdown. |
|
|
||||||
| `server.crashed` | `error` | Not always sent (a hard crash may skip it). |
|
|
||||||
| `world.save.before` / `world.save.after` | (`after` adds `items`, `mobiles`) | Save-cycle boundaries; a natural consistency checkpoint. |
|
|
||||||
|
|
||||||
#### Sessions & identity
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
|
|
||||||
| `mob.logout` | `who` |
|
|
||||||
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
|
|
||||||
|
|
||||||
#### Economy & commerce
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `gold.change` | `acct`, `old`, `new`, `delta` | AccountGold flow (gold in bank/account, not physical coins). |
|
|
||||||
| `vendor.buy` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor purchase (validation stage). |
|
|
||||||
| `vendor.sell` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor sale. |
|
|
||||||
| `vendor.sale` | `buyerSerial`, `buyerAcct`, `ownerSerial`, `ownerAcct`, `vendorSerial`, `itemType`, `itemSerial`, `itemId`, `amount`, `price`, `commission`, `committed:true` | **Player** vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. |
|
|
||||||
| `vendor.placed` | `owner`, `vendor` | A player vendor was placed. |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
|
|
||||||
"ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
|
|
||||||
"itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
|
|
||||||
"price":100,"commission":0,"t":1783720195626}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Character progression & vitals
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `char.vitals` | `serial`, `hits`,`hitsMax`, `mana`,`manaMax`, `stam`,`stamMax`, `str`,`dex`,`int`, `map`, `x`,`y` | Periodic snapshot of each **online** player (~every 30s; configurable). Diff successive snapshots to detect change. |
|
|
||||||
| `skill.gain` | `who`, `skill`, `gained`, `base`, `cap` | Player skill gains only (NPC gains are filtered out). |
|
|
||||||
| `fame.change` / `karma.change` | `who`, `old`, `new` | Player only. |
|
|
||||||
| `quest.complete` | `who`, `quest` | |
|
|
||||||
|
|
||||||
#### Death & PvP
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `player.death` | `who`, `killer` |
|
|
||||||
| `player.murdered` | `victim`, `murderer` |
|
|
||||||
| `mob.killed` | `killed`, `killer` — only kills that involve a player |
|
|
||||||
|
|
||||||
#### Housing / IDOC
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
|
|
||||||
|
|
||||||
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
|
||||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
|
||||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
|
||||||
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Economy supply (periodic)
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `economy.supply` | `accounts`, `gold` — total money supply across all accounts (~every 5 min; configurable) |
|
|
||||||
|
|
||||||
#### Cheat detection & staff audit
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. |
|
|
||||||
| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. |
|
|
||||||
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. |
|
|
||||||
| `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 & provisioning
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. |
|
|
||||||
| `account.audit` | `origin`, `action`, `actor`, `target`, `websiteUserId` | A provisioning action was applied from the site (`origin:"web"`, `actor:"web:<user>"`). `action` is `create` or `unlink`; `target` is the account. Broadcast to every dashboard. **Never carries the password.** Emitted alongside the `account.ok` reply; see §6. |
|
|
||||||
| `account.unlinked` | `origin`, `account`, `websiteUserId`, `char` | A player ran `[unlink` **in game** (`origin:"in-game"`), severing the tie themselves. Drop the link from any roster you cache and reconcile your own record. |
|
|
||||||
|
|
||||||
#### Help-page (support) queue
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). |
|
|
||||||
| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). |
|
|
||||||
| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). |
|
|
||||||
|
|
||||||
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
|
|
||||||
|
|
||||||
#### Champion spawns
|
|
||||||
|
|
||||||
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
|
|
||||||
|
|
||||||
| `category` | source | what it is |
|
|
||||||
|------------|--------|-----------|
|
|
||||||
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
|
|
||||||
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
|
|
||||||
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
|
|
||||||
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
|
|
||||||
|
|
||||||
`status` is one of:
|
|
||||||
- **`active`** — running (or, for `sea`, the boss is alive).
|
|
||||||
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
|
|
||||||
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
|
|
||||||
|
|
||||||
Category-specific fields on `champ.update`:
|
|
||||||
|
|
||||||
| category | extra fields |
|
|
||||||
|----------|--------------|
|
|
||||||
| `champion` | `level` (0–16), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
|
|
||||||
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
|
|
||||||
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
|
||||||
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
|
|
||||||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
|
|
||||||
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
|
|
||||||
|
|
||||||
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
|
|
||||||
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
|
|
||||||
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
|
|
||||||
```
|
|
||||||
|
|
||||||
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
|
|
||||||
|
|
||||||
#### Guilds (Protocol 2.0)
|
|
||||||
|
|
||||||
Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board.
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. |
|
|
||||||
| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
|
|
||||||
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
|
|
||||||
|
|
||||||
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
|
|
||||||
"online":3,"alliance":"Britannian Pact",
|
|
||||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
|
||||||
"t":1752489280000}
|
|
||||||
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}
|
|
||||||
```
|
|
||||||
|
|
||||||
Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events.
|
|
||||||
|
|
||||||
#### Town governors (Protocol 2.0)
|
|
||||||
|
|
||||||
In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set of cities is polled (`CitySweepSeconds`, default 300s); each city emits `city.update` (full-state upsert) only when its governor, governor-elect, or election phase changes. **No events at all unless the shard runs the City Loyalty system.**
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `city.update` | `city`, `governor` (actor or null), `governorElect` (actor or null), `electionPhase`, `candidates`, `autoPickAt` (ISO-8601 UTC, when an election is ongoing) | A city's governance changed. Derive "the governor changed" by comparing to your stored board. |
|
|
||||||
|
|
||||||
`electionPhase` is one of `none` / `nominate` / `vote` / `pending`. Cities: Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
|
|
||||||
"governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
|
||||||
"governorElect":null,"t":1752489280000}
|
|
||||||
```
|
|
||||||
|
|
||||||
Render the current board from `GET /governors` (§6) on connect, then keep it live with these events.
|
|
||||||
|
|
||||||
#### Presence (Protocol 2.0)
|
|
||||||
|
|
||||||
Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, default 30s) and emitted **only when it changes**; region transitions arrive in real time.
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `presence.online` | `count`, `byFacet` `{map: n}`, `byRegion` `{region: n}` | The current online population. Emitted when the count or any breakdown changes. `GET /online` gives the latest; `GET /history?kind=presence.online` the time series. |
|
|
||||||
| `region.enter` | `from` (or null), `to` (or null), `map`, `who` (actor object) | A player crossed into a new named region. `from`/`to` are region names (`Wilderness` is unnamed). Cheap "who's where" feed. |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
|
|
||||||
"byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000}
|
|
||||||
{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca",
|
|
||||||
"who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Houses (Protocol 2.0)
|
|
||||||
|
|
||||||
The house registry — one row per house, complementing the `house.decay` *transition* feed (§ above). Polled (`HousingSweepSeconds`, default 300s) and diffed like the other boards.
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `house.update` | `serial`, `name`, `owner` (actor or null), `coOwners`, `friends`, `region`, `map`, `x`,`y`,`z`, `decay`, `price`, `builtOn`, `lastRefreshed` | A house's owner/region/decay/co-owners changed, or first sight this connection. `decay` is the level name (e.g. `LikeNew`). `price` is the placement value — **stock ServUO has no "for sale" flag**, so this is not a listing. |
|
|
||||||
| `house.remove` | `serial` | The house was demolished or no longer exists. Drop the row. |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil","decay":"LikeNew",
|
|
||||||
"price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain",
|
|
||||||
"owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
|
||||||
"coOwners":2,"friends":5,"builtOn":"2026-01-02T00:00:00Z","lastRefreshed":"2026-07-10T00:00:00Z",
|
|
||||||
"t":1752489280000}
|
|
||||||
```
|
|
||||||
|
|
||||||
Render from `GET /houses` (§6) on connect, then keep live with these events.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. REST — read queries
|
|
||||||
|
|
||||||
These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning **504**.
|
|
||||||
|
|
||||||
### Character profile
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /char/{account}/{slot} # by account + character slot (0-based)
|
|
||||||
GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C
|
|
||||||
```
|
|
||||||
|
|
||||||
Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for **offline** characters too. `GET /char/serial/...` falls back to the last **cached** profile if the shard is unreachable (so a page still renders during a shard restart).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
|
|
||||||
"body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
|
|
||||||
"stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
|
|
||||||
"mana":123,"manaMax":123, "stam":120,"stamMax":120,
|
|
||||||
"fame":0,"karma":0,"luck":0,
|
|
||||||
"resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
|
|
||||||
"skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
|
|
||||||
"equipment": [
|
|
||||||
{ "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
|
|
||||||
"cliloc":1027933,"mods":{} },
|
|
||||||
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
|
|
||||||
"weapon":{"minDamage":16,"maxDamage":18},
|
|
||||||
"mods":{"WeaponDamage":50,"HitLightning":40} }
|
|
||||||
],
|
|
||||||
"titles": { "selected": 0, "fameKarma": "Lord", "skill": "Grandmaster Swordsman",
|
|
||||||
"reward": ["1154060", "The Bold"] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Field notes:
|
|
||||||
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
|
|
||||||
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
|
|
||||||
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site.
|
|
||||||
- `titles` (Protocol 2.0): `selected` is the index into `reward` currently displayed (`-1` if none). `fameKarma`/`skill` are computed display titles, omitted when the character has none. `reward` entries may be a **cliloc number as a string** or a literal string — resolve numeric ones against your cliloc table, same as item names.
|
|
||||||
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
|
|
||||||
|
|
||||||
### Account roster
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /roster/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
Lightweight list of an account's characters (up to 5–7), including offline ones. Use this for a character-picker, then fetch the full profile on demand.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind":"account.roster", "acct":"whitlocktech",
|
|
||||||
"chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Player vendors
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /vendors/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every player vendor owned by any character on the account, with held gold and current listings.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind":"vendor.snapshot", "acct":"seed_000",
|
|
||||||
"vendors":[
|
|
||||||
{ "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
|
|
||||||
"ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
|
|
||||||
"listings":[
|
|
||||||
{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
|
|
||||||
] } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. REST — commands & history
|
|
||||||
|
|
||||||
### Confirm an account link
|
|
||||||
|
|
||||||
The in-game `[link` flow: the player runs `[link`, the shard emits a `link.request` event (over the WebSocket) carrying a one-time `code`. Your site shows the logged-in website user a box to enter that code, then:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /link/confirm
|
|
||||||
{ "code": "AB12CD", "websiteUserId": "9931" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Success → **200** `{"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}`. The game account is now permanently tagged with your `websiteUserId` (persisted on the shard); subsequent `mob.login` events for that account carry `webId`.
|
|
||||||
- Bad/expired code → **404** `{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}`.
|
|
||||||
|
|
||||||
Codes are one-time and expire (default 5 min).
|
|
||||||
|
|
||||||
### Look up an existing link
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /link/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **200** `{"account":"PerryAdimn","websiteUserId":"9931"}` if linked.
|
|
||||||
- **404** `{"account":"PerryAdimn","linked":false}` if not.
|
|
||||||
|
|
||||||
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
|
|
||||||
|
|
||||||
### Create a game account (Protocol 2.0)
|
|
||||||
|
|
||||||
Provision a game account from your signup form and link it to the website user in one step. Requires a **v2** sidecar. Whether this is honored depends on the shard's signup mode (`website`/`hybrid` accept it; `game` refuses).
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /accounts/create
|
|
||||||
{ "actor": "whitlocktech", "account": "bob", "password": "hunter2",
|
|
||||||
"websiteUserId": "9931", "ip": "203.0.113.7" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- `actor` — the website user/staff id, recorded in the audit. Required.
|
|
||||||
- `account`, `password` — the game-client credentials the player chose. The password is hashed on the shard and **never** appears in any reply, event, or log.
|
|
||||||
- `websiteUserId` — the site user to auto-link.
|
|
||||||
- `ip` — **the end user's browser IP**, which you read from your own request context (remote-addr, or a trusted `X-Forwarded-For`). The shard enforces its per-IP account cap with this, exactly as it does for in-game signups. The sidecar cannot see the browser's IP (it only sees your server), so you must send it.
|
|
||||||
|
|
||||||
Responses:
|
|
||||||
|
|
||||||
- Success → **200** `{"kind":"account.ok","action":"create","account":"bob","websiteUserId":"9931"}`. The account exists and is linked; subsequent `mob.login` events carry `webId`.
|
|
||||||
- Name already taken → **409** `{"kind":"account.error","reason":"account already exists"}`.
|
|
||||||
- Per-IP cap hit → **429** `{"kind":"account.error","reason":"ip account limit reached"}`.
|
|
||||||
- Signups disabled for this mode → **403** `{"kind":"account.error","reason":"signups disabled for this mode"}`.
|
|
||||||
- Missing browser IP (when the shard requires it) → **400** `{"kind":"account.error","reason":"client ip required"}`.
|
|
||||||
- Bad username/password, or a missing field → **400**.
|
|
||||||
|
|
||||||
Abuse control beyond the per-IP cap (captcha, email verification, signup rate) is your site's responsibility.
|
|
||||||
|
|
||||||
### Unlink an account (Protocol 2.0)
|
|
||||||
|
|
||||||
Sever a game account's tie to its website user, from the site side. Requires a **v2** sidecar.
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /link/{account}
|
|
||||||
{ "actor": "whitlocktech" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Success → **200** `{"kind":"account.ok","action":"unlink","account":"bob"}`. The `WebsiteUserId` tag is cleared on the shard and the sidecar's link mirror is dropped, so attribution stops immediately.
|
|
||||||
- Not linked → **404** `{"kind":"account.error","reason":"not linked"}`.
|
|
||||||
- Protected staff account → **403** `{"kind":"account.error","reason":"target is protected staff; refused"}`.
|
|
||||||
- Missing `actor` → **400**.
|
|
||||||
|
|
||||||
A player can also unlink themselves in game with `[unlink`; that emits an `account.unlinked` event (see §4) so you can reconcile your record.
|
|
||||||
|
|
||||||
### Publish / remove town-crier news
|
|
||||||
|
|
||||||
Push a message that every in-game town crier announces until it expires.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /towncrier
|
|
||||||
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`. Re-posting the same `id` replaces the prior entry.
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /towncrier/{id}
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`, or **404** `{"kind":"towncrier.error","reason":"unknown id"}`.
|
|
||||||
|
|
||||||
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
|
|
||||||
|
|
||||||
### Publish / remove Town Cryer **news** (Protocol 2.1)
|
|
||||||
|
|
||||||
Distinct from the scrolling-crier lines above: this puts a full article — title, HTML body, image, and a "more info" URL — into the in-game **Town Cryer News gump**, and (by default) has the criers proclaim the **title** in-world.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /news
|
|
||||||
{ "id": "42", "title": "Double XP Weekend",
|
|
||||||
"body": "<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.",
|
|
||||||
"image": 1614, "url": "https://yoursite/news/42" }
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"news.ok","id":"42"}`. Re-posting the same `id` **replaces** the prior article in place.
|
|
||||||
|
|
||||||
- `id`, `title` required. `body` (HTML supported), `image` (a UO gump id; a neutral scroll if omitted), `url` (a browser button in the gump) optional.
|
|
||||||
- `announce` defaults to **true** — the criers proclaim the title. Send `"announce": false` to post silently (e.g. a correction).
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /news/{id}
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"news.ok","id":"42"}`, or **404** `{"kind":"news.error","reason":"unknown id"}`.
|
|
||||||
|
|
||||||
Caps apply (title/body length, max active articles). The **website is the source of truth**: the shard rebuilds its news list on restart and does not persist yours, so the sidecar automatically re-pushes your articles (silently) whenever the shard reconnects. Stock ServUO news is left intact — your articles are tracked separately.
|
|
||||||
|
|
||||||
### Staff moderation — the write plane
|
|
||||||
|
|
||||||
Account and session moderation against the live shard. **These are privileged.** The sidecar does
|
|
||||||
not model per-user roles — **your site must authenticate the staff user and check their permission
|
|
||||||
before calling.** The shard trusts the loopback socket and applies each command with CoOwner-level
|
|
||||||
authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner
|
|
||||||
account) is refused (**403**). The whole plane is **opt-in on the shard** (`AdminWriteEnabled` in
|
|
||||||
`Bridge.cfg`); when it's off, every call returns **403** `"admin write plane disabled"`.
|
|
||||||
|
|
||||||
Every request requires an **`actor`** — the website username/id of the staff member taking the
|
|
||||||
action. It is recorded in the shard console log, the ban's `BanDealer` tag, and the `admin.audit`
|
|
||||||
event, so actions are always attributable. A missing `actor` is **400**.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /admin/kick { "actor":"jane", "account":"griefer42" } # or "serial":"0x2E0"
|
|
||||||
POST /admin/ban { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
|
|
||||||
POST /admin/unban { "actor":"jane", "account":"griefer42" }
|
|
||||||
POST /admin/broadcast { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
|
|
||||||
```
|
|
||||||
|
|
||||||
- **kick** — disconnects every live session of the target account (including one parked at
|
|
||||||
character-select). Target by `account` or `serial`. Reply carries `sessions` (how many were cut).
|
|
||||||
- **ban** — bans the account (works offline) and disconnects any live sessions. `durationSec > 0`
|
|
||||||
is a timed ban that auto-expires; `0`/absent is indefinite. Clamped to the shard's
|
|
||||||
`AdminBanMaxDurationSec`.
|
|
||||||
- **unban** — clears the ban.
|
|
||||||
- **broadcast** — a system message to everyone online. `hue` optional (default `53`, staff green).
|
|
||||||
Length-capped by the shard.
|
|
||||||
|
|
||||||
Success → **200** with an `admin.ok`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Failure → an `admin.error` with a mapped status:
|
|
||||||
|
|
||||||
| Status | When |
|
|
||||||
|--------|------|
|
|
||||||
| 400 | missing `actor`, malformed body, or bad parameter |
|
|
||||||
| 401 | missing/invalid auth token |
|
|
||||||
| 403 | target is protected (at/above the floor), or the write plane is disabled on the shard |
|
|
||||||
| 404 | unknown or accountless target |
|
|
||||||
| 503 / 504 | shard not connected / didn't reply in time |
|
|
||||||
|
|
||||||
Each applied action also emits an unsolicited **`admin.audit`** frame on the WebSocket (§4) with
|
|
||||||
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
|
|
||||||
by staff in the game client surfaces the same way with `origin:"in-game"`.
|
|
||||||
|
|
||||||
### Help-page (support) queue
|
|
||||||
|
|
||||||
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
|
|
||||||
roles, like the moderation endpoints above.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /pages # the open queue, newest state
|
|
||||||
POST /pages/{pageId}/respond { "message":"...", "close": false }
|
|
||||||
POST /pages/{pageId}/close
|
|
||||||
```
|
|
||||||
|
|
||||||
- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new`
|
|
||||||
event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live
|
|
||||||
with the `page.new` / `page.updated` / `page.closed` events.
|
|
||||||
- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if
|
|
||||||
they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass
|
|
||||||
`"close": true` to resolve the page in the same call. → **200** `page.ok`.
|
|
||||||
- **close** removes the page from the queue. → **200** `page.ok`.
|
|
||||||
- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**.
|
|
||||||
|
|
||||||
```json
|
|
||||||
POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true }
|
|
||||||
→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true }
|
|
||||||
```
|
|
||||||
|
|
||||||
### History (from the sidecar's database)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /history?kind={kind}&limit={n} # kind optional, limit default 100 (max 1000)
|
|
||||||
GET /economy?limit={n} # the money-supply series (economy.supply events)
|
|
||||||
```
|
|
||||||
|
|
||||||
Recent events, **newest first**, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC".
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /history?kind=vendor.sale&limit=50
|
|
||||||
→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] }
|
|
||||||
|
|
||||||
GET /economy?limit=200
|
|
||||||
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Champion-spawn board
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /champs
|
|
||||||
```
|
|
||||||
|
|
||||||
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /champs
|
|
||||||
→ { "spawns": [
|
|
||||||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
|
||||||
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
|
|
||||||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
|
|
||||||
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
|
|
||||||
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
|
|
||||||
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
|
|
||||||
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
|
|
||||||
] }
|
|
||||||
```
|
|
||||||
|
|
||||||
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
|
|
||||||
|
|
||||||
### Guild board (Protocol 2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /guilds
|
|
||||||
→ { "guilds": [ {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"members":14,"online":3,"alliance":"Britannian Pact",
|
|
||||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
|
||||||
"t":1752489280000}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart.
|
|
||||||
|
|
||||||
### Governor board (Protocol 2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /governors
|
|
||||||
→ { "cities": [ {"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
|
|
||||||
"governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
|
||||||
"governorElect":null,"t":1752489280000}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city.
|
|
||||||
|
|
||||||
### Online population (Protocol 2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /online
|
|
||||||
→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
|
|
||||||
"byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000}
|
|
||||||
```
|
|
||||||
|
|
||||||
The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`.
|
|
||||||
|
|
||||||
### House registry (Protocol 2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /houses
|
|
||||||
→ { "houses": [ {"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil",
|
|
||||||
"decay":"LikeNew","price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain",
|
|
||||||
"owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
|
||||||
"coOwners":2,"friends":5,"builtOn":"...","lastRefreshed":"...","t":...}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the `house.*` stream (§4). Ordered by name. Survives a sidecar restart.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Status codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| 200 | OK |
|
|
||||||
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
|
||||||
| 401 | Missing or invalid auth token |
|
|
||||||
| 404 | Not found (unknown account / character / id, or a not-linked account) |
|
|
||||||
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
|
|
||||||
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
|
|
||||||
| 500 | Internal error (e.g. database) |
|
|
||||||
| 503 | Shard not connected — the query needs the live game and it's down |
|
|
||||||
| 504 | Shard connected but didn't reply within 10s |
|
|
||||||
|
|
||||||
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Putting it together
|
|
||||||
|
|
||||||
A typical character page:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
|
|
||||||
|
|
||||||
// 1. render the roster
|
|
||||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
|
||||||
|
|
||||||
// 2. full sheet for the selected character
|
|
||||||
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
|
|
||||||
if (res.status === 503) showBanner("Game server is restarting…");
|
|
||||||
else renderProfile(await res.json());
|
|
||||||
|
|
||||||
// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
|
|
||||||
// (see the WebSocket client in §4)
|
|
||||||
|
|
||||||
// 4. recent sales widget
|
|
||||||
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
|
|
||||||
.then(r => r.json());
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Caveats & current limits
|
|
||||||
|
|
||||||
- **No rate limiting yet.** The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
|
|
||||||
- **WebSocket is push-only and live-only.** No client→server messages, no replay. Backfill via `/history`.
|
|
||||||
- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
|
|
||||||
- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted.
|
|
||||||
- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape.
|
|
||||||
508
docs/PLAN.md
508
docs/PLAN.md
@@ -1,508 +0,0 @@
|
|||||||
# ServUO Bridge Plugin — Implementation Plan & Data Catalog
|
|
||||||
|
|
||||||
**Status:** Design, grounded in **measurements taken on this shard**, not estimates.
|
|
||||||
**Date:** 2026-07-10
|
|
||||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
|
||||||
**Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) for where that document is wrong.
|
|
||||||
|
|
||||||
Test scaffolding used to produce this plan lives in `Scripts/Custom/BridgeSeeder.cs` (world population) and `Scripts/Custom/BridgeProbe.cs` (timing). Both are gated behind `Config/Bridge.cfg` flags and default to off. **Neither is part of the bridge.** Delete before production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Measured budget
|
|
||||||
|
|
||||||
Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the **Core thread** — the probe printed `thread: Core Thread (id 1)`, which empirically confirms the threading model that `BRIDGE_FINDINGS.md` could only infer from a crash log.
|
|
||||||
|
|
||||||
| Read | Cost | Payload | Per-unit |
|
|
||||||
|------|------|---------|----------|
|
|
||||||
| Full character profile | **0.069 ms/char** | 2,386 B JSON | — |
|
|
||||||
| Vitals sweep (150 chars) | 0.223 ms | ~180 B/char | 0.0015 ms/char |
|
|
||||||
| House decay sweep (35 houses) | 0.007 ms | — | 0.0002 ms/house |
|
|
||||||
| Economy supply sweep (51 accounts) | 0.001 ms | — | ~0.00002 ms/acct |
|
|
||||||
| Vendor snapshot (30 vendors, 1200 listings) | 0.343 ms | — | 0.0003 ms/listing |
|
|
||||||
|
|
||||||
Linear extrapolation at the same gear complexity:
|
|
||||||
|
|
||||||
| Scenario | Cost | Verdict |
|
|
||||||
|----------|------|---------|
|
|
||||||
| Vitals sweep @ 200 online | 0.30 ms | free |
|
|
||||||
| Vitals sweep @ 1000 online | 1.49 ms | free |
|
|
||||||
| Decay sweep @ 2000 houses | 0.38 ms | free |
|
|
||||||
| Economy @ 5000 accounts | 0.06 ms | free |
|
|
||||||
| **Profiles for 1000 chars** | **69.4 ms** | **stall — never in a sweep** |
|
|
||||||
|
|
||||||
**The headline result inverts the original doc's anxiety.** `BRIDGE_FINDINGS.md` treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is *not* free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. **Tier by volatility and serve profiles on demand.** That conclusion survives; the reasoning behind it changes.
|
|
||||||
|
|
||||||
### Caveat on these numbers
|
|
||||||
|
|
||||||
Seeded characters carry **8 equipped items with ~6 non-zero mods each and ~12 trained skills**. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore **understated, plausibly by 2–4×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~6–8 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars.
|
|
||||||
|
|
||||||
Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Architecture (confirmed, unchanged)
|
|
||||||
|
|
||||||
```
|
|
||||||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
|
||||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
|
||||||
```
|
|
||||||
|
|
||||||
ServUO does **not** speak WebSocket. It writes `{...}\n` lines to `127.0.0.1`. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust.
|
|
||||||
|
|
||||||
Non-negotiable rules, all of which the measurements support:
|
|
||||||
|
|
||||||
- **Every world read happens on the Core thread.** Verified: probe reported `Core Thread (id 1)`.
|
|
||||||
- **The Core thread never touches the socket.** Producer formats a line, enqueues to a bounded `ConcurrentQueue`, returns. A dedicated writer thread drains it.
|
|
||||||
- **Inbound commands marshal back via `Timer.DelayCall(TimeSpan.Zero, ...)`**, which is lock-protected and cross-thread safe (`Server/Timer.cs:243-251`). The read thread touches no `World`/`Mobile`/`Item` API.
|
|
||||||
- **Bound the outbound queue** (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard.
|
|
||||||
- **Never block or throw inside an EventSink handler.** Several are veto hooks sitting in a transaction path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Prerequisite: fix the build, or the plugin will not load
|
|
||||||
|
|
||||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) runs `dotnet build Scripts/Scripts.csproj -c Release`, **prints the output, never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Two consequences:
|
|
||||||
|
|
||||||
1. A failing script build is **silently ignored** and the previous `Scripts.dll` reloads. (`BRIDGE_FINDINGS.md` §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.)
|
|
||||||
2. The build passes no `Platform`, so it defaults to `AnyCPU`. `OutputPath` is only set under the `Release|x64` condition, so the DLL lands in `Scripts/bin/Release/` while the server loads `Scripts.dll` from the repo root. **Script edits currently never take effect.**
|
|
||||||
|
|
||||||
**Fix before writing any bridge code.** Either add a default `<Platform>x64</Platform>` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Plugin layout
|
|
||||||
|
|
||||||
All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug.
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. |
|
|
||||||
| `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. |
|
|
||||||
| `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. |
|
|
||||||
| `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. |
|
|
||||||
| `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. |
|
|
||||||
| `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. |
|
|
||||||
| `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. |
|
|
||||||
|
|
||||||
**Lifecycle** (`Server/Main.cs:544-562`, all Core thread):
|
|
||||||
`Configure()` → `World.Load()` → `Initialize()` → `EventSink.ServerStarted`.
|
|
||||||
|
|
||||||
Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Data catalog — everything the shard can give you
|
|
||||||
|
|
||||||
91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified.
|
|
||||||
|
|
||||||
### 5.1 Session & identity
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
|
||||||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
|
||||||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
|
||||||
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
|
|
||||||
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
|
||||||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
|
||||||
|
|
||||||
### 5.2 Character state
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. |
|
|
||||||
| **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. |
|
|
||||||
| Skill progression | `SkillGain` | medium | High-signal. Ship it. |
|
|
||||||
| Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. |
|
|
||||||
| Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. |
|
|
||||||
| Hunger | `HungerChanged` | low | Cosmetic; optional. |
|
|
||||||
|
|
||||||
> ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free.
|
|
||||||
>
|
|
||||||
> ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7.
|
|
||||||
|
|
||||||
### 5.3 Economy & commerce
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). |
|
|
||||||
| **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. |
|
|
||||||
| NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** |
|
|
||||||
| NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. |
|
|
||||||
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
|
|
||||||
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
|
|
||||||
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
|
|
||||||
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
|
|
||||||
|
|
||||||
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
|
|
||||||
|
|
||||||
Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold.
|
|
||||||
|
|
||||||
### 5.4 Housing / IDOC
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
|
||||||
|
|
||||||
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
|
|
||||||
|
|
||||||
**The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected:
|
|
||||||
|
|
||||||
- `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 h random** (`DynamicDecay.cs:18`).
|
|
||||||
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
|
|
||||||
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
|
|
||||||
|
|
||||||
Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
|
|
||||||
|
|
||||||
### 5.5 Combat, death, PvP
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Player death | `PlayerDeath` | low | |
|
|
||||||
| Murder | `PlayerMurdered` | low | High-signal for the website. |
|
|
||||||
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
|
|
||||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
|
||||||
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
|
|
||||||
|
|
||||||
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
|
|
||||||
|
|
||||||
### 5.6 Progression & activity
|
|
||||||
|
|
||||||
`QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`.
|
|
||||||
|
|
||||||
`OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`.
|
|
||||||
|
|
||||||
> ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`.
|
|
||||||
>
|
|
||||||
> Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object.
|
|
||||||
|
|
||||||
### 5.7 Cheat detection & staff audit
|
|
||||||
|
|
||||||
This is where the catalog earns its keep, and it is thin in the original doc.
|
|
||||||
|
|
||||||
| Signal | Hook | Why |
|
|
||||||
|--------|------|-----|
|
|
||||||
| **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. |
|
|
||||||
| **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. |
|
|
||||||
| Staff commands | `EventSink.Command` | Every command invocation. |
|
|
||||||
| **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. |
|
|
||||||
| Gold flow | `AccountGoldChange` | Reconcile against sale stream. |
|
|
||||||
|
|
||||||
### 5.8 Lifecycle
|
|
||||||
|
|
||||||
`ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`.
|
|
||||||
|
|
||||||
`AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.**
|
|
||||||
|
|
||||||
### 5.9 Known gaps (no clean hook)
|
|
||||||
|
|
||||||
- **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.**
|
|
||||||
- **Per-hit combat damage.** Virtual overrides only.
|
|
||||||
- **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only.
|
|
||||||
- **Stat/vital deltas.** Sweep. (Cheap — see §5.2.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. The one core edit: `PlayerVendorSale`
|
|
||||||
|
|
||||||
Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// PlayerVendorGumps.cs:84-96
|
|
||||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
|
||||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
|
||||||
int commission = 0;
|
|
||||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
|
||||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed
|
|
||||||
```
|
|
||||||
|
|
||||||
At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale.
|
|
||||||
|
|
||||||
Three edits, then the bridge stays pure-subscription:
|
|
||||||
|
|
||||||
1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape).
|
|
||||||
2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96.
|
|
||||||
3. Bridge subscribes in `Initialize` like any other event.
|
|
||||||
|
|
||||||
~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Wire protocol
|
|
||||||
|
|
||||||
Newline-delimited JSON, one object per line, `serial` as the primary key.
|
|
||||||
|
|
||||||
### Outbound (shard → sidecar)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"t":1752…,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2,
|
|
||||||
"items":206467,"mobiles":42826,"accounts":51}
|
|
||||||
{"t":1752…,"kind":"server.shutdown"}
|
|
||||||
{"t":1752…,"kind":"server.crashed","error":"…"}
|
|
||||||
{"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
|
|
||||||
{"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
|
|
||||||
"str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
|
|
||||||
{"t":1752…,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
|
|
||||||
{"t":1752…,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
|
||||||
"owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
|
|
||||||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
|
|
||||||
{"t":1752…,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC",
|
|
||||||
"map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0},
|
|
||||||
"region":"Britain","name":"The Silver Anvil",
|
|
||||||
"owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[],
|
|
||||||
"builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
|
|
||||||
{"t":1752…,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
|
|
||||||
{"t":1752…,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
|
|
||||||
{"t":1752…,"kind":"economy.supply","accounts":51,"gold":110478209}
|
|
||||||
```
|
|
||||||
|
|
||||||
`char.profile` follows the shape in `BRIDGE_FINDINGS.md` §IV.3 — it was correct — with `mods` a flattened union of non-zero entries across `AosAttributes`, `AosWeaponAttributes`, `AosArmorAttributes`, produced by iterating each enum through the bag's indexer (`Scripts/Misc/AOS.cs:924,1464,2238`). No hardcoded property names.
|
|
||||||
|
|
||||||
### Inbound (sidecar → shard)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"char.request","account":"PerryAdimn","slot":0}
|
|
||||||
{"kind":"account.roster","account":"PerryAdimn"}
|
|
||||||
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
|
|
||||||
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
|
|
||||||
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
|
|
||||||
{"kind":"towncrier.remove","id":"n123"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every inbound handler marshals to the Core thread before touching world state.
|
|
||||||
|
|
||||||
### `server.hello` is per-connection, not per-boot
|
|
||||||
|
|
||||||
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.
|
|
||||||
|
|
||||||
`bootId` is a GUID generated at `ServerStarted`. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes *"I reconnected"* (keep cached state) from *"the shard restarted"* (discard it). `connects` is the shard's count of successful connections, so the first `hello` of a run carries `connects:1`.
|
|
||||||
|
|
||||||
Counts in `hello` are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning.
|
|
||||||
|
|
||||||
### Item names are clilocs
|
|
||||||
|
|
||||||
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Corrections to `BRIDGE_FINDINGS.md`
|
|
||||||
|
|
||||||
| § | Claim | Reality |
|
|
||||||
|---|-------|---------|
|
|
||||||
| §1 | "A compile error in your bridge file takes the whole shard down at boot." | **False.** `Compile()` ignores the build exit code; a failing build silently reloads the stale `Scripts.dll`. Worse: your plugin would appear absent, not broken. See §3. |
|
|
||||||
| §III.3 | IDOC = 95.0–99.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 12–24 h. Also: `CanDecay` is true only for `Condemned`/`ManualRefresh`, so an active owner's newest house never decays. |
|
|
||||||
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
|
|
||||||
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
|
|
||||||
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
|
|
||||||
| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. |
|
|
||||||
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Implementation phases
|
|
||||||
|
|
||||||
0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.`
|
|
||||||
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
|
|
||||||
2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
|
|
||||||
3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
|
|
||||||
4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests.
|
|
||||||
5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15.
|
|
||||||
6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16.
|
|
||||||
7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17.
|
|
||||||
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
|
|
||||||
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
|
|
||||||
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
|
|
||||||
8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar.
|
|
||||||
|
|
||||||
### Config keys (`Config/Bridge.cfg`)
|
|
||||||
|
|
||||||
```ini
|
|
||||||
Host=127.0.0.1
|
|
||||||
Port=7788
|
|
||||||
QueueCap=10000
|
|
||||||
StatSweepSeconds=30
|
|
||||||
DecaySweepSeconds=60
|
|
||||||
EconomySweepSeconds=300
|
|
||||||
```
|
|
||||||
|
|
||||||
Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Phase 1 acceptance
|
|
||||||
|
|
||||||
Run against the seeded shard with `tools/stub_sidecar.ps1`. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed.
|
|
||||||
|
|
||||||
| Claim | Evidence |
|
|
||||||
|-------|----------|
|
|
||||||
| The shard boots normally with **no sidecar listening**. | World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat. |
|
|
||||||
| Events emitted while disconnected are **buffered and delivered on connect**. | `server.hello` carried `t=…070312` (boot) but arrived at `…114209`, 44 s later, when the sidecar first appeared. |
|
|
||||||
| Inbound commands execute on the **Core thread**. | `{"kind":"ping","id":"t1"}` → `{"kind":"pong","id":"t1"}`. |
|
|
||||||
| An **unknown kind** is ignored, not fatal. | `[Bridge] no handler for inbound kind 'nonsense.kind'` |
|
|
||||||
| **Malformed JSON** does not kill the reader. | `[Bridge] malformed inbound line, ignoring`, connection stayed up. |
|
|
||||||
| Killing the sidecar **does not disturb the shard**. | Shard stayed up, CPU unchanged, no exception, no log spam. |
|
|
||||||
| The shard **reconnects unattended**. | Second `[Bridge] connected`, `hello` re-sent with `connects:2` and the same `bootId`. |
|
|
||||||
|
|
||||||
Two defects were found this way and fixed:
|
|
||||||
|
|
||||||
- **Backoff ceiling was 30 s**, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s.
|
|
||||||
- **A stale reader could kill a fresh connection.** `reader.Join(1s)` can time out, and the old reader's `finally` then set the shared `_dead` flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Phase 7 acceptance
|
|
||||||
|
|
||||||
The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`.
|
|
||||||
|
|
||||||
Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so.
|
|
||||||
|
|
||||||
Verified end to end with a probe that fired the event using **real seeded-vendor data**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"vendor.sale","committed":true,
|
|
||||||
"buyerSerial":"0x1F8","buyerAcct":"seed_001",
|
|
||||||
"ownerSerial":"0x1F5","ownerAcct":"seed_000",
|
|
||||||
"vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword",
|
|
||||||
"itemId":3937,"amount":1,"price":69819,"commission":0}
|
|
||||||
```
|
|
||||||
|
|
||||||
Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events.
|
|
||||||
|
|
||||||
**Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Phase 6 acceptance
|
|
||||||
|
|
||||||
`BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary.
|
|
||||||
|
|
||||||
Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree:
|
|
||||||
|
|
||||||
| Sent | Reply | Crier list |
|
|
||||||
|------|-------|------------|
|
|
||||||
| `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines |
|
|
||||||
| `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list |
|
|
||||||
| `remove n1` | `towncrier.ok` | entry gone |
|
|
||||||
| `remove does-not-exist` | `towncrier.error "unknown id"` | no change |
|
|
||||||
|
|
||||||
The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged.
|
|
||||||
|
|
||||||
Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs.
|
|
||||||
|
|
||||||
No core changes; this closes the pure-plugin inbound work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Phase 5 acceptance
|
|
||||||
|
|
||||||
`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`.
|
|
||||||
|
|
||||||
Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it):
|
|
||||||
|
|
||||||
```
|
|
||||||
<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300
|
|
||||||
-> link.confirm code=77M9TK websiteUserId=web-9931
|
|
||||||
<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931
|
|
||||||
-> link.confirm code=BADCOD ...
|
|
||||||
<- link.error code=BADCOD reason="unknown or expired code"
|
|
||||||
```
|
|
||||||
|
|
||||||
**The tag persists.** After a `World.Save()`, `accounts.xml` contained:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<tags>
|
|
||||||
<tag name="WebsiteUserId">web-9931</tag>
|
|
||||||
</tags>
|
|
||||||
```
|
|
||||||
|
|
||||||
This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised.
|
|
||||||
|
|
||||||
Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup.
|
|
||||||
|
|
||||||
Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Phase 4 acceptance
|
|
||||||
|
|
||||||
`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence.
|
|
||||||
|
|
||||||
Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread:
|
|
||||||
|
|
||||||
- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline.
|
|
||||||
- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed.
|
|
||||||
- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree.
|
|
||||||
- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each.
|
|
||||||
- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`.
|
|
||||||
|
|
||||||
Two things the real character surfaced that the seeded dummies could not:
|
|
||||||
|
|
||||||
- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully.
|
|
||||||
- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking.
|
|
||||||
|
|
||||||
Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Phase 3 acceptance
|
|
||||||
|
|
||||||
`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters.
|
|
||||||
|
|
||||||
Verified on the seeded world with intervals cut to 8 s:
|
|
||||||
|
|
||||||
- **Decay is transition-only.** Baseline recorded 29 houses **silently** on `ServerStarted`. A probe bumped one house `Somewhat → Fairly` with `SetDynamicDecay`; the next sweep emitted **exactly one** `house.decay`, none for the other 28:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
|
||||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
|
||||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
|
||||||
"builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Economy supply** emitted a snapshot each interval: `{"kind":"economy.supply","accounts":51,"gold":…}`.
|
|
||||||
- **Vitals** correctly emitted nothing — the seeded characters are all offline (`NetState == null`). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client.
|
|
||||||
|
|
||||||
Notes from the run:
|
|
||||||
|
|
||||||
- **`region` is null** for the seeded houses — they sit outside any named region. The handler guards `Region`, `Sign`, and `Owner` for null; all three can be absent on abandoned or oddly-placed houses.
|
|
||||||
- The sweeps **skip emitting when the sidecar is disconnected** (`BridgeLink.Connected`), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless.
|
|
||||||
- **Config duplicate keys: last write wins** (`Config.cs` does `_Entries[key] = e`), which is why the scaffolding appends test overrides to the end of `Bridge.cfg`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Phase 2 acceptance
|
|
||||||
|
|
||||||
The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar:
|
|
||||||
|
|
||||||
```
|
|
||||||
{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
|
|
||||||
{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
|
|
||||||
{"kind":"karma.change",...,"old":7903,"new":7853}
|
|
||||||
{"kind":"world.save.before"}
|
|
||||||
{"kind":"world.save.after","items":206312,"mobiles":42826}
|
|
||||||
```
|
|
||||||
|
|
||||||
`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.
|
|
||||||
|
|
||||||
### The finding: `SkillGain` fires for NPCs, hard
|
|
||||||
|
|
||||||
The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events.
|
|
||||||
|
|
||||||
This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.
|
|
||||||
|
|
||||||
### Safety facts baked into the handlers
|
|
||||||
|
|
||||||
- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process.
|
|
||||||
- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these.
|
|
||||||
- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer.
|
|
||||||
- The property is `FastWalkEventArgs.NetState`, not `.State`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Operational notes
|
|
||||||
|
|
||||||
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
|
|
||||||
- **Pending link codes are in-memory** and lost on crash. Acceptable — the player re-runs `[link`.
|
|
||||||
- **`zlibwapi64` `DllNotFoundException`** already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
|
||||||
- The bridge should carry the resolved `websiteUserId` on every player event once the account tag is read at `Login` and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
# Protocol 2.0 — Provisioning & World-State Streams
|
|
||||||
|
|
||||||
**Status:** Parts A + B (phases 1–4) **built and smoke-tested live** on branch `feat/protocol2-account-provisioning` (2026-07-17) — booted ServUO + the real sidecar and exercised every endpoint (see §15). Part B phase 5 (Factions/VvV) deferred by owner decision.
|
|
||||||
**Date:** 2026-07-17
|
|
||||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
|
||||||
**Companion to** [`PLAN.md`](PLAN.md) (read/event plane), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), and [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
|
||||||
|
|
||||||
Protocol 1.0 shipped the read/event plane, the request/reply plane, `[link` account linking, town-crier, the player-vendor-sale core edit, the admin write plane, and the help-page queue.
|
|
||||||
|
|
||||||
2.0 has **two scope areas**:
|
|
||||||
|
|
||||||
- **A — Account provisioning & unlinking (§1–§9).** The website can **create** accounts, **unlink** them, and the shard runs in one of three **signup modes** that decide which side may mint accounts. (1.0 could only *link* an account that already existed, and a link could never be undone.)
|
|
||||||
- **B — Social & political world-state streams (§10–§11).** Guilds, town governors ("mayors"), factions/VvV, and player titles — the standings a website community page wants. §10 specs the requested streams; §11 is a menu of further integration points to pick from.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Part A — Account provisioning & unlinking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What exists today, and the gap
|
|
||||||
|
|
||||||
| Capability | 1.0 | 2.0 |
|
|
||||||
|------------|-----|-----|
|
|
||||||
| Create account in-game (first-login auto-create) | ✔ `AccountHandler.cs:281` | unchanged |
|
|
||||||
| Link an **existing** game account to a website user | ✔ `[link` → `link.confirm` | unchanged |
|
|
||||||
| **Create** a game account from the website | ✗ | **new** `account.create` |
|
|
||||||
| **Unlink** a game account from its website user | ✗ | **new** `account.unlink` + `[unlink` |
|
|
||||||
| Choose which side may create accounts | ✗ (always in-game) | **new** signup mode |
|
|
||||||
|
|
||||||
**The linking flow is not changing.** `[link`, the one-time code, `link.confirm`, and the `WebsiteUserId` tag all stay exactly as they are (`BridgeAccountLink.cs`). 2.0 only *adds* verbs alongside them.
|
|
||||||
|
|
||||||
### The account-creation facts that shape this
|
|
||||||
|
|
||||||
- `new Account(username, password)` self-registers — its constructor calls `Accounts.Add(this)` (`Account.cs:186`) and `SetPassword` hashes per the shard's `AccountHandler.ProtectPasswords` (`Account.cs:174`). So creating an account from the bridge is `new Account(un, pw)` plus the link tag — no extra persistence layer, same as the `[link` tag reaching disk on the next world save.
|
|
||||||
- ServUO's in-game auto-create is gated on the **core** config `Accounts.AutoCreateAccounts` (default `true`, read once in `AccountHandler`'s static init, `AccountHandler.cs:29`). The bridge cannot intercept that path without a core edit, so the signup mode governs the **bridge's** `account.create` verb; the in-game side is controlled by pairing it with the matching core config (see §3).
|
|
||||||
- The core `CreateAccount` path (`AccountHandler.cs:494`) validates the username/password character set (printable ASCII `0x20–0x7F`, no forbidden chars) and enforces `MaxAccountsPerIP` (`Accounts.AccountsPerIp`, default **1**). The website path has **no `NetState`**, so the browser IP must be **passed through explicitly** to enforce that same cap (§3.1); and it must reuse the **character-safety** validation before `new Account`, or it can mint an account no client can log into (or that corrupts serialization).
|
|
||||||
- There is **no `EventSink.AccountCreated`**. The create path is silent. This is why in-game→website creation sync is an open item, not committed scope (§7).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Signup modes — the model
|
|
||||||
|
|
||||||
A single shard-wide setting, `Bridge.SignupMode`, with three values. **Default `hybrid`.**
|
|
||||||
|
|
||||||
| Mode | `account.create` from website | In-game first-login auto-create | Who is the account authority |
|
|
||||||
|------|:-----------------------------:|:-------------------------------:|------------------------------|
|
|
||||||
| `website` | **accepted** | should be **off** | the website |
|
|
||||||
| `game` | **rejected** (`account.error`) | **on** | the game server |
|
|
||||||
| `hybrid` *(default)* | **accepted** | **on** | either side |
|
|
||||||
|
|
||||||
The bridge enforces exactly one half of this: whether it **honors `account.create`**. The other half — in-game auto-create — is the core `Accounts.AutoCreateAccounts` config, which the operator sets to match:
|
|
||||||
|
|
||||||
| `Bridge.SignupMode` | pair with `Accounts.AutoCreateAccounts` |
|
|
||||||
|---------------------|-----------------------------------------|
|
|
||||||
| `website` | `false` — otherwise any client that types a new name still mints an account, defeating website-only |
|
|
||||||
| `game` | `true` |
|
|
||||||
| `hybrid` | `true` |
|
|
||||||
|
|
||||||
On boot the bridge **reads `Accounts.AutoCreateAccounts` and warns** if it contradicts the selected mode (e.g. `SignupMode=website` while auto-create is still on), so a half-configured shard is loud, not silently permissive. The bridge does not try to flip the core setting — it only detects and reports the mismatch, the same defensive posture `BridgeConfig.ParseAccessLevel` already takes.
|
|
||||||
|
|
||||||
**Reconciliation in `hybrid`.** Both paths can race for the same username. `account.create` resolves it the only correct way: `Accounts.GetAccount(un) != null` → refuse with `account.error "account already exists"`. First writer wins; the loser gets a clean error, never a duplicate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. `account.create` — website-driven provisioning
|
|
||||||
|
|
||||||
The website has already authenticated and authorized the user (its own signup form). It hands the shard a username, the password the player chose, and the website user id, and asks for an account that is created **and linked in one step** — no code exchange, because the website *is* the authority here (unlike `[link`, where the game side proves ownership with a code).
|
|
||||||
|
|
||||||
### Request (website → sidecar → shard)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "account.create", "reqId": "c1", "actor": "whitlocktech",
|
|
||||||
"account": "bob", "password": "hunter2", "websiteUserId": "9931",
|
|
||||||
"ip": "203.0.113.7" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- `reqId` — correlation id, echoed on the reply (as everywhere else).
|
|
||||||
- `actor` — the website user/staff id, for the audit line. Required, non-empty (mirrors the admin plane).
|
|
||||||
- `account` — desired username.
|
|
||||||
- `password` — the game-client password the player chose on the site. Plaintext over the **loopback + token** socket, the same trust boundary every inbound verb already relies on; the shard hashes it via `SetPassword` immediately.
|
|
||||||
- `websiteUserId` — the site user to auto-link.
|
|
||||||
- `ip` — the **end user's browser IP**, so the shard can enforce `MaxAccountsPerIP` on website signups exactly as it does on in-game first-login. The website reads this from its own request context (remote-addr, or a trusted `X-Forwarded-For`); the **sidecar cannot derive it** — the sidecar only sees the website's connection IP, not the browser's, so this must be an explicit field. See §3.1.
|
|
||||||
|
|
||||||
### Shard behavior (Core thread, in `BridgeAccounts.cs`)
|
|
||||||
|
|
||||||
1. **Gate.** `SignupMode == game` → `account.error "signups disabled for this mode"`. Master switch `Bridge.AccountCreateEnabled` (default follows mode) must be on.
|
|
||||||
2. **Validate `actor`** present (as admin plane does).
|
|
||||||
3. **Validate username/password** with the same character-safety rules as `AccountHandler.CreateAccount` (printable ASCII, no leading/trailing space, no trailing dot, no forbidden chars). Enforce length caps from config.
|
|
||||||
4. **Collision check.** `Accounts.GetAccount(account) != null` → `account.error "account already exists"`.
|
|
||||||
5. **IP cap.** Parse `ip` → `IPAddress`. If `RequireIpForCreate` and it is missing/unparseable/loopback → `account.error "client ip required"` (**fail closed** — a missing IP must never silently bypass the cap; loopback is exempt in `IPLimiter`, so accepting it *is* a bypass). Then `AccountHandler.CanCreate(ip) == false` → `account.error "ip account limit reached"`. This is the same read-side check the in-game path runs at `AccountHandler.cs:510`.
|
|
||||||
6. **Create + link atomically.** `var a = new Account(account, password); a.LogAccess(ip); a.SetTag("WebsiteUserId", websiteUserId);` — `LogAccess` bumps `AccountHandler.IPTable[ip]` and records the IP into `LoginIPs` (`Account.cs:1251`), which is exactly what an in-game first-login does, so the per-IP count is both live-accurate and durable (it rebuilds from `LoginIPs[0]` on reboot). The `WebsiteUserId` tag persists to `accounts.xml` on the next world save, identical to the `[link` path.
|
|
||||||
7. **Reply** `account.ok` and **emit** an unsolicited `account.audit` (`origin:"web"`, `action:"create"`) to every dashboard, parallel to `admin.audit`.
|
|
||||||
|
|
||||||
### Reply
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "account.ok", "reqId": "c1", "action": "create",
|
|
||||||
"account": "bob", "websiteUserId": "9931" }
|
|
||||||
{ "kind": "account.error", "reqId": "c1", "reason": "account already exists" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.1 The IP flow — who sees what
|
|
||||||
|
|
||||||
```
|
|
||||||
browser ──HTTP signup──► website ──POST /accounts/create──► sidecar ──account.create──► shard
|
|
||||||
(real IP) (sees browser IP) (sees WEBSITE's IP, not browser's) (enforces cap)
|
|
||||||
```
|
|
||||||
|
|
||||||
The chain hops hosts, so the only party that sees the **end user's** IP is the website, at the edge. By the time the request reaches the sidecar, the socket's peer address is the *website*, not the player — which is why `ip` is a body field, not something the sidecar reads off the connection. The website populates it from its request context (remote-addr, or `X-Forwarded-For` from a proxy it trusts).
|
|
||||||
|
|
||||||
Two consequences to state plainly:
|
|
||||||
|
|
||||||
- **The IP is only as trustworthy as the website's proxy handling.** A compromised or misconfigured website could send a spoofed or wrong IP. That is already inside the 2.0 trust boundary (the website is trusted via loopback + token), but it means the per-IP cap is an *honesty* control against ordinary multi-account signups, not a hard security boundary against a hostile website.
|
|
||||||
- **IPv4/IPv6 skew.** A browser may present IPv6 while the UO client connects over IPv4; the two are different `IPAddress` keys, so a website account and a later in-game account from the "same" person may not share an `IPTable` bucket. Inherent to keying on raw IP — noted, not solved.
|
|
||||||
|
|
||||||
The sidecar itself does **not** validate or transform `ip`; it forwards the field and lets the shard (which owns `IPTable`) decide. If a shard wants the sidecar to reject obviously-bad input early, that is a later refinement, not required for correctness — the shard fails closed regardless.
|
|
||||||
|
|
||||||
### Sidecar route
|
|
||||||
|
|
||||||
`POST /accounts/create`, body `{actor, account, password, websiteUserId, ip}` → the inbound line, correlated on a fresh `reqId`. Status mapping (new `respond_account`, modeled on `respond_admin`):
|
|
||||||
|
|
||||||
| Reply / reason | HTTP |
|
|
||||||
|----------------|------|
|
|
||||||
| `account.ok` | 200 |
|
|
||||||
| `"account already exists"` | 409 Conflict |
|
|
||||||
| `"ip account limit reached"` | 429 Too Many Requests |
|
|
||||||
| `"signups disabled…"` | 403 |
|
|
||||||
| `"client ip required"`, `"invalid username/password"`, missing field | 400 |
|
|
||||||
| shard down / timeout | 503 / 504 |
|
|
||||||
|
|
||||||
> ⚠️ The password is a secret in a request body and a shard reply. Keep it off the WebSocket broadcast entirely: `account.audit`/`account.ok` **never carry the password**, and the console/audit log records only `account` + `actor`. This is the same discipline `BridgeEvents` already applies to the plaintext `AccountLoginEventArgs.Password` it deliberately never forwards (`PLAN.md` §12).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Unlinking
|
|
||||||
|
|
||||||
Symmetric with `[link`: either side can sever the tie. Both paths do the same one thing — remove the `WebsiteUserId` account tag (`acct.RemoveTag("WebsiteUserId")`) — and both persist on the next world save.
|
|
||||||
|
|
||||||
### 4.1 Website → `account.unlink`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "account.unlink", "reqId": "u1", "actor": "whitlocktech", "account": "bob" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Resolve by `account` (username) or `serial` (a player mobile's account), reusing `BridgeAdmin.ResolveTargetAccount`.
|
|
||||||
- Not linked → `account.error "not linked"` (a no-op is reported honestly, not faked as success).
|
|
||||||
- Apply the **Owner floor** (`BridgeAdmin.Protected`): refuse to unlink an account at/above `AdminAccessFloor`, same defense-in-depth as the admin verbs.
|
|
||||||
- Reply `account.ok action:"unlink"`; emit `account.audit action:"unlink"`.
|
|
||||||
|
|
||||||
Sidecar: `DELETE /link/{account}` (the existing `/link/:account` GET already looks a link up; this adds the delete verb next to it) → also clears the sidecar's mirrored link row (`store.record_unlink`), so event attribution stops immediately without waiting on the shard.
|
|
||||||
|
|
||||||
### 4.2 In-game → `[unlink`
|
|
||||||
|
|
||||||
`CommandSystem.Register("unlink", AccessLevel.Player, …)` in `BridgeAccountLink.cs`, next to `[link`:
|
|
||||||
|
|
||||||
- Reads the caller's own account, clears the tag, emits `account.unlinked` (so the site learns of a player-initiated unlink and can reconcile its own record).
|
|
||||||
- Player-scoped: a player can only unlink **their own** account (no target argument), so it needs no floor.
|
|
||||||
- Symmetric UX with `[link`: `"Your account is no longer linked."`
|
|
||||||
|
|
||||||
> **Note — `[link` behavior is unchanged.** `[link` still refuses when a tag already exists (`BridgeAccountLink.cs:96`). `[unlink` is what clears it; after unlinking, `[link` works again. That is the whole interaction, and it needs no change to the existing link code — only the new command beside it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Trust & attribution
|
|
||||||
|
|
||||||
Identical model to the admin write plane (`ADMIN_CONTROLS.md` §5), because these are the same shape of action (website-authorized, applied on the loopback socket):
|
|
||||||
|
|
||||||
- **Authorization lives on the website.** `account.create`/`unlink` are gated behind the site's own roles (self-service signup for create; admin/self for unlink). The shard trusts the loopback + token socket and the required `actor` field.
|
|
||||||
- **Owner floor** applies to `account.unlink` (never unlink a protected staff account from the web).
|
|
||||||
- **Attribution** is the `web:<actor>` string in the console line and the `account.audit` frame; the website keeps its own durable record, as it already does for `admin.audit`.
|
|
||||||
- **`account.create` now enforces `MaxAccountsPerIP`** using the browser IP the website forwards (§3.1), via the same `CanCreate` / `LogAccess` path as in-game first-login. But the cap is only as honest as the website's IP reporting, and it fails **closed** on a missing/loopback IP when `RequireIpForCreate` is on. Higher-order abuse control (captcha, email verification, per-account-per-day) remains the website's job — the shard cap is a floor, not the whole defense.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Config keys (`Config/Bridge.cfg`)
|
|
||||||
|
|
||||||
```ini
|
|
||||||
SignupMode=hybrid # website | game | hybrid (default hybrid)
|
|
||||||
AccountCreateEnabled=true # master switch for account.create; auto-off when SignupMode=game
|
|
||||||
RequireIpForCreate=true # fail closed if account.create omits a usable browser IP
|
|
||||||
AccountNameMaxLength=16
|
|
||||||
AccountPasswordMaxLength=30
|
|
||||||
```
|
|
||||||
|
|
||||||
Read in `BridgeConfig.Load()`, re-readable via `[bridge reload`. `SignupMode` parses like `AdminAccessFloor` — unrecognized value falls back to the safest option (`game`, i.e. no website creation) with a console warning, so a typo can never accidentally open provisioning. `RequireIpForCreate` defaults **on**: the per-IP cap only means something if a missing IP is refused rather than waved through. Turn it off only for a deployment that deliberately does not cap website signups by IP (and then `MaxAccountsPerIP` still applies in-game as before).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Open item (not committed) — in-game → website creation sync
|
|
||||||
|
|
||||||
Per the 2026-07-17 decision, **this is not in 2.0's committed scope.** When an account is created *in-game* (first-login auto-create, or staff `[AddAccount`), the website is **not** notified today, and 2.0 does not change that. Recorded here so the tradeoff is explicit, not forgotten:
|
|
||||||
|
|
||||||
- **Why it's hard cleanly:** there is no `EventSink.AccountCreated`. The only faithful tap is a core edit — an `Action<Account>` raised in the `Account(string, string)` ctor (safe: the load path is a *separate* ctor, `Account.cs:189`, so it won't fire during world load), shipped as a `patches/` diff exactly like `PlayerVendorSale` and the `CommandLogging` event.
|
|
||||||
- **Why it may not be needed:** in `website`-mode the website already knows every account (it created them). Sync only matters for `hybrid`/`game` modes where the website wants a roster of game-born accounts — and even then the sidecar can approximate "new account" from the `mob.login` `acct` field it already receives (first-seen = new), lossy but zero core edits.
|
|
||||||
- **If we do it later:** it becomes an `account.created` event stream (`origin:"in-game"`), the natural mirror of the `account.audit` (`origin:"web"`) that `account.create` emits — the same bidirectional-audit shape §5.5 of `ADMIN_CONTROLS.md` established. Revisit if a shard chooses `hybrid`/`game` and wants a complete website roster.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Where the code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeAccounts.cs` | **New.** Registers `account.create` and `account.unlink`; the create+link, char-safety validation, collision check, Owner floor on unlink, `account.audit` emission. Mirrors `BridgeAdmin.cs` structure. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs` | **Extend.** Add the `[unlink` player command beside `[link`. No change to existing link behavior. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeConfig.cs` | **Extend.** `SignupMode` (parsed, safe fallback), `AccountCreateEnabled`, `RequireIpForCreate`, name/password length caps; read `Accounts.AutoCreateAccounts` and warn on mode mismatch. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeBoot.cs` | **Extend.** Wire `BridgeAccounts.Initialize()` into `Initialize()` (one line, beside the other subsystems). |
|
|
||||||
| `overlay/Config/Bridge.cfg` + `.example` | **Extend.** The §6 keys, defaults documented. |
|
|
||||||
| `sidecar/src/web.rs` | **Extend.** `POST /accounts/create` (forwards `ip` from the body untouched), `DELETE /link/:account`; `respond_account` status mapping (409 on collision, 429 on IP cap, 400 on missing IP); scrub password from any logged/broadcast value. |
|
|
||||||
| `sidecar/src/store.rs` | **Extend.** `record_unlink` (clear the mirrored link row) beside the existing `record_link`. |
|
|
||||||
| `docs/INTEGRATION.md` | **Extend.** Document `POST /accounts/create`, `DELETE /link/{account}`, and the `account.audit` event. |
|
|
||||||
| *(website, separate repo)* | Signup form → `POST /accounts/create`; unlink control → `DELETE /link/{account}`; consume `account.audit`. |
|
|
||||||
|
|
||||||
No new core/stock edits in committed scope — `account.create`/`unlink` are all script-layer (`new Account`, `SetTag`/`RemoveTag`) called from the new overlay. The only core edit contemplated (the `AccountCreated` event, §7) is explicitly deferred.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Phasing
|
|
||||||
|
|
||||||
1. ~~**Config + modes.**~~ **Done.** `BridgeConfig` gains `SignupMode` (parsed, unrecognized → `game`), `AccountCreateEnabled` (mode-following default), `RequireIpForCreate`, name/password caps, and the boot-time `Accounts.AutoCreateAccounts` mismatch warning. `[bridge status` shows `signup=…(create=…)`.
|
|
||||||
2. ~~**`account.create`.**~~ **Done.** `BridgeAccounts.cs` + `POST /accounts/create` + `respond_account`. Gate on mode, `actor` required, char-safety mirrored from `AccountHandler`, collision → 409, IP cap via `CanCreate`/`LogAccess` (fail-closed on missing/loopback IP when `RequireIpForCreate`), create + link, `account.audit`, password never logged/echoed. *Acceptance below is written for a live run — not yet exercised end-to-end.*
|
|
||||||
3. ~~**Unlink — both surfaces.**~~ **Done.** `account.unlink` + `DELETE /link/:account` + `store.record_unlink`, and the in-game `[unlink`. Owner floor reuses `BridgeAdmin.Protected`; `[unlink` emits `account.unlinked`.
|
|
||||||
4. ~~**Docs.**~~ **Done.** `INTEGRATION.md` §2 (protocol bumped to **2**), §4 (`account.audit`/`account.unlinked`), §6 (`POST /accounts/create`, `DELETE /link/{account}`), §7 (409/429).
|
|
||||||
|
|
||||||
**Build verification (2026-07-17):** sidecar `cargo check` clean; overlay compiled in the full ServUO Scripts tree — **0 errors, 0 warnings**. **Live end-to-end run still pending** (needs a booted shard + sidecar): create+link in website/hybrid, game-mode refusal, duplicate 409, the per-IP cap holding (second create same `ip` → 429, different IP succeeds, omitted IP → 400 while `RequireIpForCreate`), `LoginIPs[0]`/`IPTable` incremented, and unlink clearing tag+mirror with the Owner floor refusing a protected target.
|
|
||||||
|
|
||||||
Deferred (revisit only if a shard needs it): the §7 in-game→website `account.created` sync; the §12.3 `account.setpassword`/`account.exists` siblings; §12.5 credential-verb rate limiting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Part B — Social & political world-state streams
|
|
||||||
|
|
||||||
These are **outbound** streams (shard → website), the natural extension of `PLAN.md`'s event/sweep plane. None needs a write plane; all reuse the transport, the bounded queue, and the sweep/emit-on-change discipline `BridgeSweeps` already established. Each entry below states its **grounded hook situation** so nothing rides an event that doesn't fire.
|
|
||||||
|
|
||||||
## 10. The requested streams
|
|
||||||
|
|
||||||
### 10.1 Guilds
|
|
||||||
|
|
||||||
**Hook reality (verified):**
|
|
||||||
|
|
||||||
- `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`.
|
|
||||||
- `EventSink.CreateGuild` is **not** a creation notification. It is the load-time deserialization factory: raised only from `Server/World.cs:517` while reading the guild index at boot, where the handler's job is to *construct* the guild instance (`Guild.cs:775` → `new Guild(args.Id)`). Player guild creation (`new Guild(pm, name, abbrev)` at `Create Guild Gump.cs:83`, `GuildDeed.cs:127`) raises **no event**. **Do not use `CreateGuild` for "a guild was created"** — it would fire once per guild at every boot and never on an actual new guild.
|
|
||||||
- Leave, disband, leader change, alliance change, rename: **no events.**
|
|
||||||
|
|
||||||
**Delivery — a guild sweep + diff, exactly like house decay (`PLAN.md` §5.4).** `BaseGuild.List` is a `Dictionary<int, BaseGuild>` (`Server/Guild.cs:54`) — the whole registry, enumerable on the Core thread. Hold a `Dictionary<int, GuildSnapshot>` (name, abbreviation, leader serial, member count, alliance name, member-serial set hash). On each sweep, diff:
|
|
||||||
|
|
||||||
- id present now, absent before → `guild.created`
|
|
||||||
- id absent now, present before → `guild.disbanded`
|
|
||||||
- leader / alliance / name / abbreviation changed → `guild.updated`
|
|
||||||
- member set grew/shrank → `guild.join` / `guild.leave` (the sweep is the reliable source for leaves; `EventSink.JoinGuild` can *also* emit an immediate `guild.join` for joins, with the sweep as the backstop)
|
|
||||||
|
|
||||||
Take a **silent baseline** on `ServerStarted` (populate without emitting), same as decay, or every guild re-announces on every boot. Cost is trivial — a shard has tens to low-hundreds of guilds, and reading `Members.Count` + `Leader` is a handful of field reads each.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"guild.created","id":1234,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"},"members":14,"alliance":null}
|
|
||||||
{"kind":"guild.leave","id":1234,"who":{"serial":"0x77","name":"Bran"},"members":13}
|
|
||||||
{"kind":"guild.disbanded","id":1234,"name":"The Silver Hand"}
|
|
||||||
```
|
|
||||||
|
|
||||||
> If real-time (not next-sweep) leave/disband ever matters, the clean tap is a one-line `patches/` hook in `Scripts/Misc/Guild.cs` `RemoveMember`/`OnDelete` — the Phase-7 `patches/` precedent. Start with the sweep; add the patch only if latency is a real complaint. Guild membership does not move fast enough to justify it up front.
|
|
||||||
|
|
||||||
### 10.2 Town governors ("mayors")
|
|
||||||
|
|
||||||
In modern ServUO the "mayor of a town" is the **Governor** in the City Loyalty System (King Blackthorn's governance). Each `City` (enum, `CityLoyaltySystem.cs:15`) has a `CityLoyaltySystem` instance carrying `Governor` (Mobile), `GovernorElect`, an `Election`, a `Citizens` count, and a herald. The `Governor` setter already broadcasts a herald message on change (`CityLoyaltySystem.cs:193`), confirming a governor transition is a first-class in-game event — there just isn't an `EventSink` for it.
|
|
||||||
|
|
||||||
**Delivery — a city sweep, emit-on-change.** `CityLoyaltySystem.Cities` (static `List<CityLoyaltySystem>`, `CityLoyaltySystem.cs:680`) is the full set, one per city. Sweep, hold `Dictionary<City, governorSerial>`, emit on transition. Governors change on the order of weeks — a slow sweep (e.g. 5 min, or fold into the economy sweep cadence) is ample. Also emit election open/close and, optionally, the standing.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"city.governor","city":"Britain","from":{"serial":"0x55","name":"Old Mayor"},
|
|
||||||
"to":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"}}
|
|
||||||
{"kind":"city.election","city":"Moonglow","phase":"nominate","candidates":3,"endsAt":"2026-07-24T…"}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Gate on `CityLoyaltySystem.Enabled`** (`CityLoyalty.Enabled`, default true). If a shard runs its own custom town-ownership system instead, this sweep should no-op — detect and log, don't assume.
|
|
||||||
|
|
||||||
### 10.3 Player titles
|
|
||||||
|
|
||||||
There is **no title-change event.** Titles are read-model state, best delivered two ways, not as a stream:
|
|
||||||
|
|
||||||
- **Enrich `char.profile`** (`BridgeProfile`) with a `titles` block. Sources on a `PlayerMobile`: the reward-title list `m_RewardTitles` (`List<object>`) + the selected index `m_SelectedTitle` (`PlayerMobile.cs:4194,4595`), the champion title `m_CurrentChampTitle`, plus the computed titles from `Titles.ComputeTitle` / `ComputeFameTitle` / `GetSkillTitle` / veteran titles (`Scripts/Misc/Titles.cs`). `char.profile` already carries all-skills, so titles slot in beside it at near-zero extra cost, and it is a *read* — no hook needed.
|
|
||||||
- **Optional `title.change`** only if the community page wants a live "so-and-so is now *Grandmaster Blacksmith*" feed — and then it comes from a **profile-diff in the sidecar**, not a shard event (the shard has nothing to subscribe to). Recommend starting with profile enrichment; add the diff feed only if there is demand.
|
|
||||||
|
|
||||||
City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`, `MerchantTitles.cs`) fold into the same `titles` block.
|
|
||||||
|
|
||||||
### 10.4 Factions / Vice vs Virtue
|
|
||||||
|
|
||||||
**Which system is live is a shard decision — verify before building.** Two exist:
|
|
||||||
|
|
||||||
- **Old Factions** (`Scripts/Services/Factions`): `Faction.Commander` (leader, `Faction.cs:160`), `Faction.Election`, `Faction.Members` (`List<PlayerState>`), and faction-controlled **Towns** (`Town.cs` — each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, **off**.
|
|
||||||
- **Vice vs Virtue** (`Scripts/Services/ViceVsVirtue`): the modern replacement. `ViceVsVirtueSystem.Enabled` (`VvV.Enabled`, default **true**), a singleton `Instance`, an active `Battle`, and per-player `VvVPlayerEntry` (score, kills, assists). City control in VvV rides the same city-loyalty/governor rails as §10.2.
|
|
||||||
|
|
||||||
**Delivery — a sweep, gated on whichever is enabled.** Neither system raises membership/leadership `EventSink`s, so it is the same sweep+diff pattern:
|
|
||||||
|
|
||||||
- VvV (recommended default): standings per side, active-battle status (`Battle.OnGoing`, current city), and the top `VvVPlayerEntry` scores → a `vvv.standings` snapshot on change + a `vvv.battle` open/close event.
|
|
||||||
- Old Factions (only if a shard runs it): `faction.control` (town → owning faction on change), `faction.commander` (leader change from the `Election`).
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"vvv.battle","phase":"start","city":"Britain","map":"Felucca","endsAt":"2026-07-17T…"}
|
|
||||||
{"kind":"vvv.standings","order":142000,"chaos":138500,"leaderSide":"Order"}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Start by detecting which system is enabled at boot and streaming only that one; emit a one-time `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing.
|
|
||||||
|
|
||||||
## 11. Further integration points — a menu to pick from
|
|
||||||
|
|
||||||
Everything below is grounded in a hook or a cheap sweep in *this* server. Ranked roughly by value-to-effort. **Pick the ones you want and I'll fold them into the phasing.** (✔ = a real `EventSink` exists; ⟳ = sweep/diff; ⚑ = needs a small `patches/` core tap.)
|
|
||||||
|
|
||||||
| # | Stream | Source | Effort | Why it's worth it |
|
|
||||||
|---|--------|--------|:------:|-------------------|
|
|
||||||
| 1 | **Who's-online / population** | ⟳ online sweep over `NetState.Instances` | low | A live "N players online", per-facet population, and a history series. The single most-asked-for website widget. |
|
|
||||||
| 2 | **Region presence** | ✔ `EventSink.OnEnterRegion` (`Region.cs:1160`, player-filtered) | low | Cheap location stream → town population heatmap, "who's in Despise" — `PLAN.md` §5.6 already flags it as the right answer over `Movement`. |
|
|
||||||
| 3 | **Crafting feed** | ✔ `EventSink.CraftSuccess` | low | Who crafted what, exceptional/runic — a crafting economy + "notable crafts" feed. |
|
|
||||||
| 4 | **Taming feed** | ✔ `EventSink.TameCreature` | low | New tames, esp. rares/greaters — high community interest. |
|
|
||||||
| 5 | **Resource harvesting** | ✔ `EventSink.ResourceHarvestSuccess` | low-med | Mining/lumber/fishing volume → the raw-material side of the economy (pairs with the vendor/gold streams already shipped). |
|
|
||||||
| 6 | **Virtue progression** | ✔ `EventSink.VirtueLevelChange` | low | Knight/Seeker/etc. virtue ranks — a progression badge system. |
|
|
||||||
| 7 | **Bulk Order Deeds** | ✔ `EventSink.BODOffered` / `BODUsed` | low | BOD turn-ins and rewards — a crafting-endgame feed and reward-title source. |
|
|
||||||
| 8 | **Guild wars** | ⟳ from the §10.1 guild sweep (war state on `Guild`) | low | Declared/active/ended wars between guilds — a PvP politics board, nearly free once guilds sweep. |
|
|
||||||
| 9 | **Player housing registry** | ⟳ extend the existing decay sweep to a full house list | med | Owner → houses map, "houses for sale" (via vendor data already streamed), a housing map. Reuses `PLAN.md` §5.4 machinery. |
|
|
||||||
| 10 | **Peerless / boss / rare drops** | ⚑ virtual-override or drop-system tap (no `EventSink`) | med | An "epic loot" feed. Honest cost: no clean event (same gap as per-hit damage, `PLAN.md` §5.9) — needs a targeted `patches/` hook, so it is a deliberate pick, not a freebie. |
|
|
||||||
| 11 | **Secure player trades** | ⚑ `SecureTrade` completion has no `EventSink` | med | Player-to-player item/gold transfers → economy + fraud signal, complements the vendor-sale core edit. Needs a core tap. |
|
|
||||||
| 12 | **Champion spawn *board*** | already shipped (`BridgeChamps`) — extend, don't rebuild | — | Champs are done in 1.0. Listed so it is not re-proposed; any gap is an extension of the existing sweep. |
|
|
||||||
|
|
||||||
**Selected for Part B (owner pick, 2026-07-17):** guilds (§10.1) + governors (§10.2) + who's-online (#1) + region presence (#2) + **housing registry (#9)** + titles (§10.3, free as profile enrichment). All reuse the sweep pattern and need no core edit; together they give a website its "living world" page — population, guild politics, town leadership, and a housing map. Factions/VvV (§10.4) is deferred until you confirm which system your shard runs. The phasing is §13.
|
|
||||||
|
|
||||||
### Where the Part B code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeSocial.cs` | **New.** The guild sweep+diff and the `EventSink.JoinGuild` subscription → `guild.*`. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeGovernance.cs` | **New.** The city sweep → `city.governor`/`city.election`; the VvV/faction standings sweep (gated on enabled) → `vvv.*` / `faction.*`; the one-time `world.systems` frame. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeProfile.cs` | **Extend.** Add the `titles` block to `char.profile`. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeSweeps.cs` | **Extend / mirror.** New sweep timers (guild, city, presence), re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`, counters in `[bridge status` — same shape as the existing sweeps. |
|
|
||||||
| `overlay/Config/Bridge.cfg` | **Extend.** `GuildSweepSeconds`, `CitySweepSeconds`, `PresenceSweepSeconds` (+ enable flags). |
|
|
||||||
| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the snapshots that back boards (guild roster, governors, population history); `GET /guilds`, `/governors`, `/online` served from the store so they survive a shard outage, exactly like `/champs` and `/economy` do today. |
|
|
||||||
| `docs/INTEGRATION.md` | **Extend.** New event catalog entries + the read endpoints. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Cross-cutting additions (recommended)
|
|
||||||
|
|
||||||
Five things that are not new *streams* but make 2.0 correct and complete. The first two I consider **essential**; the rest are high-value companions to what's already specced.
|
|
||||||
|
|
||||||
### 12.1 Bump the protocol version to 2 — **essential**
|
|
||||||
|
|
||||||
The sidecar is `PROTOCOL_VERSION = 1` (`sidecar/src/main.rs:23`), and every response carries `X-UOLink-Version`; the gate 409s a client that declares a different one (`web.rs:146`). 2.0 adds inbound verbs (`account.create`, `account.unlink`, …) and event kinds, so it must bump to `2`.
|
|
||||||
|
|
||||||
The compatibility rule to write down: **new outbound event kinds are additive** — a 1.x website ignores unknown kinds and keeps working, so the live feed stays backward-compatible. What is *not* compatible is a client that calls a **new inbound verb** against an old sidecar, or a new sidecar that a strict old client rejects on the version header. So: bump to `2`, keep the feed additive, and document that the new *verbs/endpoints* require a v2 sidecar while the *event feed* degrades gracefully.
|
|
||||||
|
|
||||||
### 12.2 Every diff stream needs a REST snapshot companion — **essential**
|
|
||||||
|
|
||||||
The Part B streams are **diff-based**: `guild.created`/`disbanded`, `city.governor`, housing changes emit only on transition (like house decay). That means a website that connects fresh — or a **sidecar that restarts** — has seen *no* deltas yet and therefore has **no current state**. The live feed alone can never answer "what are the guilds *right now*."
|
|
||||||
|
|
||||||
So every board-backed stream ships with a REST snapshot served from the sidecar's store, exactly as `/champs` and `/economy` already are (`web.rs`): `GET /guilds`, `/governors`, `/online`, `/houses`. The shard emits deltas; the sidecar persists the latest snapshot; the website hydrates from REST on load and then live-updates from the feed. This is the single most important robustness rule for Part B — without it, a sidecar restart silently blanks the community page until the next guild happens to change.
|
|
||||||
|
|
||||||
> Concretely: the sidecar keeps a `guilds` / `governors` / `population` table updated from the stream (upsert on each delta, plus a periodic full snapshot the shard can push), and the REST route reads that table. The shard should also support an on-demand full re-emit (a `snapshot.request` inbound, or just re-run the sweep with baseline suppression off) so a sidecar that lost its store can rebuild.
|
|
||||||
|
|
||||||
### 12.3 Round out the provisioning surface — password reset, existence check
|
|
||||||
|
|
||||||
`account.create` sets the account's **initial** password (§3) — that part is done. What it does not cover is the rest of the credential lifecycle. Two small siblings close it, both trivially grounded:
|
|
||||||
|
|
||||||
- **`account.setpassword`** — the **later** password *change/reset* for an account that already exists (a player who forgot theirs), distinct from the initial password `account.create` sets. `acct.SetPassword(newpw)` (`Account.cs:676`) is public; the verb takes `{actor, account, password}`, applies the Owner floor, emits `account.audit action:"setpassword"`, and — like create — **never echoes the password**. `POST /accounts/{account}/password`. Only worth building if the site will offer a "forgot password" flow.
|
|
||||||
- **`account.exists`** — the signup form wants to say "that name is taken" before submit. A read: `Accounts.GetAccount(un) != null`. `GET /accounts/{account}` → `{exists: true|false, linked: bool}`. Cheap, and it prevents the worse UX of finding out via a 409 on submit.
|
|
||||||
|
|
||||||
Both reuse the `account.*` machinery from Part A verbatim. `account.setpassword` is the higher-value of the two.
|
|
||||||
|
|
||||||
### 12.4 Mirror hygiene — deletion & link teardown
|
|
||||||
|
|
||||||
If the website mirrors rosters/links (it does — `store.record_link`), it must learn when the game side removes things, or the mirror rots:
|
|
||||||
|
|
||||||
- **Character deletion.** `EventSink.DeleteRequest` (`EventSink.cs:1754`) fires when a player deletes a character at the select screen. Emit `char.deleted` so the website drops it from any roster it caches. (`PLAN.md` §5.1 already lists this hook as a roster-honesty signal — 2.0 is where it earns its place, now that the website keeps rosters.)
|
|
||||||
- **Account deletion.** `Account.Delete()` exists (`Account.cs:642`); an optional `account.delete` verb (Owner-floor-guarded, `origin:"web"` audit) closes the lifecycle. Lower priority — most shards ban rather than delete — but list it so the option is on record.
|
|
||||||
- On any unlink **or** account delete, the sidecar clears its link mirror (the `record_unlink` already specced in §4.1), so event attribution stops immediately.
|
|
||||||
|
|
||||||
### 12.5 Rate-limit the credential verbs
|
|
||||||
|
|
||||||
`account.create` and `account.setpassword` mint/change persistent credentials. The per-IP cap (§3.1) blocks multi-accounting from one IP, but a compromised or buggy website could still hammer distinct IPs. Add a **sidecar-side rate limit** on the credential verbs — a global create-per-minute ceiling and a per-`actor` cooldown — mirroring the caps philosophy town-crier and the admin plane already follow (`BridgeConfig.TownCrier*`, `Admin*`). Cheap insurance; the shard stays the last line of defense (collision + IP cap), the sidecar is the first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Part B phasing
|
|
||||||
|
|
||||||
1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.*
|
|
||||||
2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.*
|
|
||||||
3. ~~**Housing registry.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeHousing.cs`: a house sweep over `BaseHouse.AllHouses` → `house.update`/`house.remove` (owner, region, location, decay, co-owners, friends, price), complementing the existing `house.decay` transition feed. `HousingSweepSeconds` (300s), wired into `[bridge`. Sidecar `houses` board + `GET /houses`. (Stock ServUO has no "for sale" flag, so this is owner→houses; `price` is the placement value, not a listing.) *Live run pending.*
|
|
||||||
4. ~~**Titles.**~~ **Built (2026-07-17), compiles clean.** `char.profile` gains a `titles` block (`selected`, `fameKarma`, `skill`, `reward[]`) from `PlayerMobile` accessors — no new stream, folds into `BridgeProfile`. *Live run pending.*
|
|
||||||
5. **Factions/VvV** — **deferred** (owner decision): only after confirming which system the shard runs; stream just the enabled one.
|
|
||||||
|
|
||||||
Cross-cutting, lands with Phase 1: the **protocol bump to 2** (§12.1) and the **snapshot-companion rule** (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the `account.*` surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Built-in reports — replace the FTP/HTML path with JSON over the sidecar
|
|
||||||
|
|
||||||
ServUO ships a **Reports engine** (`Server.Engines.Reports`, `Scripts/Services/Reports/`) that already compiles exactly the dashboard data a website wants — it just delivers it the way RunUO did in 2004: render static HTML and **FTP it to your website**. The bridge can tap the *compiled data* directly and ship JSON, retiring the file/FTP path entirely. **No core edit** — the compile methods are `public static`.
|
|
||||||
|
|
||||||
### 14.1 What the engine produces (verified)
|
|
||||||
|
|
||||||
`Reports.Generate()` runs hourly on the Core thread and builds a `Snapshot` from public static compile methods (`Reports.cs`):
|
|
||||||
|
|
||||||
| Method | Returns | Content |
|
|
||||||
|--------|---------|---------|
|
|
||||||
| `CompileGeneralStats()` | `Report` | NPCs, Players, Clients, Accounts, Items |
|
|
||||||
| `CompileStatChart()` | `Chart` | population over time |
|
|
||||||
| `CompileSkillReports()` | `PersistableObject[]` | **skill distribution — GM count per skill** |
|
|
||||||
| `CompileFactionReports()` | `PersistableObject[]` | faction membership / stats |
|
|
||||||
| `Reports.StaffHistory` | `StaffHistory` | staff activity per account (`StaffInfo`/`UserInfo` hashtables), **help-page-queue length over time** (`QueueStats`), page history |
|
|
||||||
|
|
||||||
Each `Report` is structured (`Columns` + `Items`), so it serializes to JSON cleanly with the hand-rolled `BridgeJson` writers — no reflection serializer. The engine also persists an hourly **`SnapshotHistory`** series to disk, so a backfill of historical points is available if wanted.
|
|
||||||
|
|
||||||
### 14.2 How it's delivered today (the file path you flagged)
|
|
||||||
|
|
||||||
- **HTML + FTP.** `UpdateOutput` (`Reports.cs:406`, on a ThreadPool thread) runs `HtmlRenderer` into `<BaseDir>/reports/stats/` and `reports/staff/` (`Reports.Path`, default `reports`), then `Upload()` writes an `upload.ftp` job to FTP the HTML to a website. Gated on `Reports.AutoGenerate` (**default off**).
|
|
||||||
- **WebStatus.** A *separate* mechanism (`Scripts/Misc/WebStatus.cs`): an in-process `HttpListener` on `:80/status/` serving a live status HTML page. Default `Enabled = false`.
|
|
||||||
|
|
||||||
Both are the "report goes to a file / gets pushed out-of-band" pattern. The sidecar already replaces the second one (`/health` + the live feed cover what `WebStatus` served); §14 replaces the first.
|
|
||||||
|
|
||||||
### 14.3 The tap — a report sweep, JSON out
|
|
||||||
|
|
||||||
`BridgeReports.cs` runs a **Core-thread timer** (`ReportSweepSeconds`, e.g. hourly to match stock, or faster) that calls the same public compile methods, serializes the `Report`/`Chart` objects to JSON, emits `report.*`, and hands the sidecar a snapshot to persist and serve over REST:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"report.skills","t":1752…,"skills":[
|
|
||||||
{"skill":"Swordsmanship","gms":42},{"skill":"Magery","gms":88}, …]}
|
|
||||||
{"kind":"report.general","players":142,"npcs":42826,"clients":150,"accounts":51,"items":206467}
|
|
||||||
{"kind":"report.staff","window":"7d","staff":[
|
|
||||||
{"account":"GreyBeard","actions":318}],"pageQueue":[{"t":…,"open":4}, …]}
|
|
||||||
```
|
|
||||||
|
|
||||||
Served for hydration (the §12.2 snapshot rule): `GET /reports/skills`, `/reports/general`, `/reports/staff`.
|
|
||||||
|
|
||||||
Key points, all grounded:
|
|
||||||
|
|
||||||
- **No core edit, no HTML, no FTP.** Calling `Compile*` directly skips `HtmlRenderer`/`Upload` entirely. Leave `Reports.AutoGenerate` **off** (no HTML files written) and run the bridge tap instead. The FTP `upload.ftp` path and `Reports.Path` become dead weight for a bridge-connected shard.
|
|
||||||
- **Threading.** `Compile*` read `World.Mobiles`/`Skills`, so they must run on the Core thread — which the bridge's sweep timers already are (`PLAN.md` non-negotiables). Stock only offloaded the *HTML rendering* (slow string work) to a ThreadPool; the bridge skips that step, so there's nothing to offload. Skill distribution walks all mobiles once — treat it like the profile-bulk warning in `PLAN.md` §1: run it on a slow cadence (hourly is plenty), never in a fast sweep.
|
|
||||||
- **Dedupe against Part B.** `report.general` and the population chart overlap with who's-online (§11 #1); faction reports overlap with §10.4. The **unique** wins here are **skill distribution** (a GM-per-skill leaderboard available nowhere else in the bridge) and the **staff-activity + page-queue-length history** (aggregates that complement the per-action `admin.audit` we already stream). Prioritize those two; treat the rest as "already covered, don't double-emit."
|
|
||||||
- **Optional backfill.** On first connect the sidecar could ingest the engine's persisted `SnapshotHistory` (`Reports.StaffHistory`/stats history) to seed the historical series instead of starting empty. Nice-to-have, not required.
|
|
||||||
|
|
||||||
### 14.4 Where the code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeReports.cs` | **New.** Core-thread report sweep calling `Reports.Compile*` + `Reports.StaffHistory`; serialize to `report.*`; re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`. |
|
|
||||||
| `overlay/Config/Bridge.cfg` | **Extend.** `ReportSweepSeconds` (+ enable flag). |
|
|
||||||
| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the report snapshots; `GET /reports/{skills,general,staff}` served from the store (survives shard outage, like `/champs`). |
|
|
||||||
| `docs/INTEGRATION.md` | **Extend.** `report.*` events + endpoints; note they supersede the stock FTP/HTML reports and `WebStatus`. |
|
|
||||||
|
|
||||||
> **Recommendation:** fold this in as **Part B, Phase 6** (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Smoke test — live run (2026-07-17)
|
|
||||||
|
|
||||||
Deployed the overlay to the ServUO checkout, booted the shard and the real sidecar (protocol 2, `plugin_connected: true`), and exercised every new surface over REST against the live game. All green.
|
|
||||||
|
|
||||||
**Part A — provisioning (through the real shard):**
|
|
||||||
|
|
||||||
| Check | Result |
|
|
||||||
|-------|--------|
|
|
||||||
| `POST /accounts/create` (fresh IP) | **200** `account.ok`, account created + linked |
|
|
||||||
| duplicate name | **409** `account already exists` |
|
|
||||||
| per-IP cap | shard's real `AccountsPerIp=3` enforced: 3rd from one IP allowed, **4th → 429** `ip account limit reached` |
|
|
||||||
| loopback IP with `RequireIpForCreate` | **400** `client ip required` (fails closed) |
|
|
||||||
| `GET /link/{acct}` after create | **200**, linked to the website id |
|
|
||||||
| `DELETE /link/{acct}` | **200** `unlink`; lookup then **404** |
|
|
||||||
|
|
||||||
**Part B — world-state boards (through the real shard):**
|
|
||||||
|
|
||||||
| Endpoint | Result |
|
|
||||||
|----------|--------|
|
|
||||||
| `GET /houses` | **28 houses**, full owner/decay/co-owner/built-on data (shard → `house.update` → board → REST) |
|
|
||||||
| `GET /governors` | **9 cities**, `governor: null`/`electionPhase: none` on this unseeded world |
|
|
||||||
| `GET /guilds` | `[]` — no guilds on this world; the sweep ran without error |
|
|
||||||
| `GET /online` | `count: 0` — headless (no UO client), snapshot emitted and stored |
|
|
||||||
| `GET /char/{acct}/0` | full profile incl. the new `titles` block |
|
|
||||||
|
|
||||||
**Not exercised (needs a live UO client, not a headless boot):** `presence.online` with real players, `region.enter`, real-time `guild.join`, and `char.vitals`. And `guild.join`/guild board content needs a guild to exist. These are inherent to a clientless smoke test — the board *plumbing* is proven by `/houses`, which uses the identical path.
|
|
||||||
|
|
||||||
**One operational note surfaced:** the boot-time `Dynamic` script recompile **cannot replace `Scripts.dll` while the server is running**, because the Scripts build tries to copy the locked `ServUO.exe` and fails (the `PLAN.md §3` trap). The fix used here: build `Scripts/Scripts.csproj` once with the server **stopped**, then boot — the offline build produces a fresh `Scripts.dll` the boot then loads. Rely on this, not the in-process rebuild, when deploying new bridge code. The shard's world save was left untouched (hard-kill, no autosave), so the test accounts did not persist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Town Cryer news — website articles into the news gump (Protocol 2.1)
|
|
||||||
|
|
||||||
**Status:** **Built and smoke-tested live** (2026-07-17). `BridgeNews.cs` (pure overlay, no stock edit) + `POST /news` / `DELETE /news/{id}` + reconnect replay. Verified against a booted shard: `news.add` (full + title-only) → `news.ok`, missing title → 400, idempotent replace, `news.remove` → `news.ok`, unknown id → `news.error`, no shard exceptions, and the **reconnect replay** confirmed (after a shard restart the stored article was re-pushed with `announce:false` and re-accepted). The gump rendering itself is verified by source inspection (needs a UO client to view).
|
|
||||||
|
|
||||||
There are **two** distinct town-crier surfaces in ServUO, and 2.0 has so far touched only the first:
|
|
||||||
|
|
||||||
1. **The scrolling crier** (`GlobalTownCrierEntryList`) — the wandering Town Crier NPC that *says* short announcement lines. Protocol 1.0 phase 6 (`BridgeTownCrier.cs`, `towncrier.add`/`remove`) already drives this.
|
|
||||||
2. **The Town Cryer News gump** (`TownCryerSystem.NewsEntries`) — the paged news UI with title + body + image + a "more info" URL per article. **Nothing drives this yet.** This section adds it.
|
|
||||||
|
|
||||||
The ask: a website news article should land as a full article in the **news gump** (2), and the crier should also *say* just the **title** through the existing say feature (1) — so players get the audible "Hear ye!" proclamation while the full write-up lives in the gump.
|
|
||||||
|
|
||||||
### 16.1 The hook (verified in the shard's source)
|
|
||||||
|
|
||||||
- **`TownCryerSystem.NewsEntries`** (`TownCryerSystem.cs:40`) — `public static List<TownCryerNewsEntry>`. The setter is private, but the **list is public and mutable**, so it can be inserted into and removed from directly.
|
|
||||||
- **`TownCryerNewsEntry(TextDefinition title, TextDefinition body, int gumpImage, Type questType, string url)`** (`TownCryerNewsEntry.cs`) — public ctor. Pass `questType: null` for website news.
|
|
||||||
- **The display gumps already handle string content**, so no gump edits are needed:
|
|
||||||
- List view (`TownCryerGump.cs:97-103`): `if (entry.Title.Number > 0) AddHtmlLocalized(...) else AddLabelCropped(..., entry.Title)`.
|
|
||||||
- Detail view (`TownCryerNewsGump.cs:27-42`): `if (Entry.Body.Number > 0) AddHtmlLocalized(...) else AddHtml(..., Entry.Body.String, ..., true)` — a **string body renders as HTML** (so `<CENTER>…</CENTER><BR><BR>…` works), `AddImage(..., Entry.GumpImage)`, and `InfoUrl` becomes a `LaunchBrowser` button.
|
|
||||||
- **Stock news is live on this shard.** `TownCryerSystem.Initialize()` adds ~18 hardcoded `uo.com` entries whenever `TownCryerSystem.Enabled` (`TownCryerSystem.cs:93-120`) — *not* gated by `UsePreloadedMessages` (that only gates a reload command). So the list is not empty, and our sync must not clobber it (see §16.3).
|
|
||||||
|
|
||||||
### 16.2 Evaluating the pasted guidance
|
|
||||||
|
|
||||||
The pasted analysis is **substantially correct** and useful — it identifies the right hook (`NewsEntries`), the right constructor, the cliloc-vs-string branching the gump already does, the image/url fields, and the important instinct to keep stock news separate. Two adjustments for *this* architecture:
|
|
||||||
|
|
||||||
- **No stock patch is needed.** The pasted plan adds `AddNewsEntry` / `ClearExternalNews` methods to the stock `TownCryerSystem.cs`. That file is stock ServUO, so editing it would ship as a `patches/` diff (like `PlayerVendorSale`). We can avoid that entirely: because `NewsEntries` is a **public mutable list**, the bridge overlay inserts and removes directly — `TownCryerSystem.NewsEntries.Insert(0, entry)` / `.Remove(entry)` — and keeps the "which entries are ours" bookkeeping in an **overlay-side list**, not in a new field on the stock class. This is exactly how `BridgeTownCrier` already mutates `GlobalTownCrierEntryList` from the overlay. Pure overlay, zero stock edits.
|
|
||||||
- **Track our entries to keep stock intact.** Rather than the pasted `ExternalNewsEntries` field on the stock class, the overlay holds `List<TownCryerNewsEntry> _ours`. On a sync we `Remove` our previous entries from `NewsEntries` and insert the new set — the stock `uo.com` articles are never touched. `MaxNewsEntries` is 100 (`TownCryerSystem.cs:26`); the overlay caps its own contribution well under that.
|
|
||||||
|
|
||||||
Everything else in the pasted note stands, and the "this is one of the easier integrations — you're replacing the content provider" framing is right.
|
|
||||||
|
|
||||||
### 16.3 The two surfaces, tied together
|
|
||||||
|
|
||||||
On an inbound article the bridge does two things on the Core thread:
|
|
||||||
|
|
||||||
1. **News gump** — build `new TownCryerNewsEntry(new TextDefinition(title), new TextDefinition(body), image, null, url)` and `Insert(0, …)` at the top of `TownCryerSystem.NewsEntries`, tracking it in `_ours`; trim `_ours` past the cap by removing the oldest (from both `_ours` and `NewsEntries`).
|
|
||||||
2. **Say the title** — reuse the scrolling-crier path (`GlobalTownCrierEntryList`, as `BridgeTownCrier` does) to announce a single line, the **title only**, for a short duration, so the crier proclaims it in-world. **On by default**; set `announce: false` on an article to suppress it (e.g. a silent correction that should not re-proclaim).
|
|
||||||
|
|
||||||
### 16.4 Protocol
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// website → sidecar → shard
|
|
||||||
{"kind":"news.add","id":"42","title":"Double XP Weekend",
|
|
||||||
"body":"<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.",
|
|
||||||
"image":1614,"url":"https://uomysticmoon.com/news/42"}
|
|
||||||
// announce defaults to true; add "announce":false to suppress the crier proclamation
|
|
||||||
{"kind":"news.remove","id":"42"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Correlated by `id` (echoed on the reply), like town-crier. Re-adding an `id` **replaces** the prior entry (find-by-id in `_ours`, remove, re-insert) — idempotent.
|
|
||||||
- `title` required; `body`/`image`/`url` optional (a title-only blurb is valid). `image` defaults to a neutral scroll gump id when absent.
|
|
||||||
- Caps (defense in depth, mirroring `TownCrier*`): title/body length, max external entries. Replies `news.ok` / `news.error`.
|
|
||||||
- Sidecar: `POST /news` (add/replace), `DELETE /news/{id}`. Same `respond`-style status mapping as town-crier.
|
|
||||||
|
|
||||||
### 16.5 Restart & re-sync (the source-of-truth rule)
|
|
||||||
|
|
||||||
`NewsEntries` is **not persisted** by ServUO — it is rebuilt at every boot from stock `Initialize()` plus whatever we have inserted since. So our external articles vanish on a shard restart until re-pushed. The **website is the source of truth**: the sidecar re-sends the current external news set on every shard (re)connect, the same discipline §12.2 uses for the diff boards. (The sidecar persists the external set in its store so it can replay it without the website being up.)
|
|
||||||
|
|
||||||
### 16.6 Where the code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeNews.cs` | **New.** `news.add` / `news.remove`: insert/remove `TownCryerNewsEntry` in the public `NewsEntries` list, track `_ours`, cap; optional title announcement via `GlobalTownCrierEntryList`; replies + caps. No stock edit. |
|
|
||||||
| `overlay/Config/Bridge.cfg` | **Extend.** `NewsMaxTitleLength`, `NewsMaxBodyLength`, `NewsMaxExternal`, default announce duration. |
|
|
||||||
| `sidecar/src/web.rs` + `store.rs` | **Extend.** `POST /news`, `DELETE /news/{id}`; persist the external-news set; replay it on shard (re)connect. |
|
|
||||||
| `docs/INTEGRATION.md` | **Extend.** The `news.*` verbs + endpoints. |
|
|
||||||
|
|
||||||
No core or stock ServUO change — the whole integration rides the public `TownCryerSystem.NewsEntries` list and the existing crier say path.
|
|
||||||
544
docs/RESEARCH.md
544
docs/RESEARCH.md
@@ -1,544 +0,0 @@
|
|||||||
# ServUO ⇄ External Service Bridge — Research Findings
|
|
||||||
|
|
||||||
**Status:** Research only, no implementation.
|
|
||||||
**Architecture:** Rust sidecar owns a bidirectional WebSocket + JSON endpoint for the website; ServUO links to it over a **local loopback socket**. Tracking players/stats/gold/economy/NPC+player-vendor sales, IDOC/house decay, in-game **`[link`** account linking, and website→game town-crier news. See **Part II** (design/transport/tracking/link), **Part III** (player-vendor, IDOC, town crier, config), and **Part IV** (full character profiles — gear/skills/stats, online & offline, up to 5/account).
|
|
||||||
**Date:** 2026-07-07
|
|
||||||
**Codebase:** ServUO 57.4 (this repo, `C:\Users\colby\Desktop\servuo`), target framework **.NET Framework 4.8 / x64**.
|
|
||||||
**Method:** Grounded in this repo's source. Where the running server would normally be used to confirm behavior, see the note in [§0](#0-note-on-empirical-verification) — the shard was **not running** at research time, so live-boot verification was deliberately skipped and replaced with source-level proof plus evidence from this repo's own crash logs. A ready-to-run empirical probe is included in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Note on empirical verification
|
|
||||||
|
|
||||||
You said the shard was running and to verify against it. At research time **no `ServUO.exe` / `dotnet` process was live** (`Get-Process` returned nothing; `Logs/Console.log` absent). I chose **not** to boot it myself because a cold boot on this machine would:
|
|
||||||
|
|
||||||
- shell out to `dotnet build Scripts.csproj` (per `ScriptCompiler.Compile`, `Compiler.Dynamic=true` by default),
|
|
||||||
- **bind the live game port** and load/write your actual `Saves/` world (117 mobiles / 2469 items per the last crash report),
|
|
||||||
- run `EventSink.ServerStarted` and AutoSave against real state.
|
|
||||||
|
|
||||||
That's outward-facing and hard to reverse, so it needs your go-ahead. **It turned out not to be necessary for the core threading claims**, because:
|
|
||||||
|
|
||||||
1. The source pins the threading model exactly (call sites shown below), and
|
|
||||||
2. **Your own crash log is live evidence.** `Crash 6-5-2026-22-38-3.log` contains this stack:
|
|
||||||
|
|
||||||
```
|
|
||||||
Server.EventSink.InvokeClientVersionReceived(...)
|
|
||||||
Server.Network.MessagePump.HandleReceive(NetState ns)
|
|
||||||
Server.Network.MessagePump.Slice()
|
|
||||||
Server.Core.Main(String[] args)
|
|
||||||
```
|
|
||||||
|
|
||||||
That is a network-triggered EventSink handler executing **inside `MessagePump.Slice()`, called directly from `Core.Main`** — i.e. on the Core (main) thread, synchronously in the game loop. This is exactly the thread-identity fact item 3/5 hinges on, captured from this instance at runtime.
|
|
||||||
|
|
||||||
If you want the live thread-ID trace anyway (Timer + ServerStarted, no client needed), drop in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself) and start the shard, or tell me to boot it.
|
|
||||||
|
|
||||||
> ⚠️ Unrelated but worth flagging: that crash was `DllNotFoundException: zlibwapi64`. The DLL **is** present in the repo root, so this is a working-directory / native-load-path issue that has already crashed your shard once when sending a packed gump. Not a bridge concern, but it will bite the bridge too if the bridge ever triggers gump sends. Track separately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART II — Re-evaluation for the Rust WebSocket sidecar (READ FIRST)
|
|
||||||
|
|
||||||
**Confirmed architecture (from you):** a **Rust sidecar** holds a bidirectional **WebSocket** connection and exposes a **JSON endpoint the website consumes**. Goals: track players + stats, gold, overall economy, vendor sales; and an in-game **`[link`** command that ties a game account to a website account.
|
|
||||||
|
|
||||||
The §1–§5 findings below are unchanged and still govern (lifecycle, events, timers, threading). This part maps them onto *your* design and supersedes the old §6/§7.
|
|
||||||
|
|
||||||
### II.1 Transport: put the WebSocket in Rust, keep the C# side dumb
|
|
||||||
|
|
||||||
```
|
|
||||||
ServUO plugin (C#, net48) ──local loopback, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
|
||||||
(main-thread events) ◄──inbound commands (link, etc.)──┘ (owns WS, buffering, auth, fan-out)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation: ServUO ↔ sidecar = a plain local TCP loopback socket (`127.0.0.1`), newline-delimited JSON, bidirectional. Do NOT make ServUO speak WebSocket.**
|
|
||||||
|
|
||||||
- `System.Net.WebSockets.ClientWebSocket` *does* exist on net48 + Windows 11 and would work, but it's the wrong place for WS complexity. The sidecar already terminates WS for the website; a second WS hop inside the shard buys nothing and adds a heavier, blockier client on the one thread you must never block (§5). A raw `TcpClient` with `\n`-framed JSON is ~30 lines of C#, trivially non-blocking, and lets the **sidecar restart independently** without touching the shard.
|
|
||||||
- Named pipes (old §6) also work and are fine if you prefer them; loopback TCP is marginally simpler cross-process and cross-language (Rust `tokio::net::TcpListener` ↔ C# `TcpClient`).
|
|
||||||
- **This split is exactly what §5 demands.** All backpressure, reconnect, retry, website fan-out, and schema validation live in **Rust**. ServUO only ever does: (outbound) format a small JSON line → enqueue → a background writer thread drains to the socket; (inbound) a background read loop parses a line → `Timer.DelayCall` to the main thread. A slow or absent website can never stall the shard, because the Rust side owns the buffer and the socket write from C# is to loopback with a bounded local queue in front of it.
|
|
||||||
|
|
||||||
**Framing:** newline-delimited JSON objects (`{...}\n`), `PipeTransmissionMode`/message-mode not needed. One writer thread on the C# side keeps event ordering intact. Bound the outbound queue (drop-oldest + a dropped-counter) so a stalled sidecar can't OOM the shard.
|
|
||||||
|
|
||||||
### II.2 Tracking targets → concrete hooks (and the gaps)
|
|
||||||
|
|
||||||
| Target | Hook | Freq | Notes / caveats |
|
|
||||||
|--------|------|------|-----------------|
|
|
||||||
| **Player online / identity** | `EventSink.Login` / `Logout` | Low | Snapshot `Account.Username`, char name, `Mobile.Serial`, `Map`, `Location`. Best per-player anchor. |
|
|
||||||
| **Player stats** (Str/Dex/Int, Hits/Mana/Stam, skills, Fame/Karma) | ⚑ **No per-change EventSink** | — | Strategy: full snapshot on `Login`, then a **periodic sweep** (every 15–30 s) of online `PlayerMobile`s pushed as-is; let the **sidecar diff** and forward only changes. Add `FameChange`/`KarmaChange`/`SkillGain` for high-signal jumps. Don't try to hook the per-stat delta system — it's invasive and firehose-y. |
|
|
||||||
| **Gold (per player)** | `EventSink.AccountGoldChange` | Low–Med | ✔ **AccountGold is ENABLED on this shard** (expansion EJ ≥ TOL, `CurrentExpansion.cs:20`). Args give `IAccount` + `OldAmount`/`NewAmount` (`TotalCurrency`, a `double`). Most gold flow fires this. Caveat: physical coins/checks sitting in a bankbox aren't fully reflected here — see economy row. |
|
|
||||||
| **Overall economy / money supply** | Periodic account sweep + flow events | Low | Money **supply** = periodic sum of `TotalCurrency` across all `Accounts` (+ optionally bankbox coin/check items) on the main thread, pushed as a snapshot. Money **velocity/flow** = the `AccountGoldChange` + vendor-sale event stream. Sidecar aggregates both. |
|
|
||||||
| **NPC vendor — player buys** | `EventSink.ValidVendorPurchase` | Med | Args: `Mobile` (buyer), `Vendor`, `Bought` (IEntity/item), `AmountPerUnit`. **Total = AmountPerUnit × stack `Amount`.** Raised from `GenericBuy.cs:379`. |
|
|
||||||
| **NPC vendor — player sells** | `EventSink.ValidVendorSell` | Med | Args mirror above (`Sold`, `AmountPerUnit`). Raised from `BaseVendor.cs:2209`. |
|
|
||||||
| **Player vendor sales** | ⚑ **No EventSink (gap)** | Med | Player-vendor buys go through `PlayerVendor.TryToBuy` (`PlayerVendor.cs:447`), not the Valid* events. To capture these you must override/patch the PlayerVendor buy completion. Flag if the spec counts player-vendor commerce as "vendor sales." |
|
|
||||||
| **Account ↔ website link** | `Account.Username` + `Account.SetTag/GetTag` | — | `SetTag("WebsiteUserId", id)` persists to `accounts.xml` across restarts (`Account.cs:1078,1093`). No schema/DB work needed on the C# side. |
|
|
||||||
|
|
||||||
> ⚠️ The `Valid*` vendor events are **validation-stage veto hooks**, not "sale committed" callbacks. They fire when the purchase is being validated; in rare cases a sale could still fail afterward. For coarse economy metrics that's fine; if you need exact ledger accuracy, treat them as "sale attempted" and reconcile against `AccountGoldChange`, or hook the actual completion path. **Never block or throw in these handlers** — you're inside the transaction path.
|
|
||||||
|
|
||||||
### II.3 The `[link` command flow
|
|
||||||
|
|
||||||
Prefix is `[` (`Commands.cs:131`), so `[link` is registered directly. Everything below runs on the main thread except the socket I/O.
|
|
||||||
|
|
||||||
1. **Register** in your plugin's `Initialize()`:
|
|
||||||
`CommandSystem.Register("link", AccessLevel.Player, OnLink);`
|
|
||||||
2. **`[link` handler** (`e.Mobile`): read `e.Mobile.Account as Account`. If already tagged (`GetTag("WebsiteUserId") != null`), tell them so. Otherwise generate a **short, one-time, expiring code** (e.g. 6–8 chars, 5-min TTL), store `code → {accountUsername, expiry}` in an in-memory dict (main thread), and:
|
|
||||||
- push `{"kind":"link.request","code":"AB12CD","account":"PerryAdimn","char":"Thunderheat"}` to the sidecar, and
|
|
||||||
- `e.Mobile.SendMessage("Enter code AB12CD at https://yoursite/link to connect your account.")`
|
|
||||||
3. **Website** (user logged in there) submits the code → sidecar → ServUO inbound line `{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}`.
|
|
||||||
4. **Inbound handler** marshals to main thread (`Timer.DelayCall`), validates code + TTL, then `account.SetTag("WebsiteUserId","9931")`, drops the code, and replies `{"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}`. Optionally `SendMessage` the player if still online.
|
|
||||||
5. **Thereafter**, every player event you emit can carry the resolved `websiteUserId` (read the tag on Login and cache account→id in the sidecar), so the website can attribute stats/gold/sales to a site user.
|
|
||||||
|
|
||||||
Security notes: codes one-time + short-TTL; the link socket is **loopback-only** (bind `127.0.0.1`, never `0.0.0.0`); the account write happens on the main thread; rate-limit `[link` per account to avoid code spam. Treat `websiteUserId` from the sidecar as trusted only because the socket is local — if the sidecar is ever exposed, add a shared secret.
|
|
||||||
|
|
||||||
### II.4 Revised flags for THIS architecture
|
|
||||||
|
|
||||||
1. **✔ Threading is a solved problem given the split.** Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget + `Timer.DelayCall` inbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO.
|
|
||||||
2. **⚑ Player stats have no change-event** → sweep-and-diff in the sidecar (II.2). Budget for a 15–30 s snapshot of online players; don't expect push-on-change.
|
|
||||||
3. **⚑ Player-vendor sales aren't covered by any EventSink** (II.2) — **RESOLVED in §III.1.** You've confirmed this stream is critical (economy + cheat detection), so add the small `PlayerVendorSale` EventSink (~15 lines of core instrumentation). It's the one non-drop-in piece.
|
|
||||||
4. **⚑ "Economy" needs both a periodic supply snapshot and the flow stream.** `AccountGoldChange` alone is flow, not total; physical bank coins/checks aren't in it. Do a periodic `Accounts` `TotalCurrency` sum for money supply.
|
|
||||||
5. **✔ Linking needs no new persistence layer** — account tags serialize to `accounts.xml` for free (II.3). Survives restarts and saves.
|
|
||||||
6. **⚑ Commands/inbound don't apply during world saves** (§5 pitfall 3, ~every 5 min). A `[link.confirm` arriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly.
|
|
||||||
7. **⚑ Crash path skips `Shutdown`** (§1): the sidecar must treat socket EOF as normal and reconnect; don't rely on a clean goodbye frame. Pending link codes are in-memory and lost on crash — acceptable (user re-runs `[link`).
|
|
||||||
8. **⚑ (unchanged) Item pickup/drop and per-hit combat have no EventSink** (§2 gap) — only relevant if the tracking scope grows beyond stats/gold/economy/vendors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART III — Player-vendor tracking, IDOC, town-crier news, config
|
|
||||||
|
|
||||||
Follow-ups you added: **(1)** player-vendor tracking is *critical* (economy balance + admin cheat detection); **(2)** the 30 s stat sweep must be config-editable; **(3)** hook **IDOC / house decay**; **(4)** town criers receive **news pushed from the website**.
|
|
||||||
|
|
||||||
### III.1 Player-vendor sales — the one place you need a small core touch
|
|
||||||
|
|
||||||
There is genuinely **no EventSink** on the player-vendor buy path (confirmed). The purchase *completes* in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), specifically at the gold transfer:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// PlayerVendorGumps.cs ~line 81-96 (existing code)
|
|
||||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
|
||||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
|
||||||
...
|
|
||||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
|
||||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited ◄── sale is now committed
|
|
||||||
```
|
|
||||||
|
|
||||||
At that point every field cheat-detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the real player who profits), **item** (`m_VI.Item`, incl. `Serial`, type, `Amount`), **price** (`m_VI.Price`), and **commission**. This is *better* data than the NPC-vendor `Valid*` events (which lack owner + commission), and unlike them it fires on a **committed** sale, not a validation stage.
|
|
||||||
|
|
||||||
**Recommendation (idiomatic, minimal): add a first-class EventSink event, mirroring the existing vendor events.** Three tiny edits, then the bridge stays pure-subscription like everything else:
|
|
||||||
|
|
||||||
1. In `Server/EventSink.cs`: declare `public static event PlayerVendorSaleEventHandler PlayerVendorSale;`, an `InvokePlayerVendorSale`, and a `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape at `EventSink.cs:1508`).
|
|
||||||
2. In `PlayerVendorGumps.cs`, one line right after the `HoldGold +=` at ~line 96:
|
|
||||||
`EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));`
|
|
||||||
3. Bridge subscribes in `Initialize` like any other event.
|
|
||||||
|
|
||||||
This is **the single spot where the bridge can't be pure drop-in** — worth calling out explicitly since I'd earlier listed player vendors as a "gap." It's a ~15-line core instrumentation, not a rework. (Alternative if you refuse to touch core scripts: a periodic diff of every `PlayerVendor`'s inventory + `HoldGold` — but that can't attribute the *buyer*, which is exactly what cheat detection needs, so it's a poor substitute.)
|
|
||||||
|
|
||||||
**For cheat detection specifically**, emit per sale: buyer serial+account, owner serial+account, item type/serial/amount, price, commission, vendor serial, house/region, timestamp. The sidecar can then flag e.g. same-account buyer≈owner (gold laundering), wildly off-market prices, or burst patterns. Note `m_Vendor.Owner` + `from.Account` are the two identities that matter; both are readable synchronously in the handler (main thread).
|
|
||||||
|
|
||||||
### III.2 Config-editable sweep interval (and other tunables)
|
|
||||||
|
|
||||||
Use ServUO's own config system (`Server/Config.cs`), which reads `Config/*.cfg`. Read tunables in `Configure()` (runs before world load):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
StatSweep = Config.Get("Bridge.StatSweepSeconds", 30);
|
|
||||||
DecaySweep = Config.Get("Bridge.DecaySweepSeconds", 60);
|
|
||||||
```
|
|
||||||
|
|
||||||
Drop a `Config/Bridge.cfg` with `Bridge.StatSweepSeconds=30` etc. `Config.Get<T>` handles `int`/`TimeSpan`/`bool`. Make the sweep timer re-readable on demand (a `[bridge reload` admin command that re-reads config and re-arms the `Timer`) so you can retune without a restart. Store all bridge knobs (sweep intervals, which event streams are enabled, sidecar host/port, queue cap) in that one cfg.
|
|
||||||
|
|
||||||
### III.3 IDOC / house decay — sweep `BaseHouse.AllHouses`, emit on transition
|
|
||||||
|
|
||||||
Also **no EventSink** here. The model (`Scripts/Multis/BaseHouse.cs`):
|
|
||||||
|
|
||||||
- `DecayLevel` enum (`BaseHouse.cs:4341`): `Ageless, LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, DemolitionPending`. **IDOC = 95.0–99.9%** of the decay period elapsed (`GetOldDecayLevel`, `BaseHouse.cs:211-213`); `Collapsed` = 100%.
|
|
||||||
- `BaseHouse.AllHouses` is a static list of every house; `Decay_OnTick` (`BaseHouse.cs:59`) already periodically calls `CheckDecay()` on all of them.
|
|
||||||
- The `DecayLevel` getter has internal transition detection (`m_LastDecayLevel`, `BaseHouse.cs:193`) but it's private and only invalidates the sign — **not** exposed as an event.
|
|
||||||
|
|
||||||
**Decision: emit on transition only, tracked plugin-side.** A low-frequency **sweep** (30–60 s, config per III.2) over `BaseHouse.AllHouses` reads `house.DecayLevel` on the main thread. The plugin holds a `Dictionary<Serial, DecayLevel>` of last-known levels and emits **only when a house's level changes** — no per-sweep spam, one message per real transition. Houses number in the hundreds/thousands (not the mobile firehose), so the sweep is cheap even though we scan all of them each pass.
|
|
||||||
|
|
||||||
**State & re-baseline (important, since the plugin now holds state):**
|
|
||||||
- The last-known map is **in-memory and resets on restart**. On `ServerStarted` (§1), do a **silent baseline pass**: populate the dictionary from the current `DecayLevel` of every house **without emitting** — otherwise every house re-announces its current stage on every boot. Optionally emit a single `idoc.snapshot` of all houses already at IDOC/Collapsed so the website/admin panel is correct immediately after a restart, clearly flagged as a snapshot (not a transition).
|
|
||||||
- Emit direction matters for cheat/economy signals: include both `from`/`to` levels so the consumer can tell decay progression from a **refresh** (owner logged in → level jumps back toward `LikeNew`; `RefreshDecay`, `BaseHouse.cs`). A house leaving IDOC because someone refreshed it is itself a useful signal.
|
|
||||||
- `house.DecayLevel` is a computed property — read it **once per house per sweep** into a local, don't call it repeatedly.
|
|
||||||
|
|
||||||
**Payload (home location state you asked for — all readable synchronously in the sweep):** `BaseHouse` is a `BaseMulti` (an item), so it has `Serial`, `Location`/`X`/`Y`/`Z`, `Map`. Plus:
|
|
||||||
|
|
||||||
| Field | Source |
|
|
||||||
|-------|--------|
|
|
||||||
| house serial | `house.Serial` |
|
|
||||||
| decay from → to | tracked dict → `house.DecayLevel` |
|
|
||||||
| coords + facet | `house.X/Y/Z`, `house.Map` |
|
|
||||||
| stable landmark (where a player stands) | `house.BanLocation` (`BaseHouse.cs:3637`) |
|
|
||||||
| region / area name | `house.Region` (`:3672`) → `Region.Name` |
|
|
||||||
| house name | `house.Sign?.GetName()` (`:2108`) |
|
|
||||||
| owner | `house.Owner` (`:3564`) → serial + `Owner.Account.Username` (may be null if abandoned) |
|
|
||||||
| co-owners / friends | `house.CoOwners`, `house.Friends` (`:3679-3680`) — serials/accounts |
|
|
||||||
| built / last refreshed | `house.BuiltOn`, `house.LastRefreshed` (`:3786,:66`) |
|
|
||||||
| time-to-collapse | `house.NextDecayStage` and/or derive from `LastRefreshed + DecayPeriod` |
|
|
||||||
|
|
||||||
Example emit:
|
|
||||||
```jsonc
|
|
||||||
{ "kind":"house.decay", "serial":"0x40001234", "from":"Greatly", "to":"IDOC",
|
|
||||||
"map":"Felucca", "x":1420, "y":1631, "z":0, "ban":{"x":1422,"y":1635,"z":0},
|
|
||||||
"region":"Britain", "name":"The Silver Anvil",
|
|
||||||
"owner":{"serial":"0x1A2B","account":"PerryAdimn"},
|
|
||||||
"coOwners":[], "builtOn":"2026-01-02T...", "lastRefreshed":"2026-06-30T...",
|
|
||||||
"collapseEta":"2026-07-08T..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
This gives the website a live IDOC feed with exact map pins and the admin side an owner-attributed decay timeline. Guard against `Owner`/`Sign`/`Region` being null (abandoned or mid-demolition houses).
|
|
||||||
|
|
||||||
### III.4 Town-crier news pushed from the website (inbound → main thread)
|
|
||||||
|
|
||||||
Clean API, no core changes needed: `GlobalTownCrierEntryList.Instance.AddEntry(string[] lines, TimeSpan duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`) posts a **global** entry that *every* town crier announces until it expires; `RemoveEntry(entry)` pulls it early. `AddEntry` returns the `TownCrierEntry`.
|
|
||||||
|
|
||||||
**Flow:** website publishes news → sidecar → ServUO inbound `{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","The market tax is now 5%."],"durationSec":3600}` → **marshal to main thread** (`Timer.DelayCall`) → `var e = GlobalTownCrierEntryList.Instance.AddEntry(lines, TimeSpan.FromSeconds(durationSec));` and stash `id → e` so a later `{"kind":"towncrier.remove","id":"n123"}` can call `RemoveEntry(e)`.
|
|
||||||
|
|
||||||
Must run on the main thread (mutates a shared list and sends packets to crier NPCs) — same marshaling rule as `[link` (§II.3 / §5). Guard against abuse: cap line length/count and active-entry count in the handler; the socket being loopback-only is your trust boundary. Note the crier speaks lines on its own timer, so there's a natural delay before players hear it — fine for news.
|
|
||||||
|
|
||||||
### III.5 Updated capability map
|
|
||||||
|
|
||||||
| Capability | Mechanism | Core touch? | Runs on |
|
|
||||||
|-----------|-----------|:-----------:|---------|
|
|
||||||
| Player online/stats/gold | EventSink + 30 s sweep (§II.2) | No | main thread |
|
|
||||||
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | main thread |
|
|
||||||
| **Player-vendor sales** | **new `PlayerVendorSale` EventSink** (§III.1) | **Yes, ~15 lines** | main thread |
|
|
||||||
| `[link` account linking | `CommandSystem.Register` + account tags (§II.3) | No | main thread |
|
|
||||||
| IDOC / house decay | sweep `BaseHouse.AllHouses` on transition (§III.3) | No | main thread |
|
|
||||||
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | main thread (marshaled) |
|
|
||||||
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
|
|
||||||
|
|
||||||
**Net:** everything you listed is doable, and **only player-vendor sales requires a (small, idiomatic) core edit** — which is justified because it's your critical/cheat-detection stream and reflection-based alternatives can't identify the buyer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART IV — Full character profiles (armor / weapons / skills / everything)
|
|
||||||
|
|
||||||
You want the site's **player endpoint** to show a whole character — worn gear, weapon/armor detail, every skill, all stats — for **up to 5 characters per account**, online *or* offline, and eventually their vendor stats. The object model supports all of it; the design question is *how to ship it without turning the 30 s sweep into a firehose.*
|
|
||||||
|
|
||||||
### IV.1 It's all on the live `Mobile` — and offline chars stay resident
|
|
||||||
|
|
||||||
- **Account → characters:** `Account` holds `Mobile[] m_Mobiles` with `account.Length` slots and `account[index]` (`Account.cs:592,598`); non-null slots are the characters (max 5, engine allows up to 7). Iterate them to enumerate an account's roster.
|
|
||||||
- **Offline = still in memory.** Mobiles are removed from `World.Mobiles` **only on `Delete()`, never on logout.** A logged-off character is a live `Mobile` with `NetState == null`; all its gear/skills/stats are intact. **→ the bridge can build a full profile for any character at any time, online or offline** — exactly what "see my characters from the website" needs. `m.NetState != null` (or `m.Player && online`) is your online flag.
|
|
||||||
- **Stats/vitals** (`Server/Mobile.cs`): `Str/Dex/Int` (`:8276+`), `Hits/HitsMax`, `Mana/ManaMax`, `Stam/StamMax` (`:8554+`), the five resists `PhysicalResistance…EnergyResistance` (`:931+`), `VirtualArmor`, plus `Fame`, `Karma`, `Luck`, `TotalWeight`, `Title`, `Body`, `Hue`, `Name`.
|
|
||||||
- **Skills** (`Server/Skills.cs`): `m.Skills` is `IEnumerable<Skill>` (`:1099`) with `Length` + indexer. Each `Skill`: `SkillName`, `Base`, `Value` (base + item/temp bonuses), `Cap`, `Lock` (`Skills.cs:259,322,373,350,269`). Emit all ~58.
|
|
||||||
- **Worn equipment:** `m.Items` (`Mobile.cs:6695`) is the list of *equipped* items (one per `Layer`); `FindItemOnLayer(Layer)` (`:10545`) fetches a slot. `Layer` enum (`Item.cs:25`) covers the ~25 wearable slots (OneHanded, TwoHanded, Helm, Gloves, Ring, Neck, Arms, InnerTorso, Talisman, …). Filter out non-gear layers (Backpack, Bank, Mount, Hair/FacialHair) unless you want them.
|
|
||||||
- **Weapon/armor detail** (`BaseWeapon.cs`, `BaseArmor.cs`): rich AOS attribute objects — `Attributes` (`AosAttributes`), `WeaponAttributes`, `ArmorAttributes`, `AosElementDamages`, `ExtendedWeaponAttributes`, `NegativeAttributes`, plus `MinDamage/MaxDamage/StrRequirement` (weapon) and `BaseArmorRating`/resists (armor). **Each attribute bag exposes an enum indexer** — `AosAttributes[AosAttribute]`, `AosWeaponAttributes[AosWeaponAttribute]`, `AosArmorAttributes[AosArmorAttribute]` (`Scripts/Misc/AOS.cs:924,1464,2238`) — so you can **flatten every mod generically** by iterating the enum and emitting non-zero entries, without hardcoding 30+ property names.
|
|
||||||
|
|
||||||
### IV.2 Ship it tiered + on-demand (don't stream heavy profiles blindly)
|
|
||||||
|
|
||||||
A full profile ≈ 58 skills + ~15 gear items each with a mod table. Pushing that for every character every 30 s (× N accounts, most idle/offline, most unviewed) is wasteful. Split by volatility:
|
|
||||||
|
|
||||||
| Tier | Contents | When emitted |
|
|
||||||
|------|----------|--------------|
|
|
||||||
| **Vitals** (small, volatile) | hits/mana/stam, current str/dex/int, gold, location, online flag | 30 s sweep of **online** players + events |
|
|
||||||
| **Profile** (large, semi-static) | all skills, worn equipment + item mods, resists, caps, fame/karma/luck | on `Login`, on equip/skill change, and **on demand** |
|
|
||||||
|
|
||||||
**On-demand request/response drives the website player endpoint.** When the site opens a character page: website → sidecar → ServUO `{"kind":"char.request","account":"PerryAdimn","slot":0}` (or by serial) → marshal to main thread → build the full profile → reply `{"kind":"char.profile", …}`. The **sidecar caches** the last profile so the page renders instantly and the game only rebuilds on request or on change. This scales: you never pay to serialize characters nobody is looking at. (For a "roster" view, a light `{"kind":"account.roster"}` returning name/body/slot/online per character is enough; fetch the heavy profile only when a specific char is opened.)
|
|
||||||
|
|
||||||
### IV.3 Character-profile schema (sketch)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"kind": "char.profile",
|
|
||||||
"account": "PerryAdimn", "slot": 0,
|
|
||||||
"serial": "0x0075", "name": "Thunderheat", "title": "the Legendary",
|
|
||||||
"body": 400, "hue": 33770, "online": true,
|
|
||||||
"stats": { "str":100,"dex":90,"int":45, "hits":95,"hitsMax":100,
|
|
||||||
"mana":40,"manaMax":45,"stam":88,"stamMax":90,
|
|
||||||
"resist":{"phys":70,"fire":68,"cold":55,"pois":60,"energy":62},
|
|
||||||
"gold":124500, "fame":12000,"karma":-4000,"luck":140,"weight":320 },
|
|
||||||
"skills": [ {"name":"Swords","base":100.0,"value":120.0,"cap":120.0,"lock":"Up"},
|
|
||||||
{"name":"Tactics","base":100.0,"value":110.0,"cap":120.0,"lock":"Locked"} /* …all */ ],
|
|
||||||
"equipment": [
|
|
||||||
{ "serial":"0x4001A2","layer":"TwoHanded","itemId":5046,"hue":0,
|
|
||||||
"name":null,"cliloc":1023721, // resolve name via cliloc (IV.4)
|
|
||||||
"weapon":{"minDamage":16,"maxDamage":18,"strReq":40},
|
|
||||||
"mods":{"WeaponDamage":50,"HitLightning":40,"SwingSpeedIncrement":30,"DefendChance":15} },
|
|
||||||
{ "serial":"0x4002B3","layer":"InnerTorso","itemId":7168,"hue":1157,
|
|
||||||
"name":"Ancient Plate","armor":{"baseRating":45},
|
|
||||||
"mods":{"ResistFireBonus":15,"LowerManaCost":8,"BonusHits":5} }
|
|
||||||
],
|
|
||||||
"vendorsOwned": 3 // future (IV.5)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Locks/enum values serialize as their names. `mods` is the flattened non-zero union across the item's attribute bags.
|
|
||||||
|
|
||||||
### IV.4 Gotchas for the profile export
|
|
||||||
|
|
||||||
- **⚑ Item names are usually clilocs, not strings.** `Item.Name` (`Item.cs:4860`) is frequently `null`; the real display name is `LabelNumber` (`:3771`), a cliloc ID resolved against `Data/Cliloc.enu`. For the website either (a) resolve cliloc → text server-side from the cliloc file and send the string, or (b) send the number and resolve on the site with a cliloc map. Crafted/renamed items *do* carry a plain `Name`. Send both (`name` + `cliloc`) and prefer `name` when present.
|
|
||||||
- **⚑ Don't recurse the whole backpack/bank by default.** A pack can hold hundreds of nested items — that's a different (huge) payload than "what they're wearing." Ship **worn equipment** fully; expose backpack/bank as an opt-in or a summarized count, not a default deep dump.
|
|
||||||
- **Building a profile allocates** (skill list + per-item mod scans). Keep it on-demand / on-change, **not** in the 30 s vitals sweep. A burst of `char.request`s should be fine (main-thread, fast) but rate-limit at the sidecar.
|
|
||||||
- **`Value` vs `Base` for skills:** `Base` is the trained number; `Value` includes item/temp bonuses (what the client shows in combat). Send both — the site likely wants `Base` for "character sheet" and `Value` for "effective."
|
|
||||||
- **Read on the main thread only.** Everything above touches live `Mobile`/`Item` state (§5). Build the DTO synchronously in the request handler / sweep, hand the finished JSON to the writer thread.
|
|
||||||
|
|
||||||
### IV.5 Vendor stats per player (the "eventually")
|
|
||||||
|
|
||||||
Ties into §III.1. A character/account can own player vendors; each `PlayerVendor` has `Owner`, an inventory of `VendorItem`s (item, `Price`, description), `HoldGold`, `BankAccount`, and commission. For a player-facing "my vendors" view, enumerate `PlayerVendor`s whose `Owner` is one of the account's mobiles and emit: vendor serial, house/location, held gold, and inventory (item, price, sold-state). Combined with the §III.1 `PlayerVendorSale` stream, the site can show both **current listings** and **sales history**. Same tiered/on-demand rule — fetch on request, refresh on sale.
|
|
||||||
|
|
||||||
### IV.6 Updated capability map (supersedes III.5)
|
|
||||||
|
|
||||||
| Capability | Mechanism | Core touch? | Cadence |
|
|
||||||
|-----------|-----------|:-----------:|---------|
|
|
||||||
| Player vitals (hp/mana/stam/gold/loc) | 30 s sweep of online + events | No | periodic/event |
|
|
||||||
| **Full character profile** (stats/skills/gear/mods) | build from live `Mobile`, **on-demand + on-change** (§IV) | No | request/response + on change |
|
|
||||||
| Account roster (up to 5 chars) | `account[0..Length]`, incl. offline (§IV.1) | No | on request |
|
|
||||||
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | event |
|
|
||||||
| Player-vendor sales | new `PlayerVendorSale` EventSink (§III.1) | **Yes, ~15 lines** | event |
|
|
||||||
| Player-owned vendor stats | enumerate `PlayerVendor` by owner (§IV.5) | No | on request |
|
|
||||||
| `[link` account linking | `CommandSystem` + account tags (§II.3) | No | event |
|
|
||||||
| IDOC / house decay | sweep `AllHouses`, transition-only (§III.3) | No | 30–60 s sweep |
|
|
||||||
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | inbound |
|
|
||||||
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
|
|
||||||
|
|
||||||
**Net:** the full-character requirement adds **no** new core touches — it's all readable off live objects. The only structural addition it implies is an **inbound request/response channel** (already needed for `[link` and town-crier), used here as `char.request` / `account.roster`, with the sidecar caching profiles for the website.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Script lifecycle — how `Scripts/Custom` loads and hooks startup/shutdown
|
|
||||||
|
|
||||||
**Compilation model (this is a *modern* ServUO, not the old CodeDom one).**
|
|
||||||
`Server/ScriptCompiler.cs:18` → when `Compiler.Dynamic` is true (default), the core literally runs:
|
|
||||||
|
|
||||||
```
|
|
||||||
dotnet build "Scripts/Scripts.csproj" -c Release (or Debug)
|
|
||||||
```
|
|
||||||
|
|
||||||
then `Assembly.LoadFrom("Scripts.dll")` (`ScriptCompiler.cs:63`). `Scripts.csproj` is SDK-style (`Microsoft.NET.Sdk`) with **default globbing**, so **every `.cs` anywhere under `Scripts/` — including `Scripts/Custom/` — is compiled automatically**. There is no per-file registration. A new plugin = drop a `.cs` file in `Scripts/Custom/` and restart (or rebuild `Scripts.dll`).
|
|
||||||
|
|
||||||
- If `dotnet build` fails, the core loops asking to retry (`Main.cs:525`); under `-service` it just returns/exits. So **a compile error in your bridge file takes the whole shard down at boot** — keep the plugin minimal and defensive.
|
|
||||||
- `-service`/non-interactive suppresses the console prompt (`Main.cs:386`).
|
|
||||||
|
|
||||||
**Lifecycle entry points (in boot order, all on the Core thread — `Main.cs:544-562`):**
|
|
||||||
|
|
||||||
| Order | Mechanism | How you hook it |
|
|
||||||
|------:|-----------|-----------------|
|
|
||||||
| 1 | `ScriptCompiler.Invoke("Configure")` | Any `public static void Configure()` in any script type |
|
|
||||||
| 2 | `World.Load()` | (world state restored from `Saves/`) |
|
|
||||||
| 3 | `ScriptCompiler.Invoke("Initialize")` | Any `public static void Initialize()` in any script type |
|
|
||||||
| 4 | `EventSink.InvokeServerStarted()` | `EventSink.ServerStarted += ...` |
|
|
||||||
|
|
||||||
`Invoke()` (`ScriptCompiler.cs:87`) reflects over **all** loaded types, finds the named `public static` method, sorts by `[CallPriority(n)]` (`Server/Attributes.cs:27`), and calls them. **`Configure` runs *before* `World.Load`; `Initialize` runs *after*.** → Register EventSink handlers in `Initialize` (or `Configure`); read config in `Configure`. Canonical example already in-tree: `Scripts/Misc/WeightOverloading.cs:15` subscribes to `EventSink.Movement` inside `Initialize()`.
|
|
||||||
|
|
||||||
**Shutdown.** Two clean hooks, both fire on the Core thread:
|
|
||||||
- `EventSink.Shutdown` — invoked from `Core.HandleClosed()` (`Main.cs:313`) on normal exit, *after* `World.WaitForWriteCompletion()`. **Not** invoked if `_Crashed`.
|
|
||||||
- `EventSink.Crashed` — invoked from the unhandled-exception handler (`Main.cs:198`); gives you an `args.Close` vote.
|
|
||||||
- Windows console-close / Ctrl-C routes through `OnConsoleEvent` → `Kill()` → `HandleClosed()` (`Main.cs:254`), so `Shutdown` normally still fires.
|
|
||||||
|
|
||||||
**Bridge implication:** your named-pipe writer/listener should be **created in `Initialize` (or on `ServerStarted`) and torn down in `Shutdown`**. Don't assume `Shutdown` runs on a crash — the pipe handle may be abandoned; the external service must tolerate an abrupt EOF.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. EventSink — available events, subscription, and frequency
|
|
||||||
|
|
||||||
**Subscription pattern:** `EventSink.<Name> += handler;` (static multicast delegates, declared `Server/EventSink.cs:1692-1784`). Handlers are plain delegates invoked synchronously via `EventSink.Invoke<Name>(args)` from the code path that raises them. **Every handler runs on whatever thread raised the event — in practice always the Core thread** (movement, speech, combat, login all originate from packet handling in `MessagePump.Slice()` or from the main-loop delta processing).
|
|
||||||
|
|
||||||
### Events relevant to a state-export bridge
|
|
||||||
|
|
||||||
| Event | Fires when | Frequency | Notes for export |
|
|
||||||
|-------|-----------|-----------|------------------|
|
|
||||||
| `Login` | Player fully in-world | Low | Best "player online" signal; gives `Mobile`. |
|
|
||||||
| `Logout` | Player disconnect (in-world) | Low | Pair with Login. |
|
|
||||||
| `Connected` / `Disconnected` | Socket up/down | Low | Lower-level than Login/Logout (fires for char-select too). |
|
|
||||||
| `PlayerDeath` | Player dies | Low | `PlayerDeathEventArgs` (mobile, corpse-ish context). |
|
|
||||||
| `CreatureDeath` | NPC/creature dies | **Medium–High** | Fires for *every* mob kill; on a busy shard this is a firehose. Filter/aggregate. |
|
|
||||||
| `Speech` | Player/NPC speech | Medium | `SpeechEventArgs`; raised from `Mobile.cs:5114`. Includes NPC/system speech. |
|
|
||||||
| `Movement` | **Any mobile takes a step** | **Very High** | See ⚠️ below. |
|
|
||||||
| `AggressiveAction` | Combat aggression declared | Medium–High | `AggressiveActionEventArgs` (`EventSink.cs:372`). Not per-swing, per aggression state change. |
|
|
||||||
| `ItemCreated` / `ItemDeleted` | Item constructed/deleted | **Very High** | Fires for *every* item incl. transient/loot/internal. Huge volume. |
|
|
||||||
| `MobileCreated` / `MobileDeleted` | Mobile constructed/deleted | High | Same caveat as items. |
|
|
||||||
| `SkillGain`, `CraftSuccess`, `ResourceHarvestSuccess` | Progression | Medium | Good "interesting player activity" signals. |
|
|
||||||
| `AccountGoldChange`, `FameChange`, `KarmaChange` | Economy/rep deltas | Low–Medium | Naturally diff-shaped. |
|
|
||||||
| `QuestComplete`, `JoinGuild`, `TameCreature`, `PlayerMurdered` | Milestone events | Low | Cheap, high-signal — ideal to export. |
|
|
||||||
| `WorldSave` / `BeforeWorldSave` / `AfterWorldSave` | Save cycle | Low (~5 min) | Natural checkpoint boundary for the bridge. |
|
|
||||||
| `ServerStarted` / `Shutdown` / `Crashed` | Lifecycle | Once | Bridge connect/disconnect signaling. |
|
|
||||||
|
|
||||||
Full list of 70+ events at `EventSink.cs:1692-1784` (context menus, vendor buy/sell, BOD, virtue, targeting macros, etc.).
|
|
||||||
|
|
||||||
> ⚠️ **`Movement` is the single most dangerous event to naively export.** `EventSink.InvokeMovement` is called from `Mobile.InternalOnMove` (`Mobile.cs:3029`), which runs for **every mobile that takes a step — all NPCs, all creatures, not just players.** On a populated shard that's thousands of invocations/second. It is **synchronous and cancellable** (`args.Blocked` gates the move), so your handler sits *inside the movement decision path* — any latency there (a blocking pipe write!) stalls the whole server. Additionally the args object is **pooled and immediately `Free()`d** (see §5). Rules: filter to `PlayerMobile` at the top of the handler, copy out primitives synchronously, never block, never retain the args reference.
|
|
||||||
|
|
||||||
### ⚑ Gap flag — events with *no* clean EventSink hook
|
|
||||||
|
|
||||||
These are things a bridge spec commonly wants to export but that **do not have a first-class `EventSink`**:
|
|
||||||
|
|
||||||
- **Item pickup / drop / "lift".** There is **no `EventSink` for picking up or dropping items.** It's handled by **virtual methods** on the objects: `Item.OnDragLift` / `Item.OnDragDrop` / `Item.OnDroppedInto` (`Item.cs:4647,2157,5060`) and `Mobile.OnDragDrop` / `Mobile.OnDragLift` (`Mobile.cs:10877,10949`). To observe these you must **override them on your own subclasses** or patch base classes — you can't subscribe globally from `Initialize`. Partial coverage exists via `EventSink.OnItemObtained`, `EventSink.ContainerDroppedTo`, and `EventSink.CorpseLoot`, but none of these is a universal "player moved item X from A to B" hook. **This is the biggest event-availability gap for the bridge.**
|
|
||||||
- **Per-hit combat damage.** `AggressiveAction` marks aggression, not each swing/damage tick. For damage numbers you'd hook `Mobile.Damage` / weapon `OnHit` paths (virtual/override), not an EventSink.
|
|
||||||
- **Equip/unequip of items generally.** `CheckEquipItem` exists (a *veto* hook), plus `EquipMacro`/`UnequipMacro` (macro-triggered only). No clean "item equipped" firehose via EventSink.
|
|
||||||
- **Stat/hits/mana/stam changes.** No EventSink; these move through the delta/`ProcessDeltaQueue` system (§4). You'd poll or hook `Mobile` delta handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Timers — mechanism and which thread callbacks run on
|
|
||||||
|
|
||||||
**This is the crux, and the answer is unambiguous.** ServUO splits timers into a *scheduler thread* and *main-thread execution*:
|
|
||||||
|
|
||||||
- **Timer Thread** (`Main.cs:429-434`, named `"Timer Thread"`) runs `Timer.TimerThread.TimerMain` (`Timer.cs:314`). Its *only* job is bookkeeping: walk the priority buckets, decide which timers are due, and **enqueue** them into a shared `m_Queue` (`Timer.cs:354-357`). It **does not execute callbacks.** When anything becomes due it calls `Core.Set()` (`Timer.cs:374`) to wake the main loop.
|
|
||||||
- **Core / main thread** runs `Timer.Slice()` (`Timer.cs:391`, called from `Core.Main` at `Main.cs:580`). This dequeues due timers and calls **`t.OnTick()` on the main thread** (`Timer.cs:409`).
|
|
||||||
|
|
||||||
**→ Every `Timer` / `Timer.DelayCall` callback executes on the Core (main) game thread.** The separate Timer Thread never touches game state; it's a scheduling clock. This is verifiable live via Appendix A (the probe logs `Thread.CurrentThread` from a Timer tick and from `Initialize` — they match, and match the network path shown in your crash log).
|
|
||||||
|
|
||||||
Other properties worth knowing:
|
|
||||||
- Timers are bucketed by `TimerPriority` (`EveryTick`, `TenMS`, … `OneMinute`); priority is auto-computed from delay/interval (`Timer.cs:468`).
|
|
||||||
- `Timer.Slice` has a `BreakCount` (default **20000**, `Timer.cs:383`) — if more than that many timers are due in one slice, the overflow waits for the next slice. Relevant if the bridge ever schedules a flood of one-shot timers.
|
|
||||||
- **Timers do not fire during world save/load.** `TimerMain` early-continues while `World.Loading || World.Saving` (`Timer.cs:322`). See §5 — this directly affects inbound-command latency.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Object model & serialization — and a diff-friendly state shape
|
|
||||||
|
|
||||||
**Identity.** `Serial` (`Server/Serial.cs:7`) is a `struct` wrapping a single `int`. **Mobiles** get serials `< 0x40000000`; **items** start at `0x40000000` (`Serial.cs:11-12`); `IsItem`/`IsMobile` test that boundary. Serials are stable for an object's lifetime and are the natural **primary key** for any external mirror of state. `World.Mobiles` / `World.Items` are `Dictionary<Serial, …>` (`World.cs:19-20`) — O(1) lookup by serial from the main thread.
|
|
||||||
|
|
||||||
**ServUO's own persistence** (`Server/Serialization.cs`, `Server/World.cs`):
|
|
||||||
- Every `Item`/`Mobile`/`SaveData` implements `Serialize(GenericWriter)` / `Deserialize(GenericReader)` plus a serial-taking ctor. `Core.VerifySerialization` (`Main.cs:679`) enforces this at boot.
|
|
||||||
- `GenericWriter`/`GenericReader` are a **versioned, positional binary stream** of primitives (`ReadInt`, `ReadString`, `ReadMobile`, `ReadPoint3D`, …; `Serialization.cs:17+`). Each object writes an `int` version first, then fields in a fixed order. It is **compact but *not* diff-friendly**: it's a full positional snapshot with no field names, meaningless without the exact type+version that wrote it, and it encodes the *entire* object every save.
|
|
||||||
- Saves are orchestrated by `World.Save` (`World.cs:1102`) on the main thread; a `SaveStrategy` may flush bytes to disk on a **background thread**, guarded by `m_DiskWriteHandle` (`ManualResetEvent`, `World.cs:29`). During a save `World.Saving` is true and object add/delete is deferred into `_addQueue`/`_deleteQueue` (`World.cs:1247-1280`).
|
|
||||||
|
|
||||||
**Recommendation for a diff-friendly representation (do NOT reuse the save system):**
|
|
||||||
The internal serializer is the wrong tool for the bridge — it's full-snapshot, schema-coupled, and versioned per type. Instead, build an **event-sourced delta keyed by `Serial`**:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// one line per change, main-thread produced, drained by background writer
|
|
||||||
{ "t": 172..., "kind": "mob.move", "serial": "0x1A2B", "x": 1420, "y": 1631, "z": 0, "dir": "North" }
|
|
||||||
{ "t": 172..., "kind": "mob.login", "serial": "0x1A2B", "name": "Thunderheat", "acct": "PerryAdimn" }
|
|
||||||
{ "t": 172..., "kind": "item.gold", "serial": "0x1A2B", "delta": -500, "total": 12000 }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Derive fields from the **EventSink args + the live object** at event time (e.g. `m.X/Y/Z/Map/Serial`), not from `Serialize`.
|
|
||||||
- Keyed by `Serial` so the external service maintains its own mirror and applies deltas.
|
|
||||||
- Emit a periodic/`ServerStarted` **full snapshot** (iterate `World.Mobiles`/`World.Items` on the main thread) as a baseline the deltas layer onto; `AfterWorldSave` is a natural snapshot boundary.
|
|
||||||
- Keep each record to primitives copied out **synchronously on the main thread** (pooled args, live objects mutate — see §5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Thread-safety rules & marshaling onto the main thread
|
|
||||||
|
|
||||||
**Golden rule (RunUO/ServUO-wide):** the world — `World.Mobiles`, `World.Items`, every `Mobile`/`Item`/`Account`, the delta queues, packet sends — is **single-threaded and owned by the Core thread.** None of it is locked for general access. Reading or mutating any of it from another thread is a data race / heisenbug generator. The dictionaries aren't concurrent; `Mobile.ProcessDeltaQueue`/`Item.ProcessDeltaQueue` run on the main loop (`Main.cs:577-578`) with no cross-thread guard.
|
|
||||||
|
|
||||||
**What *is* safe from a non-main thread:**
|
|
||||||
- `Core.Set()` — wake the main loop (`AutoResetEvent`, `Main.cs:324`).
|
|
||||||
- **`Timer.DelayCall(...)`** — verified safe cross-thread. `DelayCall`→`Start`→`TimerThread.AddTimer`→`Change` takes `lock (m_Changed)` and signals the timer thread (`Timer.cs:243-251,883-892`). The scheduling call is lock-protected; the **callback then runs on the main thread.** This is the intended marshaling primitive.
|
|
||||||
- Pushing onto a **`ConcurrentQueue`** you own, then letting the main thread drain it — this is literally how the network stack works: `MessagePump.m_Queue` is a `ConcurrentQueue<NetState>` (`MessagePump.cs:14`) filled by listener threads and drained by `MessagePump.Slice()` on the main thread (`MessagePump.cs:113`).
|
|
||||||
|
|
||||||
**The two marshaling patterns for inbound named-pipe commands** (pick one; pattern A is simplest):
|
|
||||||
|
|
||||||
- **A — `Timer.DelayCall` from the pipe thread.** On each inbound command, from the pipe read-callback thread call `Timer.DelayCall(TimeSpan.Zero, () => ApplyCommand(cmd))`. The lambda executes on the main thread on the next slice. Zero shared mutable state of your own. Caveat: a burst of commands = a burst of one-shot timers (mind `BreakCount`).
|
|
||||||
- **B — your own `ConcurrentQueue` + `Core.Slice`.** Pipe thread enqueues; register a handler on the `Core.Slice` delegate (`Main.cs:41,586`) that drains the queue every loop iteration on the main thread. Mirrors the network design; better for high inbound rates.
|
|
||||||
|
|
||||||
**Pitfalls specific to this codebase:**
|
|
||||||
1. **Pooled event args.** `MovementEventArgs` (and several others) are recycled via a plain `Queue` pool and `Free()`d immediately after the event (`EventSink.cs:802-834`). The pool itself is **not** thread-safe (main-thread-only). **Never** hand an args object to the pipe writer thread; copy primitives out first. Holding the reference = reading fields that belong to an unrelated later mobile.
|
|
||||||
2. **Blocking the main thread = stalling the shard.** EventSink handlers and Timer ticks run on the Core thread. A synchronous named-pipe **write** that blocks (slow/absent reader, full pipe buffer) will freeze movement, combat, saves — everything. The writer *must* be fire-and-forget onto a background queue (see §6).
|
|
||||||
3. **Timers pause during save/load.** Because `TimerMain` skips while `World.Saving`/`World.Loading` (`Timer.cs:322`), **inbound commands marshaled via `Timer.DelayCall` are deferred until the save finishes** (typically seconds; longer with background write). If commands must apply during a save window, prefer pattern B (Core.Slice) — but note the main loop also spends the save inside `World.Save`, so nothing script-side really runs mid-save regardless. Treat "commands don't apply during a save" as a design constraint, and have the external side tolerate the latency spike.
|
|
||||||
4. **Reentrancy / world-mutation during save.** Adding/deleting entities during a save is deferred to safety queues and logs a warning (`World.cs:988,1247`). If a bridge command spawns/deletes, it may silently queue.
|
|
||||||
5. **Crash path skips `Shutdown`.** Don't rely on graceful pipe teardown (§1).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Local ServUO↔sidecar transport (net48) — non-blocking bridge I/O
|
|
||||||
|
|
||||||
> **Superseded by [Part II.1](#ii1-transport-put-the-websocket-in-rust-keep-the-c-side-dumb).** For the Rust WS sidecar design the recommended C↔Rust link is **loopback TCP + newline-JSON**, not a named pipe, and **ServUO should not speak WebSocket**. The non-blocking principles below still apply verbatim to whichever local transport you pick.
|
|
||||||
|
|
||||||
Target is **net48** (`Scripts.csproj:3`), so you have `System.IO.Pipes` / `System.Net.Sockets` with `async`/`await` and `Begin/End` APIs, but **not** the newer `IAsyncEnumerable`/`CancellationToken` niceties of modern .NET. Design around that.
|
|
||||||
|
|
||||||
**Outbound (fire-and-forget writer) — the important one:**
|
|
||||||
- The producer is the Core thread (event handlers). It must **never touch the pipe directly.** Producer does only: format the delta record → `ConcurrentQueue.Enqueue` → return. This is a non-blocking, allocation-only operation.
|
|
||||||
- A **single dedicated background writer thread** (or a long-running `Task`) owns the `NamedPipeServerStream`/`ClientStream` and drains the queue, using `WriteAsync`/`FlushAsync`. One writer = writes stay ordered and you avoid interleaved frames on the pipe.
|
|
||||||
- Use a **length-prefixed or newline-delimited framing** (`PipeTransmissionMode.Byte` is simplest and most portable; `Message` mode has size/OS quirks). Don't rely on message boundaries.
|
|
||||||
- **Bound the queue.** If the external reader stalls, an unbounded queue is a memory leak that eventually OOMs the shard. Drop-oldest or drop-on-full with a dropped-count counter is the safe default for telemetry-style data.
|
|
||||||
- Handle `IOException`/`Broken pipe` by reconnecting in the writer thread; the game keeps running, the queue keeps the newest N records.
|
|
||||||
|
|
||||||
**Inbound (command listener):**
|
|
||||||
- A separate background thread/loop `WaitForConnectionAsync` → `ReadAsync` loop, parse a framed command, then **marshal to the main thread** via pattern A or B from §5. The read thread must not call any `World`/`Mobile`/`Item` API.
|
|
||||||
- Server vs client: making ServUO the **`NamedPipeServerStream`** (external service connects in) is usually cleaner for lifecycle — the shard owns the pipe, survives external restarts, and you control `maxNumberOfServerInstances`. Two half-duplex pipes (one in, one out) are simpler to reason about than one duplex pipe shared across your writer and reader threads.
|
|
||||||
- Set `PipeOptions.Asynchronous` at construction — required for the `*Async` methods to actually overlap I/O rather than block a thread-pool thread.
|
|
||||||
|
|
||||||
**Pitfalls:**
|
|
||||||
- Don't `await` pipe I/O on the Core thread — there's no synchronization context that returns you to the Core thread anyway, and you'd risk resuming world access on a thread-pool thread. Keep all pipe `await`s on your dedicated background threads.
|
|
||||||
- Named-pipe ACLs: if the external service runs as a different user/session, set a `PipeSecurity` explicitly or the connect will `UnauthorizedAccessException`.
|
|
||||||
- First-chance `IOException` on client disconnect is normal; log-and-reconnect, don't crash the writer loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Flags against the bridge architecture
|
|
||||||
|
|
||||||
> **See [Part II.4](#ii4-revised-flags-for-this-architecture) for the flags that matter to the Rust WS sidecar + tracking/link design.** The list below is the original generic set (still valid background).
|
|
||||||
|
|
||||||
1. **⚑ Item pickup/drop has no EventSink (§2 gap).** If the spec assumes "subscribe to item move events" the way you subscribe to login/movement, that assumption is wrong. Pickup/drop/lift live on **virtual methods** (`Item.OnDragLift/OnDragDrop/OnDroppedInto`, `Mobile.OnDragDrop`). Exporting them cleanly requires base-class overrides/patching, not `Initialize`-time subscription. This is the item most likely to change the design.
|
|
||||||
2. **⚑ `Movement` (and `Item/MobileCreated/Deleted`) are firehoses on the main thread (§2, §5).** Any spec that says "export all movement" must add player-filtering + aggregation, and the export path must be non-blocking. `Movement` args are **pooled** — copy-out-synchronously is mandatory, not optional.
|
|
||||||
3. **⚑ Everything you'd export runs on the single Core thread (§3, §5).** The whole bridge stands or falls on the writer being fire-and-forget. If the spec has event handlers writing to the pipe synchronously, that's a shard-wide stall waiting to happen. Confirmed by your own crash log that even packet-triggered handlers run inline on `Core.Main`.
|
|
||||||
4. **✔ Inbound commands *can* be safely marshaled to the main thread** via `Timer.DelayCall` (verified thread-safe) or a `ConcurrentQueue` drained on `Core.Slice`. The named-pipe approach is **not** blocked by threading — but:
|
|
||||||
5. **⚑ Commands don't apply during world saves (§5 pitfall 3).** Timers pause and the main loop is inside `World.Save` (~seconds, every ~5 min by default). If the spec expects sub-second inbound command latency 100% of the time, it needs to tolerate periodic save-window spikes.
|
|
||||||
6. **⚑ Don't mirror state via ServUO's serializer (§4).** If the spec imagined "reuse ServUO's save format to ship state," reconsider — it's full-snapshot, schema-versioned, and unnamed. Use event-derived deltas keyed by `Serial` + periodic snapshots.
|
|
||||||
7. **⚑ Crash path skips graceful shutdown (§1).** The external service must treat pipe EOF as normal and re-handshake; don't assume a clean `Shutdown` teardown.
|
|
||||||
8. **⚑ A compile error in the bridge plugin fails the whole shard boot (§1).** Keep the plugin small, wrap handler bodies in try/catch, and never let a bridge exception escape into a game code path.
|
|
||||||
9. **(Environmental) The `zlibwapi64` native-load crash (§0)** already downed this shard once. Unrelated to the bridge, but resolve it before load-testing or it will confound results.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A — Drop-in empirical probe (run this yourself)
|
|
||||||
|
|
||||||
Save as `Scripts/Custom/BridgeThreadProbe.cs`, start the shard, watch the console. **No game client needed** — it proves the thread identity of `Initialize`, `ServerStarted`, a `Timer` tick, and `Core.Slice`. Delete the file afterward. (This is a throwaway diagnostic, not the bridge.)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using Server;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
public static class BridgeThreadProbe
|
|
||||||
{
|
|
||||||
private static void Log(string where)
|
|
||||||
{
|
|
||||||
var t = Thread.CurrentThread;
|
|
||||||
Console.WriteLine("[PROBE] {0,-16} thread id={1} name=\"{2}\"",
|
|
||||||
where, t.ManagedThreadId, t.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
Log("Initialize"); // expect: Core Thread
|
|
||||||
|
|
||||||
EventSink.ServerStarted += () => Log("ServerStarted"); // expect: Core Thread
|
|
||||||
EventSink.Login += e => Log("Login (client)"); // needs a client login
|
|
||||||
|
|
||||||
// Timer tick — proves callbacks run on the main thread, not the Timer Thread.
|
|
||||||
Timer.DelayCall(TimeSpan.FromSeconds(3), () => Log("Timer.DelayCall")); // expect: Core Thread
|
|
||||||
|
|
||||||
// Cross-thread marshal test: schedule from a raw background thread,
|
|
||||||
// confirm the callback still lands on Core Thread.
|
|
||||||
new Thread(() =>
|
|
||||||
{
|
|
||||||
Log("raw bg thread"); // expect: some worker id, NOT Core Thread
|
|
||||||
Timer.DelayCall(TimeSpan.Zero, () => Log("marshaled->main"));
|
|
||||||
}).Start();
|
|
||||||
|
|
||||||
// Core.Slice runs every main-loop iteration; log once then detach.
|
|
||||||
Slice one = null;
|
|
||||||
one = () => { Log("Core.Slice"); Core.Slice -= one; };
|
|
||||||
Core.Slice += one; // expect: Core Thread
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected result:** every line except `raw bg thread` reports `name="Core Thread"` with the same managed id as `Initialize` — confirming EventSink handlers, Timer ticks, and `Core.Slice` all execute on the one main thread, and that `Timer.DelayCall` from a background thread correctly hops work onto it. If you connect a client, `Login (client)` also reports `Core Thread`, matching the `MessagePump.Slice` evidence in your crash log.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key source references
|
|
||||||
|
|
||||||
| Topic | File:line |
|
|
||||||
|-------|-----------|
|
|
||||||
| Main game loop / thread setup | `Server/Main.cs:329,410-434,573-599` |
|
|
||||||
| `Core.Slice` main-thread hook | `Server/Main.cs:41,586` |
|
|
||||||
| `Core.Set` wake main loop | `Server/Main.cs:322-327` |
|
|
||||||
| Shutdown / Crashed hooks | `Server/Main.cs:198,313` |
|
|
||||||
| Script compile (`dotnet build`) | `Server/ScriptCompiler.cs:18-65` |
|
|
||||||
| `Configure`/`Initialize` invoke + CallPriority | `Server/ScriptCompiler.cs:87-112`, `Server/Attributes.cs:27` |
|
|
||||||
| EventSink event declarations | `Server/EventSink.cs:1692-1784` |
|
|
||||||
| Movement raise (all mobiles, pooled, cancellable) | `Server/Mobile.cs:3020-3036`, `Server/EventSink.cs:792-834` |
|
|
||||||
| Item pickup/drop = virtual, no EventSink | `Server/Item.cs:2157,4647,5060`, `Server/Mobile.cs:10877,10949` |
|
|
||||||
| Timer scheduler thread (enqueue only) | `Server/Timer.cs:314-379` |
|
|
||||||
| Timer execution on main thread | `Server/Timer.cs:391-419`, `Server/Main.cs:580` |
|
|
||||||
| `Timer.DelayCall` cross-thread safety | `Server/Timer.cs:243-251,524-534,883-892` |
|
|
||||||
| Network marshaling (ConcurrentQueue → main) | `Server/Network/MessagePump.cs:14,108,113` |
|
|
||||||
| Serial identity | `Server/Serial.cs:7-33` |
|
|
||||||
| Serialization API | `Server/Serialization.cs:17+` |
|
|
||||||
| World save threading / safety queues | `Server/World.cs:29,1102-1208,1247-1280` |
|
|
||||||
| Runtime evidence: EventSink on Core thread | `Crash 6-5-2026-22-38-3.log` |
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Shard prerequisites
|
|
||||||
|
|
||||||
Repairs the target shard (`C:\Users\colby\Desktop\servuo`, ServUO 57.4) required before the bridge could load. These are **deletions and edits of existing files**, so they cannot be expressed as an overlay copy. They are recorded here, and where practical as diffs under `patches/`.
|
|
||||||
|
|
||||||
Applied 2026-07-10. Backups on the Desktop: `servuo_saves_backup_2026-07-10_032608`, `servuo_bin_backup_2026-07-10_032608`, `servuo_removed_files_2026-07-10`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The symptom
|
|
||||||
|
|
||||||
`Scripts.dll` had not been rebuilt since **2026-05-30 17:01**. Every script change after that — including all of `Scripts/Custom/Named/`, `MyStats.cs`, and `SearchAdd.cs` — had never executed.
|
|
||||||
|
|
||||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) shells out to `dotnet build`, prints the output, ignores the exit code, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. A failing script build is invisible: the stale DLL simply reloads. The retry loop at `Main.cs:525` never trips.
|
|
||||||
|
|
||||||
Four independent breakages, all introduced between 17:14 and 21:55 on 2026-05-30.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Stray `Server/Gumps/Gumps.cs`
|
|
||||||
|
|
||||||
A **byte-identical copy** of `Scripts/Services/Pet Training/Gumps.cs` (75,468 bytes), sitting in the Server project. It declares `namespace Server.Mobiles` and extends `BaseGump`, referencing `BaseCreature`, `PlayerMobile`, `TrainingPoint` — all defined in Scripts. Server cannot reference Scripts, so `Server.csproj` failed with 35 errors.
|
|
||||||
|
|
||||||
**Action:** deleted. The canonical copy under `Scripts/Services/Pet Training/` was edited 10 minutes later and is the one that matters.
|
|
||||||
|
|
||||||
## 2. Eleven duplicate creature classes
|
|
||||||
|
|
||||||
`Scripts/Custom/{Named,Legendary}/` redefined classes already present in `Scripts/Mobiles/Normal/`, producing `CS0111` / `CS0579`.
|
|
||||||
|
|
||||||
**Named** — `Eowmu`, `SkeletalCat`, `Windrunner`. The stock files each define **two** types: the mount *and* an `ICreatureStatuette` item (`EowmuStatue`, …) that `Scripts/Services/UltimaStore/UltimaStore.cs` references. Deleting the stock files outright would have re-broken the build.
|
|
||||||
|
|
||||||
**Action:** removed only the duplicate mount class from each stock file; kept the statues.
|
|
||||||
|
|
||||||
**Legendary** — `FireSteed`, `Kirin`, `Nightmare`, `OsseinRam`, `Phoenix`, `PolarBear`, `ShadowWyrm`, `TsukiWolf`. Clean 1:1 pairs. All custom versions sit in `namespace Server.Mobiles`, so the serialized type name is unchanged, and each `Deserialize` guards on `version` and migrates from 0 (`ShadowWyrm`: `if (version >= 1)`; `FireSteed`: `if (version < 1)` skill-cap migration; `Kirin`: `if (version == 0)` AI fixup).
|
|
||||||
|
|
||||||
**Action:** deleted the eight stock files. Custom wins.
|
|
||||||
|
|
||||||
## 3. `PolarBear` — a base-class change, not a version bump
|
|
||||||
|
|
||||||
Custom `PolarBear : BaseMount`; stock `PolarBear : BaseCreature`. The saved world contained a bear serialized through the `BaseCreature` chain, so loading it as a `BaseMount` misaligned the stream. World load aborted at `Server.Mobiles.PolarBear` serial `0x00000412` with `Delete the object? (y/n)`.
|
|
||||||
|
|
||||||
**Changing a saved type's base class is not version-migratable.** The custom class also carried `[TypeAlias("Server.Mobiles.Polarbear")]`, which would have hijacked the same records.
|
|
||||||
|
|
||||||
**Action:** restored stock `PolarBear : BaseCreature`; renamed the custom mount to `LegendaryPolarBear` and dropped the `TypeAlias`. Stock scripts referencing `typeof(PolarBear)` (`TalismanSlayer`, `SpeedInfo`, `RoyalZooDonationBox`, `SummonCreature`, `PetTrainingHelper`) continue to resolve to the `BaseCreature`.
|
|
||||||
|
|
||||||
Note: `Scripts/Custom/Legendary/PolarBear.cs` was renamed to `LegendaryPolarBear.cs`.
|
|
||||||
|
|
||||||
## 4. `AnimalLore.cs` referenced a package that does not exist
|
|
||||||
|
|
||||||
`Scripts/Skills/AnimalLore.cs` had `using ShrinkSystem;` and two `IShrinkItem` branches. No `ShrinkSystem` namespace exists anywhere in the repo, and `IShrinkItem` appears nowhere in the stale `Scripts.dll` — **the code had never compiled or run.** (`Scripts/Misc/ShrinkTable.cs` is unrelated stock: `namespace Server`, class `ShrinkTable`.)
|
|
||||||
|
|
||||||
**Action:** removed the `using` and collapsed the shrink branches back to the `BaseCreature` path. This restores exactly the behavior the shard was already running.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After the repairs, `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` succeeded with 0 warnings, 0 errors. Rebuilding `ServUO.exe` and `Ultima.dll` from current source produced **byte-identical** binaries (same SHA-256), confirming the core was never stale in content — only `Scripts.dll` was.
|
|
||||||
|
|
||||||
With Phase 0 applied, a plain boot shows:
|
|
||||||
|
|
||||||
```
|
|
||||||
Core: Compiling scripts...
|
|
||||||
Build succeeded.
|
|
||||||
Core: Verified 6023 item and 1385 mobile types
|
|
||||||
World: Loading...
|
|
||||||
...done (206208 items, 42771 mobiles, 0 customs)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Unrelated, still open
|
|
||||||
|
|
||||||
`DllNotFoundException: zlibwapi64` crashed this shard once (`Crash 6-5-2026-22-38-3.log`) while sending a packed gump. `zlibwapi64.dll` is present in the repo root, so this is a working-directory / native-load-path problem. It will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
|
|
||||||
# uo-link bridge settings.
|
|
||||||
#
|
|
||||||
# Key scope is the filename: Bridge.cfg + StatSweepSeconds => "Bridge.StatSweepSeconds".
|
|
||||||
# Read in Configure(), which runs before World.Load.
|
|
||||||
|
|
||||||
# Loopback only. The socket being local is the trust boundary for inbound commands;
|
|
||||||
# if the sidecar ever moves off-host, add a shared secret first.
|
|
||||||
Host=127.0.0.1
|
|
||||||
Port=7788
|
|
||||||
|
|
||||||
# Outbound queue cap. On overflow the plugin drops oldest and counts the drops,
|
|
||||||
# because a stalled sidecar must never OOM the shard.
|
|
||||||
QueueCap=10000
|
|
||||||
|
|
||||||
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs
|
|
||||||
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See docs/PLAN.md §1.
|
|
||||||
StatSweepSeconds=30
|
|
||||||
DecaySweepSeconds=60
|
|
||||||
EconomySweepSeconds=300
|
|
||||||
|
|
||||||
# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
|
|
||||||
# this interval to emit champ.update on any status/level/kills/boss change. The world holds
|
|
||||||
# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
|
|
||||||
ChampSweepSeconds=10
|
|
||||||
|
|
||||||
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
|
|
||||||
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
|
|
||||||
# support queue; the full open queue is also available on demand via pages.snapshot.
|
|
||||||
PageSweepSeconds=5
|
|
||||||
|
|
||||||
# Guild roster poll (docs/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
|
|
||||||
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
|
|
||||||
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
|
|
||||||
GuildSweepSeconds=60
|
|
||||||
|
|
||||||
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
|
|
||||||
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
|
|
||||||
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
|
|
||||||
CitySweepSeconds=300
|
|
||||||
|
|
||||||
# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
|
|
||||||
# interval and emitted as presence.online only when it changes. Region transitions come
|
|
||||||
# through separately in real time as region.enter (EventSink.OnEnterRegion).
|
|
||||||
PresenceSweepSeconds=30
|
|
||||||
|
|
||||||
# Housing registry poll. Every house is diffed on this interval to emit house.update /
|
|
||||||
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
|
|
||||||
HousingSweepSeconds=300
|
|
||||||
|
|
||||||
# Shown to a player when they run [link. The website page where they enter the code.
|
|
||||||
LinkUrl=https://yoursite/link
|
|
||||||
|
|
||||||
# Town-crier news pushed from the website. Caps are defense in depth on top of the
|
|
||||||
# loopback trust boundary: a buggy or compromised sidecar still cannot flood the criers.
|
|
||||||
TownCrierMaxLines=6
|
|
||||||
TownCrierMaxLineLength=200
|
|
||||||
TownCrierMaxActive=20
|
|
||||||
TownCrierMaxDurationSec=86400
|
|
||||||
|
|
||||||
# Town Cryer news gump. Website articles (news.add) become entries in the modern Town
|
|
||||||
# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines
|
|
||||||
# above. The article title is also proclaimed by the criers (announce defaults on). Caps
|
|
||||||
# are defense in depth on top of the loopback trust boundary.
|
|
||||||
NewsMaxTitleLength=100
|
|
||||||
NewsMaxBodyLength=2000
|
|
||||||
NewsMaxExternal=20
|
|
||||||
NewsAnnounceDurationSec=300
|
|
||||||
|
|
||||||
# Admin write plane (staff moderation from the website). OFF by default: the whole
|
|
||||||
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
|
|
||||||
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
|
|
||||||
# loopback socket and applies a hard floor below.
|
|
||||||
AdminWriteEnabled=false
|
|
||||||
|
|
||||||
# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
|
|
||||||
# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
|
|
||||||
# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
|
|
||||||
# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
|
|
||||||
AdminAccessFloor=CoOwner
|
|
||||||
|
|
||||||
# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
|
|
||||||
AdminBroadcastMaxLength=300
|
|
||||||
AdminReasonMaxLength=400
|
|
||||||
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
|
|
||||||
AdminBanMaxDurationSec=31536000
|
|
||||||
|
|
||||||
# Account provisioning (docs/PROTOCOL_2.md Part A). Which side may mint game accounts:
|
|
||||||
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
|
|
||||||
# (else an in-game login of any new name still mints an account).
|
|
||||||
# game — the game server is the authority; website account.create is refused.
|
|
||||||
# hybrid — either side may create (the default).
|
|
||||||
# The bridge governs only the account.create verb; the in-game first-login auto-create is
|
|
||||||
# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
|
|
||||||
# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
|
|
||||||
# (the safest — no website creation).
|
|
||||||
SignupMode=hybrid
|
|
||||||
|
|
||||||
# Master switch for the account.create verb. Absent, it follows the mode (on unless
|
|
||||||
# SignupMode=game). Set explicitly to force it on or off regardless of mode.
|
|
||||||
AccountCreateEnabled=true
|
|
||||||
|
|
||||||
# Fail closed if account.create omits a usable browser IP. The per-IP cap
|
|
||||||
# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
|
|
||||||
# than waved through. Turn off only for a deployment that deliberately does not cap website
|
|
||||||
# signups by IP (MaxAccountsPerIP still applies in-game either way).
|
|
||||||
RequireIpForCreate=true
|
|
||||||
|
|
||||||
# Length caps on a website-supplied username / password, checked before the account is made.
|
|
||||||
AccountNameMaxLength=16
|
|
||||||
AccountPasswordMaxLength=30
|
|
||||||
|
|
||||||
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
|
||||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
|
||||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
|
||||||
# server never runs the scaffolding even if its .cs files are present.
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Commands;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Ties a game account to a website account.
|
|
||||||
///
|
|
||||||
/// Flow:
|
|
||||||
/// 1. In game, the player runs [link. The shard mints a short, one-time, expiring code,
|
|
||||||
/// holds it in memory keyed to their account, and emits link.request to the sidecar.
|
|
||||||
/// 2. The player enters that code on the website. The website tells the sidecar, which
|
|
||||||
/// sends link.confirm inbound.
|
|
||||||
/// 3. The shard validates the code, writes Account tag "WebsiteUserId", drops the code,
|
|
||||||
/// and replies link.ok. The tag persists to accounts.xml across restarts.
|
|
||||||
///
|
|
||||||
/// The code table and the account write both live on the Core thread. The websiteUserId in
|
|
||||||
/// link.confirm is trusted only because the socket is loopback-only (docs/PLAN.md §2); if the
|
|
||||||
/// sidecar ever moves off-host, gate it behind a shared secret.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeAccountLink
|
|
||||||
{
|
|
||||||
private const string Tag = "WebsiteUserId";
|
|
||||||
|
|
||||||
// Unambiguous alphabet: no O/0, I/1, so a player reading a code aloud can't get it wrong.
|
|
||||||
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
private const int CodeLength = 6;
|
|
||||||
|
|
||||||
private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
|
|
||||||
private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
|
|
||||||
|
|
||||||
private sealed class Pending
|
|
||||||
{
|
|
||||||
public string Account;
|
|
||||||
public DateTime Expires;
|
|
||||||
}
|
|
||||||
|
|
||||||
// code -> pending link. Core-thread only.
|
|
||||||
private static readonly Dictionary<string, Pending> _codes =
|
|
||||||
new Dictionary<string, Pending>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// account -> last [link time, to rate-limit code spam.
|
|
||||||
private static readonly Dictionary<string, DateTime> _lastRequest =
|
|
||||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
|
|
||||||
CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
|
|
||||||
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
|
|
||||||
|
|
||||||
// Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
|
|
||||||
Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), PurgeExpired);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads the linked website id for an account, or null. Used to enrich events.</summary>
|
|
||||||
public static string WebIdFor(Account acct)
|
|
||||||
{
|
|
||||||
if (acct == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return acct.GetTag(Tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- [link ----
|
|
||||||
|
|
||||||
[Usage("link")]
|
|
||||||
[Description("Links this game account to your website account via a one-time code.")]
|
|
||||||
private static void OnLinkCommand(CommandEventArgs e)
|
|
||||||
{
|
|
||||||
RequestLink(e.Mobile);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mints a one-time code for the mobile's account and emits link.request. This is the
|
|
||||||
/// body of the [link command, exposed so it can be driven in tests without a client.
|
|
||||||
/// </summary>
|
|
||||||
public static void RequestLink(Mobile m)
|
|
||||||
{
|
|
||||||
if (m == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var acct = m.Account as Account;
|
|
||||||
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
m.SendMessage("Bridge: no account on this character.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing = acct.GetTag(Tag);
|
|
||||||
if (existing != null)
|
|
||||||
{
|
|
||||||
m.SendMessage("Your account is already linked to website user {0}.", existing);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime last;
|
|
||||||
if (_lastRequest.TryGetValue(acct.Username, out last) && DateTime.UtcNow - last < RequestCooldown)
|
|
||||||
{
|
|
||||||
m.SendMessage("Please wait a moment before requesting another link code.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One outstanding code per account: drop any prior code so only the newest works.
|
|
||||||
DropCodesFor(acct.Username);
|
|
||||||
|
|
||||||
var code = MintCode();
|
|
||||||
_codes[code] = new Pending { Account = acct.Username, Expires = DateTime.UtcNow + CodeTtl };
|
|
||||||
_lastRequest[acct.Username] = DateTime.UtcNow;
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("link.request")
|
|
||||||
.Str("code", code)
|
|
||||||
.Str("account", acct.Username)
|
|
||||||
.Str("char", m.Name)
|
|
||||||
.Num("ttlSec", (long)CodeTtl.TotalSeconds)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
var url = BridgeConfig.LinkUrl;
|
|
||||||
m.SendMessage(0x35, "Link code: {0}", code);
|
|
||||||
m.SendMessage("Enter it at {0} within {1} minutes to link your account.",
|
|
||||||
url, (int)CodeTtl.TotalMinutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- [unlink ----
|
|
||||||
|
|
||||||
[Usage("unlink")]
|
|
||||||
[Description("Unlinks this game account from your website account.")]
|
|
||||||
private static void OnUnlinkCommand(CommandEventArgs e)
|
|
||||||
{
|
|
||||||
Unlink(e.Mobile);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so
|
|
||||||
/// the website can reconcile a player-initiated unlink. Player-scoped (own account only),
|
|
||||||
/// so it needs no access floor. After unlinking, [link works again.
|
|
||||||
/// </summary>
|
|
||||||
public static void Unlink(Mobile m)
|
|
||||||
{
|
|
||||||
if (m == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var acct = m.Account as Account;
|
|
||||||
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
m.SendMessage("Bridge: no account on this character.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing = acct.GetTag(Tag);
|
|
||||||
if (existing == null)
|
|
||||||
{
|
|
||||||
m.SendMessage("Your account is not linked to a website account.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
acct.RemoveTag(Tag);
|
|
||||||
DropCodesFor(acct.Username); // drop any pending codes so nothing dangles
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("account.unlinked")
|
|
||||||
.Str("origin", "in-game")
|
|
||||||
.Str("account", acct.Username)
|
|
||||||
.Str("websiteUserId", existing)
|
|
||||||
.Str("char", m.Name)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- inbound link.confirm ----
|
|
||||||
|
|
||||||
private static void OnLinkConfirm(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var code = BridgeJson.GetString(o, "code");
|
|
||||||
var webId = BridgeJson.GetString(o, "websiteUserId");
|
|
||||||
|
|
||||||
if (code == null || webId == null)
|
|
||||||
{
|
|
||||||
Reply("link.error", null, null, "malformed link.confirm");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Pending pending;
|
|
||||||
if (!_codes.TryGetValue(code, out pending))
|
|
||||||
{
|
|
||||||
Reply("link.error", code, null, "unknown or expired code");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_codes.Remove(code);
|
|
||||||
|
|
||||||
if (DateTime.UtcNow > pending.Expires)
|
|
||||||
{
|
|
||||||
Reply("link.error", code, pending.Account, "code expired");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var acct = Accounting.Accounts.GetAccount(pending.Account) as Account;
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Reply("link.error", code, pending.Account, "account no longer exists");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persisted to accounts.xml on the next world save.
|
|
||||||
acct.SetTag(Tag, webId);
|
|
||||||
DropCodesFor(pending.Account);
|
|
||||||
|
|
||||||
Reply("link.ok", code, pending.Account, null, webId);
|
|
||||||
|
|
||||||
NotifyOnline(acct, webId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers ----
|
|
||||||
|
|
||||||
private static void Reply(string kind, string code, string account, string reason, string webId = null)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin(kind);
|
|
||||||
if (code != null) sb.Str("code", code);
|
|
||||||
if (account != null) sb.Str("account", account);
|
|
||||||
if (webId != null) sb.Str("websiteUserId", webId);
|
|
||||||
if (reason != null) sb.Str("reason", reason);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void NotifyOnline(Account acct, string webId)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < acct.Length; i++)
|
|
||||||
{
|
|
||||||
var m = acct[i];
|
|
||||||
if (m != null && m.NetState != null)
|
|
||||||
m.SendMessage(0x40, "Your account is now linked to website user {0}.", webId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string MintCode()
|
|
||||||
{
|
|
||||||
// Avoid a collision with an outstanding code, though at 32^6 it is astronomically rare.
|
|
||||||
for (int attempt = 0; attempt < 8; attempt++)
|
|
||||||
{
|
|
||||||
var chars = new char[CodeLength];
|
|
||||||
for (int i = 0; i < CodeLength; i++)
|
|
||||||
chars[i] = Alphabet[Utility.Random(Alphabet.Length)];
|
|
||||||
|
|
||||||
var code = new string(chars);
|
|
||||||
if (!_codes.ContainsKey(code))
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to a guaranteed-unique code.
|
|
||||||
return "L" + DateTime.UtcNow.Ticks.ToString("X").Substring(0, CodeLength - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DropCodesFor(string account)
|
|
||||||
{
|
|
||||||
var doomed = new List<string>();
|
|
||||||
|
|
||||||
foreach (var kv in _codes)
|
|
||||||
{
|
|
||||||
if (String.Equals(kv.Value.Account, account, StringComparison.OrdinalIgnoreCase))
|
|
||||||
doomed.Add(kv.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var c in doomed)
|
|
||||||
_codes.Remove(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PurgeExpired()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var doomed = new List<string>();
|
|
||||||
|
|
||||||
foreach (var kv in _codes)
|
|
||||||
{
|
|
||||||
if (now > kv.Value.Expires)
|
|
||||||
doomed.Add(kv.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var c in doomed)
|
|
||||||
_codes.Remove(c);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] link purge threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Misc;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The account provisioning plane (docs/PROTOCOL_2.md Part A): website-driven account
|
|
||||||
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
|
|
||||||
/// is unchanged.
|
|
||||||
///
|
|
||||||
/// account.create — mint a game account and link it to a website user in one step.
|
|
||||||
/// account.unlink — sever the WebsiteUserId tie from the website side.
|
|
||||||
///
|
|
||||||
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
|
|
||||||
/// Timer.DelayCall first), so they touch accounts freely.
|
|
||||||
///
|
|
||||||
/// Trust model matches the admin plane (docs/ADMIN_CONTROLS.md §5): authorization lives on
|
|
||||||
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
|
|
||||||
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
|
|
||||||
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
|
|
||||||
/// AccountCreateEnabled.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeAccounts
|
|
||||||
{
|
|
||||||
private const string Tag = "WebsiteUserId";
|
|
||||||
|
|
||||||
// Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an
|
|
||||||
// in-game one (AccountHandler.cs). Kept local because that array is private.
|
|
||||||
private static readonly char[] ForbiddenChars =
|
|
||||||
{
|
|
||||||
'<', '>', ':', '"', '/', '\\', '|', '?', '*', ' '
|
|
||||||
};
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeBoot.RegisterHandler("account.create", OnCreate);
|
|
||||||
BridgeBoot.RegisterHandler("account.unlink", OnUnlink);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- account.create ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a game account and links it to the given website user. Refused unless the
|
|
||||||
/// signup mode allows website creation. Enforces the same username/password character
|
|
||||||
/// safety and per-IP cap as ServUO's in-game create path; the password never leaves the
|
|
||||||
/// process in any reply, audit, or log.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnCreate(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
var actor = BridgeJson.GetString(o, "actor");
|
|
||||||
const string action = "create";
|
|
||||||
|
|
||||||
if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "signups disabled for this mode");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "missing actor");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var account = BridgeJson.GetString(o, "account");
|
|
||||||
var password = BridgeJson.GetString(o, "password");
|
|
||||||
var webId = BridgeJson.GetString(o, "websiteUserId");
|
|
||||||
var ipStr = BridgeJson.GetString(o, "ip");
|
|
||||||
|
|
||||||
if (String.IsNullOrEmpty(account))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "missing account");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String.IsNullOrEmpty(password))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "missing password");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String.IsNullOrEmpty(webId))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "missing websiteUserId");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.Length > BridgeConfig.AccountNameMaxLength ||
|
|
||||||
password.Length > BridgeConfig.AccountPasswordMaxLength)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "username or password too long");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IsSafeUsername(account) || !IsSafePassword(password))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "invalid username/password");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collision: the only correct resolution of a website/in-game race for a name.
|
|
||||||
if (Accounts.GetAccount(account) != null)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "account already exists");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is
|
|
||||||
// exempt in IPLimiter, so accepting it would silently bypass the cap.
|
|
||||||
IPAddress ip;
|
|
||||||
bool haveIp = TryParseIp(ipStr, out ip);
|
|
||||||
|
|
||||||
if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip)))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "client ip required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (haveIp && !AccountHandler.CanCreate(ip))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "ip account limit reached");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create + link. new Account self-registers (Accounts.Add) and hashes the password per
|
|
||||||
// the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an
|
|
||||||
// in-game first-login does; the tag persists on the next world save.
|
|
||||||
var acct = new Account(account, password);
|
|
||||||
|
|
||||||
if (haveIp)
|
|
||||||
acct.LogAccess(ip);
|
|
||||||
|
|
||||||
acct.SetTag(Tag, webId);
|
|
||||||
|
|
||||||
Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}",
|
|
||||||
actor, account, webId, haveIp ? ip.ToString() : "-");
|
|
||||||
|
|
||||||
BridgeLink.Emit(AuditBegin(action, actor, account)
|
|
||||||
.Str("websiteUserId", webId)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("account.ok");
|
|
||||||
if (reqId != null) sb.Str("reqId", reqId);
|
|
||||||
sb.Str("action", action).Str("account", account).Str("websiteUserId", webId);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- account.unlink ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the
|
|
||||||
/// Owner floor keeps a protected staff account unreachable from the web.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnUnlink(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
var actor = BridgeJson.GetString(o, "actor");
|
|
||||||
const string action = "unlink";
|
|
||||||
|
|
||||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "missing actor");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var acct = BridgeAdmin.ResolveTargetAccount(o);
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "unknown or accountless target");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BridgeAdmin.Protected(acct))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "target is protected staff; refused");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing = acct.GetTag(Tag);
|
|
||||||
if (existing == null)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "not linked");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
acct.RemoveTag(Tag);
|
|
||||||
|
|
||||||
Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})",
|
|
||||||
actor, acct.Username, existing);
|
|
||||||
|
|
||||||
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
|
||||||
.Str("websiteUserId", existing)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("account.ok");
|
|
||||||
if (reqId != null) sb.Str("reqId", reqId);
|
|
||||||
sb.Str("action", action).Str("account", acct.Username);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers ----
|
|
||||||
|
|
||||||
private static void Err(string reqId, string action, string reason)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("account.error");
|
|
||||||
if (reqId != null) sb.Str("reqId", reqId);
|
|
||||||
if (action != null) sb.Str("action", action);
|
|
||||||
sb.Str("reason", reason);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
|
|
||||||
/// admin.audit. Never carries the password.
|
|
||||||
/// </summary>
|
|
||||||
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
|
||||||
{
|
|
||||||
return BridgeJson.Begin("account.audit")
|
|
||||||
.Str("origin", "web")
|
|
||||||
.Str("action", action)
|
|
||||||
.Str("actor", "web:" + actor)
|
|
||||||
.Str("target", target);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Mirrors the username safety rules in AccountHandler.CreateAccount.</summary>
|
|
||||||
private static bool IsSafeUsername(string un)
|
|
||||||
{
|
|
||||||
if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith("."))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for (int i = 0; i < un.Length; i++)
|
|
||||||
{
|
|
||||||
char c = un[i];
|
|
||||||
if (c < 0x20 || c >= 0x7F || IsForbidden(c))
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Mirrors the password safety rules in AccountHandler.CreateAccount.</summary>
|
|
||||||
private static bool IsSafePassword(string pw)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < pw.Length; i++)
|
|
||||||
{
|
|
||||||
char c = pw[i];
|
|
||||||
if (c < 0x20 || c >= 0x7F)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsForbidden(char c)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < ForbiddenChars.Length; i++)
|
|
||||||
if (c == ForbiddenChars[i])
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryParseIp(string s, out IPAddress ip)
|
|
||||||
{
|
|
||||||
ip = null;
|
|
||||||
|
|
||||||
if (String.IsNullOrEmpty(s))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return IPAddress.TryParse(s.Trim(), out ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Network;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The staff write plane: moderation actions the website drives against the live shard.
|
|
||||||
/// Phase 1 verbs are admin.kick, admin.ban, admin.unban, admin.broadcast.
|
|
||||||
///
|
|
||||||
/// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
|
|
||||||
/// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
|
|
||||||
///
|
|
||||||
/// Trust model (docs/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
|
|
||||||
/// these commands are gated there behind admin/moderator roles. The shard trusts the
|
|
||||||
/// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
|
|
||||||
/// CoOwner authority. Its one hard floor is <see cref="Protected"/>: a command refuses any
|
|
||||||
/// target at or above BridgeConfig.AdminAccessFloor (default CoOwner), so a compromised or
|
|
||||||
/// buggy sidecar can never ban, kick, or otherwise touch the Owner.
|
|
||||||
///
|
|
||||||
/// The whole plane is opt-in: nothing here acts unless BridgeConfig.AdminWriteEnabled is set.
|
|
||||||
/// Attribution rides on a required "actor" field (the website staff user); every applied
|
|
||||||
/// action logs to the console and emits an admin.audit event the website persists.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeAdmin
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeBoot.RegisterHandler("admin.kick", OnKick);
|
|
||||||
BridgeBoot.RegisterHandler("admin.ban", OnBan);
|
|
||||||
BridgeBoot.RegisterHandler("admin.unban", OnUnban);
|
|
||||||
BridgeBoot.RegisterHandler("admin.broadcast", OnBroadcast);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- admin.kick ----
|
|
||||||
|
|
||||||
/// <summary>Disconnects every live session of the target account. Target by serial or account.</summary>
|
|
||||||
private static void OnKick(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
var actor = BridgeJson.GetString(o, "actor");
|
|
||||||
const string action = "kick";
|
|
||||||
|
|
||||||
if (!Ready(reqId, action, actor))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var acct = ResolveTargetAccount(o);
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "unknown or accountless target");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Protected(acct))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "target is protected staff; refused");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int kicked = KickAccountSessions(acct);
|
|
||||||
var reason = Reason(o);
|
|
||||||
|
|
||||||
Log(actor, action, acct.Username, reason);
|
|
||||||
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
|
||||||
.Num("sessions", kicked)
|
|
||||||
.Str("reason", reason)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("admin.ok");
|
|
||||||
if (reqId != null) sb.Str("reqId", reqId);
|
|
||||||
sb.Str("action", action).Str("target", acct.Username).Num("sessions", kicked);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- admin.ban ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bans an account (offline-capable) and disconnects any live sessions. A positive
|
|
||||||
/// durationSec makes it a timed ban that auto-expires; zero/absent is indefinite. Mirrors
|
|
||||||
/// the in-game [ban path (KickCommand), but takes the duration explicitly instead of a gump.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnBan(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
var actor = BridgeJson.GetString(o, "actor");
|
|
||||||
const string action = "ban";
|
|
||||||
|
|
||||||
if (!Ready(reqId, action, actor))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var acct = ResolveTargetAccount(o);
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "unknown or accountless target");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Protected(acct))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "target is protected staff; refused");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int durationSec = BridgeJson.GetInt(o, "durationSec", 0);
|
|
||||||
if (durationSec < 0)
|
|
||||||
durationSec = 0;
|
|
||||||
if (durationSec > BridgeConfig.AdminBanMaxDurationSec)
|
|
||||||
durationSec = BridgeConfig.AdminBanMaxDurationSec;
|
|
||||||
|
|
||||||
if (durationSec > 0)
|
|
||||||
acct.SetBanTags(null, DateTime.UtcNow, TimeSpan.FromSeconds(durationSec));
|
|
||||||
else
|
|
||||||
acct.SetUnspecifiedBan(null); // clears any prior duration tags -> indefinite
|
|
||||||
|
|
||||||
// SetBanTags/SetUnspecifiedBan(null) clear the BanDealer tag; set our own attribution.
|
|
||||||
acct.SetTag("BanDealer", WebActor(actor));
|
|
||||||
acct.Banned = true;
|
|
||||||
|
|
||||||
int kicked = KickAccountSessions(acct);
|
|
||||||
var reason = Reason(o);
|
|
||||||
|
|
||||||
Log(actor, action, acct.Username, reason);
|
|
||||||
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
|
||||||
.Num("durationSec", durationSec)
|
|
||||||
.Num("sessions", kicked)
|
|
||||||
.Str("reason", reason)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("admin.ok");
|
|
||||||
if (reqId != null) sb.Str("reqId", reqId);
|
|
||||||
sb.Str("action", action).Str("target", acct.Username).Num("durationSec", durationSec).Num("sessions", kicked);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- admin.unban ----
|
|
||||||
|
|
||||||
private static void OnUnban(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
var actor = BridgeJson.GetString(o, "actor");
|
|
||||||
const string action = "unban";
|
|
||||||
|
|
||||||
if (!Ready(reqId, action, actor))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var acct = ResolveTargetAccount(o);
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "unknown or accountless target");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
acct.Banned = false;
|
|
||||||
acct.SetUnspecifiedBan(null); // clears BanTime/BanDuration/BanDealer tags
|
|
||||||
|
|
||||||
var reason = Reason(o);
|
|
||||||
|
|
||||||
Log(actor, action, acct.Username, reason);
|
|
||||||
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
|
||||||
.Str("reason", reason)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
Ok(reqId, action, acct.Username);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- admin.broadcast ----
|
|
||||||
|
|
||||||
private static void OnBroadcast(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
var actor = BridgeJson.GetString(o, "actor");
|
|
||||||
const string action = "broadcast";
|
|
||||||
|
|
||||||
if (!Ready(reqId, action, actor))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var text = BridgeJson.GetString(o, "text");
|
|
||||||
if (String.IsNullOrEmpty(text))
|
|
||||||
{
|
|
||||||
Err(reqId, action, "missing text");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.Length > BridgeConfig.AdminBroadcastMaxLength)
|
|
||||||
text = text.Substring(0, BridgeConfig.AdminBroadcastMaxLength);
|
|
||||||
|
|
||||||
// Default to the staff-broadcast green; callers may override.
|
|
||||||
int hue = BridgeJson.GetInt(o, "hue", 0x35);
|
|
||||||
|
|
||||||
World.Broadcast(hue, false, text);
|
|
||||||
|
|
||||||
Log(actor, action, null, text);
|
|
||||||
BridgeLink.Emit(AuditBegin(action, actor, null)
|
|
||||||
.Num("hue", hue)
|
|
||||||
.Str("text", text)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("admin.ok");
|
|
||||||
if (reqId != null) sb.Str("reqId", reqId);
|
|
||||||
sb.Str("action", action);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- shared prologue / replies ----
|
|
||||||
|
|
||||||
/// <summary>Common gate: the write plane must be enabled and an actor must be present.</summary>
|
|
||||||
private static bool Ready(string reqId, string action, string actor)
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.AdminWriteEnabled)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "admin write plane disabled");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
|
||||||
{
|
|
||||||
Err(reqId, action, "missing actor");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Ok(string reqId, string action, string target)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("admin.ok");
|
|
||||||
if (reqId != null) sb.Str("reqId", reqId);
|
|
||||||
sb.Str("action", action);
|
|
||||||
if (target != null) sb.Str("target", target);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Err(string reqId, string action, string reason)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("admin.error");
|
|
||||||
if (reqId != null) sb.Str("reqId", reqId);
|
|
||||||
if (action != null) sb.Str("action", action);
|
|
||||||
sb.Str("reason", reason);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
|
|
||||||
/// connected dashboard so the website's moderation log stays complete regardless of which
|
|
||||||
/// client issued the action. The in-game counterpart (origin=in-game) is emitted from
|
|
||||||
/// BridgeEvents; see docs/ADMIN_CONTROLS.md §5.5.
|
|
||||||
/// </summary>
|
|
||||||
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
|
||||||
{
|
|
||||||
return BridgeJson.Begin("admin.audit")
|
|
||||||
.Str("origin", "web")
|
|
||||||
.Str("action", action)
|
|
||||||
.Str("actor", WebActor(actor))
|
|
||||||
.Str("target", target);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string WebActor(string actor)
|
|
||||||
{
|
|
||||||
return "web:" + actor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads and length-clamps the optional reason string.</summary>
|
|
||||||
private static string Reason(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reason = BridgeJson.GetString(o, "reason");
|
|
||||||
if (reason != null && reason.Length > BridgeConfig.AdminReasonMaxLength)
|
|
||||||
reason = reason.Substring(0, BridgeConfig.AdminReasonMaxLength);
|
|
||||||
return reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Log(string actor, string action, string target, string detail)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge][admin] {0} {1} target={2} detail={3}",
|
|
||||||
WebActor(actor), action, target ?? "-", detail ?? "-");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- target resolution & floor ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolves the command's target account, by "serial" (a player mobile's account) or by
|
|
||||||
/// "account" (username). Returns null if neither resolves to a real account. Public so the
|
|
||||||
/// account plane (unlink) resolves targets the same way the moderation plane does.
|
|
||||||
/// </summary>
|
|
||||||
public static Account ResolveTargetAccount(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var serialStr = BridgeJson.GetString(o, "serial");
|
|
||||||
if (serialStr != null)
|
|
||||||
{
|
|
||||||
var m = ResolveSerial(serialStr);
|
|
||||||
return m == null ? null : m.Account as Account;
|
|
||||||
}
|
|
||||||
|
|
||||||
var acctName = BridgeJson.GetString(o, "account");
|
|
||||||
return acctName == null ? null : Accounts.GetAccount(acctName) as Account;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The one shard-side safety floor. Protects any account whose effective access level —
|
|
||||||
/// the account's own or the highest of its characters' — is at or above the configured
|
|
||||||
/// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
|
|
||||||
/// so the account plane (unlink) enforces the identical floor.
|
|
||||||
/// </summary>
|
|
||||||
public static bool Protected(Account acct)
|
|
||||||
{
|
|
||||||
var lvl = acct.AccessLevel;
|
|
||||||
|
|
||||||
for (int i = 0; i < acct.Length; i++)
|
|
||||||
{
|
|
||||||
var m = acct[i];
|
|
||||||
if (m != null && m.AccessLevel > lvl)
|
|
||||||
lvl = m.AccessLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return lvl >= BridgeConfig.AdminAccessFloor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Disconnects every live NetState bound to this account. Enumerating NetState.Instances
|
|
||||||
/// (rather than walking the account's characters) also catches a session parked at
|
|
||||||
/// character-select, which has an account but no mobile yet. Snapshot first, since Dispose
|
|
||||||
/// mutates the instance set.
|
|
||||||
/// </summary>
|
|
||||||
private static int KickAccountSessions(Account acct)
|
|
||||||
{
|
|
||||||
var doomed = new List<NetState>();
|
|
||||||
|
|
||||||
foreach (var ns in NetState.Instances)
|
|
||||||
{
|
|
||||||
if (ns != null && ns.Account == acct)
|
|
||||||
doomed.Add(ns);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var ns in doomed)
|
|
||||||
ns.Dispose();
|
|
||||||
|
|
||||||
return doomed.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Mobile ResolveSerial(string serialStr)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var s = serialStr.Trim();
|
|
||||||
int value;
|
|
||||||
|
|
||||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
|
||||||
value = Convert.ToInt32(s.Substring(2), 16);
|
|
||||||
else
|
|
||||||
value = Convert.ToInt32(s, 10);
|
|
||||||
|
|
||||||
return World.FindMobile(value);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Commands;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Lifecycle wiring. Boot order (Server/Main.cs:544-562, all on the Core thread):
|
|
||||||
///
|
|
||||||
/// Configure() -> World.Load() -> Initialize() -> EventSink.ServerStarted
|
|
||||||
///
|
|
||||||
/// Config is read in Configure. Handlers are attached in Initialize. The socket opens on
|
|
||||||
/// ServerStarted, once the world is actually there to describe.
|
|
||||||
///
|
|
||||||
/// EventSink.Shutdown does NOT fire on a crash (Server/Main.cs:198,313), so the sidecar
|
|
||||||
/// must treat socket EOF as normal and re-handshake rather than waiting for a goodbye.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeBoot
|
|
||||||
{
|
|
||||||
private static readonly Dictionary<string, Action<Dictionary<string, object>>> _handlers =
|
|
||||||
new Dictionary<string, Action<Dictionary<string, object>>>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Identifies this run of the shard. It is stable across sidecar reconnects and changes
|
|
||||||
/// on every shard restart, which is how the sidecar tells "I reconnected" (keep my
|
|
||||||
/// cached state) from "the shard restarted" (discard it).
|
|
||||||
/// </summary>
|
|
||||||
private static string _bootId;
|
|
||||||
|
|
||||||
public static void Configure()
|
|
||||||
{
|
|
||||||
BridgeConfig.Configure();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] disabled by config");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CommandSystem.Register("bridge", AccessLevel.Administrator, Bridge_OnCommand);
|
|
||||||
|
|
||||||
RegisterHandler("ping", OnPing);
|
|
||||||
|
|
||||||
BridgeLink.InboundLine += OnInboundLine;
|
|
||||||
BridgeLink.Connected_Core += EmitHello;
|
|
||||||
|
|
||||||
EventSink.ServerStarted += OnServerStarted;
|
|
||||||
EventSink.Shutdown += OnShutdown;
|
|
||||||
EventSink.Crashed += OnCrashed;
|
|
||||||
|
|
||||||
Console.WriteLine("[Bridge] {0}", BridgeConfig.Describe());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Handlers run on the Core thread. They may touch the world freely.</summary>
|
|
||||||
public static void RegisterHandler(string kind, Action<Dictionary<string, object>> handler)
|
|
||||||
{
|
|
||||||
_handlers[kind] = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnServerStarted()
|
|
||||||
{
|
|
||||||
_bootId = Guid.NewGuid().ToString("N");
|
|
||||||
BridgeLink.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Core thread, once per connection. The sidecar restarts independently of the shard,
|
|
||||||
/// so this is sent on every connect rather than once at boot — otherwise a sidecar that
|
|
||||||
/// came up second would never learn which shard it is talking to.
|
|
||||||
/// </summary>
|
|
||||||
private static void EmitHello()
|
|
||||||
{
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("server.hello")
|
|
||||||
.Str("shard", Server.Misc.ServerList.ServerName)
|
|
||||||
.Str("bootId", _bootId)
|
|
||||||
.Num("connects", BridgeLink.Connects)
|
|
||||||
.Num("items", World.Items.Count)
|
|
||||||
.Num("mobiles", World.Mobiles.Count)
|
|
||||||
.Num("accounts", Accounting.Accounts.Count)
|
|
||||||
.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnShutdown(ShutdownEventArgs e)
|
|
||||||
{
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("server.shutdown").End());
|
|
||||||
|
|
||||||
// Stop() joins the link thread for up to 2s, which gives the writer a chance to drain
|
|
||||||
// the goodbye. Best effort: the sidecar must not depend on receiving it.
|
|
||||||
BridgeLink.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnCrashed(CrashedEventArgs e)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("server.crashed")
|
|
||||||
.Str("error", e.Exception == null ? null : e.Exception.Message)
|
|
||||||
.End());
|
|
||||||
|
|
||||||
BridgeLink.Stop();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// The process is already going down. Never make a crash worse.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Core thread, one call per inbound line.</summary>
|
|
||||||
private static void OnInboundLine(string line)
|
|
||||||
{
|
|
||||||
var obj = BridgeJson.Parse(line);
|
|
||||||
|
|
||||||
if (obj == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] malformed inbound line, ignoring");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var kind = BridgeJson.GetString(obj, "kind");
|
|
||||||
|
|
||||||
if (kind == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Action<Dictionary<string, object>> handler;
|
|
||||||
|
|
||||||
if (!_handlers.TryGetValue(kind, out handler))
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] no handler for inbound kind '{0}'", kind);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handler(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnPing(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("pong");
|
|
||||||
|
|
||||||
var id = BridgeJson.GetString(o, "id");
|
|
||||||
|
|
||||||
if (id != null)
|
|
||||||
sb.Str("id", id);
|
|
||||||
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Usage("bridge [status | reload | ping | sweepnow]")]
|
|
||||||
[Description("Inspects and controls the sidecar link.")]
|
|
||||||
private static void Bridge_OnCommand(CommandEventArgs e)
|
|
||||||
{
|
|
||||||
var arg = e.Length > 0 ? e.GetString(0).ToLowerInvariant() : "status";
|
|
||||||
|
|
||||||
switch (arg)
|
|
||||||
{
|
|
||||||
case "reload":
|
|
||||||
BridgeConfig.Load();
|
|
||||||
BridgeSweeps.Rearm();
|
|
||||||
BridgePages.Rearm();
|
|
||||||
BridgeChamps.Rearm();
|
|
||||||
BridgeSocial.Rearm();
|
|
||||||
BridgeGovernance.Rearm();
|
|
||||||
BridgePresence.Rearm();
|
|
||||||
BridgeHousing.Rearm();
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
|
||||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "ping":
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("ping").End());
|
|
||||||
e.Mobile.SendMessage("Bridge: ping queued.");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "sweepnow":
|
|
||||||
BridgeSweeps.SweepOnce();
|
|
||||||
BridgeChamps.SweepOnce();
|
|
||||||
BridgeSocial.SweepOnce();
|
|
||||||
BridgeGovernance.SweepOnce();
|
|
||||||
BridgePresence.SweepOnce();
|
|
||||||
BridgeHousing.SweepOnce();
|
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
|
||||||
e.Mobile.SendMessage(
|
|
||||||
"Bridge: connected={0} depth={1} sent={2} dropped={3} received={4} connects={5} writeErrors={6}",
|
|
||||||
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
|
||||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
|
|
||||||
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
|
|
||||||
/// the operator pairs with this (docs/PROTOCOL_2.md §2).
|
|
||||||
/// </summary>
|
|
||||||
public enum SignupMode
|
|
||||||
{
|
|
||||||
Website, // website is the account authority; in-game auto-create should be off
|
|
||||||
Game, // game server is the authority; account.create is refused
|
|
||||||
Hybrid // either side may create
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
|
|
||||||
/// reads as "Bridge.Port" here.
|
|
||||||
///
|
|
||||||
/// Loaded in Configure(), which ScriptCompiler invokes before World.Load.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeConfig
|
|
||||||
{
|
|
||||||
public static string Host { get; private set; }
|
|
||||||
public static int Port { get; private set; }
|
|
||||||
public static int QueueCap { get; private set; }
|
|
||||||
|
|
||||||
public static int StatSweepSeconds { get; private set; }
|
|
||||||
public static int DecaySweepSeconds { get; private set; }
|
|
||||||
public static int EconomySweepSeconds { get; private set; }
|
|
||||||
public static int PageSweepSeconds { get; private set; }
|
|
||||||
public static int ChampSweepSeconds { get; private set; }
|
|
||||||
public static int GuildSweepSeconds { get; private set; }
|
|
||||||
public static int CitySweepSeconds { get; private set; }
|
|
||||||
public static int PresenceSweepSeconds { get; private set; }
|
|
||||||
public static int HousingSweepSeconds { get; private set; }
|
|
||||||
|
|
||||||
public static string LinkUrl { get; private set; }
|
|
||||||
|
|
||||||
public static int TownCrierMaxLines { get; private set; }
|
|
||||||
public static int TownCrierMaxLineLength { get; private set; }
|
|
||||||
public static int TownCrierMaxActive { get; private set; }
|
|
||||||
public static int TownCrierMaxDurationSec { get; private set; }
|
|
||||||
|
|
||||||
// Town Cryer news gump (docs/PROTOCOL_2.md §16).
|
|
||||||
public static int NewsMaxTitleLength { get; private set; }
|
|
||||||
public static int NewsMaxBodyLength { get; private set; }
|
|
||||||
public static int NewsMaxExternal { get; private set; }
|
|
||||||
public static int NewsAnnounceDurationSec { get; private set; }
|
|
||||||
|
|
||||||
public static bool AdminWriteEnabled { get; private set; }
|
|
||||||
public static AccessLevel AdminAccessFloor { get; private set; }
|
|
||||||
public static int AdminBroadcastMaxLength { get; private set; }
|
|
||||||
public static int AdminReasonMaxLength { get; private set; }
|
|
||||||
public static int AdminBanMaxDurationSec { get; private set; }
|
|
||||||
|
|
||||||
// ---- account provisioning (docs/PROTOCOL_2.md Part A) ----
|
|
||||||
public static SignupMode Signup { get; private set; }
|
|
||||||
public static bool AccountCreateEnabled { get; private set; }
|
|
||||||
public static bool RequireIpForCreate { get; private set; }
|
|
||||||
public static int AccountNameMaxLength { get; private set; }
|
|
||||||
public static int AccountPasswordMaxLength { get; private set; }
|
|
||||||
|
|
||||||
public static bool Enabled { get; private set; }
|
|
||||||
|
|
||||||
public static void Configure()
|
|
||||||
{
|
|
||||||
Load();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Re-readable at runtime via `[bridge reload`.</summary>
|
|
||||||
public static void Load()
|
|
||||||
{
|
|
||||||
Enabled = Config.Get("Bridge.Enabled", true);
|
|
||||||
|
|
||||||
Host = Config.Get("Bridge.Host", "127.0.0.1");
|
|
||||||
Port = Config.Get("Bridge.Port", 7788);
|
|
||||||
QueueCap = Config.Get("Bridge.QueueCap", 10000);
|
|
||||||
|
|
||||||
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
|
||||||
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
|
||||||
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
|
||||||
PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
|
|
||||||
if (PageSweepSeconds < 1)
|
|
||||||
PageSweepSeconds = 1;
|
|
||||||
|
|
||||||
ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
|
|
||||||
if (ChampSweepSeconds < 1)
|
|
||||||
ChampSweepSeconds = 1;
|
|
||||||
|
|
||||||
// Social/political sweeps (docs/PROTOCOL_2.md Part B). Both change slowly, so the
|
|
||||||
// defaults are unhurried; the pass is a handful of field reads over a small set.
|
|
||||||
GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
|
|
||||||
if (GuildSweepSeconds < 1)
|
|
||||||
GuildSweepSeconds = 1;
|
|
||||||
|
|
||||||
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
|
|
||||||
if (CitySweepSeconds < 1)
|
|
||||||
CitySweepSeconds = 1;
|
|
||||||
|
|
||||||
PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
|
|
||||||
if (PresenceSweepSeconds < 1)
|
|
||||||
PresenceSweepSeconds = 1;
|
|
||||||
|
|
||||||
HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
|
|
||||||
if (HousingSweepSeconds < 1)
|
|
||||||
HousingSweepSeconds = 1;
|
|
||||||
|
|
||||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
|
||||||
|
|
||||||
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
|
||||||
TownCrierMaxLineLength = Config.Get("Bridge.TownCrierMaxLineLength", 200);
|
|
||||||
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
|
||||||
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
|
|
||||||
|
|
||||||
NewsMaxTitleLength = Config.Get("Bridge.NewsMaxTitleLength", 100);
|
|
||||||
NewsMaxBodyLength = Config.Get("Bridge.NewsMaxBodyLength", 2000);
|
|
||||||
NewsMaxExternal = Config.Get("Bridge.NewsMaxExternal", 20);
|
|
||||||
NewsAnnounceDurationSec = Config.Get("Bridge.NewsAnnounceDurationSec", 300);
|
|
||||||
if (NewsAnnounceDurationSec < 1)
|
|
||||||
NewsAnnounceDurationSec = 1;
|
|
||||||
|
|
||||||
AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
|
|
||||||
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
|
|
||||||
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
|
|
||||||
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
|
||||||
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
|
||||||
|
|
||||||
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
|
|
||||||
// unrecognized* value falls back to Game (the safest — no website creation), so a
|
|
||||||
// typo can never accidentally open provisioning.
|
|
||||||
Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game);
|
|
||||||
// Default follows the mode: creation is on unless the shard is game-authority.
|
|
||||||
AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game);
|
|
||||||
RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true);
|
|
||||||
AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16);
|
|
||||||
AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30);
|
|
||||||
if (AccountNameMaxLength < 1)
|
|
||||||
AccountNameMaxLength = 1;
|
|
||||||
if (AccountPasswordMaxLength < 1)
|
|
||||||
AccountPasswordMaxLength = 1;
|
|
||||||
|
|
||||||
if (QueueCap < 16)
|
|
||||||
QueueCap = 16;
|
|
||||||
|
|
||||||
WarnOnSignupMismatch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The bridge governs only the account.create verb; ServUO's in-game first-login
|
|
||||||
/// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves
|
|
||||||
/// disagree is quietly broken (website-only that still auto-creates in game, or a mode
|
|
||||||
/// that expects in-game creation with it switched off), so surface the contradiction
|
|
||||||
/// loudly rather than silently doing the permissive thing.
|
|
||||||
/// </summary>
|
|
||||||
private static void WarnOnSignupMismatch()
|
|
||||||
{
|
|
||||||
var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true);
|
|
||||||
|
|
||||||
if (Signup == SignupMode.Website && autoCreate)
|
|
||||||
Console.WriteLine(
|
|
||||||
"[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; "
|
|
||||||
+ "an in-game login of any new name still mints an account. Set it false for website-only.");
|
|
||||||
else if (Signup == SignupMode.Game && !autoCreate)
|
|
||||||
Console.WriteLine(
|
|
||||||
"[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; "
|
|
||||||
+ "in-game creation is off and account.create is refused, so no account can be created.");
|
|
||||||
else if (Signup == SignupMode.Hybrid && !autoCreate)
|
|
||||||
Console.WriteLine(
|
|
||||||
"[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; "
|
|
||||||
+ "in-game first-login creation is off. Only website account.create will work.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a SignupMode name, case-insensitively, falling back to <paramref name="fallback"/>
|
|
||||||
/// on anything unrecognized so a typo can never open provisioning wider than intended.
|
|
||||||
/// </summary>
|
|
||||||
private static SignupMode ParseSignupMode(string value, SignupMode fallback)
|
|
||||||
{
|
|
||||||
SignupMode parsed;
|
|
||||||
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
|
||||||
Enum.IsDefined(typeof(SignupMode), parsed))
|
|
||||||
return parsed;
|
|
||||||
|
|
||||||
Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses an AccessLevel name from config, case-insensitively, falling back to the given
|
|
||||||
/// default on anything unrecognized so a typo can never open the floor wider than intended.
|
|
||||||
/// </summary>
|
|
||||||
private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
|
|
||||||
{
|
|
||||||
AccessLevel parsed;
|
|
||||||
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
|
||||||
Enum.IsDefined(typeof(AccessLevel), parsed))
|
|
||||||
return parsed;
|
|
||||||
|
|
||||||
Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Describe()
|
|
||||||
{
|
|
||||||
return String.Format(
|
|
||||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
|
|
||||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
|
||||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Commands;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// EventSink subscriptions. Every handler runs on the Core thread, synchronously, inside
|
|
||||||
/// the code path that raised it. Three rules, all load-bearing:
|
|
||||||
///
|
|
||||||
/// 1. Never block. Emit() enqueues and returns; that is the only I/O allowed here.
|
|
||||||
/// 2. Never throw. A bridge exception escaping into a game code path is a shard bug,
|
|
||||||
/// so every handler body is wrapped.
|
|
||||||
/// 3. Never mutate the args. Several of these are veto hooks — AccountLogin has
|
|
||||||
/// Accepted/RejectReason, FastWalk has Blocked — and we are an observer, not a
|
|
||||||
/// participant.
|
|
||||||
///
|
|
||||||
/// Copy primitives out synchronously. Some args objects are pooled and freed immediately
|
|
||||||
/// after the event returns.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeEvents
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Session
|
|
||||||
EventSink.Login += OnLogin;
|
|
||||||
EventSink.Logout += OnLogout;
|
|
||||||
EventSink.AccountLogin += OnAccountLogin;
|
|
||||||
|
|
||||||
// Economy
|
|
||||||
EventSink.AccountGoldChange += OnGoldChange;
|
|
||||||
EventSink.ValidVendorPurchase += OnVendorPurchase;
|
|
||||||
EventSink.ValidVendorSell += OnVendorSell;
|
|
||||||
EventSink.PlacePlayerVendor += OnVendorPlaced;
|
|
||||||
|
|
||||||
// Progression
|
|
||||||
EventSink.SkillGain += OnSkillGain;
|
|
||||||
EventSink.FameChange += OnFameChange;
|
|
||||||
EventSink.KarmaChange += OnKarmaChange;
|
|
||||||
EventSink.QuestComplete += OnQuestComplete;
|
|
||||||
|
|
||||||
// Death
|
|
||||||
EventSink.PlayerDeath += OnPlayerDeath;
|
|
||||||
EventSink.PlayerMurdered += OnPlayerMurdered;
|
|
||||||
EventSink.OnKilledBy += OnKilledBy;
|
|
||||||
|
|
||||||
// Cheat detection and staff audit
|
|
||||||
EventSink.FastWalk += OnFastWalk;
|
|
||||||
EventSink.OnPropertyChanged += OnStaffPropertySet;
|
|
||||||
EventSink.Command += OnStaffCommand;
|
|
||||||
|
|
||||||
// Save boundaries
|
|
||||||
EventSink.BeforeWorldSave += OnBeforeWorldSave;
|
|
||||||
EventSink.AfterWorldSave += OnAfterWorldSave;
|
|
||||||
|
|
||||||
Console.WriteLine("[Bridge] event streams attached");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers ----
|
|
||||||
|
|
||||||
/// <summary>Writes a nested actor object: serial, name, and account when there is one.</summary>
|
|
||||||
private static StringBuilder Mob(this StringBuilder sb, string field, Mobile m)
|
|
||||||
{
|
|
||||||
sb.Append(",\"").Append(field).Append("\":");
|
|
||||||
|
|
||||||
if (m == null)
|
|
||||||
{
|
|
||||||
sb.Append("null");
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
|
|
||||||
sb.Append('}');
|
|
||||||
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long ToGold(double currency)
|
|
||||||
{
|
|
||||||
return (long)(currency * Account.CurrencyThreshold);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Guard(string kind, Action body)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
body();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Swallow: we are inside a game code path and must not disturb it.
|
|
||||||
Console.WriteLine("[Bridge] handler '{0}' threw: {1}", kind, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- session ----
|
|
||||||
|
|
||||||
private static void OnLogin(LoginEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("mob.login", () =>
|
|
||||||
{
|
|
||||||
var m = e.Mobile;
|
|
||||||
|
|
||||||
if (m == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Carry the linked website id on the login anchor so the sidecar can attribute
|
|
||||||
// this session (and everything after it) to a site user without a lookup.
|
|
||||||
var webId = BridgeAccountLink.WebIdFor(m.Account as Account);
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("mob.login")
|
|
||||||
.Mob("who", m)
|
|
||||||
.Str("map", m.Map == null ? null : m.Map.Name)
|
|
||||||
.Num("x", m.X).Num("y", m.Y).Num("z", m.Z);
|
|
||||||
|
|
||||||
if (webId != null)
|
|
||||||
sb.Str("webId", webId);
|
|
||||||
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnLogout(LogoutEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("mob.logout", () =>
|
|
||||||
{
|
|
||||||
var m = e.Mobile;
|
|
||||||
|
|
||||||
if (m == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("mob.logout").Mob("who", m).End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Veto hook: AccountLoginEventArgs carries Accepted and RejectReason, and a plaintext
|
|
||||||
/// Password. We read the username only. The password must never leave the process.
|
|
||||||
/// Fires before the auth decision, so this is an attempt, not a result.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnAccountLogin(AccountLoginEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("account.login.attempt", () =>
|
|
||||||
{
|
|
||||||
string address = null;
|
|
||||||
|
|
||||||
if (e.State != null && e.State.Address != null)
|
|
||||||
address = e.State.Address.ToString();
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("account.login.attempt")
|
|
||||||
.Str("acct", e.Username)
|
|
||||||
.Str("ip", address)
|
|
||||||
.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- economy ----
|
|
||||||
|
|
||||||
private static void OnGoldChange(AccountGoldChangeEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("gold.change", () =>
|
|
||||||
{
|
|
||||||
var acct = e.Account as Account;
|
|
||||||
|
|
||||||
if (acct == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
long oldGold = ToGold(e.OldAmount);
|
|
||||||
long newGold = ToGold(e.NewAmount);
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("gold.change")
|
|
||||||
.Str("acct", acct.Username)
|
|
||||||
.Num("old", oldGold)
|
|
||||||
.Num("new", newGold)
|
|
||||||
.Num("delta", newGold - oldGold)
|
|
||||||
.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ValidVendorPurchase is a validation-stage hook, not a committed sale. Treat as
|
|
||||||
/// "attempted". Total is AmountPerUnit times the stack size, not AmountPerUnit.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnVendorPurchase(ValidVendorPurchaseEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("vendor.buy", () => EmitVendorTrade("vendor.buy", e.Mobile, e.Vendor, e.Bought, e.AmountPerUnit));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnVendorSell(ValidVendorSellEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("vendor.sell", () => EmitVendorTrade("vendor.sell", e.Mobile, e.Vendor, e.Sold, e.AmountPerUnit));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EmitVendorTrade(string kind, Mobile who, Mobile vendor, IEntity entity, int perUnit)
|
|
||||||
{
|
|
||||||
int amount = 1;
|
|
||||||
var item = entity as Item;
|
|
||||||
|
|
||||||
if (item != null)
|
|
||||||
amount = Math.Max(1, item.Amount);
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin(kind)
|
|
||||||
.Mob("who", who)
|
|
||||||
.Mob("vendor", vendor)
|
|
||||||
.Str("item", entity == null ? null : entity.GetType().Name)
|
|
||||||
.Num("amount", amount)
|
|
||||||
.Num("perUnit", perUnit)
|
|
||||||
.Num("total", (long)perUnit * amount)
|
|
||||||
.Bool("committed", false); // validation stage; reconcile against gold.change
|
|
||||||
|
|
||||||
if (entity != null)
|
|
||||||
sb.Ser("itemSerial", entity.Serial);
|
|
||||||
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnVendorPlaced(PlacePlayerVendorEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("vendor.placed", () =>
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("vendor.placed")
|
|
||||||
.Mob("owner", e.Mobile)
|
|
||||||
.Mob("vendor", e.Vendor)
|
|
||||||
.End()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- progression ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Player-only. SkillGain fires for creatures too, and they train constantly: on this
|
|
||||||
/// shard a single boot produced 115 gains in four seconds, every one of them an NPC
|
|
||||||
/// grinding Meditation. Unfiltered this is a firehose of noise.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnSkillGain(SkillGainEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("skill.gain", () =>
|
|
||||||
{
|
|
||||||
if (e.Skill == null || e.From == null || !e.From.Player)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("skill.gain")
|
|
||||||
.Mob("who", e.From)
|
|
||||||
.Str("skill", e.Skill.SkillName.ToString())
|
|
||||||
.Num("gained", e.Gained)
|
|
||||||
.Num("base", e.Skill.Base)
|
|
||||||
.Num("cap", e.Skill.Cap)
|
|
||||||
.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnFameChange(FameChangeEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("fame.change", () =>
|
|
||||||
{
|
|
||||||
if (e.Mobile == null || !e.Mobile.Player)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("fame.change")
|
|
||||||
.Mob("who", e.Mobile)
|
|
||||||
.Num("old", e.OldValue)
|
|
||||||
.Num("new", e.NewValue)
|
|
||||||
.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnKarmaChange(KarmaChangeEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("karma.change", () =>
|
|
||||||
{
|
|
||||||
if (e.Mobile == null || !e.Mobile.Player)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("karma.change")
|
|
||||||
.Mob("who", e.Mobile)
|
|
||||||
.Num("old", e.OldValue)
|
|
||||||
.Num("new", e.NewValue)
|
|
||||||
.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnQuestComplete(QuestCompleteEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("quest.complete", () =>
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("quest.complete")
|
|
||||||
.Mob("who", e.Mobile)
|
|
||||||
.Str("quest", e.QuestType == null ? null : e.QuestType.Name)
|
|
||||||
.End()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- death ----
|
|
||||||
|
|
||||||
private static void OnPlayerDeath(PlayerDeathEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("player.death", () =>
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("player.death")
|
|
||||||
.Mob("who", e.Mobile)
|
|
||||||
.Mob("killer", e.Killer)
|
|
||||||
.End()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnPlayerMurdered(PlayerMurderedEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("player.murdered", () =>
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("player.murdered")
|
|
||||||
.Mob("victim", e.Victim)
|
|
||||||
.Mob("murderer", e.Murderer)
|
|
||||||
.End()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fires for creatures too. Only a kill involving a player is interesting, and filtering
|
|
||||||
/// here rather than in the sidecar keeps the mob-grinding firehose off the socket.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnKilledBy(OnKilledByEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("mob.killed", () =>
|
|
||||||
{
|
|
||||||
var killed = e.Killed;
|
|
||||||
var killer = e.KilledBy;
|
|
||||||
|
|
||||||
bool involvesPlayer = (killed != null && killed.Player) || (killer != null && killer.Player);
|
|
||||||
|
|
||||||
if (!involvesPlayer)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("mob.killed")
|
|
||||||
.Mob("killed", killed)
|
|
||||||
.Mob("killer", killer)
|
|
||||||
.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- cheat detection and staff audit ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Veto hook: FastWalkEventArgs.Blocked gates the move. Read only. The args carry only
|
|
||||||
/// a NetState, and NetState.Mobile can be null mid-handshake.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnFastWalk(FastWalkEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("cheat.fastwalk", () =>
|
|
||||||
{
|
|
||||||
var state = e.NetState;
|
|
||||||
|
|
||||||
if (state == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("cheat.fastwalk")
|
|
||||||
.Mob("who", state.Mobile);
|
|
||||||
|
|
||||||
if (state.Address != null)
|
|
||||||
sb.Str("ip", state.Address.ToString());
|
|
||||||
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raised only from Scripts/Commands/Properties.cs, i.e. staff `[set`. This is a
|
|
||||||
/// GM-abuse audit trail, not a stat-change stream. One of its three raise sites passes
|
|
||||||
/// a null Mobile, so the staffer is not always known.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnStaffPropertySet(OnPropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("audit.set", () =>
|
|
||||||
{
|
|
||||||
if (e.Property == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("audit.set")
|
|
||||||
.Mob("staff", e.Mobile)
|
|
||||||
.Str("prop", e.Property.Name)
|
|
||||||
.Str("target", e.Instance == null ? null : e.Instance.GetType().Name)
|
|
||||||
.Str("old", e.OldValue == null ? null : e.OldValue.ToString())
|
|
||||||
.Str("new", e.NewValue == null ? null : e.NewValue.ToString());
|
|
||||||
|
|
||||||
var ent = e.Instance as IEntity;
|
|
||||||
|
|
||||||
if (ent != null)
|
|
||||||
sb.Ser("targetSerial", ent.Serial);
|
|
||||||
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnStaffCommand(CommandEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("audit.command", () =>
|
|
||||||
{
|
|
||||||
if (e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
|
|
||||||
return; // player commands are noise; staff commands are the audit trail
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("audit.command")
|
|
||||||
.Mob("staff", e.Mobile)
|
|
||||||
.Str("command", e.Command)
|
|
||||||
.Str("args", e.ArgString)
|
|
||||||
.End());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- save boundaries ----
|
|
||||||
|
|
||||||
private static void OnBeforeWorldSave(BeforeWorldSaveEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("world.save.before", () =>
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("world.save.before").End()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A natural checkpoint: the sidecar can treat this as a consistency boundary. Note that
|
|
||||||
/// timers and inbound commands do not run during the save itself.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnAfterWorldSave(AfterWorldSaveEventArgs e)
|
|
||||||
{
|
|
||||||
Guard("world.save.after", () =>
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("world.save.after")
|
|
||||||
.Num("items", World.Items.Count)
|
|
||||||
.Num("mobiles", World.Mobiles.Count)
|
|
||||||
.End()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Engines.CityLoyalty;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The town-governor stream (docs/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a
|
|
||||||
/// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of
|
|
||||||
/// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises
|
|
||||||
/// an EventSink, so — like <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — the set
|
|
||||||
/// is polled and each city emits `city.update` only when its signature changes. Governors turn
|
|
||||||
/// over on the order of weeks, so a slow sweep (default 5 min) is ample.
|
|
||||||
///
|
|
||||||
/// The wire model is uniform with the rest of Part B: a full-state `city.update` upsert, with
|
|
||||||
/// "the governor changed" derived sidecar-side by comparing to the stored board — rather than a
|
|
||||||
/// discrete from→to event, which a sidecar reconnect (cache cleared, full re-emit) would
|
|
||||||
/// otherwise fire spuriously for every city.
|
|
||||||
///
|
|
||||||
/// Gated on CityLoyaltySystem.Enabled: a shard running its own town system emits nothing here.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeGovernance
|
|
||||||
{
|
|
||||||
private static Timer _timer;
|
|
||||||
|
|
||||||
// City enum value -> last-emitted signature.
|
|
||||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
|
||||||
|
|
||||||
private static long _sweeps, _emitted;
|
|
||||||
private static bool _warnedDisabled;
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
EventSink.ServerStarted += OnServerStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnServerStarted()
|
|
||||||
{
|
|
||||||
BridgeLink.Connected_Core += OnConnected;
|
|
||||||
Rearm();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnConnected()
|
|
||||||
{
|
|
||||||
_last.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
public static void Rearm()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
|
|
||||||
_timer = Timer.DelayCall(
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
|
|
||||||
CitySweep);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Stop()
|
|
||||||
{
|
|
||||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Status()
|
|
||||||
{
|
|
||||||
return String.Format("cities(enabled={0} sweeps={1} emitted={2} tracked={3})",
|
|
||||||
CityLoyaltySystem.Enabled, _sweeps, _emitted, _last.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
public static void SweepOnce()
|
|
||||||
{
|
|
||||||
CitySweep();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CitySweep()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_sweeps++;
|
|
||||||
|
|
||||||
if (!CityLoyaltySystem.Enabled || CityLoyaltySystem.Cities == null)
|
|
||||||
{
|
|
||||||
if (!_warnedDisabled)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] city loyalty disabled; governor stream idle.");
|
|
||||||
_warnedDisabled = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!BridgeLink.Connected)
|
|
||||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
|
||||||
|
|
||||||
foreach (var city in CityLoyaltySystem.Cities)
|
|
||||||
{
|
|
||||||
if (city == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var sig = Signature(city);
|
|
||||||
|
|
||||||
int key = (int)city.City;
|
|
||||||
|
|
||||||
string prior;
|
|
||||||
if (_last.TryGetValue(key, out prior) && prior == sig)
|
|
||||||
continue; // unchanged since last emit
|
|
||||||
|
|
||||||
_last[key] = sig;
|
|
||||||
BridgeLink.Emit(WriteCity(city));
|
|
||||||
_emitted++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] city sweep threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The volatile fields: governor, governor-elect, and the election phase / candidate count.
|
|
||||||
private static string Signature(CityLoyaltySystem city)
|
|
||||||
{
|
|
||||||
var gov = city.Governor == null ? 0 : city.Governor.Serial.Value;
|
|
||||||
var elect = city.GovernorElect == null ? 0 : city.GovernorElect.Serial.Value;
|
|
||||||
|
|
||||||
var e = city.Election;
|
|
||||||
var phase = ElectionPhase(e);
|
|
||||||
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
|
|
||||||
|
|
||||||
return String.Concat(
|
|
||||||
gov.ToString(), "|", elect.ToString(), "|", phase, "|", candidates.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string WriteCity(CityLoyaltySystem city)
|
|
||||||
{
|
|
||||||
var e = city.Election;
|
|
||||||
var phase = ElectionPhase(e);
|
|
||||||
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("city.update")
|
|
||||||
.Str("city", city.City.ToString())
|
|
||||||
.Str("electionPhase", phase)
|
|
||||||
.Num("candidates", candidates);
|
|
||||||
|
|
||||||
sb.Actor("governor", city.Governor);
|
|
||||||
sb.Actor("governorElect", city.GovernorElect);
|
|
||||||
|
|
||||||
if (e != null && e.Ongoing)
|
|
||||||
sb.Str("autoPickAt", e.AutoPickGovernor.ToUniversalTime().ToString("o"));
|
|
||||||
|
|
||||||
return sb.End();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Folds the election state into one of: none / nominate / vote / pending.</summary>
|
|
||||||
private static string ElectionPhase(CityElection e)
|
|
||||||
{
|
|
||||||
if (e == null)
|
|
||||||
return "none";
|
|
||||||
|
|
||||||
if (e.CanNominate())
|
|
||||||
return "nominate";
|
|
||||||
|
|
||||||
if (e.CanVote())
|
|
||||||
return "vote";
|
|
||||||
|
|
||||||
if (e.Ongoing)
|
|
||||||
return "pending";
|
|
||||||
|
|
||||||
return "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The housing registry (docs/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay
|
|
||||||
/// *transitions*; this is the complementary *board*: one row per house with owner, location,
|
|
||||||
/// region, co-owners, value, and current decay level, so the website can render an owner→houses
|
|
||||||
/// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit
|
|
||||||
/// house.update only when a house's signature changes, and house.remove when a house is gone.
|
|
||||||
///
|
|
||||||
/// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the
|
|
||||||
/// registry is owner→houses; `price` is the house's placement value, not a sale listing.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeHousing
|
|
||||||
{
|
|
||||||
private static Timer _timer;
|
|
||||||
|
|
||||||
// house serial -> last-emitted signature.
|
|
||||||
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
|
||||||
|
|
||||||
private static long _sweeps, _emitted, _removed;
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
EventSink.ServerStarted += OnServerStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnServerStarted()
|
|
||||||
{
|
|
||||||
BridgeLink.Connected_Core += OnConnected;
|
|
||||||
Rearm();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnConnected()
|
|
||||||
{
|
|
||||||
_last.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
public static void Rearm()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
|
|
||||||
_timer = Timer.DelayCall(
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
|
|
||||||
HouseSweep);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Stop()
|
|
||||||
{
|
|
||||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Status()
|
|
||||||
{
|
|
||||||
return String.Format("housing(sweeps={0} emitted={1} removed={2} tracked={3})",
|
|
||||||
_sweeps, _emitted, _removed, _last.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
public static void SweepOnce()
|
|
||||||
{
|
|
||||||
HouseSweep();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void HouseSweep()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_sweeps++;
|
|
||||||
|
|
||||||
if (!BridgeLink.Connected)
|
|
||||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
|
||||||
|
|
||||||
var seen = new HashSet<Serial>();
|
|
||||||
|
|
||||||
foreach (var house in BaseHouse.AllHouses)
|
|
||||||
{
|
|
||||||
if (house == null || house.Deleted)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
seen.Add(house.Serial);
|
|
||||||
|
|
||||||
var level = house.DecayLevel; // computed getter — read once
|
|
||||||
var sig = Signature(house, level);
|
|
||||||
|
|
||||||
string prior;
|
|
||||||
if (_last.TryGetValue(house.Serial, out prior) && prior == sig)
|
|
||||||
continue; // unchanged since last emit
|
|
||||||
|
|
||||||
_last[house.Serial] = sig;
|
|
||||||
BridgeLink.Emit(WriteHouse(house, level));
|
|
||||||
_emitted++;
|
|
||||||
}
|
|
||||||
|
|
||||||
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
|
||||||
foreach (var serial in gone)
|
|
||||||
{
|
|
||||||
_last.Remove(serial);
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End());
|
|
||||||
_removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Signature(BaseHouse house, DecayLevel level)
|
|
||||||
{
|
|
||||||
var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value;
|
|
||||||
var region = house.Region;
|
|
||||||
var regionName = region == null ? "" : (region.Name ?? "");
|
|
||||||
var sign = house.Sign;
|
|
||||||
var name = sign == null ? "" : (sign.GetName() ?? "");
|
|
||||||
var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count;
|
|
||||||
|
|
||||||
return String.Concat(
|
|
||||||
ownerSerial.ToString(), "|",
|
|
||||||
level.ToString(), "|",
|
|
||||||
regionName, "|",
|
|
||||||
name, "|",
|
|
||||||
coOwners.ToString(), "|",
|
|
||||||
house.Price.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string WriteHouse(BaseHouse house, DecayLevel level)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("house.update")
|
|
||||||
.Ser("serial", house.Serial)
|
|
||||||
.Str("decay", level.ToString())
|
|
||||||
.Num("price", house.Price)
|
|
||||||
.Str("map", house.Map == null ? null : house.Map.Name)
|
|
||||||
.Num("x", house.X).Num("y", house.Y).Num("z", house.Z);
|
|
||||||
|
|
||||||
var sign = house.Sign;
|
|
||||||
if (sign != null)
|
|
||||||
sb.Str("name", sign.GetName());
|
|
||||||
|
|
||||||
var region = house.Region;
|
|
||||||
if (region != null)
|
|
||||||
sb.Str("region", region.Name);
|
|
||||||
|
|
||||||
sb.Actor("owner", house.Owner);
|
|
||||||
|
|
||||||
sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count);
|
|
||||||
sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count);
|
|
||||||
|
|
||||||
sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o"));
|
|
||||||
sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o"));
|
|
||||||
|
|
||||||
return sb.End();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
using System.Web.Script.Serialization;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
|
|
||||||
/// every emitted event, and the measured budget in docs/PLAN.md assumes this cost, not a
|
|
||||||
/// reflection serializer's.
|
|
||||||
///
|
|
||||||
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
|
|
||||||
/// correctness beats speed there, and parsing happens on the reader thread anyway.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeJson
|
|
||||||
{
|
|
||||||
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
[ThreadStatic]
|
|
||||||
private static JavaScriptSerializer _parser;
|
|
||||||
|
|
||||||
public static long NowMs()
|
|
||||||
{
|
|
||||||
return (long)(DateTime.UtcNow - Epoch).TotalMilliseconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- outbound ----
|
|
||||||
|
|
||||||
/// <summary>Opens an object and writes the `t` and `kind` fields.</summary>
|
|
||||||
public static StringBuilder Begin(string kind)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder(256);
|
|
||||||
sb.Append("{\"t\":").Append(NowMs());
|
|
||||||
sb.Append(",\"kind\":\"").Append(kind).Append('"');
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static StringBuilder Str(this StringBuilder sb, string name, string value)
|
|
||||||
{
|
|
||||||
sb.Append(",\"").Append(name).Append("\":");
|
|
||||||
|
|
||||||
if (value == null)
|
|
||||||
sb.Append("null");
|
|
||||||
else
|
|
||||||
Escape(sb, value);
|
|
||||||
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static StringBuilder Num(this StringBuilder sb, string name, long value)
|
|
||||||
{
|
|
||||||
sb.Append(",\"").Append(name).Append("\":").Append(value);
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static StringBuilder Num(this StringBuilder sb, string name, double value)
|
|
||||||
{
|
|
||||||
sb.Append(",\"").Append(name).Append("\":")
|
|
||||||
.Append(value.ToString("R", CultureInfo.InvariantCulture));
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static StringBuilder Bool(this StringBuilder sb, string name, bool value)
|
|
||||||
{
|
|
||||||
sb.Append(",\"").Append(name).Append("\":").Append(value ? "true" : "false");
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Serial as the canonical "0x1A2B" string the sidecar keys on.</summary>
|
|
||||||
public static StringBuilder Ser(this StringBuilder sb, string name, Serial serial)
|
|
||||||
{
|
|
||||||
sb.Append(",\"").Append(name).Append("\":\"0x")
|
|
||||||
.Append(serial.Value.ToString("X")).Append('"');
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes a nested actor object: serial, name, account (when there is one), the linked
|
|
||||||
/// webId (when the account is linked), and the player flag. A `null` mobile writes null.
|
|
||||||
/// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a
|
|
||||||
/// guild leader / joiner / governor can be attributed to a site user without a lookup.
|
|
||||||
/// </summary>
|
|
||||||
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
|
|
||||||
{
|
|
||||||
sb.Append(",\"").Append(name).Append("\":");
|
|
||||||
|
|
||||||
if (m == null)
|
|
||||||
{
|
|
||||||
sb.Append("null");
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
|
||||||
|
|
||||||
sb.Append(",\"name\":");
|
|
||||||
Escape(sb, m.Name ?? "");
|
|
||||||
|
|
||||||
var acct = m.Account as Accounting.Account;
|
|
||||||
if (acct != null)
|
|
||||||
{
|
|
||||||
sb.Append(",\"acct\":");
|
|
||||||
Escape(sb, acct.Username);
|
|
||||||
|
|
||||||
var webId = BridgeAccountLink.WebIdFor(acct);
|
|
||||||
if (webId != null)
|
|
||||||
{
|
|
||||||
sb.Append(",\"webId\":");
|
|
||||||
Escape(sb, webId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
|
|
||||||
sb.Append('}');
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
|
||||||
public static string End(this StringBuilder sb)
|
|
||||||
{
|
|
||||||
sb.Append('}');
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Escape(StringBuilder sb, string value)
|
|
||||||
{
|
|
||||||
sb.Append('"');
|
|
||||||
|
|
||||||
for (int i = 0; i < value.Length; i++)
|
|
||||||
{
|
|
||||||
char c = value[i];
|
|
||||||
|
|
||||||
switch (c)
|
|
||||||
{
|
|
||||||
case '"': sb.Append("\\\""); break;
|
|
||||||
case '\\': sb.Append("\\\\"); break;
|
|
||||||
case '\n': sb.Append("\\n"); break;
|
|
||||||
case '\r': sb.Append("\\r"); break;
|
|
||||||
case '\t': sb.Append("\\t"); break;
|
|
||||||
case '\b': sb.Append("\\b"); break;
|
|
||||||
case '\f': sb.Append("\\f"); break;
|
|
||||||
default:
|
|
||||||
if (c < ' ')
|
|
||||||
sb.Append("\\u").Append(((int)c).ToString("x4"));
|
|
||||||
else
|
|
||||||
sb.Append(c);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append('"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- inbound ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses one line into a dictionary. Returns null on malformed input rather than
|
|
||||||
/// throwing: a bad line from the sidecar must never reach a game code path.
|
|
||||||
/// </summary>
|
|
||||||
public static Dictionary<string, object> Parse(string line)
|
|
||||||
{
|
|
||||||
if (String.IsNullOrEmpty(line))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_parser == null)
|
|
||||||
{
|
|
||||||
_parser = new JavaScriptSerializer();
|
|
||||||
_parser.MaxJsonLength = 1 << 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _parser.Deserialize<Dictionary<string, object>>(line);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetString(Dictionary<string, object> o, string key)
|
|
||||||
{
|
|
||||||
object v;
|
|
||||||
|
|
||||||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return v as string ?? Convert.ToString(v, CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts a JSON array of strings. JavaScriptSerializer materializes JSON arrays as
|
|
||||||
/// object[] (or ArrayList) when the target is object, so handle both and stringify each
|
|
||||||
/// element. Returns an empty list for a missing or non-array value, never null.
|
|
||||||
/// </summary>
|
|
||||||
public static List<string> GetStringList(Dictionary<string, object> o, string key)
|
|
||||||
{
|
|
||||||
var result = new List<string>();
|
|
||||||
|
|
||||||
object v;
|
|
||||||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
|
||||||
return result;
|
|
||||||
|
|
||||||
var enumerable = v as System.Collections.IEnumerable;
|
|
||||||
|
|
||||||
if (enumerable == null || v is string)
|
|
||||||
return result;
|
|
||||||
|
|
||||||
foreach (var item in enumerable)
|
|
||||||
{
|
|
||||||
if (item == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
result.Add(item as string ?? Convert.ToString(item, CultureInfo.InvariantCulture));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int GetInt(Dictionary<string, object> o, string key, int fallback)
|
|
||||||
{
|
|
||||||
object v;
|
|
||||||
|
|
||||||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
|
||||||
return fallback;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Convert.ToInt32(v, CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The loopback link to the Rust sidecar. Newline-delimited JSON, bidirectional.
|
|
||||||
///
|
|
||||||
/// Threading contract, which the whole bridge depends on:
|
|
||||||
///
|
|
||||||
/// * <see cref="Emit"/> is called from the Core thread. It formats nothing, blocks on
|
|
||||||
/// nothing, and touches no socket. It enqueues and returns. A slow, wedged, or absent
|
|
||||||
/// sidecar cannot stall the shard.
|
|
||||||
/// * One link thread owns the socket. It connects, drains the queue, and reconnects with
|
|
||||||
/// backoff. A single writer keeps event ordering intact.
|
|
||||||
/// * A reader thread parses inbound lines and hands each to the Core thread via
|
|
||||||
/// Timer.DelayCall. The reader never touches World, Mobile, Item, or Account.
|
|
||||||
///
|
|
||||||
/// The outbound queue is bounded. On overflow the oldest record is dropped and counted,
|
|
||||||
/// because telemetry is worth less than the shard's memory.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeLink
|
|
||||||
{
|
|
||||||
private static readonly ConcurrentQueue<string> _outbound = new ConcurrentQueue<string>();
|
|
||||||
private static readonly AutoResetEvent _wake = new AutoResetEvent(false);
|
|
||||||
|
|
||||||
private static Thread _link;
|
|
||||||
private static volatile bool _running;
|
|
||||||
private static volatile bool _connected;
|
|
||||||
|
|
||||||
// Set when the peer goes away, so the writer stops trying.
|
|
||||||
private static volatile bool _dead;
|
|
||||||
|
|
||||||
// Incremented per connection attempt. A reader from a previous connection must not be
|
|
||||||
// able to mark a newer one dead — reader.Join can time out, and the stale thread's
|
|
||||||
// finally block would otherwise tear down the connection that replaced it.
|
|
||||||
private static int _epoch;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
|
|
||||||
/// a few seconds of buffering, not half a minute.
|
|
||||||
/// </summary>
|
|
||||||
private const int MaxBackoffMs = 5000;
|
|
||||||
|
|
||||||
private static int _depth;
|
|
||||||
private static long _sent, _dropped, _received, _connects, _writeErrors;
|
|
||||||
|
|
||||||
/// <summary>Raised on the <b>Core thread</b>, one call per inbound line.</summary>
|
|
||||||
public static event Action<string> InboundLine;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raised on the <b>Core thread</b> after each successful connect. The sidecar may
|
|
||||||
/// restart independently of the shard, so anything it needs to know up front has to be
|
|
||||||
/// re-sent per connection, not once at ServerStarted.
|
|
||||||
/// </summary>
|
|
||||||
public static event Action Connected_Core;
|
|
||||||
|
|
||||||
public static bool Connected { get { return _connected; } }
|
|
||||||
public static int Depth { get { return Volatile.Read(ref _depth); } }
|
|
||||||
public static long Sent { get { return Interlocked.Read(ref _sent); } }
|
|
||||||
public static long Dropped { get { return Interlocked.Read(ref _dropped); } }
|
|
||||||
public static long Received { get { return Interlocked.Read(ref _received); } }
|
|
||||||
/// <summary>Total successful connections, so the first connect counts as 1.</summary>
|
|
||||||
public static long Connects { get { return Interlocked.Read(ref _connects); } }
|
|
||||||
public static long WriteErrors { get { return Interlocked.Read(ref _writeErrors); } }
|
|
||||||
|
|
||||||
public static void Start()
|
|
||||||
{
|
|
||||||
if (_running)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_running = true;
|
|
||||||
|
|
||||||
_link = new Thread(LinkLoop)
|
|
||||||
{
|
|
||||||
Name = "Bridge Link",
|
|
||||||
IsBackground = true
|
|
||||||
};
|
|
||||||
|
|
||||||
_link.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Stop()
|
|
||||||
{
|
|
||||||
if (!_running)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_running = false;
|
|
||||||
_wake.Set();
|
|
||||||
|
|
||||||
var t = _link;
|
|
||||||
|
|
||||||
if (t != null && !t.Join(TimeSpan.FromSeconds(2.0)))
|
|
||||||
Console.WriteLine("[Bridge] link thread did not stop cleanly");
|
|
||||||
|
|
||||||
_link = null;
|
|
||||||
_connected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Core thread. Non-blocking. `line` must already be a complete JSON object with no
|
|
||||||
/// embedded newline; the newline is appended by the writer as the frame delimiter.
|
|
||||||
/// </summary>
|
|
||||||
public static void Emit(string line)
|
|
||||||
{
|
|
||||||
if (!_running || line == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over
|
|
||||||
// the cap but never grows without limit.
|
|
||||||
while (Volatile.Read(ref _depth) >= BridgeConfig.QueueCap)
|
|
||||||
{
|
|
||||||
string discard;
|
|
||||||
|
|
||||||
if (!_outbound.TryDequeue(out discard))
|
|
||||||
break;
|
|
||||||
|
|
||||||
Interlocked.Decrement(ref _depth);
|
|
||||||
Interlocked.Increment(ref _dropped);
|
|
||||||
}
|
|
||||||
|
|
||||||
_outbound.Enqueue(line);
|
|
||||||
Interlocked.Increment(ref _depth);
|
|
||||||
|
|
||||||
_wake.Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void LinkLoop()
|
|
||||||
{
|
|
||||||
int backoffMs = 500;
|
|
||||||
|
|
||||||
while (_running)
|
|
||||||
{
|
|
||||||
TcpClient client = null;
|
|
||||||
Thread reader = null;
|
|
||||||
|
|
||||||
int epoch = Interlocked.Increment(ref _epoch);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
client = new TcpClient();
|
|
||||||
client.NoDelay = true;
|
|
||||||
client.Connect(BridgeConfig.Host, BridgeConfig.Port);
|
|
||||||
|
|
||||||
var stream = client.GetStream();
|
|
||||||
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
|
|
||||||
|
|
||||||
_dead = false;
|
|
||||||
_connected = true;
|
|
||||||
backoffMs = 500;
|
|
||||||
|
|
||||||
Interlocked.Increment(ref _connects);
|
|
||||||
Console.WriteLine("[Bridge] connected to {0}:{1}", BridgeConfig.Host, BridgeConfig.Port);
|
|
||||||
|
|
||||||
// Building the greeting reads the world, so it must happen on the Core thread.
|
|
||||||
Timer.DelayCall(TimeSpan.Zero, () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var handler = Connected_Core;
|
|
||||||
|
|
||||||
if (handler != null)
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] connect handler threw: {0}", ex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var localStream = stream;
|
|
||||||
reader = new Thread(() => ReadLoop(localStream, epoch))
|
|
||||||
{
|
|
||||||
Name = "Bridge Reader",
|
|
||||||
IsBackground = true
|
|
||||||
};
|
|
||||||
reader.Start();
|
|
||||||
|
|
||||||
WriteLoop(stream);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
if (_connected)
|
|
||||||
Console.WriteLine("[Bridge] link error: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_connected = false;
|
|
||||||
_dead = true;
|
|
||||||
|
|
||||||
try { if (client != null) client.Close(); }
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
if (reader != null)
|
|
||||||
reader.Join(TimeSpan.FromSeconds(1.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_running)
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Nothing is listening yet, or the sidecar restarted. Both are normal.
|
|
||||||
Thread.Sleep(backoffMs);
|
|
||||||
backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
_connected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteLoop(NetworkStream stream)
|
|
||||||
{
|
|
||||||
while (_running && !_dead)
|
|
||||||
{
|
|
||||||
string line;
|
|
||||||
|
|
||||||
if (!_outbound.TryDequeue(out line))
|
|
||||||
{
|
|
||||||
_wake.WaitOne(250);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Interlocked.Decrement(ref _depth);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
|
||||||
stream.Write(bytes, 0, bytes.Length);
|
|
||||||
Interlocked.Increment(ref _sent);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
// The record is already off the queue. Count it and let the outer loop
|
|
||||||
// reconnect; re-queueing risks an unbounded retry storm against a dead peer.
|
|
||||||
Interlocked.Increment(ref _writeErrors);
|
|
||||||
_dead = true;
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReadLoop(NetworkStream stream, int epoch)
|
|
||||||
{
|
|
||||||
var buffer = new byte[8192];
|
|
||||||
var line = new StringBuilder(512);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (_running && !_dead)
|
|
||||||
{
|
|
||||||
int read = stream.Read(buffer, 0, buffer.Length);
|
|
||||||
|
|
||||||
if (read <= 0)
|
|
||||||
break; // clean EOF: the sidecar closed. Normal.
|
|
||||||
|
|
||||||
for (int i = 0; i < read; i++)
|
|
||||||
{
|
|
||||||
char c = (char)buffer[i];
|
|
||||||
|
|
||||||
if (c == '\n')
|
|
||||||
{
|
|
||||||
Dispatch(line.ToString());
|
|
||||||
line.Clear();
|
|
||||||
}
|
|
||||||
else if (c != '\r')
|
|
||||||
{
|
|
||||||
line.Append(c);
|
|
||||||
|
|
||||||
if (line.Length > (1 << 20))
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] inbound line too long, dropping");
|
|
||||||
line.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
// Expected when the peer vanishes mid-read.
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
// Expected when Stop() closes the socket under us.
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] reader error: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// Only tear down the connection this reader actually owned.
|
|
||||||
if (Volatile.Read(ref _epoch) == epoch)
|
|
||||||
{
|
|
||||||
_dead = true;
|
|
||||||
_wake.Set(); // let the writer notice and fall through to reconnect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reader thread. Marshals to the Core thread. Timer.DelayCall's scheduling path is
|
|
||||||
/// lock-protected and safe to call from any thread; the callback runs on the main loop.
|
|
||||||
/// </summary>
|
|
||||||
private static void Dispatch(string line)
|
|
||||||
{
|
|
||||||
if (line.Length == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Interlocked.Increment(ref _received);
|
|
||||||
|
|
||||||
Timer.DelayCall(TimeSpan.Zero, () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var handler = InboundLine;
|
|
||||||
|
|
||||||
if (handler != null)
|
|
||||||
handler(line);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// A malformed command must never escape into a game code path.
|
|
||||||
Console.WriteLine("[Bridge] inbound handler threw: {0}", ex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Server.Services.TownCryer;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Website news articles pushed into the modern Town Cryer News gump
|
|
||||||
/// (docs/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier
|
|
||||||
/// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML),
|
|
||||||
/// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries,
|
|
||||||
/// which the stock news gumps already render (they branch on TextDefinition.Number, so string
|
|
||||||
/// content needs no gump change).
|
|
||||||
///
|
|
||||||
/// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep
|
|
||||||
/// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just
|
|
||||||
/// the title through the existing crier say path (default on), so players hear it in-world.
|
|
||||||
///
|
|
||||||
/// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall),
|
|
||||||
/// which is required to touch the shared news list and to send crier packets.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeNews
|
|
||||||
{
|
|
||||||
// A neutral scroll gump when the website supplies no image.
|
|
||||||
private const int DefaultImage = 0x64E;
|
|
||||||
|
|
||||||
// Website id -> the news entry we created for it, so a later remove/replace can find it.
|
|
||||||
private static readonly Dictionary<string, TownCryerNewsEntry> _ours =
|
|
||||||
new Dictionary<string, TownCryerNewsEntry>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeBoot.RegisterHandler("news.add", OnAdd);
|
|
||||||
BridgeBoot.RegisterHandler("news.remove", OnRemove);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnAdd(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var id = BridgeJson.GetString(o, "id");
|
|
||||||
|
|
||||||
if (id == null)
|
|
||||||
{
|
|
||||||
Reply("news.error", null, "missing id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var list = TownCryerSystem.NewsEntries;
|
|
||||||
if (list == null)
|
|
||||||
{
|
|
||||||
Reply("news.error", id, "town cryer unavailable");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var title = BridgeJson.GetString(o, "title");
|
|
||||||
if (String.IsNullOrEmpty(title))
|
|
||||||
{
|
|
||||||
Reply("news.error", id, "missing title");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var body = BridgeJson.GetString(o, "body") ?? "";
|
|
||||||
var url = BridgeJson.GetString(o, "url");
|
|
||||||
int image = BridgeJson.GetInt(o, "image", DefaultImage);
|
|
||||||
|
|
||||||
// announce defaults to true (proclaim the title in-world); "announce":false suppresses it.
|
|
||||||
bool announce = true;
|
|
||||||
object rawAnnounce;
|
|
||||||
if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool)
|
|
||||||
announce = (bool)rawAnnounce;
|
|
||||||
|
|
||||||
if (title.Length > BridgeConfig.NewsMaxTitleLength)
|
|
||||||
title = title.Substring(0, BridgeConfig.NewsMaxTitleLength);
|
|
||||||
if (body.Length > BridgeConfig.NewsMaxBodyLength)
|
|
||||||
body = body.Substring(0, BridgeConfig.NewsMaxBodyLength);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Replace an existing id in place: drop the old entry first.
|
|
||||||
TownCryerNewsEntry old;
|
|
||||||
if (_ours.TryGetValue(id, out old) && old != null)
|
|
||||||
{
|
|
||||||
list.Remove(old);
|
|
||||||
_ours.Remove(id);
|
|
||||||
}
|
|
||||||
else if (_ours.Count >= BridgeConfig.NewsMaxExternal)
|
|
||||||
{
|
|
||||||
Reply("news.error", id, "too many news entries");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var entry = new TownCryerNewsEntry(
|
|
||||||
new TextDefinition(title),
|
|
||||||
new TextDefinition(body),
|
|
||||||
image,
|
|
||||||
null,
|
|
||||||
url);
|
|
||||||
|
|
||||||
list.Insert(0, entry); // newest first, as the gump reads top-down
|
|
||||||
_ours[id] = entry;
|
|
||||||
|
|
||||||
if (announce)
|
|
||||||
Announce(title);
|
|
||||||
|
|
||||||
Reply("news.ok", id, null);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message);
|
|
||||||
Reply("news.error", id, "internal error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnRemove(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var id = BridgeJson.GetString(o, "id");
|
|
||||||
|
|
||||||
if (id == null)
|
|
||||||
{
|
|
||||||
Reply("news.error", null, "missing id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
TownCryerNewsEntry entry;
|
|
||||||
if (!_ours.TryGetValue(id, out entry))
|
|
||||||
{
|
|
||||||
Reply("news.error", id, "unknown id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ours.Remove(id);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var list = TownCryerSystem.NewsEntries;
|
|
||||||
if (list != null && entry != null)
|
|
||||||
list.Remove(entry);
|
|
||||||
|
|
||||||
Reply("news.ok", id, null);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message);
|
|
||||||
Reply("news.error", id, "internal error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Proclaims a single line — the article title — through the town criers.</summary>
|
|
||||||
private static void Announce(string title)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
GlobalTownCrierEntryList.Instance.AddEntry(
|
|
||||||
new[] { title },
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// A failed proclamation must not fail the news add — the article is already posted.
|
|
||||||
Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Reply(string kind, string id, string reason)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin(kind);
|
|
||||||
if (id != null) sb.Str("id", id);
|
|
||||||
if (reason != null) sb.Str("reason", reason);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The presence stream (docs/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts:
|
|
||||||
///
|
|
||||||
/// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted
|
|
||||||
/// on a sweep but only when it changes, so the site has a live "N online"
|
|
||||||
/// plus a change history without a firehose of identical frames.
|
|
||||||
/// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap
|
|
||||||
/// per-player movement signal PLAN.md §5.6 recommends over Movement.
|
|
||||||
///
|
|
||||||
/// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the
|
|
||||||
/// same population the vitals sweep already walks; counting them by map and region is a handful
|
|
||||||
/// of field reads. region.enter is filtered to players.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgePresence
|
|
||||||
{
|
|
||||||
private static Timer _timer;
|
|
||||||
|
|
||||||
// Signature of the last-emitted snapshot, so an unchanged population emits nothing.
|
|
||||||
private static string _lastSig;
|
|
||||||
|
|
||||||
private static long _sweeps, _emitted, _regionEnters;
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
EventSink.OnEnterRegion += OnEnterRegion;
|
|
||||||
EventSink.ServerStarted += OnServerStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnServerStarted()
|
|
||||||
{
|
|
||||||
// Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the
|
|
||||||
// current population within one sweep.
|
|
||||||
BridgeLink.Connected_Core += OnConnected;
|
|
||||||
Rearm();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnConnected()
|
|
||||||
{
|
|
||||||
_lastSig = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
public static void Rearm()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
|
|
||||||
_timer = Timer.DelayCall(
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
|
|
||||||
PresenceSweep);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Stop()
|
|
||||||
{
|
|
||||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Status()
|
|
||||||
{
|
|
||||||
return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})",
|
|
||||||
_sweeps, _emitted, _regionEnters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
public static void SweepOnce()
|
|
||||||
{
|
|
||||||
PresenceSweep();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PresenceSweep()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_sweeps++;
|
|
||||||
|
|
||||||
if (!BridgeLink.Connected)
|
|
||||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
|
||||||
|
|
||||||
int total = 0;
|
|
||||||
var byFacet = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
|
||||||
var byRegion = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
foreach (var m in World.Mobiles.Values)
|
|
||||||
{
|
|
||||||
var pm = m as PlayerMobile;
|
|
||||||
|
|
||||||
if (pm == null || pm.NetState == null || pm.Deleted)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
total++;
|
|
||||||
|
|
||||||
var facet = pm.Map == null ? "Internal" : pm.Map.Name;
|
|
||||||
Bump(byFacet, facet);
|
|
||||||
|
|
||||||
var region = pm.Region;
|
|
||||||
var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name;
|
|
||||||
Bump(byRegion, regionName);
|
|
||||||
}
|
|
||||||
|
|
||||||
var sig = Signature(total, byFacet, byRegion);
|
|
||||||
if (sig == _lastSig)
|
|
||||||
return; // population unchanged since last emit
|
|
||||||
|
|
||||||
_lastSig = sig;
|
|
||||||
BridgeLink.Emit(WriteOnline(total, byFacet, byRegion));
|
|
||||||
_emitted++;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Bump(IDictionary<string, int> map, string key)
|
|
||||||
{
|
|
||||||
int n;
|
|
||||||
map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Signature(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
|
||||||
{
|
|
||||||
var sb = new System.Text.StringBuilder();
|
|
||||||
sb.Append(total);
|
|
||||||
foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
|
|
||||||
sb.Append('#');
|
|
||||||
foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string WriteOnline(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("presence.online").Num("count", total);
|
|
||||||
|
|
||||||
WriteCounts(sb, "byFacet", byFacet);
|
|
||||||
WriteCounts(sb, "byRegion", byRegion);
|
|
||||||
|
|
||||||
return sb.End();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Writes a nested object of {name: count} pairs.</summary>
|
|
||||||
private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary<string, int> counts)
|
|
||||||
{
|
|
||||||
sb.Append(",\"").Append(field).Append("\":{");
|
|
||||||
|
|
||||||
bool first = true;
|
|
||||||
foreach (var kv in counts)
|
|
||||||
{
|
|
||||||
if (!first)
|
|
||||||
sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
BridgeJson.Escape(sb, kv.Key);
|
|
||||||
sb.Append(':').Append(kv.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append('}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- real-time region transitions ----
|
|
||||||
|
|
||||||
private static void OnEnterRegion(OnEnterRegionEventArgs e)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (e == null || e.From == null || !e.From.Player)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var from = e.OldRegion;
|
|
||||||
var to = e.NewRegion;
|
|
||||||
|
|
||||||
// Only meaningful when the named region actually changed.
|
|
||||||
var fromName = from == null ? null : from.Name;
|
|
||||||
var toName = to == null ? null : to.Name;
|
|
||||||
if (String.Equals(fromName, toName, StringComparison.Ordinal))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("region.enter")
|
|
||||||
.Str("from", fromName)
|
|
||||||
.Str("to", toName)
|
|
||||||
.Str("map", e.From.Map == null ? null : e.From.Map.Name);
|
|
||||||
|
|
||||||
sb.Actor("who", e.From);
|
|
||||||
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
_regionEnters++;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the heavy read-models the website consumes: a full character profile, an account
|
|
||||||
/// roster, and a player's vendor holdings. All read live Mobile/Item state, so all must run
|
|
||||||
/// on the Core thread — which the inbound dispatch guarantees (BridgeLink marshals every
|
|
||||||
/// inbound line through Timer.DelayCall before a handler sees it).
|
|
||||||
///
|
|
||||||
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
|
|
||||||
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
|
|
||||||
/// a sweep. See docs/PLAN.md §1.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeProfile
|
|
||||||
{
|
|
||||||
private static readonly AosAttribute[] AllAttrs =
|
|
||||||
(AosAttribute[])Enum.GetValues(typeof(AosAttribute));
|
|
||||||
|
|
||||||
private static readonly AosWeaponAttribute[] AllWeaponAttrs =
|
|
||||||
(AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
|
|
||||||
|
|
||||||
private static readonly AosArmorAttribute[] AllArmorAttrs =
|
|
||||||
(AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
|
|
||||||
|
|
||||||
// ---- full profile ----
|
|
||||||
|
|
||||||
public static string BuildProfile(PlayerMobile m, string reqId)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("char.profile");
|
|
||||||
|
|
||||||
if (reqId != null)
|
|
||||||
sb.Str("reqId", reqId);
|
|
||||||
|
|
||||||
sb.Ser("serial", m.Serial);
|
|
||||||
sb.Str("name", m.Name);
|
|
||||||
sb.Str("title", m.Title);
|
|
||||||
sb.Num("body", m.Body.BodyID);
|
|
||||||
sb.Num("hue", m.Hue);
|
|
||||||
sb.Bool("online", m.NetState != null);
|
|
||||||
|
|
||||||
var acct = m.Account as Account;
|
|
||||||
if (acct != null)
|
|
||||||
sb.Str("acct", acct.Username);
|
|
||||||
|
|
||||||
// stats
|
|
||||||
sb.Append(",\"stats\":{");
|
|
||||||
sb.Append("\"str\":").Append(m.Str).Append(",\"dex\":").Append(m.Dex).Append(",\"int\":").Append(m.Int);
|
|
||||||
sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
|
|
||||||
sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
|
|
||||||
sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
|
|
||||||
sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
|
|
||||||
sb.Append(",\"luck\":").Append(m.Luck);
|
|
||||||
sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
|
|
||||||
sb.Append(",\"fire\":").Append(m.FireResistance);
|
|
||||||
sb.Append(",\"cold\":").Append(m.ColdResistance);
|
|
||||||
sb.Append(",\"pois\":").Append(m.PoisonResistance);
|
|
||||||
sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
|
|
||||||
|
|
||||||
// skills: trained only (Base > 0), to avoid ~50 zeroes per character
|
|
||||||
sb.Append(",\"skills\":[");
|
|
||||||
bool first = true;
|
|
||||||
for (int i = 0; i < m.Skills.Length; i++)
|
|
||||||
{
|
|
||||||
var s = m.Skills[i];
|
|
||||||
|
|
||||||
if (s == null || s.Base <= 0.0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first) sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
|
|
||||||
sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
|
|
||||||
sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
|
|
||||||
sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
|
|
||||||
sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
|
|
||||||
}
|
|
||||||
sb.Append(']');
|
|
||||||
|
|
||||||
// worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4)
|
|
||||||
sb.Append(",\"equipment\":[");
|
|
||||||
first = true;
|
|
||||||
foreach (var item in m.Items)
|
|
||||||
{
|
|
||||||
if (item == null || !IsGearLayer(item.Layer))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first) sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
WriteItem(sb, item);
|
|
||||||
}
|
|
||||||
sb.Append(']');
|
|
||||||
|
|
||||||
WriteTitles(sb, m);
|
|
||||||
|
|
||||||
return sb.End();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The titles a character holds (docs/PROTOCOL_2.md §10.3). `selected` is the index into
|
|
||||||
/// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed
|
|
||||||
/// display titles (may be absent). `reward` is the raw reward-title list — an entry may be
|
|
||||||
/// a cliloc number (as a string) or a literal string; resolve clilocs website-side.
|
|
||||||
/// </summary>
|
|
||||||
private static void WriteTitles(StringBuilder sb, PlayerMobile m)
|
|
||||||
{
|
|
||||||
sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle);
|
|
||||||
|
|
||||||
var fameKarma = m.FameKarmaTitle;
|
|
||||||
if (!String.IsNullOrEmpty(fameKarma))
|
|
||||||
{
|
|
||||||
sb.Append(",\"fameKarma\":");
|
|
||||||
BridgeJson.Escape(sb, fameKarma);
|
|
||||||
}
|
|
||||||
|
|
||||||
var skill = m.PaperdollSkillTitle;
|
|
||||||
if (!String.IsNullOrEmpty(skill))
|
|
||||||
{
|
|
||||||
sb.Append(",\"skill\":");
|
|
||||||
BridgeJson.Escape(sb, skill);
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append(",\"reward\":[");
|
|
||||||
var rewards = m.RewardTitles;
|
|
||||||
if (rewards != null)
|
|
||||||
{
|
|
||||||
bool first = true;
|
|
||||||
for (int i = 0; i < rewards.Count; i++)
|
|
||||||
{
|
|
||||||
var r = rewards[i];
|
|
||||||
if (r == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first) sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.Append("]}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsGearLayer(Layer layer)
|
|
||||||
{
|
|
||||||
switch (layer)
|
|
||||||
{
|
|
||||||
case Layer.Backpack:
|
|
||||||
case Layer.Bank:
|
|
||||||
case Layer.Hair:
|
|
||||||
case Layer.FacialHair:
|
|
||||||
case Layer.Mount:
|
|
||||||
case Layer.Invalid:
|
|
||||||
return false;
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteItem(StringBuilder sb, Item item)
|
|
||||||
{
|
|
||||||
sb.Append("{");
|
|
||||||
sb.Append("\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
|
|
||||||
sb.Append(",\"layer\":\"").Append(item.Layer).Append('"');
|
|
||||||
sb.Append(",\"itemId\":").Append(item.ItemID);
|
|
||||||
sb.Append(",\"hue\":").Append(item.Hue);
|
|
||||||
sb.Append(",\"cliloc\":").Append(item.LabelNumber);
|
|
||||||
|
|
||||||
if (item.Name != null)
|
|
||||||
{
|
|
||||||
sb.Append(",\"name\":");
|
|
||||||
BridgeJson.Escape(sb, item.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
var weapon = item as BaseWeapon;
|
|
||||||
var armor = item as BaseArmor;
|
|
||||||
|
|
||||||
if (weapon != null)
|
|
||||||
{
|
|
||||||
sb.Append(",\"weapon\":{\"minDamage\":").Append(weapon.MinDamage);
|
|
||||||
sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage).Append('}');
|
|
||||||
}
|
|
||||||
else if (armor != null)
|
|
||||||
{
|
|
||||||
sb.Append(",\"armor\":{\"baseRating\":").Append(armor.BaseArmorRating).Append('}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// flattened union of non-zero mods across every attribute bag
|
|
||||||
sb.Append(",\"mods\":{");
|
|
||||||
bool first = true;
|
|
||||||
|
|
||||||
if (weapon != null)
|
|
||||||
{
|
|
||||||
WriteAttrs(sb, weapon.Attributes, ref first);
|
|
||||||
WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref first);
|
|
||||||
}
|
|
||||||
else if (armor != null)
|
|
||||||
{
|
|
||||||
WriteAttrs(sb, armor.Attributes, ref first);
|
|
||||||
WriteArmorAttrs(sb, armor.ArmorAttributes, ref first);
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append("}}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
|
|
||||||
{
|
|
||||||
if (a == null) return;
|
|
||||||
|
|
||||||
for (int i = 0; i < AllAttrs.Length; i++)
|
|
||||||
{
|
|
||||||
int v = a[AllAttrs[i]];
|
|
||||||
if (v == 0) continue;
|
|
||||||
if (!first) sb.Append(','); first = false;
|
|
||||||
sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
|
|
||||||
{
|
|
||||||
if (a == null) return;
|
|
||||||
|
|
||||||
for (int i = 0; i < AllWeaponAttrs.Length; i++)
|
|
||||||
{
|
|
||||||
int v = a[AllWeaponAttrs[i]];
|
|
||||||
if (v == 0) continue;
|
|
||||||
if (!first) sb.Append(','); first = false;
|
|
||||||
sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
|
|
||||||
{
|
|
||||||
if (a == null) return;
|
|
||||||
|
|
||||||
for (int i = 0; i < AllArmorAttrs.Length; i++)
|
|
||||||
{
|
|
||||||
int v = a[AllArmorAttrs[i]];
|
|
||||||
if (v == 0) continue;
|
|
||||||
if (!first) sb.Append(','); first = false;
|
|
||||||
sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- account roster ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Light per-character summary for an account. Offline characters are included: a
|
|
||||||
/// logged-off mobile stays resident (World.Mobiles) until Delete, so its roster entry is
|
|
||||||
/// always available.
|
|
||||||
/// </summary>
|
|
||||||
public static string BuildRoster(Account acct, string reqId)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("account.roster");
|
|
||||||
|
|
||||||
if (reqId != null)
|
|
||||||
sb.Str("reqId", reqId);
|
|
||||||
|
|
||||||
sb.Str("acct", acct.Username);
|
|
||||||
sb.Append(",\"chars\":[");
|
|
||||||
|
|
||||||
bool first = true;
|
|
||||||
for (int i = 0; i < acct.Length; i++)
|
|
||||||
{
|
|
||||||
var m = acct[i];
|
|
||||||
if (m == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first) sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
sb.Append("{\"slot\":").Append(i);
|
|
||||||
sb.Append(",\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
|
||||||
sb.Append(",\"name\":");
|
|
||||||
BridgeJson.Escape(sb, m.Name ?? "");
|
|
||||||
sb.Append(",\"body\":").Append(m.Body.BodyID);
|
|
||||||
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
|
|
||||||
sb.Append('}');
|
|
||||||
}
|
|
||||||
sb.Append(']');
|
|
||||||
|
|
||||||
return sb.End();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Inbound request/response. The sidecar asks; the shard answers. Every handler runs on the
|
|
||||||
/// Core thread (BridgeBoot dispatches inbound lines through Timer.DelayCall first), so all of
|
|
||||||
/// these may read live world state freely.
|
|
||||||
///
|
|
||||||
/// A request carries an optional "reqId" the shard echoes back, so the sidecar can correlate
|
|
||||||
/// the reply with the request it sent. A malformed or unresolvable request gets a
|
|
||||||
/// "bridge.error" reply rather than silence, so the website can show a real failure instead
|
|
||||||
/// of hanging.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeRequests
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeBoot.RegisterHandler("char.request", OnCharRequest);
|
|
||||||
BridgeBoot.RegisterHandler("account.roster", OnRosterRequest);
|
|
||||||
BridgeBoot.RegisterHandler("vendor.snapshot", OnVendorSnapshotRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Fail(string reqId, string reason)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("bridge.error");
|
|
||||||
if (reqId != null)
|
|
||||||
sb.Str("reqId", reqId);
|
|
||||||
sb.Str("reason", reason);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- char.request ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolve a character by serial, or by account + slot, and reply with a full profile.
|
|
||||||
/// Works for offline characters too: a logged-off mobile is still resident.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnCharRequest(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
|
|
||||||
PlayerMobile pm = null;
|
|
||||||
|
|
||||||
var serialStr = BridgeJson.GetString(o, "serial");
|
|
||||||
if (serialStr != null)
|
|
||||||
{
|
|
||||||
pm = ResolveSerial(serialStr) as PlayerMobile;
|
|
||||||
|
|
||||||
if (pm == null)
|
|
||||||
{
|
|
||||||
Fail(reqId, "no player with serial " + serialStr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var acctName = BridgeJson.GetString(o, "account");
|
|
||||||
var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
|
|
||||||
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Fail(reqId, "unknown account");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int slot = BridgeJson.GetInt(o, "slot", 0);
|
|
||||||
|
|
||||||
if (slot < 0 || slot >= acct.Length)
|
|
||||||
{
|
|
||||||
Fail(reqId, "slot out of range");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pm = acct[slot] as PlayerMobile;
|
|
||||||
|
|
||||||
if (pm == null)
|
|
||||||
{
|
|
||||||
Fail(reqId, "no character in slot " + slot);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeProfile.BuildProfile(pm, reqId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- account.roster ----
|
|
||||||
|
|
||||||
private static void OnRosterRequest(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
var acctName = BridgeJson.GetString(o, "account");
|
|
||||||
var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
|
|
||||||
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Fail(reqId, "unknown account");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeProfile.BuildRoster(acct, reqId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- vendor.snapshot ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Every player vendor owned by any character on an account, with its held gold and
|
|
||||||
/// priced listings. Enumerates PlayerVendor.PlayerVendors and matches by owner account.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnVendorSnapshotRequest(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var reqId = BridgeJson.GetString(o, "reqId");
|
|
||||||
var acctName = BridgeJson.GetString(o, "account");
|
|
||||||
var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
|
|
||||||
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Fail(reqId, "unknown account");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("vendor.snapshot");
|
|
||||||
if (reqId != null)
|
|
||||||
sb.Str("reqId", reqId);
|
|
||||||
sb.Str("acct", acct.Username);
|
|
||||||
sb.Append(",\"vendors\":[");
|
|
||||||
|
|
||||||
bool firstVendor = true;
|
|
||||||
var all = PlayerVendor.PlayerVendors;
|
|
||||||
|
|
||||||
if (all != null)
|
|
||||||
{
|
|
||||||
foreach (var v in all)
|
|
||||||
{
|
|
||||||
if (v == null || v.Deleted || v.Owner == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!(v.Owner.Account is Account ownerAcct) || ownerAcct != acct)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!firstVendor) sb.Append(',');
|
|
||||||
firstVendor = false;
|
|
||||||
|
|
||||||
sb.Append("{\"serial\":\"0x").Append(v.Serial.Value.ToString("X")).Append('"');
|
|
||||||
sb.Append(",\"shopName\":");
|
|
||||||
BridgeJson.Escape(sb, v.ShopName ?? "");
|
|
||||||
sb.Append(",\"holdGold\":").Append(v.HoldGold);
|
|
||||||
sb.Append(",\"ownerSerial\":\"0x").Append(v.Owner.Serial.Value.ToString("X")).Append('"');
|
|
||||||
|
|
||||||
var house = v.Map;
|
|
||||||
sb.Append(",\"map\":");
|
|
||||||
BridgeJson.Escape(sb, v.Map == null ? "" : v.Map.Name);
|
|
||||||
sb.Append(",\"x\":").Append(v.X).Append(",\"y\":").Append(v.Y);
|
|
||||||
|
|
||||||
sb.Append(",\"listings\":[");
|
|
||||||
bool firstItem = true;
|
|
||||||
var pack = v.Backpack;
|
|
||||||
|
|
||||||
if (pack != null)
|
|
||||||
{
|
|
||||||
foreach (var item in pack.Items)
|
|
||||||
{
|
|
||||||
var vi = v.GetVendorItem(item);
|
|
||||||
if (vi == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!firstItem) sb.Append(',');
|
|
||||||
firstItem = false;
|
|
||||||
|
|
||||||
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
|
|
||||||
sb.Append(",\"itemId\":").Append(item.ItemID);
|
|
||||||
sb.Append(",\"amount\":").Append(item.Amount);
|
|
||||||
sb.Append(",\"price\":").Append(vi.Price);
|
|
||||||
sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false").Append('}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.Append("]}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append(']');
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers ----
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
using Server.Guilds;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The guild stream (docs/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
|
|
||||||
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
|
|
||||||
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
|
|
||||||
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
|
|
||||||
/// tick, fold each guild to a small signature, and emit `guild.update` only when it changes.
|
|
||||||
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
|
|
||||||
///
|
|
||||||
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
|
|
||||||
/// so joined" feed does not wait for the next sweep. A membership change also moves the board
|
|
||||||
/// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in
|
|
||||||
/// the next `guild.update`; per-member leave events would need a core tap and are a later
|
|
||||||
/// refinement (§10.1).
|
|
||||||
///
|
|
||||||
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
|
|
||||||
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
|
|
||||||
/// guild, would look like every guild being created at once.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeSocial
|
|
||||||
{
|
|
||||||
private static Timer _timer;
|
|
||||||
|
|
||||||
// guild id -> last-emitted signature. An id absent here has never been emitted (or the cache
|
|
||||||
// was cleared on reconnect), so its next sweep counts as a change.
|
|
||||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
|
||||||
|
|
||||||
private static long _sweeps, _emitted, _removed, _joins;
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
EventSink.JoinGuild += OnJoinGuild;
|
|
||||||
EventSink.ServerStarted += OnServerStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnServerStarted()
|
|
||||||
{
|
|
||||||
BridgeLink.Connected_Core += OnConnected;
|
|
||||||
Rearm();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnConnected()
|
|
||||||
{
|
|
||||||
_last.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
public static void Rearm()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
|
|
||||||
_timer = Timer.DelayCall(
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
|
|
||||||
GuildSweep);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Stop()
|
|
||||||
{
|
|
||||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Status()
|
|
||||||
{
|
|
||||||
return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
|
|
||||||
_sweeps, _emitted, _removed, _joins, _last.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
public static void SweepOnce()
|
|
||||||
{
|
|
||||||
GuildSweep();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void GuildSweep()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_sweeps++;
|
|
||||||
|
|
||||||
if (!BridgeLink.Connected)
|
|
||||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
|
||||||
|
|
||||||
var seen = new HashSet<int>();
|
|
||||||
|
|
||||||
foreach (var bg in BaseGuild.List.Values)
|
|
||||||
{
|
|
||||||
var g = bg as Guild;
|
|
||||||
|
|
||||||
// Skip disbanded guilds (leader gone): they linger in the list until cleaned up,
|
|
||||||
// and treating them as absent lets the "gone" pass below emit guild.remove.
|
|
||||||
if (g == null || g.Disbanded)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
seen.Add(g.Id);
|
|
||||||
|
|
||||||
var sig = Signature(g);
|
|
||||||
|
|
||||||
string prior;
|
|
||||||
if (_last.TryGetValue(g.Id, out prior) && prior == sig)
|
|
||||||
continue; // unchanged since last emit
|
|
||||||
|
|
||||||
_last[g.Id] = sig;
|
|
||||||
BridgeLink.Emit(WriteGuild(g));
|
|
||||||
_emitted++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anything tracked last sweep but not seen now has disbanded or been removed.
|
|
||||||
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
|
||||||
foreach (var id in gone)
|
|
||||||
{
|
|
||||||
_last.Remove(id);
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
|
|
||||||
_removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The volatile fields that define a meaningful change: name, abbreviation, leader, member
|
|
||||||
// count, the member set (order-independent serial sum), and alliance.
|
|
||||||
private static string Signature(Guild g)
|
|
||||||
{
|
|
||||||
long memberSum = 0;
|
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
var members = g.Members;
|
|
||||||
if (members != null)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < members.Count; i++)
|
|
||||||
{
|
|
||||||
var m = members[i];
|
|
||||||
if (m == null)
|
|
||||||
continue;
|
|
||||||
count++;
|
|
||||||
unchecked { memberSum += (uint)m.Serial.Value; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value;
|
|
||||||
|
|
||||||
return String.Concat(
|
|
||||||
g.Name ?? "", "|",
|
|
||||||
g.Abbreviation ?? "", "|",
|
|
||||||
leaderSerial.ToString(), "|",
|
|
||||||
count.ToString(), "|",
|
|
||||||
memberSum.ToString(), "|",
|
|
||||||
g.Alliance == null ? "" : (g.AllianceName ?? ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string WriteGuild(Guild g)
|
|
||||||
{
|
|
||||||
int online = 0, count = 0;
|
|
||||||
var members = g.Members;
|
|
||||||
if (members != null)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < members.Count; i++)
|
|
||||||
{
|
|
||||||
var m = members[i];
|
|
||||||
if (m == null)
|
|
||||||
continue;
|
|
||||||
count++;
|
|
||||||
if (m.NetState != null)
|
|
||||||
online++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("guild.update")
|
|
||||||
.Num("id", g.Id)
|
|
||||||
.Str("name", g.Name)
|
|
||||||
.Str("abbr", g.Abbreviation)
|
|
||||||
.Num("members", count)
|
|
||||||
.Num("online", online)
|
|
||||||
.Str("alliance", g.Alliance == null ? null : g.AllianceName);
|
|
||||||
|
|
||||||
sb.Actor("leader", g.Leader);
|
|
||||||
|
|
||||||
return sb.End();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- real-time join ----
|
|
||||||
|
|
||||||
private static void OnJoinGuild(JoinGuildEventArgs e)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (e == null || e.Mobile == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var g = e.Guild as Guild;
|
|
||||||
|
|
||||||
var sb = BridgeJson.Begin("guild.join");
|
|
||||||
if (g != null)
|
|
||||||
sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation);
|
|
||||||
sb.Actor("who", e.Mobile);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
_joins++;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The three polled streams, for state that has no EventSink: player vitals, house decay,
|
|
||||||
/// and money supply. All three run on the Core thread via repeating Timers, and the
|
|
||||||
/// measured cost (docs/PLAN.md §1) is why they can: at the seeded scale a full pass of all
|
|
||||||
/// three is well under a millisecond.
|
|
||||||
///
|
|
||||||
/// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed
|
|
||||||
/// mid-save simply happens a few seconds later. That is fine for all three.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeSweeps
|
|
||||||
{
|
|
||||||
private static Timer _vitals, _decay, _economy;
|
|
||||||
|
|
||||||
// Last-known decay level per house. In memory, rebuilt from a silent baseline on
|
|
||||||
// ServerStarted, so a restart does not re-announce every house's current stage.
|
|
||||||
private static readonly Dictionary<Serial, DecayLevel> _decayState =
|
|
||||||
new Dictionary<Serial, DecayLevel>();
|
|
||||||
|
|
||||||
private static bool _baselined;
|
|
||||||
|
|
||||||
private static long _vitalsSweeps, _vitalsEmitted, _decaySweeps, _decayTransitions, _economySweeps;
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
EventSink.ServerStarted += OnServerStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnServerStarted()
|
|
||||||
{
|
|
||||||
BaselineDecay();
|
|
||||||
Rearm();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timers from current config. Called by `[bridge reload`.</summary>
|
|
||||||
public static void Rearm()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
|
|
||||||
_vitals = Timer.DelayCall(
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.StatSweepSeconds),
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.StatSweepSeconds),
|
|
||||||
VitalsSweep);
|
|
||||||
|
|
||||||
_decay = Timer.DelayCall(
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.DecaySweepSeconds),
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.DecaySweepSeconds),
|
|
||||||
DecaySweep);
|
|
||||||
|
|
||||||
_economy = Timer.DelayCall(
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.EconomySweepSeconds),
|
|
||||||
TimeSpan.FromSeconds(BridgeConfig.EconomySweepSeconds),
|
|
||||||
EconomySweep);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Stop()
|
|
||||||
{
|
|
||||||
if (_vitals != null) { _vitals.Stop(); _vitals = null; }
|
|
||||||
if (_decay != null) { _decay.Stop(); _decay = null; }
|
|
||||||
if (_economy != null) { _economy.Stop(); _economy = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Status()
|
|
||||||
{
|
|
||||||
return String.Format(
|
|
||||||
"vitals(sweeps={0} emitted={1}) decay(sweeps={2} transitions={3} tracked={4}) economy(sweeps={5})",
|
|
||||||
_vitalsSweeps, _vitalsEmitted, _decaySweeps, _decayTransitions, _decayState.Count, _economySweeps);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- vitals ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Online players only. Vitals are small and volatile; the sidecar diffs successive
|
|
||||||
/// snapshots and forwards only changes. Offline characters do not move, so there is
|
|
||||||
/// nothing to sweep — their state is served on demand as a full profile instead.
|
|
||||||
/// </summary>
|
|
||||||
private static void VitalsSweep()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_vitalsSweeps++;
|
|
||||||
|
|
||||||
if (!BridgeLink.Connected)
|
|
||||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
|
||||||
|
|
||||||
foreach (var m in World.Mobiles.Values)
|
|
||||||
{
|
|
||||||
var pm = m as PlayerMobile;
|
|
||||||
|
|
||||||
if (pm == null || pm.NetState == null || pm.Deleted)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
BridgeLink.Emit(WriteVitals(pm));
|
|
||||||
_vitalsEmitted++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] vitals sweep threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string WriteVitals(PlayerMobile m)
|
|
||||||
{
|
|
||||||
return BridgeJson.Begin("char.vitals")
|
|
||||||
.Ser("serial", m.Serial)
|
|
||||||
.Num("hits", m.Hits).Num("hitsMax", m.HitsMax)
|
|
||||||
.Num("mana", m.Mana).Num("manaMax", m.ManaMax)
|
|
||||||
.Num("stam", m.Stam).Num("stamMax", m.StamMax)
|
|
||||||
.Num("str", m.Str).Num("dex", m.Dex).Num("int", m.Int)
|
|
||||||
.Str("map", m.Map == null ? null : m.Map.Name)
|
|
||||||
.Num("x", m.X).Num("y", m.Y)
|
|
||||||
.End();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- house decay ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Populates the last-known level for every house without emitting. Without this, the
|
|
||||||
/// first sweep after a restart would report every house as a fresh transition.
|
|
||||||
/// </summary>
|
|
||||||
private static void BaselineDecay()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_decayState.Clear();
|
|
||||||
|
|
||||||
foreach (var house in BaseHouse.AllHouses)
|
|
||||||
{
|
|
||||||
if (house == null || house.Deleted)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
_decayState[house.Serial] = house.DecayLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
_baselined = true;
|
|
||||||
Console.WriteLine("[Bridge] decay baseline: {0} houses", _decayState.Count);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] decay baseline threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DecaySweep()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_decaySweeps++;
|
|
||||||
|
|
||||||
if (!_baselined)
|
|
||||||
BaselineDecay();
|
|
||||||
|
|
||||||
foreach (var house in BaseHouse.AllHouses)
|
|
||||||
{
|
|
||||||
if (house == null || house.Deleted)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var level = house.DecayLevel; // computed getter — read once
|
|
||||||
var serial = house.Serial;
|
|
||||||
|
|
||||||
DecayLevel prior;
|
|
||||||
bool known = _decayState.TryGetValue(serial, out prior);
|
|
||||||
|
|
||||||
if (known && prior == level)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
_decayState[serial] = level;
|
|
||||||
|
|
||||||
if (!known)
|
|
||||||
continue; // a house that appeared since baseline; record, do not announce
|
|
||||||
|
|
||||||
_decayTransitions++;
|
|
||||||
|
|
||||||
if (BridgeLink.Connected)
|
|
||||||
BridgeLink.Emit(WriteDecay(house, prior, level));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] decay sweep threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string WriteDecay(BaseHouse house, DecayLevel from, DecayLevel to)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("house.decay")
|
|
||||||
.Ser("serial", house.Serial)
|
|
||||||
.Str("from", from.ToString())
|
|
||||||
.Str("to", to.ToString())
|
|
||||||
.Str("map", house.Map == null ? null : house.Map.Name)
|
|
||||||
.Num("x", house.X).Num("y", house.Y).Num("z", house.Z);
|
|
||||||
|
|
||||||
var region = house.Region;
|
|
||||||
if (region != null)
|
|
||||||
sb.Str("region", region.Name);
|
|
||||||
|
|
||||||
var sign = house.Sign;
|
|
||||||
if (sign != null)
|
|
||||||
sb.Str("name", sign.GetName());
|
|
||||||
|
|
||||||
var owner = house.Owner;
|
|
||||||
if (owner != null)
|
|
||||||
{
|
|
||||||
sb.Ser("ownerSerial", owner.Serial);
|
|
||||||
var acct = owner.Account as Account;
|
|
||||||
if (acct != null)
|
|
||||||
sb.Str("ownerAcct", acct.Username);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where a player would physically stand to see it.
|
|
||||||
var ban = house.BanLocation;
|
|
||||||
sb.Append(",\"ban\":{\"x\":").Append(ban.X)
|
|
||||||
.Append(",\"y\":").Append(ban.Y)
|
|
||||||
.Append(",\"z\":").Append(ban.Z).Append('}');
|
|
||||||
|
|
||||||
sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o"));
|
|
||||||
sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o"));
|
|
||||||
|
|
||||||
return sb.End();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- economy supply ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Money supply = the sum of every account's currency, as a periodic snapshot. This is
|
|
||||||
/// the level; AccountGoldChange and the vendor events are the flow. The sidecar keeps
|
|
||||||
/// both.
|
|
||||||
/// </summary>
|
|
||||||
private static void EconomySweep()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_economySweeps++;
|
|
||||||
|
|
||||||
if (!BridgeLink.Connected)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double totalCurrency = 0;
|
|
||||||
int accounts = 0;
|
|
||||||
|
|
||||||
foreach (Account a in Accounting.Accounts.GetAccounts())
|
|
||||||
{
|
|
||||||
totalCurrency += a.TotalCurrency;
|
|
||||||
accounts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
BridgeLink.Emit(BridgeJson.Begin("economy.supply")
|
|
||||||
.Num("accounts", accounts)
|
|
||||||
.Num("gold", (long)(totalCurrency * Account.CurrencyThreshold))
|
|
||||||
.End());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] economy sweep threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs each sweep once, now. For `[bridge sweepnow`.</summary>
|
|
||||||
public static void SweepOnce()
|
|
||||||
{
|
|
||||||
VitalsSweep();
|
|
||||||
DecaySweep();
|
|
||||||
EconomySweep();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Website-published news, pushed into the game's town criers.
|
|
||||||
///
|
|
||||||
/// Inbound towncrier.add adds a global entry that every town crier announces until it
|
|
||||||
/// expires; towncrier.remove pulls one early. Both run on the Core thread (inbound lines are
|
|
||||||
/// marshaled through Timer.DelayCall before a handler sees them), which is required because
|
|
||||||
/// AddEntry mutates a shared list and the criers send packets.
|
|
||||||
///
|
|
||||||
/// Loopback is the trust boundary, but the caps here (line count/length, active-entry count,
|
|
||||||
/// duration) are defense in depth: a compromised or buggy sidecar still cannot flood the
|
|
||||||
/// criers or pin a message forever.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeTownCrier
|
|
||||||
{
|
|
||||||
// Website id -> the entry we created for it, so a later remove can find it.
|
|
||||||
private static readonly Dictionary<string, TownCrierEntry> _entries =
|
|
||||||
new Dictionary<string, TownCrierEntry>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BridgeBoot.RegisterHandler("towncrier.add", OnAdd);
|
|
||||||
BridgeBoot.RegisterHandler("towncrier.remove", OnRemove);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Reply(string kind, string id, string reason)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin(kind);
|
|
||||||
if (id != null) sb.Str("id", id);
|
|
||||||
if (reason != null) sb.Str("reason", reason);
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnAdd(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var id = BridgeJson.GetString(o, "id");
|
|
||||||
|
|
||||||
if (id == null)
|
|
||||||
{
|
|
||||||
Reply("towncrier.error", null, "missing id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = BridgeJson.GetStringList(o, "lines");
|
|
||||||
|
|
||||||
if (lines.Count == 0)
|
|
||||||
{
|
|
||||||
Reply("towncrier.error", id, "no lines");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.Count > BridgeConfig.TownCrierMaxLines)
|
|
||||||
{
|
|
||||||
Reply("towncrier.error", id, "too many lines");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prune expired entries from our map before enforcing the active cap.
|
|
||||||
PruneExpired();
|
|
||||||
|
|
||||||
// Replacing an existing id is fine; otherwise enforce the active cap.
|
|
||||||
if (!_entries.ContainsKey(id) && _entries.Count >= BridgeConfig.TownCrierMaxActive)
|
|
||||||
{
|
|
||||||
Reply("towncrier.error", id, "too many active entries");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var clean = new string[lines.Count];
|
|
||||||
for (int i = 0; i < lines.Count; i++)
|
|
||||||
{
|
|
||||||
var line = lines[i] ?? "";
|
|
||||||
if (line.Length > BridgeConfig.TownCrierMaxLineLength)
|
|
||||||
line = line.Substring(0, BridgeConfig.TownCrierMaxLineLength);
|
|
||||||
clean[i] = line;
|
|
||||||
}
|
|
||||||
|
|
||||||
int durationSec = BridgeJson.GetInt(o, "durationSec", 3600);
|
|
||||||
if (durationSec < 1)
|
|
||||||
durationSec = 1;
|
|
||||||
if (durationSec > BridgeConfig.TownCrierMaxDurationSec)
|
|
||||||
durationSec = BridgeConfig.TownCrierMaxDurationSec;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// If this id already exists, replace it: remove the old entry first.
|
|
||||||
TownCrierEntry old;
|
|
||||||
if (_entries.TryGetValue(id, out old) && old != null)
|
|
||||||
GlobalTownCrierEntryList.Instance.RemoveEntry(old);
|
|
||||||
|
|
||||||
var entry = GlobalTownCrierEntryList.Instance.AddEntry(clean, TimeSpan.FromSeconds(durationSec));
|
|
||||||
_entries[id] = entry;
|
|
||||||
|
|
||||||
Reply("towncrier.ok", id, null);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] towncrier.add threw: {0}", ex.Message);
|
|
||||||
Reply("towncrier.error", id, "internal error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnRemove(Dictionary<string, object> o)
|
|
||||||
{
|
|
||||||
var id = BridgeJson.GetString(o, "id");
|
|
||||||
|
|
||||||
if (id == null)
|
|
||||||
{
|
|
||||||
Reply("towncrier.error", null, "missing id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
TownCrierEntry entry;
|
|
||||||
if (!_entries.TryGetValue(id, out entry))
|
|
||||||
{
|
|
||||||
Reply("towncrier.error", id, "unknown id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_entries.Remove(id);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (entry != null)
|
|
||||||
GlobalTownCrierEntryList.Instance.RemoveEntry(entry);
|
|
||||||
|
|
||||||
Reply("towncrier.ok", id, null);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] towncrier.remove threw: {0}", ex.Message);
|
|
||||||
Reply("towncrier.error", id, "internal error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PruneExpired()
|
|
||||||
{
|
|
||||||
var doomed = new List<string>();
|
|
||||||
|
|
||||||
foreach (var kv in _entries)
|
|
||||||
{
|
|
||||||
if (kv.Value == null || kv.Value.Expired)
|
|
||||||
doomed.Add(kv.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var id in doomed)
|
|
||||||
_entries.Remove(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net48</TargetFramework>
|
|
||||||
<OutputType>Library</OutputType>
|
|
||||||
<AssemblyName>Scripts</AssemblyName>
|
|
||||||
<RootNamespace>Server</RootNamespace>
|
|
||||||
<AppendTargetFrameworkToOutputPath>False</AppendTargetFrameworkToOutputPath>
|
|
||||||
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
|
|
||||||
<UseVSHostingProcess>False</UseVSHostingProcess>
|
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
|
||||||
<UseWindowsForms>false</UseWindowsForms>
|
|
||||||
<Platforms>x64</Platforms>
|
|
||||||
</PropertyGroup>
|
|
||||||
<!--
|
|
||||||
Conditioned on Configuration alone, not Configuration|Platform. ScriptCompiler.Compile
|
|
||||||
runs `dotnet build Scripts/Scripts.csproj -c Release` with no Platform, so Platform
|
|
||||||
defaults to AnyCPU. Under the old Platform-qualified conditions that meant OutputPath
|
|
||||||
was unset (the DLL landed in Scripts/bin/Release/ while the core loads Scripts.dll from
|
|
||||||
the base directory) and DefineConstants were empty (XmlSpawner compiled its non-ServUO
|
|
||||||
branches). The net effect was that runtime script compilation silently had no effect.
|
|
||||||
-->
|
|
||||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
|
||||||
<OutputPath>..\</OutputPath>
|
|
||||||
<DefineConstants>TRACE;DEBUG;NEWTIMERS;ServUO</DefineConstants>
|
|
||||||
<DebugType>embedded</DebugType>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
|
||||||
<OutputPath>..\</OutputPath>
|
|
||||||
<DefineConstants>TRACE;NEWTIMERS;ServUO</DefineConstants>
|
|
||||||
<DebugType>none</DebugType>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="System.Web" />
|
|
||||||
<!-- JavaScriptSerializer, for parsing inbound sidecar commands. See BridgeJson. -->
|
|
||||||
<Reference Include="System.Web.Extensions" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Server\Server.csproj" />
|
|
||||||
<ProjectReference Include="..\Ultima\Ultima.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="System.Data.DataSetExtensions" Version="4.5.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server;
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Custom.Bridge;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Subscribes to the PlayerVendorSale event added by the Phase 7 core patches. This file is
|
|
||||||
/// part of that coupled unit and is NOT in overlay/, because it references
|
|
||||||
/// PlayerVendorSaleEventArgs, which does not exist until the EventSink patch is applied —
|
|
||||||
/// shipping it in overlay/ would break the build on any install without the patch.
|
|
||||||
///
|
|
||||||
/// Deploy: apply patches/playervendor-sale-*.patch, then copy this file to
|
|
||||||
/// Scripts/Custom/Bridge/BridgeVendorSale.cs.
|
|
||||||
///
|
|
||||||
/// The event fires at the committed sale (PlayerVendorBuyGump.OnResponse), on the Core
|
|
||||||
/// thread, with buyer, vendor owner, item, price, and commission all in scope — richer than
|
|
||||||
/// the NPC ValidVendor* events (which lack owner and commission) and, unlike them, on a
|
|
||||||
/// committed sale rather than a validation stage. It is the backbone of the cheat-detection
|
|
||||||
/// feed: same-account buyer≈owner is gold laundering, off-market prices and burst patterns
|
|
||||||
/// are visible to the sidecar.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeVendorSale
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (!BridgeConfig.Enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
EventSink.PlayerVendorSale += OnPlayerVendorSale;
|
|
||||||
Console.WriteLine("[Bridge] player-vendor sale stream attached");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnPlayerVendorSale(PlayerVendorSaleEventArgs e)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("vendor.sale")
|
|
||||||
.Bool("committed", true);
|
|
||||||
|
|
||||||
// Buyer
|
|
||||||
if (e.Buyer != null)
|
|
||||||
{
|
|
||||||
sb.Ser("buyerSerial", e.Buyer.Serial);
|
|
||||||
var ba = e.Buyer.Account as Account;
|
|
||||||
if (ba != null)
|
|
||||||
sb.Str("buyerAcct", ba.Username);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vendor owner — the player who actually profits.
|
|
||||||
if (e.Owner != null)
|
|
||||||
{
|
|
||||||
sb.Ser("ownerSerial", e.Owner.Serial);
|
|
||||||
var oa = e.Owner.Account as Account;
|
|
||||||
if (oa != null)
|
|
||||||
sb.Str("ownerAcct", oa.Username);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.Vendor != null)
|
|
||||||
sb.Ser("vendorSerial", e.Vendor.Serial);
|
|
||||||
|
|
||||||
if (e.Item != null)
|
|
||||||
{
|
|
||||||
sb.Ser("itemSerial", e.Item.Serial);
|
|
||||||
sb.Str("itemType", e.Item.GetType().Name);
|
|
||||||
sb.Num("itemId", e.Item.ItemID);
|
|
||||||
sb.Num("amount", e.Item.Amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Num("price", e.Price);
|
|
||||||
sb.Num("commission", e.Commission);
|
|
||||||
|
|
||||||
BridgeLink.Emit(sb.End());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[Bridge] vendor.sale handler threw: {0}", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# patches
|
|
||||||
|
|
||||||
Unified diffs against stock ServUO 57.4 for files the bridge must **modify** rather than add. Anything that can be shipped as a whole file belongs in `overlay/` instead.
|
|
||||||
|
|
||||||
Apply from the server root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git apply --check patches/<name>.patch # dry run
|
|
||||||
git apply patches/<name>.patch
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 7 — player-vendor sale (a coupled unit)
|
|
||||||
|
|
||||||
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See `docs/PLAN.md` §6.
|
|
||||||
|
|
||||||
This is the one non-drop-in piece. Apply all three together:
|
|
||||||
|
|
||||||
| Item | Target | What |
|
|
||||||
|------|--------|------|
|
|
||||||
| `playervendor-sale-eventsink.patch` | `Server/EventSink.cs` | Adds the `PlayerVendorSale` delegate, `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }`, the event field, and `InvokePlayerVendorSale`. |
|
|
||||||
| `playervendor-sale-gump.patch` | `Scripts/Gumps/PlayerVendorGumps.cs` | One `InvokePlayerVendorSale(...)` call right after the committed `HoldGold +=`. |
|
|
||||||
| `BridgeVendorSale.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber that emits `vendor.sale`. **Not** in `overlay/` because it references `PlayerVendorSaleEventArgs`, which does not exist until the EventSink patch is applied — shipping it in overlay would break the build on any unpatched install. |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <servuo root>
|
|
||||||
git apply --check patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch # dry run
|
|
||||||
git apply patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch
|
|
||||||
cp patches/BridgeVendorSale.cs Scripts/Custom/Bridge/BridgeVendorSale.cs
|
|
||||||
```
|
|
||||||
|
|
||||||
Both patches are `git`-format and verified with `git apply --check` against stock ServUO 57.4. Modifying `EventSink.cs` means the **core** rebuilds, so `ScriptCompiler`'s dynamic script build is not enough — rebuild the solution (`dotnet build ServUO.sln`) or the server binary.
|
|
||||||
|
|
||||||
Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
|
|
||||||
|
|
||||||
## In-game moderation audit (admin controls §5.5)
|
|
||||||
|
|
||||||
So the website's moderation log stays complete, in-game uses of the write-plane verbs are forwarded to it as `admin.audit` (`origin:"in-game"`). Broadcasts already surface through `EventSink.Command`, but resolved bans/kicks only carry their target inside the command's own `CommandLogging.WriteLine` call — which has no event to subscribe to. One small change fixes that:
|
|
||||||
|
|
||||||
| Item | Target | What |
|
|
||||||
|------|--------|------|
|
|
||||||
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | Adds a `public static event Action<Mobile,string> OnWrite`, raised in `WriteLine` **before** the `m_Enabled` guard so it fires even when file logging is off. |
|
|
||||||
| `BridgeModerationAudit.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber: taps `OnWrite` for ban/kick (parsing the target out of the log line) and `EventSink.Command` for `[bcast`, emitting `admin.audit`. **Not** in `overlay/` because it references `CommandLogging.OnWrite`, which does not exist until the patch is applied. |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <servuo root>
|
|
||||||
git apply --check patches/commandlogging-event.patch # dry run
|
|
||||||
git apply patches/commandlogging-event.patch
|
|
||||||
cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/BridgeModerationAudit.cs
|
|
||||||
```
|
|
||||||
|
|
||||||
`Logging.cs` is a **Scripts** file, so this is picked up by the dynamic script build — no core/solution rebuild needed (unlike the Phase 7 `EventSink.cs` patch). Verified end-to-end with `tools/scaffolding/BridgeAuditProbe.cs` (gated by `Bridge.AuditProbeOnStart`): a genuine `[bcast` plus simulated ban/kick log lines produced the expected `admin.audit` frames, target parsed, with non-moderation lines ignored.
|
|
||||||
|
|
||||||
## Note on `Scripts.csproj`
|
|
||||||
|
|
||||||
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
|
|
||||||
|
|
||||||
## Note on shard repairs
|
|
||||||
|
|
||||||
The deletions and edits described in `docs/SHARD_PREREQS.md` are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
--- 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;
|
|
||||||
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
diff --git a/Server/EventSink.cs b/Server/EventSink.cs
|
|
||||||
index d30788f..1da2667 100644
|
|
||||||
--- a/Server/EventSink.cs
|
|
||||||
+++ b/Server/EventSink.cs
|
|
||||||
@@ -171,6 +171,8 @@ namespace Server
|
|
||||||
|
|
||||||
public delegate void ValidVendorSellEventHandler(ValidVendorSellEventArgs e);
|
|
||||||
|
|
||||||
+ public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);
|
|
||||||
+
|
|
||||||
public delegate void CorpseLootEventHandler(CorpseLootEventArgs e);
|
|
||||||
|
|
||||||
public delegate void RepairItemEventHandler(RepairItemEventArgs e);
|
|
||||||
@@ -1521,6 +1523,29 @@ namespace Server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+ // Player-vendor purchases raise no other EventSink. This fires at the committed sale in
|
|
||||||
+ // PlayerVendorBuyGump.OnResponse, where buyer, vendor owner, item, price, and commission
|
|
||||||
+ // are all in scope -- the data the bridge's cheat-detection feed needs.
|
|
||||||
+ public class PlayerVendorSaleEventArgs : EventArgs
|
|
||||||
+ {
|
|
||||||
+ public Mobile Buyer { get; set; }
|
|
||||||
+ public Mobile Vendor { get; set; }
|
|
||||||
+ public Mobile Owner { get; set; }
|
|
||||||
+ public Item Item { get; set; }
|
|
||||||
+ public int Price { get; set; }
|
|
||||||
+ public int Commission { get; set; }
|
|
||||||
+
|
|
||||||
+ public PlayerVendorSaleEventArgs(Mobile buyer, Mobile vendor, Mobile owner, Item item, int price, int commission)
|
|
||||||
+ {
|
|
||||||
+ Buyer = buyer;
|
|
||||||
+ Vendor = vendor;
|
|
||||||
+ Owner = owner;
|
|
||||||
+ Item = item;
|
|
||||||
+ Price = price;
|
|
||||||
+ Commission = commission;
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
public class CorpseLootEventArgs : EventArgs
|
|
||||||
{
|
|
||||||
public Mobile Mobile { get; set; }
|
|
||||||
@@ -1771,6 +1796,7 @@ namespace Server
|
|
||||||
public static event TameCreatureEventHandler TameCreature;
|
|
||||||
public static event ValidVendorPurchaseEventHandler ValidVendorPurchase;
|
|
||||||
public static event ValidVendorSellEventHandler ValidVendorSell;
|
|
||||||
+ public static event PlayerVendorSaleEventHandler PlayerVendorSale;
|
|
||||||
public static event CorpseLootEventHandler CorpseLoot;
|
|
||||||
public static event RepairItemEventHandler RepairItem;
|
|
||||||
public static event AlterItemEventHandler AlterItem;
|
|
||||||
@@ -2416,6 +2442,14 @@ namespace Server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+ public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)
|
|
||||||
+ {
|
|
||||||
+ if (PlayerVendorSale != null)
|
|
||||||
+ {
|
|
||||||
+ PlayerVendorSale(e);
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
public static void InvokeCorpseLoot(CorpseLootEventArgs e)
|
|
||||||
{
|
|
||||||
if (CorpseLoot != null)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
diff --git a/Scripts/Gumps/PlayerVendorGumps.cs b/Scripts/Gumps/PlayerVendorGumps.cs
|
|
||||||
index 049aae6..f1b30d2 100644
|
|
||||||
--- a/Scripts/Gumps/PlayerVendorGumps.cs
|
|
||||||
+++ b/Scripts/Gumps/PlayerVendorGumps.cs
|
|
||||||
@@ -95,6 +95,10 @@ namespace Server.Gumps
|
|
||||||
|
|
||||||
m_Vendor.HoldGold += m_VI.Price - commission;
|
|
||||||
|
|
||||||
+ // uo-link: the only committed-sale hook for player vendors (no EventSink exists).
|
|
||||||
+ EventSink.InvokePlayerVendorSale(
|
|
||||||
+ new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));
|
|
||||||
+
|
|
||||||
from.SendLocalizedMessage(503201); // You take the item.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ website ──WS (live feed) / REST (queries)──► sidecar ──loopback
|
|||||||
(this) newline-JSON, bidirectional
|
(this) newline-JSON, bidirectional
|
||||||
```
|
```
|
||||||
|
|
||||||
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See `../docs/PLAN.md` §2.
|
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §2.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -41,10 +41,10 @@ So you can never accidentally run without auth. Rotate by editing the token and
|
|||||||
|
|
||||||
## Protocol version
|
## Protocol version
|
||||||
|
|
||||||
The wire protocol has a version (`PROTOCOL_VERSION`, currently **1**), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes.
|
The wire protocol has a version (`PROTOCOL_VERSION`, currently **3**), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes.
|
||||||
|
|
||||||
- Every response carries an `X-UOLink-Version: 1` header.
|
- Every response carries an `X-UOLink-Version: 3` header.
|
||||||
- `/health` and the WebSocket `ws.hello` include `"protocol": 1`.
|
- `/health` and the WebSocket `ws.hello` include `"protocol": 3`.
|
||||||
- If a request sends `X-UOLink-Version` and it disagrees with the sidecar, the request is rejected **409 Conflict** with `{sidecar_protocol, client_protocol}` so the mismatch is obvious.
|
- If a request sends `X-UOLink-Version` and it disagrees with the sidecar, the request is rejected **409 Conflict** with `{sidecar_protocol, client_protocol}` so the mismatch is obvious.
|
||||||
|
|
||||||
Bump `PROTOCOL_VERSION` in `main.rs` whenever an event or endpoint's shape changes.
|
Bump `PROTOCOL_VERSION` in `main.rs` whenever an event or endpoint's shape changes.
|
||||||
@@ -106,4 +106,4 @@ A shard `*.error` reply maps to HTTP 404 (unknown/not-found) or 400 (bad request
|
|||||||
|
|
||||||
## Wire protocol
|
## Wire protocol
|
||||||
|
|
||||||
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in `../docs/PLAN.md` (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ use tracing_subscriber::EnvFilter;
|
|||||||
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
|
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
|
||||||
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
|
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
|
||||||
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
|
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
|
||||||
pub const PROTOCOL_VERSION: u32 = 2;
|
///
|
||||||
|
/// v3 (Protocol 3.0): adds `world.ruleset`, `points.board` and `vendor.listing` /
|
||||||
|
/// `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them
|
||||||
|
/// from the store. Same shape as the v2 bump — the kinds are additive, the endpoints are not — and
|
||||||
|
/// there is deliberately no feature-negotiation array: v3 implies all three kinds.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 3;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@@ -194,6 +199,75 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Points/loyalty boards (Protocol 3.0): one row per point system, keyed by
|
||||||
|
// the shard's own PointsType name. The plugin only emits a system whose top N
|
||||||
|
// actually moved, so this is a sparse stream of overwrites — and there is no
|
||||||
|
// `points.remove` to handle, because the shard's set of systems is fixed at
|
||||||
|
// startup and cannot shrink.
|
||||||
|
"points.board" => {
|
||||||
|
if let Some(system) = ev.value.get("system").and_then(|s| s.as_str()) {
|
||||||
|
if let Err(e) = event_store
|
||||||
|
.upsert_points_board(
|
||||||
|
system,
|
||||||
|
ev.value.get("nameString").and_then(|n| n.as_str()),
|
||||||
|
&text,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to upsert points board");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Player-vendor market index (Protocol 3.0). Each frame is authoritative for
|
||||||
|
// one vendor — the shard's round-robin sweep only emits a shop whose contents,
|
||||||
|
// prices or location actually moved — so this is a whole-row overwrite.
|
||||||
|
//
|
||||||
|
// Unlike the boards above there IS a remove: a vendor is dismissed, expires, or
|
||||||
|
// its owner switches off the in-game Vendor Search flag, and any of those must
|
||||||
|
// take the shop off the site. The last of the three is a privacy control, so
|
||||||
|
// dropping the row promptly is the point rather than housekeeping.
|
||||||
|
"vendor.listing" => {
|
||||||
|
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||||
|
let loc = ev.value.get("location");
|
||||||
|
let field = |k: &str| loc.and_then(|l| l.get(k));
|
||||||
|
if let Err(e) = event_store
|
||||||
|
.upsert_vendor(
|
||||||
|
serial,
|
||||||
|
ev.value.get("shopName").and_then(|v| v.as_str()),
|
||||||
|
ev.value.get("ownerName").and_then(|v| v.as_str()),
|
||||||
|
field("map").and_then(|v| v.as_str()),
|
||||||
|
field("x").and_then(|v| v.as_i64()),
|
||||||
|
field("y").and_then(|v| v.as_i64()),
|
||||||
|
field("region").and_then(|v| v.as_str()),
|
||||||
|
ev.value.get("count").and_then(|v| v.as_i64()),
|
||||||
|
&text,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to upsert vendor listing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"vendor.listing.remove" => {
|
||||||
|
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||||
|
if let Err(e) = event_store.delete_vendor(serial).await {
|
||||||
|
tracing::warn!(error = %e, "failed to remove vendor listing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shard ruleset (Protocol 3.0): a singleton projection. The shard re-emits
|
||||||
|
// world.ruleset on every connect, so this row is simply overwritten; `rev`
|
||||||
|
// lets a reader tell a re-send from an actual config change.
|
||||||
|
"world.ruleset" => {
|
||||||
|
if let Err(e) = event_store
|
||||||
|
.upsert_ruleset(ev.value.get("rev").and_then(|r| r.as_str()), &text, t)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to upsert ruleset");
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
|
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
|
||||||
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
|
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
|
||||||
//! is never directly reachable from the website — it exposes no port. See docs/PLAN.md §2.
|
//! is never directly reachable from the website — it exposes no port. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2.
|
||||||
//!
|
//!
|
||||||
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
|
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
|
||||||
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
||||||
|
|||||||
@@ -293,6 +293,174 @@ impl Store {
|
|||||||
Ok(parse_json_column(rows))
|
Ok(parse_json_column(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- shard ruleset (Protocol 3.0) ----
|
||||||
|
|
||||||
|
/// Stores the shard's published ruleset. A singleton (`id = 1`): the shard emits one
|
||||||
|
/// `world.ruleset` frame per connect describing how it is configured, and only the latest one
|
||||||
|
/// matters. `rev` is the shard's FNV-1a of the body, kept so a reader can tell "same ruleset,
|
||||||
|
/// re-sent on reconnect" from "the operator changed something" without diffing the JSON.
|
||||||
|
pub async fn upsert_ruleset(
|
||||||
|
&self,
|
||||||
|
rev: Option<&str>,
|
||||||
|
json: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ruleset (id, rev, json, updated_t) VALUES (1, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET rev = excluded.rev, json = excluded.json, updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(rev)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored ruleset, or `None` if the shard has never published one. Returning `None` rather
|
||||||
|
/// than an empty object is deliberate: "not published yet" and "published, everything off" are
|
||||||
|
/// different answers and the website renders them differently.
|
||||||
|
pub async fn ruleset(&self) -> anyhow::Result<Option<Value>> {
|
||||||
|
let row = sqlx::query("SELECT json FROM ruleset WHERE id = 1")
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- points / loyalty boards (Protocol 3.0) ----
|
||||||
|
|
||||||
|
/// Upserts one system's leaderboard, keyed by its `PointsType` name (`QueensLoyalty`,
|
||||||
|
/// `CleanUpBritannia`, …). Fed from `points.board`; one row per system, always the most recent
|
||||||
|
/// top-N snapshot.
|
||||||
|
///
|
||||||
|
/// There is no matching delete, and that is deliberate rather than an omission: the shard's set
|
||||||
|
/// of point systems is fixed at startup by `PointsSystem.Configure`, so a system cannot vanish
|
||||||
|
/// at runtime and the plugin emits no `points.remove`. Same argument the governor board makes.
|
||||||
|
pub async fn upsert_points_board(
|
||||||
|
&self,
|
||||||
|
system: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
json: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO points_boards (system, name, json, updated_t) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(system) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(system)
|
||||||
|
.bind(name)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every system's latest board, ordered by display name then system key. Systems the shard has
|
||||||
|
/// never published are simply absent — the website renders the set it is given.
|
||||||
|
pub async fn points_boards_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||||
|
let rows = sqlx::query("SELECT json FROM points_boards ORDER BY name, system")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(parse_json_column(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One system's board, or `None` when that system has never published one. `None` is a real
|
||||||
|
/// answer (an unknown system name, or one the operator excluded via `Bridge.cfg PointsSystems`),
|
||||||
|
/// which the website turns into a 404 rather than an empty board.
|
||||||
|
pub async fn points_board(&self, system: &str) -> anyhow::Result<Option<Value>> {
|
||||||
|
let row = sqlx::query("SELECT json FROM points_boards WHERE system = ?")
|
||||||
|
.bind(system)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- player-vendor market index (Protocol 3.0) ----
|
||||||
|
|
||||||
|
/// Upserts one vendor's whole listing, keyed by serial. Fed from `vendor.listing`, which the
|
||||||
|
/// shard emits as an authoritative per-vendor frame — so this replaces the row outright rather
|
||||||
|
/// than merging anything.
|
||||||
|
///
|
||||||
|
/// The items ride inside `json` and are deliberately NOT normalized into a `vendor_items`
|
||||||
|
/// table. The sidecar's job for the market is outage resilience (`PROTOCOL_2.md` §12.2) — hand
|
||||||
|
/// the website back what the shard last said — not search. Search lives in MariaDB on the
|
||||||
|
/// website side, where the query surface, the indexes and the cliloc-resolved display names
|
||||||
|
/// already are; a second search implementation here would be one more thing to keep in step
|
||||||
|
/// with it for no reader.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn upsert_vendor(
|
||||||
|
&self,
|
||||||
|
serial: &str,
|
||||||
|
shop_name: Option<&str>,
|
||||||
|
owner_name: Option<&str>,
|
||||||
|
map: Option<&str>,
|
||||||
|
x: Option<i64>,
|
||||||
|
y: Option<i64>,
|
||||||
|
region: Option<&str>,
|
||||||
|
count: Option<i64>,
|
||||||
|
json: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO vendors (serial, shop_name, owner_name, map, x, y, region, count, json, updated_t)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(serial) DO UPDATE SET shop_name = excluded.shop_name,
|
||||||
|
owner_name = excluded.owner_name, map = excluded.map, x = excluded.x, y = excluded.y,
|
||||||
|
region = excluded.region, count = excluded.count, json = excluded.json,
|
||||||
|
updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(serial)
|
||||||
|
.bind(shop_name)
|
||||||
|
.bind(owner_name)
|
||||||
|
.bind(map)
|
||||||
|
.bind(x)
|
||||||
|
.bind(y)
|
||||||
|
.bind(region)
|
||||||
|
.bind(count)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops one vendor from the index. Fed from `vendor.listing.remove` — a vendor dismissed,
|
||||||
|
/// expired, or whose owner switched off its in-game Vendor Search flag.
|
||||||
|
pub async fn delete_vendor(&self, serial: &str) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM vendors WHERE serial = ?")
|
||||||
|
.bind(serial)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of the index, ordered by serial.
|
||||||
|
///
|
||||||
|
/// Paged where the other boards are not, and the ordering is why it can be: a whole-world
|
||||||
|
/// market is the one board that does not fit in a response. Ordering by SERIAL rather than by
|
||||||
|
/// shop name is deliberate — the page is a snapshot cursor for the website's reconnect
|
||||||
|
/// backfill, and a serial is stable while a shop name is renameable, so a rename mid-backfill
|
||||||
|
/// cannot make a vendor skip or repeat a page.
|
||||||
|
pub async fn vendors_page(&self, limit: i64, offset: i64) -> anyhow::Result<Vec<Value>> {
|
||||||
|
let limit = limit.clamp(1, 1000);
|
||||||
|
let offset = offset.max(0);
|
||||||
|
let rows = sqlx::query("SELECT json FROM vendors ORDER BY serial LIMIT ? OFFSET ?")
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(parse_json_column(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many vendors the index holds, so a paging caller knows when to stop.
|
||||||
|
pub async fn vendors_count(&self) -> anyhow::Result<i64> {
|
||||||
|
let row = sqlx::query("SELECT COUNT(*) AS n FROM vendors")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.get::<i64, _>("n"))
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Town Cryer news (Protocol 2.1) ----
|
// ---- Town Cryer news (Protocol 2.1) ----
|
||||||
|
|
||||||
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
|
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
|
||||||
@@ -392,4 +560,39 @@ CREATE TABLE IF NOT EXISTS news (
|
|||||||
json TEXT NOT NULL,
|
json TEXT NOT NULL,
|
||||||
updated_t INTEGER NOT NULL
|
updated_t INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Points/loyalty leaderboards (Protocol 3.0). One row per point system, keyed by the shard's
|
||||||
|
-- own PointsType name; `name` is the resolved display name, hoisted only for the ORDER BY.
|
||||||
|
CREATE TABLE IF NOT EXISTS points_boards (
|
||||||
|
system TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Player-vendor market index (Protocol 3.0). One row per vendor, holding the whole authoritative
|
||||||
|
-- `vendor.listing` frame including its items. The hoisted columns exist for the ORDER BY and for
|
||||||
|
-- an operator eyeballing the table; nothing here is searched, because search is the website's job
|
||||||
|
-- (see upsert_vendor). Rows are dropped on `vendor.listing.remove`.
|
||||||
|
CREATE TABLE IF NOT EXISTS vendors (
|
||||||
|
serial TEXT PRIMARY KEY,
|
||||||
|
shop_name TEXT,
|
||||||
|
owner_name TEXT,
|
||||||
|
map TEXT,
|
||||||
|
x INTEGER,
|
||||||
|
y INTEGER,
|
||||||
|
region TEXT,
|
||||||
|
count INTEGER,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The shard's published ruleset (Protocol 3.0). Singleton: the CHECK is what makes it one,
|
||||||
|
-- so an upsert can target id = 1 unconditionally and no second row can ever appear.
|
||||||
|
CREATE TABLE IF NOT EXISTS ruleset (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
rev TEXT,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -77,11 +77,25 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
.route("/economy", get(economy))
|
.route("/economy", get(economy))
|
||||||
.route("/champs", get(champs))
|
.route("/champs", get(champs))
|
||||||
// World-state boards (Protocol 2.0), served from the store so they answer without the shard
|
// World-state boards (Protocol 2.0), served from the store so they answer without the shard
|
||||||
// and survive an outage with the last-known snapshot (docs/PROTOCOL_2.md §12.2).
|
// and survive an outage with the last-known snapshot (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §12.2).
|
||||||
.route("/guilds", get(guilds))
|
.route("/guilds", get(guilds))
|
||||||
.route("/governors", get(governors))
|
.route("/governors", get(governors))
|
||||||
.route("/online", get(online))
|
.route("/online", get(online))
|
||||||
.route("/houses", get(houses))
|
.route("/houses", get(houses))
|
||||||
|
// The shard ruleset (Protocol 3.0), likewise store-backed: the shard publishes it once per
|
||||||
|
// connect, so serving it from the store is what lets the site's rules page render while the
|
||||||
|
// shard is down.
|
||||||
|
.route("/ruleset", get(ruleset))
|
||||||
|
// Points/loyalty leaderboards (Protocol 3.0), store-backed like the other boards: the
|
||||||
|
// whole set, or one system by its PointsType name.
|
||||||
|
.route("/points", get(points))
|
||||||
|
.route("/points/:system", get(points_system))
|
||||||
|
// The player-vendor market index (Protocol 3.0). `/market`, NOT `/vendors`: axum would
|
||||||
|
// route the latter fine, but `/vendors/:account` next door is the per-account RPC, and two
|
||||||
|
// routes a prefix apart that mean "this player's shops" and "every shop on the shard" is a
|
||||||
|
// readability trap nobody wins. The only PAGED read the sidecar serves — a whole-world
|
||||||
|
// market does not fit in one response.
|
||||||
|
.route("/market", get(market))
|
||||||
.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()
|
||||||
@@ -790,6 +804,60 @@ async fn houses(State(st): State<AppState>) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shard's published ruleset: expansion, which optional systems are on, skill/stat caps,
|
||||||
|
/// account and house limits, champion scroll rules, the save/restart schedule. Served from the
|
||||||
|
/// store, so it answers during a shard outage with the last-known ruleset — which is the whole
|
||||||
|
/// point, since a rules page that goes blank when the shard restarts is worse than a stale one.
|
||||||
|
///
|
||||||
|
/// `{"ruleset": null}` means the shard has never published one (an old plugin, or
|
||||||
|
/// `Bridge.RulesetEnabled=false`), which the website renders differently from a published ruleset.
|
||||||
|
async fn ruleset(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.ruleset().await {
|
||||||
|
Ok(r) => (StatusCode::OK, Json(json!({ "ruleset": r }))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every points/loyalty leaderboard the shard publishes: one entry per point system, each with its
|
||||||
|
/// display name (literal and/or cliloc), max points, participant count and top N. Store-backed like
|
||||||
|
/// the other boards, so the site's leaderboards page renders during a shard outage — which matters
|
||||||
|
/// more here than elsewhere, since these are month-scale standings that a restart must not blank.
|
||||||
|
async fn points(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.points_boards_all().await {
|
||||||
|
Ok(boards) => (StatusCode::OK, Json(json!({"boards": boards}))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One system's board by its `PointsType` name (`QueensLoyalty`, `CleanUpBritannia`, …).
|
||||||
|
///
|
||||||
|
/// 404 rather than an empty board when the system is unknown: the shard publishes only the systems
|
||||||
|
/// it shows on the loyalty gump (or the explicit `Bridge.cfg PointsSystems` list), so "no such
|
||||||
|
/// board" and "a board with nobody on it" are different answers and the website renders them
|
||||||
|
/// differently.
|
||||||
|
async fn points_system(
|
||||||
|
State(st): State<AppState>,
|
||||||
|
Path(system): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match st.store.points_board(&system).await {
|
||||||
|
Ok(Some(board)) => (StatusCode::OK, Json(board)),
|
||||||
|
Ok(None) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({"error": "unknown points system", "system": system})),
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The current online population: total plus per-facet and per-region counts. This is the most
|
/// The current online population: total plus per-facet and per-region counts. This is the most
|
||||||
/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the
|
/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the
|
||||||
/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the
|
/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the
|
||||||
@@ -810,6 +878,54 @@ async fn online(State(st): State<AppState>) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PageQuery {
|
||||||
|
limit: Option<i64>,
|
||||||
|
offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The player-vendor market index: every vendor's shop name, owner, location and priced inventory,
|
||||||
|
/// as the shard last published it. Store-backed like the other boards, which is what lets the
|
||||||
|
/// website's market page render (labelled stale) while the shard is down.
|
||||||
|
///
|
||||||
|
/// Paged — `?limit=&offset=`, limit clamped to 1..1000, default 200 — because this is the one board
|
||||||
|
/// that can be a whole world's inventory. `total` is returned alongside so the caller knows when to
|
||||||
|
/// stop rather than paging until it sees a short page, which would race a concurrent sweep.
|
||||||
|
///
|
||||||
|
/// The frames are served VERBATIM, including owner names and coordinates. That is not an oversight:
|
||||||
|
/// the sidecar defines no audiences (docs/link/v3.md §3.2). Deciding who may see a vendor's owner
|
||||||
|
/// or whereabouts is the website's job and is admin-configurable there.
|
||||||
|
async fn market(State(st): State<AppState>, Query(q): Query<PageQuery>) -> impl IntoResponse {
|
||||||
|
let limit = q.limit.unwrap_or(200);
|
||||||
|
let offset = q.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
let total = match st.store.vendors_count().await {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match st.store.vendors_page(limit, offset).await {
|
||||||
|
Ok(vendors) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"vendors": vendors,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit.clamp(1, 1000),
|
||||||
|
"offset": offset.max(0),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
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 {
|
||||||
|
|||||||
25
sonar-project.properties
Normal file
25
sonar-project.properties
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# SonarQube analysis config for the link (uo-link sidecar) repo.
|
||||||
|
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
|
||||||
|
# The project key must match the one created in SonarQube (dashboard URL
|
||||||
|
# ?id=Runic-Gateway-link).
|
||||||
|
|
||||||
|
sonar.projectKey=Runic-Gateway-link
|
||||||
|
sonar.projectName=runic gateway link
|
||||||
|
|
||||||
|
# Analysed application code. The Rust sidecar crate lives under sidecar/src.
|
||||||
|
# Rust unit tests live inline (#[cfg(test)] modules) rather than in a separate
|
||||||
|
# tree, so there is no distinct sonar.tests path to declare.
|
||||||
|
sonar.sources=sidecar/src
|
||||||
|
|
||||||
|
# Never analyse build output, the vendored lockfile, or generated config.
|
||||||
|
sonar.exclusions=**/target/**,**/*.lock
|
||||||
|
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
# ── Optional enrichment (enable if your SonarQube edition/version supports it) ──
|
||||||
|
# SonarQube imports Clippy findings when given a JSON report. To turn this on:
|
||||||
|
# 1. In sonarqube.yml, add a step before the scan that runs:
|
||||||
|
# cargo clippy --message-format=json > sidecar/clippy-report.json
|
||||||
|
# (needs the Rust toolchain + `rustup component add clippy` on the runner).
|
||||||
|
# 2. Uncomment the line below.
|
||||||
|
# sonar.rust.clippy.reportPaths=sidecar/clippy-report.json
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Logs the global town-crier entry list every few seconds so a towncrier.add / remove
|
|
||||||
/// round-trip can be observed landing in the actual game state, not just acknowledged.
|
|
||||||
///
|
|
||||||
/// Test scaffolding. Never deployed. Read-only.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeCrierProbe
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (Config.Get("Bridge.CrierProbeOnStart", false))
|
|
||||||
EventSink.ServerStarted += () =>
|
|
||||||
Timer.DelayCall(TimeSpan.FromSeconds(3.0), TimeSpan.FromSeconds(3.0), Dump);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int _tick;
|
|
||||||
|
|
||||||
private static void Dump()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var list = GlobalTownCrierEntryList.Instance;
|
|
||||||
var entries = list == null ? null : list.Entries;
|
|
||||||
int count = entries == null ? 0 : entries.Count;
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendFormat("[CrierProbe] tick {0}: {1} entries", ++_tick, count);
|
|
||||||
|
|
||||||
if (entries != null)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < entries.Count; i++)
|
|
||||||
{
|
|
||||||
var e = entries[i];
|
|
||||||
if (e == null || e.Lines == null)
|
|
||||||
continue;
|
|
||||||
sb.AppendFormat(" | [{0}]", String.Join(" / ", e.Lines));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine(sb.ToString());
|
|
||||||
|
|
||||||
if (_tick >= 6)
|
|
||||||
Timer.DelayCall(TimeSpan.Zero, () => { }); // no-op; probe stops being interesting
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[CrierProbe] FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Fires a handful of the bridge's event streams by doing real things to the world, so the
|
|
||||||
/// emit path and JSON shape can be verified without a game client attached.
|
|
||||||
///
|
|
||||||
/// These are genuine triggers, not synthetic EventSink.Invoke calls: DepositGold raises
|
|
||||||
/// AccountGoldChange from Account.cs:1635, the Fame/Karma setters raise theirs from
|
|
||||||
/// Mobile.cs:7121,7141, and World.Save raises the save boundaries from World.cs:1151,1202.
|
|
||||||
/// Calling Invoke directly would prove only that the handler compiles.
|
|
||||||
///
|
|
||||||
/// Test scaffolding. Never deployed. Mutates the world (gold, fame, karma) and saves.
|
|
||||||
/// Run only against a seeded throwaway world with a backup.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeEventProbe
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (Config.Get("Bridge.EventProbeOnStart", false))
|
|
||||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Run()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var acct = Accounting.Accounts.GetAccount("seed_000") as Account;
|
|
||||||
|
|
||||||
if (acct == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[EventProbe] no seed_000 account; seed the world first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var pm = acct[0] as PlayerMobile;
|
|
||||||
|
|
||||||
if (pm == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[EventProbe] seed_000 has no character in slot 0");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("[EventProbe] firing gold.change ...");
|
|
||||||
acct.DepositGold(12345);
|
|
||||||
|
|
||||||
Console.WriteLine("[EventProbe] firing fame.change ...");
|
|
||||||
pm.Fame = pm.Fame + 100;
|
|
||||||
|
|
||||||
Console.WriteLine("[EventProbe] firing karma.change ...");
|
|
||||||
pm.Karma = pm.Karma - 50;
|
|
||||||
|
|
||||||
Console.WriteLine("[EventProbe] firing world.save.before / world.save.after ...");
|
|
||||||
World.Save();
|
|
||||||
|
|
||||||
Console.WriteLine("[EventProbe] done");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[EventProbe] FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Custom.Bridge;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Triggers the [link flow for a seeded character without a game client, so the account
|
|
||||||
/// linking round-trip can be tested end to end: RequestLink emits link.request, the test
|
|
||||||
/// sidecar reads the code and sends link.confirm, and the account gets tagged.
|
|
||||||
///
|
|
||||||
/// Test scaffolding. Never deployed. Writes an account tag (persisted on save).
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeLinkProbe
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (Config.Get("Bridge.LinkProbeOnStart", false))
|
|
||||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(3.0), Run);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Run()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Use a seed account not already linked. seed_001 slot 0.
|
|
||||||
var acct = Accounting.Accounts.GetAccount("seed_001") as Account;
|
|
||||||
var pm = acct == null ? null : acct[0] as PlayerMobile;
|
|
||||||
|
|
||||||
if (pm == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[LinkProbe] seed_001 slot 0 not found; seed the world first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing = acct.GetTag("WebsiteUserId");
|
|
||||||
if (existing != null)
|
|
||||||
Console.WriteLine("[LinkProbe] seed_001 already linked to {0}; re-running anyway", existing);
|
|
||||||
|
|
||||||
Console.WriteLine("[LinkProbe] requesting link for seed_001 / {0}", pm.Name);
|
|
||||||
BridgeAccountLink.RequestLink(pm);
|
|
||||||
Console.WriteLine("[LinkProbe] link.request emitted; watch for the code -> link.confirm -> link.ok");
|
|
||||||
|
|
||||||
// Give the confirm time to land and set the tag, then save so it reaches
|
|
||||||
// accounts.xml. This proves persistence across a restart.
|
|
||||||
Timer.DelayCall(TimeSpan.FromSeconds(5.0), () =>
|
|
||||||
{
|
|
||||||
var tag = acct.GetTag("WebsiteUserId");
|
|
||||||
Console.WriteLine("[LinkProbe] after confirm, seed_001 WebsiteUserId tag = {0}",
|
|
||||||
tag ?? "(null)");
|
|
||||||
Console.WriteLine("[LinkProbe] saving world to persist the tag...");
|
|
||||||
World.Save();
|
|
||||||
Console.WriteLine("[LinkProbe] saved");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[LinkProbe] FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Measures the main-thread cost of every read the bridge plugin would perform, against
|
|
||||||
/// whatever world is currently loaded. Read-only. Test scaffolding, not part of the bridge.
|
|
||||||
///
|
|
||||||
/// Everything here runs on the Core thread, which is exactly where the real plugin's
|
|
||||||
/// reads must run, so these timings are the ones that matter for frame budget.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeProbe
|
|
||||||
{
|
|
||||||
private const int Iterations = 20;
|
|
||||||
|
|
||||||
private static readonly AosAttribute[] AllAttrs =
|
|
||||||
(AosAttribute[])Enum.GetValues(typeof(AosAttribute));
|
|
||||||
|
|
||||||
private static readonly AosWeaponAttribute[] AllWeaponAttrs =
|
|
||||||
(AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
|
|
||||||
|
|
||||||
private static readonly AosArmorAttribute[] AllArmorAttrs =
|
|
||||||
(AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (Config.Get("Bridge.ProbeOnStart", false))
|
|
||||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(2.0), Run);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Log(string fmt, params object[] args)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[BridgeProbe] " + String.Format(fmt, args));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Run()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var players = CollectSeededChars();
|
|
||||||
var houses = BaseHouse.AllHouses;
|
|
||||||
var vendors = PlayerVendor.PlayerVendors ?? new List<PlayerVendor>();
|
|
||||||
|
|
||||||
Log("world: {0} seeded chars, {1} houses, {2} vendors, {3} accounts",
|
|
||||||
players.Count, houses.Count, vendors.Count, Accounting.Accounts.Count);
|
|
||||||
Log("thread: {0} (id {1})",
|
|
||||||
System.Threading.Thread.CurrentThread.Name,
|
|
||||||
System.Threading.Thread.CurrentThread.ManagedThreadId);
|
|
||||||
Log("");
|
|
||||||
|
|
||||||
// ---- one full character profile, the heaviest single read ----
|
|
||||||
var sb = new StringBuilder(8192);
|
|
||||||
double perProfile = Time(() =>
|
|
||||||
{
|
|
||||||
for (int i = 0; i < players.Count; i++)
|
|
||||||
{
|
|
||||||
sb.Clear();
|
|
||||||
WriteProfile(sb, players[i]);
|
|
||||||
}
|
|
||||||
}) / Math.Max(1, players.Count);
|
|
||||||
|
|
||||||
sb.Clear();
|
|
||||||
if (players.Count > 0)
|
|
||||||
WriteProfile(sb, players[0]);
|
|
||||||
|
|
||||||
int profileBytes = sb.Length;
|
|
||||||
|
|
||||||
Log("char.profile {0,8:F3} ms/char {1,6} bytes json -> {2:F1} ms for all {3}",
|
|
||||||
perProfile, profileBytes, perProfile * players.Count, players.Count);
|
|
||||||
|
|
||||||
// ---- vitals: the 30s sweep the doc proposes ----
|
|
||||||
double vitals = Time(() =>
|
|
||||||
{
|
|
||||||
var b = new StringBuilder(256);
|
|
||||||
for (int i = 0; i < players.Count; i++)
|
|
||||||
{
|
|
||||||
b.Clear();
|
|
||||||
WriteVitals(b, players[i]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Log("vitals sweep {0,8:F3} ms for {1} chars ({2:F4} ms/char)",
|
|
||||||
vitals, players.Count, vitals / Math.Max(1, players.Count));
|
|
||||||
|
|
||||||
// ---- house decay sweep (III.3) ----
|
|
||||||
double decay = Time(() =>
|
|
||||||
{
|
|
||||||
for (int i = 0; i < houses.Count; i++)
|
|
||||||
{
|
|
||||||
var lvl = houses[i].DecayLevel;
|
|
||||||
GC.KeepAlive(lvl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Log("decay sweep {0,8:F3} ms for {1} houses ({2:F4} ms/house)",
|
|
||||||
decay, houses.Count, decay / Math.Max(1, houses.Count));
|
|
||||||
|
|
||||||
// ---- economy: money supply snapshot ----
|
|
||||||
double econ = 0;
|
|
||||||
double total = 0;
|
|
||||||
econ = Time(() =>
|
|
||||||
{
|
|
||||||
total = 0;
|
|
||||||
foreach (Account a in Accounting.Accounts.GetAccounts())
|
|
||||||
total += a.TotalCurrency;
|
|
||||||
});
|
|
||||||
|
|
||||||
Log("economy sweep {0,8:F3} ms for {1} accounts (supply {2:N0} gold)",
|
|
||||||
econ, Accounting.Accounts.Count, total * Account.CurrencyThreshold);
|
|
||||||
|
|
||||||
// ---- player vendor snapshot ----
|
|
||||||
int listings = 0;
|
|
||||||
double vend = Time(() =>
|
|
||||||
{
|
|
||||||
listings = 0;
|
|
||||||
var b = new StringBuilder(4096);
|
|
||||||
for (int i = 0; i < vendors.Count; i++)
|
|
||||||
{
|
|
||||||
b.Clear();
|
|
||||||
listings += WriteVendor(b, vendors[i]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Log("vendor snap {0,8:F3} ms for {1} vendors ({2} listings)",
|
|
||||||
vend, vendors.Count, listings);
|
|
||||||
|
|
||||||
Log("");
|
|
||||||
Log("--- extrapolation (linear, same gear complexity) ---");
|
|
||||||
Log(" vitals sweep @ 200 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 200);
|
|
||||||
Log(" vitals sweep @ 1000 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 1000);
|
|
||||||
Log(" profiles for 1000 chars : {0,7:F1} ms <-- never do this in a sweep",
|
|
||||||
perProfile * 1000);
|
|
||||||
Log(" decay sweep @ 2000 houses: {0,7:F2} ms", decay / Math.Max(1, houses.Count) * 2000);
|
|
||||||
Log(" economy @ 5000 accts : {0,7:F2} ms", econ / Math.Max(1, Accounting.Accounts.Count) * 5000);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log("FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Best-of-N: the minimum is the least noisy estimate of true cost.</summary>
|
|
||||||
private static double Time(Action action)
|
|
||||||
{
|
|
||||||
action(); // warm up JIT and caches
|
|
||||||
|
|
||||||
double best = double.MaxValue;
|
|
||||||
var sw = new Stopwatch();
|
|
||||||
|
|
||||||
for (int i = 0; i < Iterations; i++)
|
|
||||||
{
|
|
||||||
sw.Restart();
|
|
||||||
action();
|
|
||||||
sw.Stop();
|
|
||||||
|
|
||||||
double ms = sw.Elapsed.TotalMilliseconds;
|
|
||||||
|
|
||||||
if (ms < best)
|
|
||||||
best = ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<PlayerMobile> CollectSeededChars()
|
|
||||||
{
|
|
||||||
var list = new List<PlayerMobile>();
|
|
||||||
|
|
||||||
foreach (Account a in Accounting.Accounts.GetAccounts())
|
|
||||||
{
|
|
||||||
if (!a.Username.StartsWith("seed_", StringComparison.Ordinal))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
for (int i = 0; i < a.Length; i++)
|
|
||||||
{
|
|
||||||
var pm = a[i] as PlayerMobile;
|
|
||||||
|
|
||||||
if (pm != null)
|
|
||||||
list.Add(pm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteVitals(StringBuilder sb, PlayerMobile m)
|
|
||||||
{
|
|
||||||
sb.Append("{\"kind\":\"char.vitals\",\"serial\":\"0x");
|
|
||||||
sb.Append(m.Serial.Value.ToString("X"));
|
|
||||||
sb.Append("\",\"hits\":").Append(m.Hits);
|
|
||||||
sb.Append(",\"hitsMax\":").Append(m.HitsMax);
|
|
||||||
sb.Append(",\"mana\":").Append(m.Mana);
|
|
||||||
sb.Append(",\"stam\":").Append(m.Stam);
|
|
||||||
sb.Append(",\"str\":").Append(m.Str);
|
|
||||||
sb.Append(",\"dex\":").Append(m.Dex);
|
|
||||||
sb.Append(",\"int\":").Append(m.Int);
|
|
||||||
sb.Append(",\"x\":").Append(m.X);
|
|
||||||
sb.Append(",\"y\":").Append(m.Y);
|
|
||||||
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
|
|
||||||
sb.Append('}');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteProfile(StringBuilder sb, PlayerMobile m)
|
|
||||||
{
|
|
||||||
sb.Append("{\"kind\":\"char.profile\",\"serial\":\"0x");
|
|
||||||
sb.Append(m.Serial.Value.ToString("X"));
|
|
||||||
sb.Append("\",\"name\":\"").Append(m.Name).Append('"');
|
|
||||||
sb.Append(",\"body\":").Append(m.Body.BodyID);
|
|
||||||
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
|
|
||||||
|
|
||||||
sb.Append(",\"stats\":{\"str\":").Append(m.Str);
|
|
||||||
sb.Append(",\"dex\":").Append(m.Dex);
|
|
||||||
sb.Append(",\"int\":").Append(m.Int);
|
|
||||||
sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
|
|
||||||
sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
|
|
||||||
sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
|
|
||||||
sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
|
|
||||||
sb.Append(",\"luck\":").Append(m.Luck);
|
|
||||||
sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
|
|
||||||
sb.Append(",\"fire\":").Append(m.FireResistance);
|
|
||||||
sb.Append(",\"cold\":").Append(m.ColdResistance);
|
|
||||||
sb.Append(",\"pois\":").Append(m.PoisonResistance);
|
|
||||||
sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
|
|
||||||
|
|
||||||
sb.Append(",\"skills\":[");
|
|
||||||
bool first = true;
|
|
||||||
for (int i = 0; i < m.Skills.Length; i++)
|
|
||||||
{
|
|
||||||
var s = m.Skills[i];
|
|
||||||
|
|
||||||
if (s.Base <= 0.0)
|
|
||||||
continue; // untrained: the bridge should not ship ~50 zeroes per char
|
|
||||||
|
|
||||||
if (!first)
|
|
||||||
sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
|
|
||||||
sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
|
|
||||||
sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
|
|
||||||
sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
|
|
||||||
sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
|
|
||||||
}
|
|
||||||
sb.Append(']');
|
|
||||||
|
|
||||||
sb.Append(",\"equipment\":[");
|
|
||||||
first = true;
|
|
||||||
foreach (var item in m.Items)
|
|
||||||
{
|
|
||||||
if (item.Layer == Layer.Backpack || item.Layer == Layer.Bank ||
|
|
||||||
item.Layer == Layer.Hair || item.Layer == Layer.FacialHair ||
|
|
||||||
item.Layer == Layer.Mount)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first)
|
|
||||||
sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
|
|
||||||
sb.Append("\",\"layer\":\"").Append(item.Layer).Append('"');
|
|
||||||
sb.Append(",\"itemId\":").Append(item.ItemID);
|
|
||||||
sb.Append(",\"hue\":").Append(item.Hue);
|
|
||||||
sb.Append(",\"cliloc\":").Append(item.LabelNumber);
|
|
||||||
|
|
||||||
if (item.Name != null)
|
|
||||||
sb.Append(",\"name\":\"").Append(item.Name).Append('"');
|
|
||||||
|
|
||||||
sb.Append(",\"mods\":{");
|
|
||||||
bool m1 = true;
|
|
||||||
|
|
||||||
var weapon = item as BaseWeapon;
|
|
||||||
var armor = item as BaseArmor;
|
|
||||||
|
|
||||||
if (weapon != null)
|
|
||||||
{
|
|
||||||
sb.Append("\"minDamage\":").Append(weapon.MinDamage);
|
|
||||||
sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage);
|
|
||||||
m1 = false;
|
|
||||||
|
|
||||||
WriteAttrs(sb, weapon.Attributes, ref m1);
|
|
||||||
WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref m1);
|
|
||||||
}
|
|
||||||
else if (armor != null)
|
|
||||||
{
|
|
||||||
sb.Append("\"baseRating\":").Append(armor.BaseArmorRating);
|
|
||||||
m1 = false;
|
|
||||||
|
|
||||||
WriteAttrs(sb, armor.Attributes, ref m1);
|
|
||||||
WriteArmorAttrs(sb, armor.ArmorAttributes, ref m1);
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append("}}");
|
|
||||||
}
|
|
||||||
sb.Append(']');
|
|
||||||
sb.Append('}');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
|
|
||||||
{
|
|
||||||
if (a == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (int i = 0; i < AllAttrs.Length; i++)
|
|
||||||
{
|
|
||||||
int v = a[AllAttrs[i]];
|
|
||||||
|
|
||||||
if (v == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first)
|
|
||||||
sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
|
|
||||||
{
|
|
||||||
if (a == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (int i = 0; i < AllWeaponAttrs.Length; i++)
|
|
||||||
{
|
|
||||||
int v = a[AllWeaponAttrs[i]];
|
|
||||||
|
|
||||||
if (v == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first)
|
|
||||||
sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
|
|
||||||
{
|
|
||||||
if (a == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (int i = 0; i < AllArmorAttrs.Length; i++)
|
|
||||||
{
|
|
||||||
int v = a[AllArmorAttrs[i]];
|
|
||||||
|
|
||||||
if (v == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first)
|
|
||||||
sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int WriteVendor(StringBuilder sb, PlayerVendor v)
|
|
||||||
{
|
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
sb.Append("{\"kind\":\"vendor.snapshot\",\"serial\":\"0x");
|
|
||||||
sb.Append(v.Serial.Value.ToString("X"));
|
|
||||||
sb.Append("\",\"holdGold\":").Append(v.HoldGold);
|
|
||||||
sb.Append(",\"listings\":[");
|
|
||||||
|
|
||||||
var pack = v.Backpack;
|
|
||||||
|
|
||||||
if (pack != null)
|
|
||||||
{
|
|
||||||
bool first = true;
|
|
||||||
|
|
||||||
foreach (var item in pack.Items)
|
|
||||||
{
|
|
||||||
var vi = v.GetVendorItem(item);
|
|
||||||
|
|
||||||
if (vi == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!first)
|
|
||||||
sb.Append(',');
|
|
||||||
first = false;
|
|
||||||
count++;
|
|
||||||
|
|
||||||
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
|
|
||||||
sb.Append("\",\"itemId\":").Append(item.ItemID);
|
|
||||||
sb.Append(",\"price\":").Append(vi.Price);
|
|
||||||
sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false");
|
|
||||||
sb.Append('}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append("]}");
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Commands;
|
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Populates a test shard with synthetic accounts, characters, houses and player
|
|
||||||
/// vendors so the ServUO/sidecar bridge can be exercised at a realistic scale.
|
|
||||||
///
|
|
||||||
/// Test scaffolding. Not part of the bridge. Remove before production use.
|
|
||||||
///
|
|
||||||
/// A house only reaches IDOC when BaseHouse.CanDecay is true, and CanDecay is true
|
|
||||||
/// only for DecayType.Condemned or DecayType.ManualRefresh. An active owner's newest
|
|
||||||
/// house is AutoRefresh, which never decays. So the decaying houses below are given
|
|
||||||
/// to accounts whose LastLogin is backdated past Account.InactiveDuration (180 days),
|
|
||||||
/// which makes them Condemned.
|
|
||||||
///
|
|
||||||
/// Decay stage is then forced with SetDynamicDecay rather than by backdating
|
|
||||||
/// LastRefreshed: this shard is EJ, so Core.ML is true, so DynamicDecay.Enabled is
|
|
||||||
/// true and BaseHouse.GetOldDecayLevel (the percentage-of-DecayPeriod model) is never
|
|
||||||
/// reached.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeSeeder
|
|
||||||
{
|
|
||||||
private const string Prefix = "seed_";
|
|
||||||
|
|
||||||
private const int Accounts = 50;
|
|
||||||
private const int CharsPerAccount = 3;
|
|
||||||
|
|
||||||
private const int ActiveHouses = 12; // healthy, owned by active accounts
|
|
||||||
private const int DecayingHouses = 18; // owned by inactive accounts, staged below
|
|
||||||
private const int VendorHouses = 15;
|
|
||||||
private const int VendorsPerHouse = 2;
|
|
||||||
private const int ItemsPerVendor = 40;
|
|
||||||
|
|
||||||
private static readonly Point3D HouseOrigin = new Point3D(1400, 1600, 0);
|
|
||||||
private const int HouseSpacing = 40;
|
|
||||||
private const int HousesPerRow = 6;
|
|
||||||
|
|
||||||
// Spread the decaying houses across stages so a transition sweep sees variety.
|
|
||||||
private static readonly DecayLevel[] DecayStages =
|
|
||||||
{
|
|
||||||
DecayLevel.Slightly, DecayLevel.Somewhat, DecayLevel.Fairly,
|
|
||||||
DecayLevel.Greatly, DecayLevel.IDOC, DecayLevel.IDOC
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Random Rng = new Random(20260710);
|
|
||||||
|
|
||||||
// VendorItem.Price is get-only and PlayerVendor.SetVendorItem is private, so a seeded
|
|
||||||
// vendor would otherwise be stuck at the 999 default that OnSubItemAdded assigns.
|
|
||||||
private static readonly MethodInfo SetVendorItemMethod = typeof(PlayerVendor).GetMethod(
|
|
||||||
"SetVendorItem",
|
|
||||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
|
||||||
null,
|
|
||||||
new[] { typeof(Item), typeof(int), typeof(string) },
|
|
||||||
null);
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
CommandSystem.Register("seedworld", AccessLevel.Administrator, Seed_OnCommand);
|
|
||||||
CommandSystem.Register("unseedworld", AccessLevel.Administrator, Unseed_OnCommand);
|
|
||||||
|
|
||||||
if (Config.Get("Bridge.SeedOnStart", false))
|
|
||||||
EventSink.ServerStarted += () => Run(null, save: true);
|
|
||||||
|
|
||||||
if (Config.Get("Bridge.CensusOnStart", false))
|
|
||||||
EventSink.ServerStarted += Census;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reports what the seeded world actually contains after a load, rather than what
|
|
||||||
/// the seeder intended to create. Read-only.
|
|
||||||
/// </summary>
|
|
||||||
private static void Census()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var byLevel = new Dictionary<DecayLevel, int>();
|
|
||||||
|
|
||||||
foreach (var house in BaseHouse.AllHouses)
|
|
||||||
{
|
|
||||||
var level = house.DecayLevel;
|
|
||||||
int n;
|
|
||||||
byLevel.TryGetValue(level, out n);
|
|
||||||
byLevel[level] = n + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("[BridgeSeeder] houses={0}", BaseHouse.AllHouses.Count);
|
|
||||||
|
|
||||||
foreach (var kv in byLevel)
|
|
||||||
Console.WriteLine("[BridgeSeeder] decay {0,-18} {1}", kv.Key, kv.Value);
|
|
||||||
|
|
||||||
int chars = 0, totalEquipped = 0, naked = 0;
|
|
||||||
|
|
||||||
foreach (Account a in Accounting.Accounts.GetAccounts())
|
|
||||||
{
|
|
||||||
if (!a.Username.StartsWith(Prefix, StringComparison.Ordinal))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
for (int i = 0; i < a.Length; i++)
|
|
||||||
{
|
|
||||||
var m = a[i];
|
|
||||||
|
|
||||||
if (m == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
chars++;
|
|
||||||
|
|
||||||
int worn = 0;
|
|
||||||
|
|
||||||
foreach (var item in m.Items)
|
|
||||||
{
|
|
||||||
if (item.Layer != Layer.Backpack && item.Layer != Layer.Bank &&
|
|
||||||
item.Layer != Layer.Hair && item.Layer != Layer.FacialHair)
|
|
||||||
worn++;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalEquipped += worn;
|
|
||||||
|
|
||||||
if (worn == 0)
|
|
||||||
naked++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("[BridgeSeeder] seeded chars={0} avgEquipped={1:F2} naked={2}",
|
|
||||||
chars, chars == 0 ? 0.0 : (double)totalEquipped / chars, naked);
|
|
||||||
|
|
||||||
Console.WriteLine("[BridgeSeeder] playervendors={0}",
|
|
||||||
PlayerVendor.PlayerVendors == null ? 0 : PlayerVendor.PlayerVendors.Count);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[BridgeSeeder] census failed: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Usage("seedworld")]
|
|
||||||
[Description("Populates the shard with synthetic bridge-test accounts, houses and vendors.")]
|
|
||||||
private static void Seed_OnCommand(CommandEventArgs e)
|
|
||||||
{
|
|
||||||
Run(e.Mobile, save: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Usage("unseedworld")]
|
|
||||||
[Description("Deletes everything created by [seedworld.")]
|
|
||||||
private static void Unseed_OnCommand(CommandEventArgs e)
|
|
||||||
{
|
|
||||||
Unseed(e.Mobile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Report(Mobile to, string text)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[BridgeSeeder] " + text);
|
|
||||||
|
|
||||||
if (to != null)
|
|
||||||
to.SendMessage(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Run(Mobile to, bool save)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (Accounting.Accounts.GetAccount(Prefix + "000") != null)
|
|
||||||
{
|
|
||||||
Report(to, "Seed data already present. Run [unseedworld first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var start = DateTime.UtcNow;
|
|
||||||
var seeded = Seed();
|
|
||||||
var elapsed = DateTime.UtcNow - start;
|
|
||||||
|
|
||||||
Report(to, String.Format(
|
|
||||||
"Seeded {0} accounts, {1} chars, {2} houses, {3} vendors in {4:F1}s.",
|
|
||||||
seeded.AccountCount, seeded.CharCount, seeded.HouseCount,
|
|
||||||
seeded.VendorCount, elapsed.TotalSeconds));
|
|
||||||
|
|
||||||
if (save)
|
|
||||||
{
|
|
||||||
Report(to, "Saving world...");
|
|
||||||
World.Save();
|
|
||||||
Report(to, "Save complete.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Report(to, "FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Counts
|
|
||||||
{
|
|
||||||
public int AccountCount, CharCount, HouseCount, VendorCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Counts Seed()
|
|
||||||
{
|
|
||||||
var counts = new Counts();
|
|
||||||
|
|
||||||
var accounts = new List<Account>();
|
|
||||||
var owners = new List<PlayerMobile>();
|
|
||||||
|
|
||||||
for (int i = 0; i < Accounts; i++)
|
|
||||||
{
|
|
||||||
var acct = new Account(String.Format("{0}{1:000}", Prefix, i), Guid.NewGuid().ToString("N"));
|
|
||||||
acct.DepositGold(Utility.RandomMinMax(5000, 4000000));
|
|
||||||
|
|
||||||
accounts.Add(acct);
|
|
||||||
counts.AccountCount++;
|
|
||||||
|
|
||||||
for (int c = 0; c < CharsPerAccount; c++)
|
|
||||||
{
|
|
||||||
var pm = CreateChar(acct, c);
|
|
||||||
acct[c] = pm;
|
|
||||||
counts.CharCount++;
|
|
||||||
|
|
||||||
if (c == 0)
|
|
||||||
owners.Add(pm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Houses. The first ActiveHouses go to accounts left active; the remainder go to
|
|
||||||
// accounts backdated into inactivity so their houses are Condemned and will decay.
|
|
||||||
int placed = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < ActiveHouses && i < owners.Count; i++, placed++)
|
|
||||||
{
|
|
||||||
PlaceHouse(owners[i], placed);
|
|
||||||
counts.HouseCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < DecayingHouses && (ActiveHouses + i) < owners.Count; i++, placed++)
|
|
||||||
{
|
|
||||||
var owner = owners[ActiveHouses + i];
|
|
||||||
var house = PlaceHouse(owner, placed);
|
|
||||||
counts.HouseCount++;
|
|
||||||
|
|
||||||
// Condemn the account: LastLogin older than Account.InactiveDuration (180d).
|
|
||||||
var acct = (Account)owner.Account;
|
|
||||||
acct.LastLogin = DateTime.UtcNow - TimeSpan.FromDays(200 + i);
|
|
||||||
|
|
||||||
// Force the stage directly; DynamicDecay owns the model on this shard.
|
|
||||||
var stage = DecayStages[i % DecayStages.Length];
|
|
||||||
house.SetDynamicDecay(stage);
|
|
||||||
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vendors on the first VendorHouses placed.
|
|
||||||
var allHouses = new List<BaseHouse>();
|
|
||||||
|
|
||||||
foreach (var pm in owners)
|
|
||||||
allHouses.AddRange(BaseHouse.GetHouses(pm));
|
|
||||||
|
|
||||||
for (int h = 0; h < VendorHouses && h < allHouses.Count; h++)
|
|
||||||
{
|
|
||||||
var house = allHouses[h];
|
|
||||||
|
|
||||||
for (int v = 0; v < VendorsPerHouse; v++)
|
|
||||||
{
|
|
||||||
CreateVendor(house);
|
|
||||||
counts.VendorCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PlayerMobile CreateChar(Account acct, int slot)
|
|
||||||
{
|
|
||||||
var pm = new PlayerMobile
|
|
||||||
{
|
|
||||||
Player = true,
|
|
||||||
AccessLevel = AccessLevel.Player,
|
|
||||||
Name = String.Format("Seed{0}{1}", acct.Username.Substring(Prefix.Length), (char)('A' + slot)),
|
|
||||||
Female = Utility.RandomBool(),
|
|
||||||
Hue = Utility.RandomSkinHue(),
|
|
||||||
Fame = Utility.RandomMinMax(0, 15000),
|
|
||||||
Karma = Utility.RandomMinMax(-15000, 15000)
|
|
||||||
};
|
|
||||||
|
|
||||||
pm.Body = pm.Female ? 401 : 400;
|
|
||||||
|
|
||||||
// Str must clear the plate requirements in EquipGear (PlateChest needs 95), or
|
|
||||||
// BaseArmor.CanEquip refuses and the gear is left parentless for Cleanup to sweep.
|
|
||||||
pm.RawStr = Utility.RandomMinMax(100, 125);
|
|
||||||
pm.RawDex = Utility.RandomMinMax(25, 125);
|
|
||||||
pm.RawInt = Utility.RandomMinMax(25, 125);
|
|
||||||
|
|
||||||
pm.Hits = pm.HitsMax;
|
|
||||||
pm.Mana = pm.ManaMax;
|
|
||||||
pm.Stam = pm.StamMax;
|
|
||||||
|
|
||||||
// Full skill sheet: the bridge's profile export reads every skill.
|
|
||||||
for (int i = 0; i < pm.Skills.Length; i++)
|
|
||||||
{
|
|
||||||
pm.Skills[i].Base = Rng.Next(100) < 20 ? Utility.RandomMinMax(60, 120) : 0.0;
|
|
||||||
pm.Skills[i].Cap = 120.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
var pack = new Backpack { Movable = false };
|
|
||||||
pm.AddItem(pack);
|
|
||||||
|
|
||||||
EquipGear(pm);
|
|
||||||
|
|
||||||
pm.MoveToWorld(
|
|
||||||
new Point3D(HouseOrigin.X + Utility.RandomMinMax(-50, 50),
|
|
||||||
HouseOrigin.Y + Utility.RandomMinMax(-50, 50), 0),
|
|
||||||
Map.Felucca);
|
|
||||||
|
|
||||||
return pm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gear carries AOS attribute bags. The profile exporter has to walk these, so a
|
|
||||||
/// seeded character must have them populated or the cost measurement is meaningless.
|
|
||||||
/// </summary>
|
|
||||||
private static void EquipGear(PlayerMobile pm)
|
|
||||||
{
|
|
||||||
Equip(pm, MakeWeapon());
|
|
||||||
|
|
||||||
Equip(pm, Decorate(new PlateChest()));
|
|
||||||
Equip(pm, Decorate(new PlateLegs()));
|
|
||||||
Equip(pm, Decorate(new PlateHelm()));
|
|
||||||
Equip(pm, Decorate(new PlateArms()));
|
|
||||||
Equip(pm, Decorate(new PlateGloves()));
|
|
||||||
Equip(pm, new Boots(Utility.RandomNeutralHue()));
|
|
||||||
Equip(pm, new Cloak(Utility.RandomNeutralHue()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A rejected EquipItem leaves the item in World.Items with no parent, which the
|
|
||||||
/// Cleanup pass later deletes en masse. Drop it immediately instead.
|
|
||||||
/// </summary>
|
|
||||||
private static void Equip(PlayerMobile pm, Item item)
|
|
||||||
{
|
|
||||||
if (!pm.EquipItem(item))
|
|
||||||
{
|
|
||||||
Console.WriteLine("[BridgeSeeder] equip rejected: {0} on {1}", item.GetType().Name, pm.Name);
|
|
||||||
item.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BaseWeapon MakeWeapon()
|
|
||||||
{
|
|
||||||
BaseWeapon w;
|
|
||||||
|
|
||||||
switch (Utility.Random(3))
|
|
||||||
{
|
|
||||||
case 0: w = new Longsword(); break;
|
|
||||||
case 1: w = new Katana(); break;
|
|
||||||
default: w = new Broadsword(); break;
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Hue = Utility.RandomNeutralHue();
|
|
||||||
w.Attributes.WeaponDamage = Utility.RandomMinMax(10, 50);
|
|
||||||
w.Attributes.AttackChance = Utility.RandomMinMax(5, 15);
|
|
||||||
w.Attributes.DefendChance = Utility.RandomMinMax(5, 15);
|
|
||||||
w.Attributes.BonusHits = Utility.RandomMinMax(1, 8);
|
|
||||||
w.WeaponAttributes.HitLightning = Utility.RandomMinMax(10, 50);
|
|
||||||
w.WeaponAttributes.HitLeechHits = Utility.RandomMinMax(10, 40);
|
|
||||||
|
|
||||||
return w;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BaseArmor Decorate(BaseArmor a)
|
|
||||||
{
|
|
||||||
a.Hue = Utility.RandomNeutralHue();
|
|
||||||
a.Attributes.BonusHits = Utility.RandomMinMax(1, 6);
|
|
||||||
a.Attributes.LowerManaCost = Utility.RandomMinMax(1, 8);
|
|
||||||
a.ArmorAttributes.SelfRepair = Utility.RandomMinMax(1, 5);
|
|
||||||
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BaseHouse PlaceHouse(PlayerMobile owner, int index)
|
|
||||||
{
|
|
||||||
int x = HouseOrigin.X + (index % HousesPerRow) * HouseSpacing;
|
|
||||||
int y = HouseOrigin.Y + (index / HousesPerRow) * HouseSpacing;
|
|
||||||
|
|
||||||
// Placement validation (HousePlacement.Check) is deliberately bypassed: the bridge
|
|
||||||
// only reads serial/owner/coords/decay off these, never their terrain validity.
|
|
||||||
var house = new SmallOldHouse(owner, 0x64);
|
|
||||||
house.MoveToWorld(new Point3D(x, y, 0), Map.Felucca);
|
|
||||||
|
|
||||||
house.BuiltOn = DateTime.UtcNow - TimeSpan.FromDays(Utility.RandomMinMax(10, 400));
|
|
||||||
house.LastRefreshed = house.BuiltOn;
|
|
||||||
|
|
||||||
if (house.Sign != null)
|
|
||||||
house.Sign.Name = String.Format("Seed House {0}", index);
|
|
||||||
|
|
||||||
return house;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CreateVendor(BaseHouse house)
|
|
||||||
{
|
|
||||||
var owner = house.Owner;
|
|
||||||
|
|
||||||
var vendor = new PlayerVendor(owner, house)
|
|
||||||
{
|
|
||||||
Name = "seed vendor",
|
|
||||||
ShopName = String.Format("Seed Shop {0}", Utility.Random(1000))
|
|
||||||
};
|
|
||||||
|
|
||||||
vendor.MoveToWorld(
|
|
||||||
new Point3D(house.X + Utility.RandomMinMax(-2, 2),
|
|
||||||
house.Y + Utility.RandomMinMax(-2, 2), house.Z),
|
|
||||||
house.Map);
|
|
||||||
|
|
||||||
vendor.HoldGold = Utility.RandomMinMax(1000, 250000);
|
|
||||||
|
|
||||||
for (int i = 0; i < ItemsPerVendor; i++)
|
|
||||||
{
|
|
||||||
Item item = MakeWeapon();
|
|
||||||
|
|
||||||
// Dropping into the pack fires OnSubItemAdded, which registers a VendorItem
|
|
||||||
// at the default price of 999; then correct the price.
|
|
||||||
vendor.Backpack.DropItem(item);
|
|
||||||
|
|
||||||
if (SetVendorItemMethod != null)
|
|
||||||
SetVendorItemMethod.Invoke(vendor, new object[] { item, Utility.RandomMinMax(50, 75000), "" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Unseed(Mobile to)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var doomed = new List<Account>();
|
|
||||||
|
|
||||||
foreach (Account a in Accounting.Accounts.GetAccounts())
|
|
||||||
{
|
|
||||||
if (a.Username.StartsWith(Prefix, StringComparison.Ordinal))
|
|
||||||
doomed.Add(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account.Delete also deletes the account's characters and their houses.
|
|
||||||
foreach (var a in doomed)
|
|
||||||
a.Delete();
|
|
||||||
|
|
||||||
Report(to, String.Format("Removed {0} seed accounts (chars and houses included).", doomed.Count));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Report(to, "FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Forces a house-decay transition so the decay sweep's transition detection can be
|
|
||||||
/// observed without waiting out a real 12–24 h IDOC stage.
|
|
||||||
///
|
|
||||||
/// After the bridge takes its silent baseline on ServerStarted, this bumps one condemned
|
|
||||||
/// seeded house one stage further with SetDynamicDecay. The next decay sweep should see the
|
|
||||||
/// level change and emit exactly one house.decay.
|
|
||||||
///
|
|
||||||
/// Test scaffolding. Never deployed. Only meaningful against the seeded world.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeSweepProbe
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (Config.Get("Bridge.SweepProbeOnStart", false))
|
|
||||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(6.0), Run);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Run()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
BaseHouse target = null;
|
|
||||||
DecayLevel current = DecayLevel.Ageless;
|
|
||||||
|
|
||||||
// Find a house already decaying (not Ageless/LikeNew), so a bump is a real move.
|
|
||||||
foreach (var h in BaseHouse.AllHouses)
|
|
||||||
{
|
|
||||||
if (h == null || h.Deleted)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var lvl = h.DecayLevel;
|
|
||||||
|
|
||||||
if (lvl == DecayLevel.Greatly || lvl == DecayLevel.Fairly || lvl == DecayLevel.Somewhat)
|
|
||||||
{
|
|
||||||
target = h;
|
|
||||||
current = lvl;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[SweepProbe] no decaying house found to bump");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var next = current + 1; // e.g. Somewhat -> Fairly -> Greatly -> IDOC
|
|
||||||
|
|
||||||
Console.WriteLine("[SweepProbe] bumping house 0x{0:X} from {1} to {2}",
|
|
||||||
target.Serial.Value, current, next);
|
|
||||||
|
|
||||||
target.SetDynamicDecay(next);
|
|
||||||
|
|
||||||
Console.WriteLine("[SweepProbe] done; the next decay sweep should emit house.decay");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[SweepProbe] FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Fires PlayerVendorSale with real seeded-vendor data to prove the full chain: the
|
|
||||||
/// EventSink event added by the Phase 7 patch, its args, the subscriber, and the vendor.sale
|
|
||||||
/// JSON. It does NOT exercise the gump call site (that needs a live buyer with a NetState) —
|
|
||||||
/// the hook line is placed at the committed-sale point in PlayerVendorBuyGump.OnResponse and
|
|
||||||
/// is verified by a real in-game purchase.
|
|
||||||
///
|
|
||||||
/// Test scaffolding. Never deployed. Read-only (invokes an event; changes no world state).
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeVendorSaleProbe
|
|
||||||
{
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (Config.Get("Bridge.VendorSaleProbeOnStart", false))
|
|
||||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Run()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var vendors = PlayerVendor.PlayerVendors;
|
|
||||||
|
|
||||||
if (vendors == null || vendors.Count == 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[VendorSaleProbe] no player vendors; seed the world first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A real seeded vendor, its owner, and one of its actual priced items.
|
|
||||||
PlayerVendor vendor = null;
|
|
||||||
Item item = null;
|
|
||||||
int price = 0;
|
|
||||||
|
|
||||||
foreach (var v in vendors)
|
|
||||||
{
|
|
||||||
if (v == null || v.Deleted || v.Owner == null || v.Backpack == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (var it in v.Backpack.Items)
|
|
||||||
{
|
|
||||||
var vi = v.GetVendorItem(it);
|
|
||||||
if (vi != null)
|
|
||||||
{
|
|
||||||
vendor = v; item = it; price = vi.Price;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vendor != null)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vendor == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[VendorSaleProbe] no vendor with a priced item found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A buyer on a DIFFERENT account than the owner, so buyerAcct != ownerAcct.
|
|
||||||
Mobile buyer = null;
|
|
||||||
foreach (Account a in Accounting.Accounts.GetAccounts())
|
|
||||||
{
|
|
||||||
if (!a.Username.StartsWith("seed_", StringComparison.Ordinal))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var pm = a[0] as PlayerMobile;
|
|
||||||
if (pm != null && pm != vendor.Owner &&
|
|
||||||
!(vendor.Owner != null && vendor.Owner.Account == a))
|
|
||||||
{
|
|
||||||
buyer = pm;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int commission = 0;
|
|
||||||
if (vendor.IsCommission)
|
|
||||||
commission = (int)(price * (vendor.CommissionPerc / 100));
|
|
||||||
|
|
||||||
Console.WriteLine("[VendorSaleProbe] firing PlayerVendorSale: buyer={0} owner={1} item={2} price={3} commission={4}",
|
|
||||||
buyer == null ? "?" : buyer.Name,
|
|
||||||
vendor.Owner == null ? "?" : vendor.Owner.Name,
|
|
||||||
item.GetType().Name, price, commission);
|
|
||||||
|
|
||||||
EventSink.InvokePlayerVendorSale(
|
|
||||||
new PlayerVendorSaleEventArgs(buyer, vendor, vendor.Owner, item, price, commission));
|
|
||||||
|
|
||||||
Console.WriteLine("[VendorSaleProbe] done; watch for vendor.sale");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[VendorSaleProbe] FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Spawns a reachable, houseless player vendor next to the `tester` character (wttest) and
|
|
||||||
/// gives tester gold, so the PlayerVendorSale core patch can be exercised by a real in-game
|
|
||||||
/// purchase. The buyer must be a non-GM — IsOwner() treats any GameMaster+ as the owner of
|
|
||||||
/// every player vendor, so an Owner-level character can never buy.
|
|
||||||
///
|
|
||||||
/// Owner is a seed_000 character, so buyer (wttest) and owner (seed_000) are different
|
|
||||||
/// accounts — a clean cheat-detection example.
|
|
||||||
///
|
|
||||||
/// Test scaffolding. Never deployed. Spawns a mobile and hands out gold; run only on the
|
|
||||||
/// throwaway seeded world.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeVendorTestProbe
|
|
||||||
{
|
|
||||||
private static readonly MethodInfo SetVendorItem = typeof(PlayerVendor).GetMethod(
|
|
||||||
"SetVendorItem",
|
|
||||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
|
||||||
null,
|
|
||||||
new[] { typeof(Item), typeof(int), typeof(string) },
|
|
||||||
null);
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
if (Config.Get("Bridge.VendorTestOnStart", false))
|
|
||||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(3.0), Run);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Run()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var ownerAcct = Accounting.Accounts.GetAccount("seed_000") as Account;
|
|
||||||
var owner = ownerAcct == null ? null : ownerAcct[0];
|
|
||||||
|
|
||||||
var buyerAcct = Accounting.Accounts.GetAccount("wttest") as Account;
|
|
||||||
var buyer = buyerAcct == null ? null : buyerAcct[0] as PlayerMobile;
|
|
||||||
|
|
||||||
if (owner == null || buyer == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[VendorTest] need seed_000 owner and wttest/tester; not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any prior test vendor (e.g. one orphaned on the Internal map).
|
|
||||||
if (PlayerVendor.PlayerVendors != null)
|
|
||||||
{
|
|
||||||
var doomed = new System.Collections.Generic.List<PlayerVendor>();
|
|
||||||
foreach (var v in PlayerVendor.PlayerVendors)
|
|
||||||
if (v != null && !v.Deleted && v.ShopName == "Bridge Test Shop")
|
|
||||||
doomed.Add(v);
|
|
||||||
foreach (var v in doomed)
|
|
||||||
v.Delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
// A confirmed-walkable spot where tester was already standing this session, so the
|
|
||||||
// vendor is on solid ground and reachable. Then force tester's logout location right
|
|
||||||
// next to it, so tester logs in beside the vendor regardless of where it was.
|
|
||||||
var map = Map.Trammel;
|
|
||||||
var loc = new Point3D(3533, 2546, 20); // vendor
|
|
||||||
buyer.LogoutMap = map;
|
|
||||||
buyer.LogoutLocation = new Point3D(3532, 2546, 20); // tester appears here
|
|
||||||
|
|
||||||
var vendor = new PlayerVendor(owner, null)
|
|
||||||
{
|
|
||||||
Name = "test vendor",
|
|
||||||
ShopName = "Bridge Test Shop"
|
|
||||||
};
|
|
||||||
vendor.MoveToWorld(loc, map);
|
|
||||||
|
|
||||||
var blade = new Longsword();
|
|
||||||
vendor.Backpack.DropItem(blade);
|
|
||||||
if (SetVendorItem != null)
|
|
||||||
SetVendorItem.Invoke(vendor, new object[] { blade, 100, "a test blade" });
|
|
||||||
|
|
||||||
// Make sure tester can afford it.
|
|
||||||
if (buyer.Backpack != null)
|
|
||||||
buyer.Backpack.DropItem(new Gold(1000));
|
|
||||||
|
|
||||||
Console.WriteLine(
|
|
||||||
"[VendorTest] spawned '{0}' (owner {1}/seed_000) next to {2}/wttest at {3} on {4}; test blade = 100 gold; gave tester 1000 gold",
|
|
||||||
vendor.ShopName, owner.Name, buyer.Name, loc, map);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[VendorTest] FAILED: " + ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Test scaffolding
|
|
||||||
|
|
||||||
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
|
|
||||||
|
|
||||||
These two scripts produced the measured budget in `docs/PLAN.md` §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
|
|
||||||
|
|
||||||
| File | Server path when testing | What |
|
|
||||||
|------|--------------------------|------|
|
|
||||||
| `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. |
|
|
||||||
| `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. |
|
|
||||||
| `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. |
|
|
||||||
| `BridgeSweepProbe.cs` | `Scripts/Custom/BridgeSweepProbe.cs` | Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: `SweepProbeOnStart`. Pair with short `*SweepSeconds` overrides. |
|
|
||||||
| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
|
|
||||||
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
|
|
||||||
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
|
|
||||||
|
|
||||||
## Deploy overwrites Bridge.cfg
|
|
||||||
|
|
||||||
`deploy.ps1` copies `overlay/Config/Bridge.cfg`, which deliberately omits the scaffolding flags. So **every deploy strips `SeedOnStart` / `EventProbeOnStart` / etc.** Re-append the flag you need after deploying, or the probe silently does nothing on the next boot. (This bit once during Phase 2 testing.)
|
|
||||||
|
|
||||||
## Using them
|
|
||||||
|
|
||||||
Copy both into `Scripts/Custom/`, then append the flags to `Config/Bridge.cfg`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
SeedOnStart=True
|
|
||||||
CensusOnStart=False
|
|
||||||
ProbeOnStart=False
|
|
||||||
```
|
|
||||||
|
|
||||||
Boot once to seed and save, then set `SeedOnStart=False`. `CensusOnStart` reports what the loaded world actually contains; `ProbeOnStart` prints timings two seconds after `ServerStarted`.
|
|
||||||
|
|
||||||
Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.cfg` lacks these keys never runs the scaffolding — even if the `.cs` files are sitting in `Scripts/Custom/`. That is the safety net, not an excuse to ship them.
|
|
||||||
|
|
||||||
In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
|
|
||||||
|
|
||||||
## Back up `Saves/` first
|
|
||||||
|
|
||||||
`[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination.
|
|
||||||
|
|
||||||
`[unseedworld` deletes every `seed_*` account, which takes their characters and houses with it — but not necessarily their `PlayerVendor` mobiles. Restoring a backup is the reliable reset.
|
|
||||||
|
|
||||||
## What the seeder had to work around
|
|
||||||
|
|
||||||
Worth knowing before you trust its output:
|
|
||||||
|
|
||||||
- **Plate needs strength.** `BaseArmor.CanEquip` rejects when `from.Str < strReq` (`PlateChest` needs 95). A rejected `EquipItem` leaves the item parentless, and the `Cleanup` pass later deletes it en masse. The seeder gives characters `Str` 100–125 and deletes any item whose equip is refused, rather than orphaning it.
|
|
||||||
- **`VendorItem.Price` is get-only** and `PlayerVendor.SetVendorItem` is private. Dropping an item into a vendor's pack fires `OnSubItemAdded`, which registers the item at the default price of 999. The seeder reaches `SetVendorItem` by reflection to set a real price. Acceptable in throwaway scaffolding; do not do this in the plugin.
|
|
||||||
- **Houses only decay when condemned.** `BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `ManualRefresh`. An active owner's newest house is `AutoRefresh` and never decays. The seeder backdates 18 accounts past `Account.InactiveDuration` (180 days) to condemn them, then forces stages with `SetDynamicDecay` — not by backdating `LastRefreshed`, because `DynamicDecay.Enabled` is true on this expansion and `GetOldDecayLevel` is unreachable.
|
|
||||||
|
|
||||||
## Reference output
|
|
||||||
|
|
||||||
Census after a fresh load of the seeded world:
|
|
||||||
|
|
||||||
```
|
|
||||||
[BridgeSeeder] houses=35
|
|
||||||
[BridgeSeeder] decay Ageless 13, Slightly 3, Somewhat 7, Fairly 3, Greatly 3, IDOC 6
|
|
||||||
[BridgeSeeder] seeded chars=150 avgEquipped=8.00 naked=0
|
|
||||||
[BridgeSeeder] playervendors=30
|
|
||||||
```
|
|
||||||
|
|
||||||
Probe, best-of-20 on the Core thread:
|
|
||||||
|
|
||||||
```
|
|
||||||
[BridgeProbe] char.profile 0.069 ms/char 2386 bytes json
|
|
||||||
[BridgeProbe] vitals sweep 0.223 ms for 150 chars (0.0015 ms/char)
|
|
||||||
[BridgeProbe] decay sweep 0.007 ms for 35 houses (0.0002 ms/house)
|
|
||||||
[BridgeProbe] economy sweep 0.001 ms for 51 accounts (supply 110,478,209 gold)
|
|
||||||
[BridgeProbe] vendor snap 0.343 ms for 30 vendors (1200 listings)
|
|
||||||
```
|
|
||||||
|
|
||||||
Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 2–4× for a fully-kitted character.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
param(
|
|
||||||
[int] $Port = 7788,
|
|
||||||
[string] $Log = "$PSScriptRoot\sc_robust.log"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Robust stub sidecar: survives port-in-use from a just-killed instance, and never
|
|
||||||
# dies on a transient error. Test scaffolding only.
|
|
||||||
|
|
||||||
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 "[sidecar] 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)
|
|
||||||
|
|
||||||
# Wait for the port to become bindable if a prior instance is still lingering.
|
|
||||||
$bound = $false
|
|
||||||
for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
|
|
||||||
try { $listener.Start(); $bound = $true }
|
|
||||||
catch { Say "[sidecar] bind retry: $($_.Exception.Message)"; Start-Sleep -Seconds 1 }
|
|
||||||
}
|
|
||||||
if (-not $bound) { Say "[sidecar] could not bind $Port; giving up"; exit 1 }
|
|
||||||
|
|
||||||
Say "[sidecar] listening"
|
|
||||||
|
|
||||||
while ($true) {
|
|
||||||
try {
|
|
||||||
$client = $listener.AcceptTcpClient()
|
|
||||||
Say "[sidecar] === shard connected ==="
|
|
||||||
$reader = New-Object System.IO.StreamReader($client.GetStream())
|
|
||||||
while ($null -ne ($line = $reader.ReadLine())) { Say "[sidecar] <- $line" }
|
|
||||||
Say "[sidecar] === shard disconnected ==="
|
|
||||||
$client.Close()
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Say "[sidecar] loop error: $($_.Exception.Message)"
|
|
||||||
Start-Sleep -Milliseconds 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
param(
|
|
||||||
[int] $Port = 7788,
|
|
||||||
[string] $Log = "$PSScriptRoot\sc_crier.log"
|
|
||||||
)
|
|
||||||
|
|
||||||
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 "[crier-sc] 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 "[crier-sc] could not bind"; exit 1 }
|
|
||||||
Say "[crier-sc] listening"
|
|
||||||
|
|
||||||
$client = $listener.AcceptTcpClient()
|
|
||||||
Say "[crier-sc] === shard connected ==="
|
|
||||||
$stream = $client.GetStream()
|
|
||||||
$stream.ReadTimeout = 1500
|
|
||||||
$reader = New-Object System.IO.StreamReader($stream)
|
|
||||||
$writer = New-Object System.IO.StreamWriter($stream)
|
|
||||||
$writer.AutoFlush = $true
|
|
||||||
Start-Sleep -Milliseconds 500
|
|
||||||
|
|
||||||
# Blocking read with a timeout, so StreamReader-buffered lines are not missed the way
|
|
||||||
# checking $stream.DataAvailable does.
|
|
||||||
function Drain($seconds) {
|
|
||||||
$deadline = (Get-Date).AddSeconds($seconds)
|
|
||||||
while ((Get-Date) -lt $deadline) {
|
|
||||||
try {
|
|
||||||
$l = $reader.ReadLine()
|
|
||||||
if ($null -ne $l) { Say "[crier-sc] <- $l" }
|
|
||||||
} catch { Start-Sleep -Milliseconds 50 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Send($obj) {
|
|
||||||
$writer.WriteLine($obj)
|
|
||||||
Say "[crier-sc] -> $obj"
|
|
||||||
Drain 1.5
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. valid add
|
|
||||||
Send '{"kind":"towncrier.add","id":"n1","lines":["Hear ye, hear ye!","The market tax is now 5 percent."],"durationSec":3600}'
|
|
||||||
# 2. add exceeding the line cap (default 6) -> error
|
|
||||||
Send '{"kind":"towncrier.add","id":"n2","lines":["1","2","3","4","5","6","7","8"],"durationSec":60}'
|
|
||||||
# 3. remove the valid one
|
|
||||||
Send '{"kind":"towncrier.remove","id":"n1"}'
|
|
||||||
# 4. remove an unknown id -> error
|
|
||||||
Send '{"kind":"towncrier.remove","id":"does-not-exist"}'
|
|
||||||
|
|
||||||
Drain 3
|
|
||||||
|
|
||||||
Say "[crier-sc] done"
|
|
||||||
$client.Close(); $listener.Stop()
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
param(
|
|
||||||
[int] $Port = 7788,
|
|
||||||
[string] $Log = "$PSScriptRoot\sc_link.log"
|
|
||||||
)
|
|
||||||
|
|
||||||
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 "[link-sc] 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 "[link-sc] could not bind"; exit 1 }
|
|
||||||
Say "[link-sc] listening"
|
|
||||||
|
|
||||||
$client = $listener.AcceptTcpClient()
|
|
||||||
Say "[link-sc] === shard connected ==="
|
|
||||||
$stream = $client.GetStream()
|
|
||||||
$reader = New-Object System.IO.StreamReader($stream)
|
|
||||||
$writer = New-Object System.IO.StreamWriter($stream)
|
|
||||||
$writer.AutoFlush = $true
|
|
||||||
|
|
||||||
$deadline = (Get-Date).AddSeconds(20)
|
|
||||||
$confirmed = $false
|
|
||||||
|
|
||||||
while ((Get-Date) -lt $deadline) {
|
|
||||||
if ($stream.DataAvailable) {
|
|
||||||
$line = $reader.ReadLine()
|
|
||||||
if ($null -eq $line) { break }
|
|
||||||
Say "[link-sc] <- $line"
|
|
||||||
|
|
||||||
# When the shard emits a link.request, extract the code and confirm it.
|
|
||||||
if (-not $confirmed -and $line -match '"kind":"link\.request"') {
|
|
||||||
if ($line -match '"code":"([^"]+)"') {
|
|
||||||
$code = $Matches[1]
|
|
||||||
$confirm = '{"kind":"link.confirm","code":"' + $code + '","websiteUserId":"web-9931"}'
|
|
||||||
$writer.WriteLine($confirm)
|
|
||||||
Say "[link-sc] -> $confirm"
|
|
||||||
$confirmed = $true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Start-Sleep -Milliseconds 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Second exchange: send a bad confirm to prove the error path.
|
|
||||||
$writer.WriteLine('{"kind":"link.confirm","code":"BADCOD","websiteUserId":"web-0000"}')
|
|
||||||
Say '[link-sc] -> {"kind":"link.confirm","code":"BADCOD","websiteUserId":"web-0000"}'
|
|
||||||
$t = (Get-Date).AddSeconds(3)
|
|
||||||
while ((Get-Date) -lt $t) {
|
|
||||||
if ($stream.DataAvailable) { $l = $reader.ReadLine(); if ($l) { Say "[link-sc] <- $l" } }
|
|
||||||
else { Start-Sleep -Milliseconds 100 }
|
|
||||||
}
|
|
||||||
|
|
||||||
Say "[link-sc] done"
|
|
||||||
$client.Close(); $listener.Stop()
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
param(
|
|
||||||
[int] $Port = 7788,
|
|
||||||
[string] $Log = "$PSScriptRoot\sc_request.log"
|
|
||||||
)
|
|
||||||
|
|
||||||
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 "[req] 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 "[req] could not bind"; exit 1 }
|
|
||||||
|
|
||||||
Say "[req] listening"
|
|
||||||
$client = $listener.AcceptTcpClient()
|
|
||||||
Say "[req] === shard connected ==="
|
|
||||||
|
|
||||||
$stream = $client.GetStream()
|
|
||||||
$reader = New-Object System.IO.StreamReader($stream)
|
|
||||||
$writer = New-Object System.IO.StreamWriter($stream)
|
|
||||||
$writer.AutoFlush = $true
|
|
||||||
|
|
||||||
# Give the shard a beat to send its hello, then fire the requests.
|
|
||||||
Start-Sleep -Milliseconds 500
|
|
||||||
|
|
||||||
$requests = @(
|
|
||||||
'{"kind":"account.roster","reqId":"r-roster","account":"whitlocktech"}',
|
|
||||||
'{"kind":"char.request","reqId":"r-darrow","account":"whitlocktech","slot":0}',
|
|
||||||
'{"kind":"vendor.snapshot","reqId":"r-vendor","account":"seed_000"}',
|
|
||||||
'{"kind":"char.request","reqId":"r-bad","account":"does_not_exist","slot":0}',
|
|
||||||
'{"kind":"char.request","reqId":"r-serial","serial":"0x24C"}'
|
|
||||||
)
|
|
||||||
|
|
||||||
foreach ($r in $requests) {
|
|
||||||
$writer.WriteLine($r)
|
|
||||||
Say "[req] -> $r"
|
|
||||||
Start-Sleep -Milliseconds 400
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read replies for a few seconds.
|
|
||||||
$deadline = (Get-Date).AddSeconds(8)
|
|
||||||
while ((Get-Date) -lt $deadline) {
|
|
||||||
if ($stream.DataAvailable) {
|
|
||||||
$line = $reader.ReadLine()
|
|
||||||
if ($null -ne $line) { Say "[req] <- $line" }
|
|
||||||
} else {
|
|
||||||
Start-Sleep -Milliseconds 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Say "[req] done"
|
|
||||||
$client.Close()
|
|
||||||
$listener.Stop()
|
|
||||||
Reference in New Issue
Block a user