Compare commits
34 Commits
v0.2.0
...
2301c57768
| Author | SHA1 | Date | |
|---|---|---|---|
| 2301c57768 | |||
| cfe9ec9017 | |||
| 5f50b881ca | |||
| 05e192ca70 | |||
| 480423090a | |||
| 2e386a9d5c | |||
| 21a1462e62 | |||
| 41811d40af | |||
| 8e018a01e9 | |||
| 7e4177c6c0 | |||
| ecdaf9171b | |||
| f23b9030ed | |||
| 31497c38b2 | |||
| 21b9920f80 | |||
| 7224b9b834 | |||
| 45bb8b0de4 | |||
|
|
6138ba8c65 | ||
| a7bf3c7bb1 | |||
| 21d5de9fd4 | |||
| 2b0d6635bb | |||
| 8751151abc | |||
| 6542282ffb | |||
| 957f5701d4 | |||
| 813ff52059 | |||
| ed8d24bb1d | |||
| 484bc33706 | |||
| fd9c9fd96a | |||
| 6f76a8d35f | |||
| 92374ba15c | |||
| e0445d3f94 | |||
| b858d526b8 | |||
| d47170581d | |||
| 2ed0b0bd00 | |||
| 808f6ab68b |
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()
|
||||
@@ -22,7 +22,7 @@
|
||||
# nothing releasable -> no release is cut
|
||||
# (first ever run, no tag) -> releases the current Cargo.toml version as-is
|
||||
#
|
||||
# Prerequisites (Settings → Actions → Secrets on UOM/link):
|
||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/link):
|
||||
# REGISTRY_USER — Gitea username the token below belongs to
|
||||
# REGISTRY_TOKEN — Gitea access token. For image builds it needed
|
||||
# write:package; THIS workflow additionally needs
|
||||
@@ -46,7 +46,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: UOM/link
|
||||
REPO: RunicGateway/link
|
||||
WORKDIR: sidecar
|
||||
BIN: uo-link-sidecar
|
||||
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>.
|
||||
123
README.md
123
README.md
@@ -1,94 +1,81 @@
|
||||
# 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
|
||||
(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
|
||||
|
||||
| Path | What |
|
||||
|------|------|
|
||||
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
|
||||
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
||||
| `sidecar/` | The Rust sidecar: terminates the loopback link to the shard, exposes WS + REST to the website. See `sidecar/README.md`. |
|
||||
| `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. |
|
||||
| `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). |
|
||||
| `.gitea/workflows/release.yml` | Builds + releases the sidecar binary (Linux + Windows) on every merge to `main`. |
|
||||
|
||||
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
|
||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify # show what would change
|
||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo # write
|
||||
```bash
|
||||
cd sidecar
|
||||
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 |
|
||||
|------:|-------|
|
||||
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
||||
| 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** |
|
||||
## Deployment & compatibility
|
||||
|
||||
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.
|
||||
The plugin ([RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins))
|
||||
and this sidecar are deployed **together** but built **independently**:
|
||||
|
||||
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).
|
||||
- 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.
|
||||
|
||||
## Phase 0 — what it fixes
|
||||
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.
|
||||
|
||||
`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.
|
||||
## License
|
||||
|
||||
Runtime script compilation therefore had no effect, silently. `overlay/Scripts/Scripts.csproj` conditions both property groups on `Configuration` alone.
|
||||
Runic Gateway is free software, licensed under the **GNU General Public License
|
||||
v3.0 or later** — see [LICENSE.md](LICENSE.md).
|
||||
|
||||
`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`.
|
||||
Copyright (C) 2026 Runic Gateway
|
||||
|
||||
## The plugin (Phase 1)
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
|
||||
|
||||
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in `docs/PLAN.md` §1. See `tools/scaffolding/README.md`.
|
||||
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,519 +0,0 @@
|
||||
# uo-link Sidecar — Website Integration Guide
|
||||
|
||||
This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly.
|
||||
|
||||
```
|
||||
website ──WebSocket (live feed) + REST (queries/commands)──► sidecar ──loopback──► shard
|
||||
```
|
||||
|
||||
- **Base URL** — default `http://127.0.0.1:8080` (WebSocket: `ws://127.0.0.1:8080`). Configurable in `sidecar.toml` (`web.bind`) or `UOLINK_WEB_BIND`. If you serve the site from another host, bind the sidecar to `0.0.0.0:8080` and put it behind TLS.
|
||||
- **Content type** — all request and response bodies are JSON (`application/json`).
|
||||
- **Timestamps** — every `t` field is **epoch milliseconds** (UTC). Human-readable timestamps (e.g. `house.decay.builtOn`, `/health.last_event`) are ISO-8601 UTC.
|
||||
- **Serials** — game object ids are hex strings like `"0x24C"` (mobiles) or `"0x40013AAD"` (items). Treat them as opaque keys.
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
Every route **except `GET /health`** requires the shared token from `sidecar.toml` (`web.auth_token`). Present it any of these ways:
|
||||
|
||||
| Transport | How |
|
||||
|-----------|-----|
|
||||
| REST | `Authorization: Bearer <token>` |
|
||||
| REST | `X-Api-Key: <token>` |
|
||||
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
|
||||
|
||||
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
|
||||
|
||||
---
|
||||
|
||||
## 2. Protocol version
|
||||
|
||||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
||||
|
||||
- Every response carries an **`X-UOLink-Version: 1`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 1`.
|
||||
- **Optionally**, send `X-UOLink-Version: 1` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
|
||||
```json
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" }
|
||||
```
|
||||
|
||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
||||
|
||||
---
|
||||
|
||||
## 3. Health
|
||||
|
||||
```
|
||||
GET /health (no auth)
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
|
||||
"protocol": 1,
|
||||
"plugin_connected": true, // is the shard link up right now?
|
||||
"database": "ok", // "ok" | "error"
|
||||
"uptime": "3d 12h",
|
||||
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
|
||||
}
|
||||
```
|
||||
|
||||
Always returns HTTP 200 (read `status`/`plugin_connected` for real state). Use it for liveness checks and to detect when the shard has dropped (`plugin_connected: false`).
|
||||
|
||||
---
|
||||
|
||||
## 4. WebSocket live feed
|
||||
|
||||
```
|
||||
GET /ws?token=<token> (WebSocket upgrade)
|
||||
```
|
||||
|
||||
A push-only stream of game events as they happen. You do **not** send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.
|
||||
|
||||
**On connect**, the first frame is:
|
||||
|
||||
```json
|
||||
{ "kind": "ws.hello", "protocol": 1 }
|
||||
```
|
||||
|
||||
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
|
||||
|
||||
Notes:
|
||||
- **Live-only, no replay.** A client that connects now sees events from now on. For history/backfill, use `GET /history`.
|
||||
- The sidecar sends WebSocket **ping** frames every ~30s for keepalive; browser clients answer automatically.
|
||||
- You may occasionally see a `{"kind":"pong",...}` frame (the sidecar's internal heartbeat to the shard). Ignore any `kind` you don't handle.
|
||||
- A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.
|
||||
|
||||
### Minimal browser client
|
||||
|
||||
```js
|
||||
const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
|
||||
ws.onmessage = (m) => {
|
||||
const ev = JSON.parse(m.data);
|
||||
switch (ev.kind) {
|
||||
case "ws.hello": /* check ev.protocol === 1 */ break;
|
||||
case "mob.login": onLogin(ev); break;
|
||||
case "vendor.sale": onSale(ev); break;
|
||||
case "house.decay": onIdoc(ev); break;
|
||||
// ...handle the kinds you care about; ignore the rest
|
||||
}
|
||||
};
|
||||
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history
|
||||
```
|
||||
|
||||
### Event catalog
|
||||
|
||||
Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"serial","name","acct","player"}` (`acct` present only for player-owned mobiles).
|
||||
|
||||
#### Lifecycle
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `server.hello` | `shard`, `bootId`, `connects`, `items`, `mobiles`, `accounts` | Sent to the sidecar on every shard (re)connect. `bootId` changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". |
|
||||
| `server.shutdown` | — | Clean shutdown. |
|
||||
| `server.crashed` | `error` | Not always sent (a hard crash may skip it). |
|
||||
| `world.save.before` / `world.save.after` | (`after` adds `items`, `mobiles`) | Save-cycle boundaries; a natural consistency checkpoint. |
|
||||
|
||||
#### Sessions & identity
|
||||
| kind | fields |
|
||||
|------|--------|
|
||||
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
|
||||
| `mob.logout` | `who` |
|
||||
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
|
||||
|
||||
#### Economy & commerce
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `gold.change` | `acct`, `old`, `new`, `delta` | AccountGold flow (gold in bank/account, not physical coins). |
|
||||
| `vendor.buy` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor purchase (validation stage). |
|
||||
| `vendor.sell` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor sale. |
|
||||
| `vendor.sale` | `buyerSerial`, `buyerAcct`, `ownerSerial`, `ownerAcct`, `vendorSerial`, `itemType`, `itemSerial`, `itemId`, `amount`, `price`, `commission`, `committed:true` | **Player** vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. |
|
||||
| `vendor.placed` | `owner`, `vendor` | A player vendor was placed. |
|
||||
|
||||
```json
|
||||
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
|
||||
"ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
|
||||
"itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
|
||||
"price":100,"commission":0,"t":1783720195626}
|
||||
```
|
||||
|
||||
#### Character progression & vitals
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `char.vitals` | `serial`, `hits`,`hitsMax`, `mana`,`manaMax`, `stam`,`stamMax`, `str`,`dex`,`int`, `map`, `x`,`y` | Periodic snapshot of each **online** player (~every 30s; configurable). Diff successive snapshots to detect change. |
|
||||
| `skill.gain` | `who`, `skill`, `gained`, `base`, `cap` | Player skill gains only (NPC gains are filtered out). |
|
||||
| `fame.change` / `karma.change` | `who`, `old`, `new` | Player only. |
|
||||
| `quest.complete` | `who`, `quest` | |
|
||||
|
||||
#### Death & PvP
|
||||
| kind | fields |
|
||||
|------|--------|
|
||||
| `player.death` | `who`, `killer` |
|
||||
| `player.murdered` | `victim`, `murderer` |
|
||||
| `mob.killed` | `killed`, `killer` — only kills that involve a player |
|
||||
|
||||
#### Housing / IDOC
|
||||
| kind | fields |
|
||||
|------|--------|
|
||||
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
|
||||
|
||||
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
|
||||
|
||||
```json
|
||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
||||
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
|
||||
```
|
||||
|
||||
#### Economy supply (periodic)
|
||||
| kind | fields |
|
||||
|------|--------|
|
||||
| `economy.supply` | `accounts`, `gold` — total money supply across all accounts (~every 5 min; configurable) |
|
||||
|
||||
#### Cheat detection & staff audit
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. |
|
||||
| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. |
|
||||
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. |
|
||||
| `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:<user>"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. |
|
||||
|
||||
#### Account linking
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. |
|
||||
|
||||
#### Help-page (support) queue
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). |
|
||||
| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). |
|
||||
| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). |
|
||||
|
||||
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
|
||||
|
||||
#### Champion spawns
|
||||
|
||||
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
|
||||
|
||||
| `category` | source | what it is |
|
||||
|------------|--------|-----------|
|
||||
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
|
||||
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
|
||||
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
|
||||
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
|
||||
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
|
||||
|
||||
`status` is one of:
|
||||
- **`active`** — running (or, for `sea`, the boss is alive).
|
||||
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
|
||||
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
|
||||
|
||||
Category-specific fields on `champ.update`:
|
||||
|
||||
| category | extra fields |
|
||||
|----------|--------------|
|
||||
| `champion` | `level` (0–16), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
|
||||
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
|
||||
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
|
||||
|
||||
```json
|
||||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
||||
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
|
||||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
|
||||
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
|
||||
|
||||
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
|
||||
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
|
||||
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
|
||||
```
|
||||
|
||||
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
|
||||
|
||||
---
|
||||
|
||||
## 5. REST — read queries
|
||||
|
||||
These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning **504**.
|
||||
|
||||
### Character profile
|
||||
|
||||
```
|
||||
GET /char/{account}/{slot} # by account + character slot (0-based)
|
||||
GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C
|
||||
```
|
||||
|
||||
Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for **offline** characters too. `GET /char/serial/...` falls back to the last **cached** profile if the shard is unreachable (so a page still renders during a shard restart).
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
|
||||
"body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
|
||||
"stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
|
||||
"mana":123,"manaMax":123, "stam":120,"stamMax":120,
|
||||
"fame":0,"karma":0,"luck":0,
|
||||
"resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
|
||||
"skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
|
||||
"equipment": [
|
||||
{ "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
|
||||
"cliloc":1027933,"mods":{} },
|
||||
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
|
||||
"weapon":{"minDamage":16,"maxDamage":18},
|
||||
"mods":{"WeaponDamage":50,"HitLightning":40} }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Field notes:
|
||||
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
|
||||
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
|
||||
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site.
|
||||
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
|
||||
|
||||
### Account roster
|
||||
|
||||
```
|
||||
GET /roster/{account}
|
||||
```
|
||||
|
||||
Lightweight list of an account's characters (up to 5–7), including offline ones. Use this for a character-picker, then fetch the full profile on demand.
|
||||
|
||||
```json
|
||||
{ "kind":"account.roster", "acct":"whitlocktech",
|
||||
"chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }
|
||||
```
|
||||
|
||||
### Player vendors
|
||||
|
||||
```
|
||||
GET /vendors/{account}
|
||||
```
|
||||
|
||||
Every player vendor owned by any character on the account, with held gold and current listings.
|
||||
|
||||
```json
|
||||
{ "kind":"vendor.snapshot", "acct":"seed_000",
|
||||
"vendors":[
|
||||
{ "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
|
||||
"ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
|
||||
"listings":[
|
||||
{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
|
||||
] } ] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. REST — commands & history
|
||||
|
||||
### Confirm an account link
|
||||
|
||||
The in-game `[link` flow: the player runs `[link`, the shard emits a `link.request` event (over the WebSocket) carrying a one-time `code`. Your site shows the logged-in website user a box to enter that code, then:
|
||||
|
||||
```
|
||||
POST /link/confirm
|
||||
{ "code": "AB12CD", "websiteUserId": "9931" }
|
||||
```
|
||||
|
||||
- Success → **200** `{"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}`. The game account is now permanently tagged with your `websiteUserId` (persisted on the shard); subsequent `mob.login` events for that account carry `webId`.
|
||||
- Bad/expired code → **404** `{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}`.
|
||||
|
||||
Codes are one-time and expire (default 5 min).
|
||||
|
||||
### Look up an existing link
|
||||
|
||||
```
|
||||
GET /link/{account}
|
||||
```
|
||||
|
||||
- **200** `{"account":"PerryAdimn","websiteUserId":"9931"}` if linked.
|
||||
- **404** `{"account":"PerryAdimn","linked":false}` if not.
|
||||
|
||||
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
|
||||
|
||||
### Publish / remove town-crier news
|
||||
|
||||
Push a message that every in-game town crier announces until it expires.
|
||||
|
||||
```
|
||||
POST /towncrier
|
||||
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }
|
||||
```
|
||||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`. Re-posting the same `id` replaces the prior entry.
|
||||
|
||||
```
|
||||
DELETE /towncrier/{id}
|
||||
```
|
||||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`, or **404** `{"kind":"towncrier.error","reason":"unknown id"}`.
|
||||
|
||||
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Status codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | OK |
|
||||
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
||||
| 401 | Missing or invalid auth token |
|
||||
| 404 | Not found (unknown account / character / id) |
|
||||
| 409 | Protocol version mismatch (you sent `X-UOLink-Version` and it disagreed) |
|
||||
| 500 | Internal error (e.g. database) |
|
||||
| 503 | Shard not connected — the query needs the live game and it's down |
|
||||
| 504 | Shard connected but didn't reply within 10s |
|
||||
|
||||
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
|
||||
|
||||
---
|
||||
|
||||
## 8. Putting it together
|
||||
|
||||
A typical character page:
|
||||
|
||||
```js
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" };
|
||||
|
||||
// 1. render the roster
|
||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||
|
||||
// 2. full sheet for the selected character
|
||||
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
|
||||
if (res.status === 503) showBanner("Game server is restarting…");
|
||||
else renderProfile(await res.json());
|
||||
|
||||
// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
|
||||
// (see the WebSocket client in §4)
|
||||
|
||||
// 4. recent sales widget
|
||||
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
|
||||
.then(r => r.json());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Caveats & current limits
|
||||
|
||||
- **No rate limiting yet.** The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
|
||||
- **WebSocket is push-only and live-only.** No client→server messages, no replay. Backfill via `/history`.
|
||||
- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
|
||||
- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted.
|
||||
- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape.
|
||||
508
docs/PLAN.md
508
docs/PLAN.md
@@ -1,508 +0,0 @@
|
||||
# ServUO Bridge Plugin — Implementation Plan & Data Catalog
|
||||
|
||||
**Status:** Design, grounded in **measurements taken on this shard**, not estimates.
|
||||
**Date:** 2026-07-10
|
||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
||||
**Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) for where that document is wrong.
|
||||
|
||||
Test scaffolding used to produce this plan lives in `Scripts/Custom/BridgeSeeder.cs` (world population) and `Scripts/Custom/BridgeProbe.cs` (timing). Both are gated behind `Config/Bridge.cfg` flags and default to off. **Neither is part of the bridge.** Delete before production.
|
||||
|
||||
---
|
||||
|
||||
## 1. Measured budget
|
||||
|
||||
Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the **Core thread** — the probe printed `thread: Core Thread (id 1)`, which empirically confirms the threading model that `BRIDGE_FINDINGS.md` could only infer from a crash log.
|
||||
|
||||
| Read | Cost | Payload | Per-unit |
|
||||
|------|------|---------|----------|
|
||||
| Full character profile | **0.069 ms/char** | 2,386 B JSON | — |
|
||||
| Vitals sweep (150 chars) | 0.223 ms | ~180 B/char | 0.0015 ms/char |
|
||||
| House decay sweep (35 houses) | 0.007 ms | — | 0.0002 ms/house |
|
||||
| Economy supply sweep (51 accounts) | 0.001 ms | — | ~0.00002 ms/acct |
|
||||
| Vendor snapshot (30 vendors, 1200 listings) | 0.343 ms | — | 0.0003 ms/listing |
|
||||
|
||||
Linear extrapolation at the same gear complexity:
|
||||
|
||||
| Scenario | Cost | Verdict |
|
||||
|----------|------|---------|
|
||||
| Vitals sweep @ 200 online | 0.30 ms | free |
|
||||
| Vitals sweep @ 1000 online | 1.49 ms | free |
|
||||
| Decay sweep @ 2000 houses | 0.38 ms | free |
|
||||
| Economy @ 5000 accounts | 0.06 ms | free |
|
||||
| **Profiles for 1000 chars** | **69.4 ms** | **stall — never in a sweep** |
|
||||
|
||||
**The headline result inverts the original doc's anxiety.** `BRIDGE_FINDINGS.md` treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is *not* free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. **Tier by volatility and serve profiles on demand.** That conclusion survives; the reasoning behind it changes.
|
||||
|
||||
### Caveat on these numbers
|
||||
|
||||
Seeded characters carry **8 equipped items with ~6 non-zero mods each and ~12 trained skills**. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore **understated, plausibly by 2–4×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~6–8 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars.
|
||||
|
||||
Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture (confirmed, unchanged)
|
||||
|
||||
```
|
||||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
||||
```
|
||||
|
||||
ServUO does **not** speak WebSocket. It writes `{...}\n` lines to `127.0.0.1`. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust.
|
||||
|
||||
Non-negotiable rules, all of which the measurements support:
|
||||
|
||||
- **Every world read happens on the Core thread.** Verified: probe reported `Core Thread (id 1)`.
|
||||
- **The Core thread never touches the socket.** Producer formats a line, enqueues to a bounded `ConcurrentQueue`, returns. A dedicated writer thread drains it.
|
||||
- **Inbound commands marshal back via `Timer.DelayCall(TimeSpan.Zero, ...)`**, which is lock-protected and cross-thread safe (`Server/Timer.cs:243-251`). The read thread touches no `World`/`Mobile`/`Item` API.
|
||||
- **Bound the outbound queue** (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard.
|
||||
- **Never block or throw inside an EventSink handler.** Several are veto hooks sitting in a transaction path.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prerequisite: fix the build, or the plugin will not load
|
||||
|
||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) runs `dotnet build Scripts/Scripts.csproj -c Release`, **prints the output, never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Two consequences:
|
||||
|
||||
1. A failing script build is **silently ignored** and the previous `Scripts.dll` reloads. (`BRIDGE_FINDINGS.md` §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.)
|
||||
2. The build passes no `Platform`, so it defaults to `AnyCPU`. `OutputPath` is only set under the `Release|x64` condition, so the DLL lands in `Scripts/bin/Release/` while the server loads `Scripts.dll` from the repo root. **Script edits currently never take effect.**
|
||||
|
||||
**Fix before writing any bridge code.** Either add a default `<Platform>x64</Platform>` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch.
|
||||
|
||||
---
|
||||
|
||||
## 4. Plugin layout
|
||||
|
||||
All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug.
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. |
|
||||
| `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. |
|
||||
| `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. |
|
||||
| `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. |
|
||||
| `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. |
|
||||
| `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. |
|
||||
| `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. |
|
||||
|
||||
**Lifecycle** (`Server/Main.cs:544-562`, all Core thread):
|
||||
`Configure()` → `World.Load()` → `Initialize()` → `EventSink.ServerStarted`.
|
||||
|
||||
Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data catalog — everything the shard can give you
|
||||
|
||||
91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified.
|
||||
|
||||
### 5.1 Session & identity
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
||||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
||||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
||||
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
|
||||
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
||||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
||||
|
||||
### 5.2 Character state
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. |
|
||||
| **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. |
|
||||
| Skill progression | `SkillGain` | medium | High-signal. Ship it. |
|
||||
| Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. |
|
||||
| Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. |
|
||||
| Hunger | `HungerChanged` | low | Cosmetic; optional. |
|
||||
|
||||
> ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free.
|
||||
>
|
||||
> ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7.
|
||||
|
||||
### 5.3 Economy & commerce
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). |
|
||||
| **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. |
|
||||
| NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** |
|
||||
| NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. |
|
||||
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
|
||||
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
|
||||
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
|
||||
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
|
||||
|
||||
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
|
||||
|
||||
Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold.
|
||||
|
||||
### 5.4 Housing / IDOC
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
||||
|
||||
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
|
||||
|
||||
**The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected:
|
||||
|
||||
- `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 h random** (`DynamicDecay.cs:18`).
|
||||
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
|
||||
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
|
||||
|
||||
Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
|
||||
|
||||
### 5.5 Combat, death, PvP
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Player death | `PlayerDeath` | low | |
|
||||
| Murder | `PlayerMurdered` | low | High-signal for the website. |
|
||||
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
|
||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
||||
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
|
||||
|
||||
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
|
||||
|
||||
### 5.6 Progression & activity
|
||||
|
||||
`QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`.
|
||||
|
||||
`OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`.
|
||||
|
||||
> ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`.
|
||||
>
|
||||
> Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object.
|
||||
|
||||
### 5.7 Cheat detection & staff audit
|
||||
|
||||
This is where the catalog earns its keep, and it is thin in the original doc.
|
||||
|
||||
| Signal | Hook | Why |
|
||||
|--------|------|-----|
|
||||
| **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. |
|
||||
| **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. |
|
||||
| Staff commands | `EventSink.Command` | Every command invocation. |
|
||||
| **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. |
|
||||
| Gold flow | `AccountGoldChange` | Reconcile against sale stream. |
|
||||
|
||||
### 5.8 Lifecycle
|
||||
|
||||
`ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`.
|
||||
|
||||
`AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.**
|
||||
|
||||
### 5.9 Known gaps (no clean hook)
|
||||
|
||||
- **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.**
|
||||
- **Per-hit combat damage.** Virtual overrides only.
|
||||
- **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only.
|
||||
- **Stat/vital deltas.** Sweep. (Cheap — see §5.2.)
|
||||
|
||||
---
|
||||
|
||||
## 6. The one core edit: `PlayerVendorSale`
|
||||
|
||||
Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer:
|
||||
|
||||
```csharp
|
||||
// PlayerVendorGumps.cs:84-96
|
||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
||||
int commission = 0;
|
||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed
|
||||
```
|
||||
|
||||
At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale.
|
||||
|
||||
Three edits, then the bridge stays pure-subscription:
|
||||
|
||||
1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape).
|
||||
2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96.
|
||||
3. Bridge subscribes in `Initialize` like any other event.
|
||||
|
||||
~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs.
|
||||
|
||||
---
|
||||
|
||||
## 7. Wire protocol
|
||||
|
||||
Newline-delimited JSON, one object per line, `serial` as the primary key.
|
||||
|
||||
### Outbound (shard → sidecar)
|
||||
|
||||
```jsonc
|
||||
{"t":1752…,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2,
|
||||
"items":206467,"mobiles":42826,"accounts":51}
|
||||
{"t":1752…,"kind":"server.shutdown"}
|
||||
{"t":1752…,"kind":"server.crashed","error":"…"}
|
||||
{"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
|
||||
{"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
|
||||
"str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
|
||||
{"t":1752…,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
|
||||
{"t":1752…,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
||||
"owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
|
||||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
|
||||
{"t":1752…,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC",
|
||||
"map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0},
|
||||
"region":"Britain","name":"The Silver Anvil",
|
||||
"owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[],
|
||||
"builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
|
||||
{"t":1752…,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
|
||||
{"t":1752…,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
|
||||
{"t":1752…,"kind":"economy.supply","accounts":51,"gold":110478209}
|
||||
```
|
||||
|
||||
`char.profile` follows the shape in `BRIDGE_FINDINGS.md` §IV.3 — it was correct — with `mods` a flattened union of non-zero entries across `AosAttributes`, `AosWeaponAttributes`, `AosArmorAttributes`, produced by iterating each enum through the bag's indexer (`Scripts/Misc/AOS.cs:924,1464,2238`). No hardcoded property names.
|
||||
|
||||
### Inbound (sidecar → shard)
|
||||
|
||||
```jsonc
|
||||
{"kind":"char.request","account":"PerryAdimn","slot":0}
|
||||
{"kind":"account.roster","account":"PerryAdimn"}
|
||||
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
|
||||
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
|
||||
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
|
||||
{"kind":"towncrier.remove","id":"n123"}
|
||||
```
|
||||
|
||||
Every inbound handler marshals to the Core thread before touching world state.
|
||||
|
||||
### `server.hello` is per-connection, not per-boot
|
||||
|
||||
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.
|
||||
|
||||
`bootId` is a GUID generated at `ServerStarted`. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes *"I reconnected"* (keep cached state) from *"the shard restarted"* (discard it). `connects` is the shard's count of successful connections, so the first `hello` of a run carries `connects:1`.
|
||||
|
||||
Counts in `hello` are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning.
|
||||
|
||||
### Item names are clilocs
|
||||
|
||||
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
|
||||
|
||||
---
|
||||
|
||||
## 8. Corrections to `BRIDGE_FINDINGS.md`
|
||||
|
||||
| § | Claim | Reality |
|
||||
|---|-------|---------|
|
||||
| §1 | "A compile error in your bridge file takes the whole shard down at boot." | **False.** `Compile()` ignores the build exit code; a failing build silently reloads the stale `Scripts.dll`. Worse: your plugin would appear absent, not broken. See §3. |
|
||||
| §III.3 | IDOC = 95.0–99.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 12–24 h. Also: `CanDecay` is true only for `Condemned`/`ManualRefresh`, so an active owner's newest house never decays. |
|
||||
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
|
||||
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
|
||||
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
|
||||
| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. |
|
||||
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation phases
|
||||
|
||||
0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.`
|
||||
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
|
||||
2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
|
||||
3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
|
||||
4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests.
|
||||
5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15.
|
||||
6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16.
|
||||
7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17.
|
||||
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
|
||||
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
|
||||
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
|
||||
8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar.
|
||||
|
||||
### Config keys (`Config/Bridge.cfg`)
|
||||
|
||||
```ini
|
||||
Host=127.0.0.1
|
||||
Port=7788
|
||||
QueueCap=10000
|
||||
StatSweepSeconds=30
|
||||
DecaySweepSeconds=60
|
||||
EconomySweepSeconds=300
|
||||
```
|
||||
|
||||
Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Phase 1 acceptance
|
||||
|
||||
Run against the seeded shard with `tools/stub_sidecar.ps1`. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed.
|
||||
|
||||
| Claim | Evidence |
|
||||
|-------|----------|
|
||||
| The shard boots normally with **no sidecar listening**. | World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat. |
|
||||
| Events emitted while disconnected are **buffered and delivered on connect**. | `server.hello` carried `t=…070312` (boot) but arrived at `…114209`, 44 s later, when the sidecar first appeared. |
|
||||
| Inbound commands execute on the **Core thread**. | `{"kind":"ping","id":"t1"}` → `{"kind":"pong","id":"t1"}`. |
|
||||
| An **unknown kind** is ignored, not fatal. | `[Bridge] no handler for inbound kind 'nonsense.kind'` |
|
||||
| **Malformed JSON** does not kill the reader. | `[Bridge] malformed inbound line, ignoring`, connection stayed up. |
|
||||
| Killing the sidecar **does not disturb the shard**. | Shard stayed up, CPU unchanged, no exception, no log spam. |
|
||||
| The shard **reconnects unattended**. | Second `[Bridge] connected`, `hello` re-sent with `connects:2` and the same `bootId`. |
|
||||
|
||||
Two defects were found this way and fixed:
|
||||
|
||||
- **Backoff ceiling was 30 s**, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s.
|
||||
- **A stale reader could kill a fresh connection.** `reader.Join(1s)` can time out, and the old reader's `finally` then set the shared `_dead` flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned.
|
||||
|
||||
---
|
||||
|
||||
## 17. Phase 7 acceptance
|
||||
|
||||
The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`.
|
||||
|
||||
Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so.
|
||||
|
||||
Verified end to end with a probe that fired the event using **real seeded-vendor data**:
|
||||
|
||||
```json
|
||||
{"kind":"vendor.sale","committed":true,
|
||||
"buyerSerial":"0x1F8","buyerAcct":"seed_001",
|
||||
"ownerSerial":"0x1F5","ownerAcct":"seed_000",
|
||||
"vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword",
|
||||
"itemId":3937,"amount":1,"price":69819,"commission":0}
|
||||
```
|
||||
|
||||
Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events.
|
||||
|
||||
**Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`).
|
||||
|
||||
---
|
||||
|
||||
## 16. Phase 6 acceptance
|
||||
|
||||
`BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary.
|
||||
|
||||
Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree:
|
||||
|
||||
| Sent | Reply | Crier list |
|
||||
|------|-------|------------|
|
||||
| `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines |
|
||||
| `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list |
|
||||
| `remove n1` | `towncrier.ok` | entry gone |
|
||||
| `remove does-not-exist` | `towncrier.error "unknown id"` | no change |
|
||||
|
||||
The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged.
|
||||
|
||||
Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs.
|
||||
|
||||
No core changes; this closes the pure-plugin inbound work.
|
||||
|
||||
---
|
||||
|
||||
## 15. Phase 5 acceptance
|
||||
|
||||
`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`.
|
||||
|
||||
Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it):
|
||||
|
||||
```
|
||||
<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300
|
||||
-> link.confirm code=77M9TK websiteUserId=web-9931
|
||||
<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931
|
||||
-> link.confirm code=BADCOD ...
|
||||
<- link.error code=BADCOD reason="unknown or expired code"
|
||||
```
|
||||
|
||||
**The tag persists.** After a `World.Save()`, `accounts.xml` contained:
|
||||
|
||||
```xml
|
||||
<tags>
|
||||
<tag name="WebsiteUserId">web-9931</tag>
|
||||
</tags>
|
||||
```
|
||||
|
||||
This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised.
|
||||
|
||||
Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup.
|
||||
|
||||
Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Phase 4 acceptance
|
||||
|
||||
`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence.
|
||||
|
||||
Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread:
|
||||
|
||||
- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline.
|
||||
- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed.
|
||||
- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree.
|
||||
- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each.
|
||||
- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`.
|
||||
|
||||
Two things the real character surfaced that the seeded dummies could not:
|
||||
|
||||
- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully.
|
||||
- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking.
|
||||
|
||||
Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete.
|
||||
|
||||
---
|
||||
|
||||
## 13. Phase 3 acceptance
|
||||
|
||||
`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters.
|
||||
|
||||
Verified on the seeded world with intervals cut to 8 s:
|
||||
|
||||
- **Decay is transition-only.** Baseline recorded 29 houses **silently** on `ServerStarted`. A probe bumped one house `Somewhat → Fairly` with `SetDynamicDecay`; the next sweep emitted **exactly one** `house.decay`, none for the other 28:
|
||||
|
||||
```json
|
||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
||||
"builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"}
|
||||
```
|
||||
|
||||
- **Economy supply** emitted a snapshot each interval: `{"kind":"economy.supply","accounts":51,"gold":…}`.
|
||||
- **Vitals** correctly emitted nothing — the seeded characters are all offline (`NetState == null`). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client.
|
||||
|
||||
Notes from the run:
|
||||
|
||||
- **`region` is null** for the seeded houses — they sit outside any named region. The handler guards `Region`, `Sign`, and `Owner` for null; all three can be absent on abandoned or oddly-placed houses.
|
||||
- The sweeps **skip emitting when the sidecar is disconnected** (`BridgeLink.Connected`), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless.
|
||||
- **Config duplicate keys: last write wins** (`Config.cs` does `_Entries[key] = e`), which is why the scaffolding appends test overrides to the end of `Bridge.cfg`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phase 2 acceptance
|
||||
|
||||
The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar:
|
||||
|
||||
```
|
||||
{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
|
||||
{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
|
||||
{"kind":"karma.change",...,"old":7903,"new":7853}
|
||||
{"kind":"world.save.before"}
|
||||
{"kind":"world.save.after","items":206312,"mobiles":42826}
|
||||
```
|
||||
|
||||
`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.
|
||||
|
||||
### The finding: `SkillGain` fires for NPCs, hard
|
||||
|
||||
The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events.
|
||||
|
||||
This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.
|
||||
|
||||
### Safety facts baked into the handlers
|
||||
|
||||
- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process.
|
||||
- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these.
|
||||
- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer.
|
||||
- The property is `FastWalkEventArgs.NetState`, not `.State`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Operational notes
|
||||
|
||||
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
|
||||
- **Pending link codes are in-memory** and lost on crash. Acceptable — the player re-runs `[link`.
|
||||
- **`zlibwapi64` `DllNotFoundException`** already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
||||
- The bridge should carry the resolved `websiteUserId` on every player event once the account tag is read at `Login` and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.
|
||||
544
docs/RESEARCH.md
544
docs/RESEARCH.md
@@ -1,544 +0,0 @@
|
||||
# ServUO ⇄ External Service Bridge — Research Findings
|
||||
|
||||
**Status:** Research only, no implementation.
|
||||
**Architecture:** Rust sidecar owns a bidirectional WebSocket + JSON endpoint for the website; ServUO links to it over a **local loopback socket**. Tracking players/stats/gold/economy/NPC+player-vendor sales, IDOC/house decay, in-game **`[link`** account linking, and website→game town-crier news. See **Part II** (design/transport/tracking/link), **Part III** (player-vendor, IDOC, town crier, config), and **Part IV** (full character profiles — gear/skills/stats, online & offline, up to 5/account).
|
||||
**Date:** 2026-07-07
|
||||
**Codebase:** ServUO 57.4 (this repo, `C:\Users\colby\Desktop\servuo`), target framework **.NET Framework 4.8 / x64**.
|
||||
**Method:** Grounded in this repo's source. Where the running server would normally be used to confirm behavior, see the note in [§0](#0-note-on-empirical-verification) — the shard was **not running** at research time, so live-boot verification was deliberately skipped and replaced with source-level proof plus evidence from this repo's own crash logs. A ready-to-run empirical probe is included in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself).
|
||||
|
||||
---
|
||||
|
||||
## 0. Note on empirical verification
|
||||
|
||||
You said the shard was running and to verify against it. At research time **no `ServUO.exe` / `dotnet` process was live** (`Get-Process` returned nothing; `Logs/Console.log` absent). I chose **not** to boot it myself because a cold boot on this machine would:
|
||||
|
||||
- shell out to `dotnet build Scripts.csproj` (per `ScriptCompiler.Compile`, `Compiler.Dynamic=true` by default),
|
||||
- **bind the live game port** and load/write your actual `Saves/` world (117 mobiles / 2469 items per the last crash report),
|
||||
- run `EventSink.ServerStarted` and AutoSave against real state.
|
||||
|
||||
That's outward-facing and hard to reverse, so it needs your go-ahead. **It turned out not to be necessary for the core threading claims**, because:
|
||||
|
||||
1. The source pins the threading model exactly (call sites shown below), and
|
||||
2. **Your own crash log is live evidence.** `Crash 6-5-2026-22-38-3.log` contains this stack:
|
||||
|
||||
```
|
||||
Server.EventSink.InvokeClientVersionReceived(...)
|
||||
Server.Network.MessagePump.HandleReceive(NetState ns)
|
||||
Server.Network.MessagePump.Slice()
|
||||
Server.Core.Main(String[] args)
|
||||
```
|
||||
|
||||
That is a network-triggered EventSink handler executing **inside `MessagePump.Slice()`, called directly from `Core.Main`** — i.e. on the Core (main) thread, synchronously in the game loop. This is exactly the thread-identity fact item 3/5 hinges on, captured from this instance at runtime.
|
||||
|
||||
If you want the live thread-ID trace anyway (Timer + ServerStarted, no client needed), drop in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself) and start the shard, or tell me to boot it.
|
||||
|
||||
> ⚠️ Unrelated but worth flagging: that crash was `DllNotFoundException: zlibwapi64`. The DLL **is** present in the repo root, so this is a working-directory / native-load-path issue that has already crashed your shard once when sending a packed gump. Not a bridge concern, but it will bite the bridge too if the bridge ever triggers gump sends. Track separately.
|
||||
|
||||
---
|
||||
|
||||
## PART II — Re-evaluation for the Rust WebSocket sidecar (READ FIRST)
|
||||
|
||||
**Confirmed architecture (from you):** a **Rust sidecar** holds a bidirectional **WebSocket** connection and exposes a **JSON endpoint the website consumes**. Goals: track players + stats, gold, overall economy, vendor sales; and an in-game **`[link`** command that ties a game account to a website account.
|
||||
|
||||
The §1–§5 findings below are unchanged and still govern (lifecycle, events, timers, threading). This part maps them onto *your* design and supersedes the old §6/§7.
|
||||
|
||||
### II.1 Transport: put the WebSocket in Rust, keep the C# side dumb
|
||||
|
||||
```
|
||||
ServUO plugin (C#, net48) ──local loopback, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||||
(main-thread events) ◄──inbound commands (link, etc.)──┘ (owns WS, buffering, auth, fan-out)
|
||||
```
|
||||
|
||||
**Recommendation: ServUO ↔ sidecar = a plain local TCP loopback socket (`127.0.0.1`), newline-delimited JSON, bidirectional. Do NOT make ServUO speak WebSocket.**
|
||||
|
||||
- `System.Net.WebSockets.ClientWebSocket` *does* exist on net48 + Windows 11 and would work, but it's the wrong place for WS complexity. The sidecar already terminates WS for the website; a second WS hop inside the shard buys nothing and adds a heavier, blockier client on the one thread you must never block (§5). A raw `TcpClient` with `\n`-framed JSON is ~30 lines of C#, trivially non-blocking, and lets the **sidecar restart independently** without touching the shard.
|
||||
- Named pipes (old §6) also work and are fine if you prefer them; loopback TCP is marginally simpler cross-process and cross-language (Rust `tokio::net::TcpListener` ↔ C# `TcpClient`).
|
||||
- **This split is exactly what §5 demands.** All backpressure, reconnect, retry, website fan-out, and schema validation live in **Rust**. ServUO only ever does: (outbound) format a small JSON line → enqueue → a background writer thread drains to the socket; (inbound) a background read loop parses a line → `Timer.DelayCall` to the main thread. A slow or absent website can never stall the shard, because the Rust side owns the buffer and the socket write from C# is to loopback with a bounded local queue in front of it.
|
||||
|
||||
**Framing:** newline-delimited JSON objects (`{...}\n`), `PipeTransmissionMode`/message-mode not needed. One writer thread on the C# side keeps event ordering intact. Bound the outbound queue (drop-oldest + a dropped-counter) so a stalled sidecar can't OOM the shard.
|
||||
|
||||
### II.2 Tracking targets → concrete hooks (and the gaps)
|
||||
|
||||
| Target | Hook | Freq | Notes / caveats |
|
||||
|--------|------|------|-----------------|
|
||||
| **Player online / identity** | `EventSink.Login` / `Logout` | Low | Snapshot `Account.Username`, char name, `Mobile.Serial`, `Map`, `Location`. Best per-player anchor. |
|
||||
| **Player stats** (Str/Dex/Int, Hits/Mana/Stam, skills, Fame/Karma) | ⚑ **No per-change EventSink** | — | Strategy: full snapshot on `Login`, then a **periodic sweep** (every 15–30 s) of online `PlayerMobile`s pushed as-is; let the **sidecar diff** and forward only changes. Add `FameChange`/`KarmaChange`/`SkillGain` for high-signal jumps. Don't try to hook the per-stat delta system — it's invasive and firehose-y. |
|
||||
| **Gold (per player)** | `EventSink.AccountGoldChange` | Low–Med | ✔ **AccountGold is ENABLED on this shard** (expansion EJ ≥ TOL, `CurrentExpansion.cs:20`). Args give `IAccount` + `OldAmount`/`NewAmount` (`TotalCurrency`, a `double`). Most gold flow fires this. Caveat: physical coins/checks sitting in a bankbox aren't fully reflected here — see economy row. |
|
||||
| **Overall economy / money supply** | Periodic account sweep + flow events | Low | Money **supply** = periodic sum of `TotalCurrency` across all `Accounts` (+ optionally bankbox coin/check items) on the main thread, pushed as a snapshot. Money **velocity/flow** = the `AccountGoldChange` + vendor-sale event stream. Sidecar aggregates both. |
|
||||
| **NPC vendor — player buys** | `EventSink.ValidVendorPurchase` | Med | Args: `Mobile` (buyer), `Vendor`, `Bought` (IEntity/item), `AmountPerUnit`. **Total = AmountPerUnit × stack `Amount`.** Raised from `GenericBuy.cs:379`. |
|
||||
| **NPC vendor — player sells** | `EventSink.ValidVendorSell` | Med | Args mirror above (`Sold`, `AmountPerUnit`). Raised from `BaseVendor.cs:2209`. |
|
||||
| **Player vendor sales** | ⚑ **No EventSink (gap)** | Med | Player-vendor buys go through `PlayerVendor.TryToBuy` (`PlayerVendor.cs:447`), not the Valid* events. To capture these you must override/patch the PlayerVendor buy completion. Flag if the spec counts player-vendor commerce as "vendor sales." |
|
||||
| **Account ↔ website link** | `Account.Username` + `Account.SetTag/GetTag` | — | `SetTag("WebsiteUserId", id)` persists to `accounts.xml` across restarts (`Account.cs:1078,1093`). No schema/DB work needed on the C# side. |
|
||||
|
||||
> ⚠️ The `Valid*` vendor events are **validation-stage veto hooks**, not "sale committed" callbacks. They fire when the purchase is being validated; in rare cases a sale could still fail afterward. For coarse economy metrics that's fine; if you need exact ledger accuracy, treat them as "sale attempted" and reconcile against `AccountGoldChange`, or hook the actual completion path. **Never block or throw in these handlers** — you're inside the transaction path.
|
||||
|
||||
### II.3 The `[link` command flow
|
||||
|
||||
Prefix is `[` (`Commands.cs:131`), so `[link` is registered directly. Everything below runs on the main thread except the socket I/O.
|
||||
|
||||
1. **Register** in your plugin's `Initialize()`:
|
||||
`CommandSystem.Register("link", AccessLevel.Player, OnLink);`
|
||||
2. **`[link` handler** (`e.Mobile`): read `e.Mobile.Account as Account`. If already tagged (`GetTag("WebsiteUserId") != null`), tell them so. Otherwise generate a **short, one-time, expiring code** (e.g. 6–8 chars, 5-min TTL), store `code → {accountUsername, expiry}` in an in-memory dict (main thread), and:
|
||||
- push `{"kind":"link.request","code":"AB12CD","account":"PerryAdimn","char":"Thunderheat"}` to the sidecar, and
|
||||
- `e.Mobile.SendMessage("Enter code AB12CD at https://yoursite/link to connect your account.")`
|
||||
3. **Website** (user logged in there) submits the code → sidecar → ServUO inbound line `{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}`.
|
||||
4. **Inbound handler** marshals to main thread (`Timer.DelayCall`), validates code + TTL, then `account.SetTag("WebsiteUserId","9931")`, drops the code, and replies `{"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}`. Optionally `SendMessage` the player if still online.
|
||||
5. **Thereafter**, every player event you emit can carry the resolved `websiteUserId` (read the tag on Login and cache account→id in the sidecar), so the website can attribute stats/gold/sales to a site user.
|
||||
|
||||
Security notes: codes one-time + short-TTL; the link socket is **loopback-only** (bind `127.0.0.1`, never `0.0.0.0`); the account write happens on the main thread; rate-limit `[link` per account to avoid code spam. Treat `websiteUserId` from the sidecar as trusted only because the socket is local — if the sidecar is ever exposed, add a shared secret.
|
||||
|
||||
### II.4 Revised flags for THIS architecture
|
||||
|
||||
1. **✔ Threading is a solved problem given the split.** Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget + `Timer.DelayCall` inbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO.
|
||||
2. **⚑ Player stats have no change-event** → sweep-and-diff in the sidecar (II.2). Budget for a 15–30 s snapshot of online players; don't expect push-on-change.
|
||||
3. **⚑ Player-vendor sales aren't covered by any EventSink** (II.2) — **RESOLVED in §III.1.** You've confirmed this stream is critical (economy + cheat detection), so add the small `PlayerVendorSale` EventSink (~15 lines of core instrumentation). It's the one non-drop-in piece.
|
||||
4. **⚑ "Economy" needs both a periodic supply snapshot and the flow stream.** `AccountGoldChange` alone is flow, not total; physical bank coins/checks aren't in it. Do a periodic `Accounts` `TotalCurrency` sum for money supply.
|
||||
5. **✔ Linking needs no new persistence layer** — account tags serialize to `accounts.xml` for free (II.3). Survives restarts and saves.
|
||||
6. **⚑ Commands/inbound don't apply during world saves** (§5 pitfall 3, ~every 5 min). A `[link.confirm` arriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly.
|
||||
7. **⚑ Crash path skips `Shutdown`** (§1): the sidecar must treat socket EOF as normal and reconnect; don't rely on a clean goodbye frame. Pending link codes are in-memory and lost on crash — acceptable (user re-runs `[link`).
|
||||
8. **⚑ (unchanged) Item pickup/drop and per-hit combat have no EventSink** (§2 gap) — only relevant if the tracking scope grows beyond stats/gold/economy/vendors.
|
||||
|
||||
---
|
||||
|
||||
## PART III — Player-vendor tracking, IDOC, town-crier news, config
|
||||
|
||||
Follow-ups you added: **(1)** player-vendor tracking is *critical* (economy balance + admin cheat detection); **(2)** the 30 s stat sweep must be config-editable; **(3)** hook **IDOC / house decay**; **(4)** town criers receive **news pushed from the website**.
|
||||
|
||||
### III.1 Player-vendor sales — the one place you need a small core touch
|
||||
|
||||
There is genuinely **no EventSink** on the player-vendor buy path (confirmed). The purchase *completes* in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), specifically at the gold transfer:
|
||||
|
||||
```csharp
|
||||
// PlayerVendorGumps.cs ~line 81-96 (existing code)
|
||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
||||
...
|
||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited ◄── sale is now committed
|
||||
```
|
||||
|
||||
At that point every field cheat-detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the real player who profits), **item** (`m_VI.Item`, incl. `Serial`, type, `Amount`), **price** (`m_VI.Price`), and **commission**. This is *better* data than the NPC-vendor `Valid*` events (which lack owner + commission), and unlike them it fires on a **committed** sale, not a validation stage.
|
||||
|
||||
**Recommendation (idiomatic, minimal): add a first-class EventSink event, mirroring the existing vendor events.** Three tiny edits, then the bridge stays pure-subscription like everything else:
|
||||
|
||||
1. In `Server/EventSink.cs`: declare `public static event PlayerVendorSaleEventHandler PlayerVendorSale;`, an `InvokePlayerVendorSale`, and a `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape at `EventSink.cs:1508`).
|
||||
2. In `PlayerVendorGumps.cs`, one line right after the `HoldGold +=` at ~line 96:
|
||||
`EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));`
|
||||
3. Bridge subscribes in `Initialize` like any other event.
|
||||
|
||||
This is **the single spot where the bridge can't be pure drop-in** — worth calling out explicitly since I'd earlier listed player vendors as a "gap." It's a ~15-line core instrumentation, not a rework. (Alternative if you refuse to touch core scripts: a periodic diff of every `PlayerVendor`'s inventory + `HoldGold` — but that can't attribute the *buyer*, which is exactly what cheat detection needs, so it's a poor substitute.)
|
||||
|
||||
**For cheat detection specifically**, emit per sale: buyer serial+account, owner serial+account, item type/serial/amount, price, commission, vendor serial, house/region, timestamp. The sidecar can then flag e.g. same-account buyer≈owner (gold laundering), wildly off-market prices, or burst patterns. Note `m_Vendor.Owner` + `from.Account` are the two identities that matter; both are readable synchronously in the handler (main thread).
|
||||
|
||||
### III.2 Config-editable sweep interval (and other tunables)
|
||||
|
||||
Use ServUO's own config system (`Server/Config.cs`), which reads `Config/*.cfg`. Read tunables in `Configure()` (runs before world load):
|
||||
|
||||
```csharp
|
||||
StatSweep = Config.Get("Bridge.StatSweepSeconds", 30);
|
||||
DecaySweep = Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||
```
|
||||
|
||||
Drop a `Config/Bridge.cfg` with `Bridge.StatSweepSeconds=30` etc. `Config.Get<T>` handles `int`/`TimeSpan`/`bool`. Make the sweep timer re-readable on demand (a `[bridge reload` admin command that re-reads config and re-arms the `Timer`) so you can retune without a restart. Store all bridge knobs (sweep intervals, which event streams are enabled, sidecar host/port, queue cap) in that one cfg.
|
||||
|
||||
### III.3 IDOC / house decay — sweep `BaseHouse.AllHouses`, emit on transition
|
||||
|
||||
Also **no EventSink** here. The model (`Scripts/Multis/BaseHouse.cs`):
|
||||
|
||||
- `DecayLevel` enum (`BaseHouse.cs:4341`): `Ageless, LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, DemolitionPending`. **IDOC = 95.0–99.9%** of the decay period elapsed (`GetOldDecayLevel`, `BaseHouse.cs:211-213`); `Collapsed` = 100%.
|
||||
- `BaseHouse.AllHouses` is a static list of every house; `Decay_OnTick` (`BaseHouse.cs:59`) already periodically calls `CheckDecay()` on all of them.
|
||||
- The `DecayLevel` getter has internal transition detection (`m_LastDecayLevel`, `BaseHouse.cs:193`) but it's private and only invalidates the sign — **not** exposed as an event.
|
||||
|
||||
**Decision: emit on transition only, tracked plugin-side.** A low-frequency **sweep** (30–60 s, config per III.2) over `BaseHouse.AllHouses` reads `house.DecayLevel` on the main thread. The plugin holds a `Dictionary<Serial, DecayLevel>` of last-known levels and emits **only when a house's level changes** — no per-sweep spam, one message per real transition. Houses number in the hundreds/thousands (not the mobile firehose), so the sweep is cheap even though we scan all of them each pass.
|
||||
|
||||
**State & re-baseline (important, since the plugin now holds state):**
|
||||
- The last-known map is **in-memory and resets on restart**. On `ServerStarted` (§1), do a **silent baseline pass**: populate the dictionary from the current `DecayLevel` of every house **without emitting** — otherwise every house re-announces its current stage on every boot. Optionally emit a single `idoc.snapshot` of all houses already at IDOC/Collapsed so the website/admin panel is correct immediately after a restart, clearly flagged as a snapshot (not a transition).
|
||||
- Emit direction matters for cheat/economy signals: include both `from`/`to` levels so the consumer can tell decay progression from a **refresh** (owner logged in → level jumps back toward `LikeNew`; `RefreshDecay`, `BaseHouse.cs`). A house leaving IDOC because someone refreshed it is itself a useful signal.
|
||||
- `house.DecayLevel` is a computed property — read it **once per house per sweep** into a local, don't call it repeatedly.
|
||||
|
||||
**Payload (home location state you asked for — all readable synchronously in the sweep):** `BaseHouse` is a `BaseMulti` (an item), so it has `Serial`, `Location`/`X`/`Y`/`Z`, `Map`. Plus:
|
||||
|
||||
| Field | Source |
|
||||
|-------|--------|
|
||||
| house serial | `house.Serial` |
|
||||
| decay from → to | tracked dict → `house.DecayLevel` |
|
||||
| coords + facet | `house.X/Y/Z`, `house.Map` |
|
||||
| stable landmark (where a player stands) | `house.BanLocation` (`BaseHouse.cs:3637`) |
|
||||
| region / area name | `house.Region` (`:3672`) → `Region.Name` |
|
||||
| house name | `house.Sign?.GetName()` (`:2108`) |
|
||||
| owner | `house.Owner` (`:3564`) → serial + `Owner.Account.Username` (may be null if abandoned) |
|
||||
| co-owners / friends | `house.CoOwners`, `house.Friends` (`:3679-3680`) — serials/accounts |
|
||||
| built / last refreshed | `house.BuiltOn`, `house.LastRefreshed` (`:3786,:66`) |
|
||||
| time-to-collapse | `house.NextDecayStage` and/or derive from `LastRefreshed + DecayPeriod` |
|
||||
|
||||
Example emit:
|
||||
```jsonc
|
||||
{ "kind":"house.decay", "serial":"0x40001234", "from":"Greatly", "to":"IDOC",
|
||||
"map":"Felucca", "x":1420, "y":1631, "z":0, "ban":{"x":1422,"y":1635,"z":0},
|
||||
"region":"Britain", "name":"The Silver Anvil",
|
||||
"owner":{"serial":"0x1A2B","account":"PerryAdimn"},
|
||||
"coOwners":[], "builtOn":"2026-01-02T...", "lastRefreshed":"2026-06-30T...",
|
||||
"collapseEta":"2026-07-08T..." }
|
||||
```
|
||||
|
||||
This gives the website a live IDOC feed with exact map pins and the admin side an owner-attributed decay timeline. Guard against `Owner`/`Sign`/`Region` being null (abandoned or mid-demolition houses).
|
||||
|
||||
### III.4 Town-crier news pushed from the website (inbound → main thread)
|
||||
|
||||
Clean API, no core changes needed: `GlobalTownCrierEntryList.Instance.AddEntry(string[] lines, TimeSpan duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`) posts a **global** entry that *every* town crier announces until it expires; `RemoveEntry(entry)` pulls it early. `AddEntry` returns the `TownCrierEntry`.
|
||||
|
||||
**Flow:** website publishes news → sidecar → ServUO inbound `{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","The market tax is now 5%."],"durationSec":3600}` → **marshal to main thread** (`Timer.DelayCall`) → `var e = GlobalTownCrierEntryList.Instance.AddEntry(lines, TimeSpan.FromSeconds(durationSec));` and stash `id → e` so a later `{"kind":"towncrier.remove","id":"n123"}` can call `RemoveEntry(e)`.
|
||||
|
||||
Must run on the main thread (mutates a shared list and sends packets to crier NPCs) — same marshaling rule as `[link` (§II.3 / §5). Guard against abuse: cap line length/count and active-entry count in the handler; the socket being loopback-only is your trust boundary. Note the crier speaks lines on its own timer, so there's a natural delay before players hear it — fine for news.
|
||||
|
||||
### III.5 Updated capability map
|
||||
|
||||
| Capability | Mechanism | Core touch? | Runs on |
|
||||
|-----------|-----------|:-----------:|---------|
|
||||
| Player online/stats/gold | EventSink + 30 s sweep (§II.2) | No | main thread |
|
||||
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | main thread |
|
||||
| **Player-vendor sales** | **new `PlayerVendorSale` EventSink** (§III.1) | **Yes, ~15 lines** | main thread |
|
||||
| `[link` account linking | `CommandSystem.Register` + account tags (§II.3) | No | main thread |
|
||||
| IDOC / house decay | sweep `BaseHouse.AllHouses` on transition (§III.3) | No | main thread |
|
||||
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | main thread (marshaled) |
|
||||
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
|
||||
|
||||
**Net:** everything you listed is doable, and **only player-vendor sales requires a (small, idiomatic) core edit** — which is justified because it's your critical/cheat-detection stream and reflection-based alternatives can't identify the buyer.
|
||||
|
||||
---
|
||||
|
||||
## PART IV — Full character profiles (armor / weapons / skills / everything)
|
||||
|
||||
You want the site's **player endpoint** to show a whole character — worn gear, weapon/armor detail, every skill, all stats — for **up to 5 characters per account**, online *or* offline, and eventually their vendor stats. The object model supports all of it; the design question is *how to ship it without turning the 30 s sweep into a firehose.*
|
||||
|
||||
### IV.1 It's all on the live `Mobile` — and offline chars stay resident
|
||||
|
||||
- **Account → characters:** `Account` holds `Mobile[] m_Mobiles` with `account.Length` slots and `account[index]` (`Account.cs:592,598`); non-null slots are the characters (max 5, engine allows up to 7). Iterate them to enumerate an account's roster.
|
||||
- **Offline = still in memory.** Mobiles are removed from `World.Mobiles` **only on `Delete()`, never on logout.** A logged-off character is a live `Mobile` with `NetState == null`; all its gear/skills/stats are intact. **→ the bridge can build a full profile for any character at any time, online or offline** — exactly what "see my characters from the website" needs. `m.NetState != null` (or `m.Player && online`) is your online flag.
|
||||
- **Stats/vitals** (`Server/Mobile.cs`): `Str/Dex/Int` (`:8276+`), `Hits/HitsMax`, `Mana/ManaMax`, `Stam/StamMax` (`:8554+`), the five resists `PhysicalResistance…EnergyResistance` (`:931+`), `VirtualArmor`, plus `Fame`, `Karma`, `Luck`, `TotalWeight`, `Title`, `Body`, `Hue`, `Name`.
|
||||
- **Skills** (`Server/Skills.cs`): `m.Skills` is `IEnumerable<Skill>` (`:1099`) with `Length` + indexer. Each `Skill`: `SkillName`, `Base`, `Value` (base + item/temp bonuses), `Cap`, `Lock` (`Skills.cs:259,322,373,350,269`). Emit all ~58.
|
||||
- **Worn equipment:** `m.Items` (`Mobile.cs:6695`) is the list of *equipped* items (one per `Layer`); `FindItemOnLayer(Layer)` (`:10545`) fetches a slot. `Layer` enum (`Item.cs:25`) covers the ~25 wearable slots (OneHanded, TwoHanded, Helm, Gloves, Ring, Neck, Arms, InnerTorso, Talisman, …). Filter out non-gear layers (Backpack, Bank, Mount, Hair/FacialHair) unless you want them.
|
||||
- **Weapon/armor detail** (`BaseWeapon.cs`, `BaseArmor.cs`): rich AOS attribute objects — `Attributes` (`AosAttributes`), `WeaponAttributes`, `ArmorAttributes`, `AosElementDamages`, `ExtendedWeaponAttributes`, `NegativeAttributes`, plus `MinDamage/MaxDamage/StrRequirement` (weapon) and `BaseArmorRating`/resists (armor). **Each attribute bag exposes an enum indexer** — `AosAttributes[AosAttribute]`, `AosWeaponAttributes[AosWeaponAttribute]`, `AosArmorAttributes[AosArmorAttribute]` (`Scripts/Misc/AOS.cs:924,1464,2238`) — so you can **flatten every mod generically** by iterating the enum and emitting non-zero entries, without hardcoding 30+ property names.
|
||||
|
||||
### IV.2 Ship it tiered + on-demand (don't stream heavy profiles blindly)
|
||||
|
||||
A full profile ≈ 58 skills + ~15 gear items each with a mod table. Pushing that for every character every 30 s (× N accounts, most idle/offline, most unviewed) is wasteful. Split by volatility:
|
||||
|
||||
| Tier | Contents | When emitted |
|
||||
|------|----------|--------------|
|
||||
| **Vitals** (small, volatile) | hits/mana/stam, current str/dex/int, gold, location, online flag | 30 s sweep of **online** players + events |
|
||||
| **Profile** (large, semi-static) | all skills, worn equipment + item mods, resists, caps, fame/karma/luck | on `Login`, on equip/skill change, and **on demand** |
|
||||
|
||||
**On-demand request/response drives the website player endpoint.** When the site opens a character page: website → sidecar → ServUO `{"kind":"char.request","account":"PerryAdimn","slot":0}` (or by serial) → marshal to main thread → build the full profile → reply `{"kind":"char.profile", …}`. The **sidecar caches** the last profile so the page renders instantly and the game only rebuilds on request or on change. This scales: you never pay to serialize characters nobody is looking at. (For a "roster" view, a light `{"kind":"account.roster"}` returning name/body/slot/online per character is enough; fetch the heavy profile only when a specific char is opened.)
|
||||
|
||||
### IV.3 Character-profile schema (sketch)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": "char.profile",
|
||||
"account": "PerryAdimn", "slot": 0,
|
||||
"serial": "0x0075", "name": "Thunderheat", "title": "the Legendary",
|
||||
"body": 400, "hue": 33770, "online": true,
|
||||
"stats": { "str":100,"dex":90,"int":45, "hits":95,"hitsMax":100,
|
||||
"mana":40,"manaMax":45,"stam":88,"stamMax":90,
|
||||
"resist":{"phys":70,"fire":68,"cold":55,"pois":60,"energy":62},
|
||||
"gold":124500, "fame":12000,"karma":-4000,"luck":140,"weight":320 },
|
||||
"skills": [ {"name":"Swords","base":100.0,"value":120.0,"cap":120.0,"lock":"Up"},
|
||||
{"name":"Tactics","base":100.0,"value":110.0,"cap":120.0,"lock":"Locked"} /* …all */ ],
|
||||
"equipment": [
|
||||
{ "serial":"0x4001A2","layer":"TwoHanded","itemId":5046,"hue":0,
|
||||
"name":null,"cliloc":1023721, // resolve name via cliloc (IV.4)
|
||||
"weapon":{"minDamage":16,"maxDamage":18,"strReq":40},
|
||||
"mods":{"WeaponDamage":50,"HitLightning":40,"SwingSpeedIncrement":30,"DefendChance":15} },
|
||||
{ "serial":"0x4002B3","layer":"InnerTorso","itemId":7168,"hue":1157,
|
||||
"name":"Ancient Plate","armor":{"baseRating":45},
|
||||
"mods":{"ResistFireBonus":15,"LowerManaCost":8,"BonusHits":5} }
|
||||
],
|
||||
"vendorsOwned": 3 // future (IV.5)
|
||||
}
|
||||
```
|
||||
|
||||
Locks/enum values serialize as their names. `mods` is the flattened non-zero union across the item's attribute bags.
|
||||
|
||||
### IV.4 Gotchas for the profile export
|
||||
|
||||
- **⚑ Item names are usually clilocs, not strings.** `Item.Name` (`Item.cs:4860`) is frequently `null`; the real display name is `LabelNumber` (`:3771`), a cliloc ID resolved against `Data/Cliloc.enu`. For the website either (a) resolve cliloc → text server-side from the cliloc file and send the string, or (b) send the number and resolve on the site with a cliloc map. Crafted/renamed items *do* carry a plain `Name`. Send both (`name` + `cliloc`) and prefer `name` when present.
|
||||
- **⚑ Don't recurse the whole backpack/bank by default.** A pack can hold hundreds of nested items — that's a different (huge) payload than "what they're wearing." Ship **worn equipment** fully; expose backpack/bank as an opt-in or a summarized count, not a default deep dump.
|
||||
- **Building a profile allocates** (skill list + per-item mod scans). Keep it on-demand / on-change, **not** in the 30 s vitals sweep. A burst of `char.request`s should be fine (main-thread, fast) but rate-limit at the sidecar.
|
||||
- **`Value` vs `Base` for skills:** `Base` is the trained number; `Value` includes item/temp bonuses (what the client shows in combat). Send both — the site likely wants `Base` for "character sheet" and `Value` for "effective."
|
||||
- **Read on the main thread only.** Everything above touches live `Mobile`/`Item` state (§5). Build the DTO synchronously in the request handler / sweep, hand the finished JSON to the writer thread.
|
||||
|
||||
### IV.5 Vendor stats per player (the "eventually")
|
||||
|
||||
Ties into §III.1. A character/account can own player vendors; each `PlayerVendor` has `Owner`, an inventory of `VendorItem`s (item, `Price`, description), `HoldGold`, `BankAccount`, and commission. For a player-facing "my vendors" view, enumerate `PlayerVendor`s whose `Owner` is one of the account's mobiles and emit: vendor serial, house/location, held gold, and inventory (item, price, sold-state). Combined with the §III.1 `PlayerVendorSale` stream, the site can show both **current listings** and **sales history**. Same tiered/on-demand rule — fetch on request, refresh on sale.
|
||||
|
||||
### IV.6 Updated capability map (supersedes III.5)
|
||||
|
||||
| Capability | Mechanism | Core touch? | Cadence |
|
||||
|-----------|-----------|:-----------:|---------|
|
||||
| Player vitals (hp/mana/stam/gold/loc) | 30 s sweep of online + events | No | periodic/event |
|
||||
| **Full character profile** (stats/skills/gear/mods) | build from live `Mobile`, **on-demand + on-change** (§IV) | No | request/response + on change |
|
||||
| Account roster (up to 5 chars) | `account[0..Length]`, incl. offline (§IV.1) | No | on request |
|
||||
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | event |
|
||||
| Player-vendor sales | new `PlayerVendorSale` EventSink (§III.1) | **Yes, ~15 lines** | event |
|
||||
| Player-owned vendor stats | enumerate `PlayerVendor` by owner (§IV.5) | No | on request |
|
||||
| `[link` account linking | `CommandSystem` + account tags (§II.3) | No | event |
|
||||
| IDOC / house decay | sweep `AllHouses`, transition-only (§III.3) | No | 30–60 s sweep |
|
||||
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | inbound |
|
||||
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
|
||||
|
||||
**Net:** the full-character requirement adds **no** new core touches — it's all readable off live objects. The only structural addition it implies is an **inbound request/response channel** (already needed for `[link` and town-crier), used here as `char.request` / `account.roster`, with the sidecar caching profiles for the website.
|
||||
|
||||
---
|
||||
|
||||
## 1. Script lifecycle — how `Scripts/Custom` loads and hooks startup/shutdown
|
||||
|
||||
**Compilation model (this is a *modern* ServUO, not the old CodeDom one).**
|
||||
`Server/ScriptCompiler.cs:18` → when `Compiler.Dynamic` is true (default), the core literally runs:
|
||||
|
||||
```
|
||||
dotnet build "Scripts/Scripts.csproj" -c Release (or Debug)
|
||||
```
|
||||
|
||||
then `Assembly.LoadFrom("Scripts.dll")` (`ScriptCompiler.cs:63`). `Scripts.csproj` is SDK-style (`Microsoft.NET.Sdk`) with **default globbing**, so **every `.cs` anywhere under `Scripts/` — including `Scripts/Custom/` — is compiled automatically**. There is no per-file registration. A new plugin = drop a `.cs` file in `Scripts/Custom/` and restart (or rebuild `Scripts.dll`).
|
||||
|
||||
- If `dotnet build` fails, the core loops asking to retry (`Main.cs:525`); under `-service` it just returns/exits. So **a compile error in your bridge file takes the whole shard down at boot** — keep the plugin minimal and defensive.
|
||||
- `-service`/non-interactive suppresses the console prompt (`Main.cs:386`).
|
||||
|
||||
**Lifecycle entry points (in boot order, all on the Core thread — `Main.cs:544-562`):**
|
||||
|
||||
| Order | Mechanism | How you hook it |
|
||||
|------:|-----------|-----------------|
|
||||
| 1 | `ScriptCompiler.Invoke("Configure")` | Any `public static void Configure()` in any script type |
|
||||
| 2 | `World.Load()` | (world state restored from `Saves/`) |
|
||||
| 3 | `ScriptCompiler.Invoke("Initialize")` | Any `public static void Initialize()` in any script type |
|
||||
| 4 | `EventSink.InvokeServerStarted()` | `EventSink.ServerStarted += ...` |
|
||||
|
||||
`Invoke()` (`ScriptCompiler.cs:87`) reflects over **all** loaded types, finds the named `public static` method, sorts by `[CallPriority(n)]` (`Server/Attributes.cs:27`), and calls them. **`Configure` runs *before* `World.Load`; `Initialize` runs *after*.** → Register EventSink handlers in `Initialize` (or `Configure`); read config in `Configure`. Canonical example already in-tree: `Scripts/Misc/WeightOverloading.cs:15` subscribes to `EventSink.Movement` inside `Initialize()`.
|
||||
|
||||
**Shutdown.** Two clean hooks, both fire on the Core thread:
|
||||
- `EventSink.Shutdown` — invoked from `Core.HandleClosed()` (`Main.cs:313`) on normal exit, *after* `World.WaitForWriteCompletion()`. **Not** invoked if `_Crashed`.
|
||||
- `EventSink.Crashed` — invoked from the unhandled-exception handler (`Main.cs:198`); gives you an `args.Close` vote.
|
||||
- Windows console-close / Ctrl-C routes through `OnConsoleEvent` → `Kill()` → `HandleClosed()` (`Main.cs:254`), so `Shutdown` normally still fires.
|
||||
|
||||
**Bridge implication:** your named-pipe writer/listener should be **created in `Initialize` (or on `ServerStarted`) and torn down in `Shutdown`**. Don't assume `Shutdown` runs on a crash — the pipe handle may be abandoned; the external service must tolerate an abrupt EOF.
|
||||
|
||||
---
|
||||
|
||||
## 2. EventSink — available events, subscription, and frequency
|
||||
|
||||
**Subscription pattern:** `EventSink.<Name> += handler;` (static multicast delegates, declared `Server/EventSink.cs:1692-1784`). Handlers are plain delegates invoked synchronously via `EventSink.Invoke<Name>(args)` from the code path that raises them. **Every handler runs on whatever thread raised the event — in practice always the Core thread** (movement, speech, combat, login all originate from packet handling in `MessagePump.Slice()` or from the main-loop delta processing).
|
||||
|
||||
### Events relevant to a state-export bridge
|
||||
|
||||
| Event | Fires when | Frequency | Notes for export |
|
||||
|-------|-----------|-----------|------------------|
|
||||
| `Login` | Player fully in-world | Low | Best "player online" signal; gives `Mobile`. |
|
||||
| `Logout` | Player disconnect (in-world) | Low | Pair with Login. |
|
||||
| `Connected` / `Disconnected` | Socket up/down | Low | Lower-level than Login/Logout (fires for char-select too). |
|
||||
| `PlayerDeath` | Player dies | Low | `PlayerDeathEventArgs` (mobile, corpse-ish context). |
|
||||
| `CreatureDeath` | NPC/creature dies | **Medium–High** | Fires for *every* mob kill; on a busy shard this is a firehose. Filter/aggregate. |
|
||||
| `Speech` | Player/NPC speech | Medium | `SpeechEventArgs`; raised from `Mobile.cs:5114`. Includes NPC/system speech. |
|
||||
| `Movement` | **Any mobile takes a step** | **Very High** | See ⚠️ below. |
|
||||
| `AggressiveAction` | Combat aggression declared | Medium–High | `AggressiveActionEventArgs` (`EventSink.cs:372`). Not per-swing, per aggression state change. |
|
||||
| `ItemCreated` / `ItemDeleted` | Item constructed/deleted | **Very High** | Fires for *every* item incl. transient/loot/internal. Huge volume. |
|
||||
| `MobileCreated` / `MobileDeleted` | Mobile constructed/deleted | High | Same caveat as items. |
|
||||
| `SkillGain`, `CraftSuccess`, `ResourceHarvestSuccess` | Progression | Medium | Good "interesting player activity" signals. |
|
||||
| `AccountGoldChange`, `FameChange`, `KarmaChange` | Economy/rep deltas | Low–Medium | Naturally diff-shaped. |
|
||||
| `QuestComplete`, `JoinGuild`, `TameCreature`, `PlayerMurdered` | Milestone events | Low | Cheap, high-signal — ideal to export. |
|
||||
| `WorldSave` / `BeforeWorldSave` / `AfterWorldSave` | Save cycle | Low (~5 min) | Natural checkpoint boundary for the bridge. |
|
||||
| `ServerStarted` / `Shutdown` / `Crashed` | Lifecycle | Once | Bridge connect/disconnect signaling. |
|
||||
|
||||
Full list of 70+ events at `EventSink.cs:1692-1784` (context menus, vendor buy/sell, BOD, virtue, targeting macros, etc.).
|
||||
|
||||
> ⚠️ **`Movement` is the single most dangerous event to naively export.** `EventSink.InvokeMovement` is called from `Mobile.InternalOnMove` (`Mobile.cs:3029`), which runs for **every mobile that takes a step — all NPCs, all creatures, not just players.** On a populated shard that's thousands of invocations/second. It is **synchronous and cancellable** (`args.Blocked` gates the move), so your handler sits *inside the movement decision path* — any latency there (a blocking pipe write!) stalls the whole server. Additionally the args object is **pooled and immediately `Free()`d** (see §5). Rules: filter to `PlayerMobile` at the top of the handler, copy out primitives synchronously, never block, never retain the args reference.
|
||||
|
||||
### ⚑ Gap flag — events with *no* clean EventSink hook
|
||||
|
||||
These are things a bridge spec commonly wants to export but that **do not have a first-class `EventSink`**:
|
||||
|
||||
- **Item pickup / drop / "lift".** There is **no `EventSink` for picking up or dropping items.** It's handled by **virtual methods** on the objects: `Item.OnDragLift` / `Item.OnDragDrop` / `Item.OnDroppedInto` (`Item.cs:4647,2157,5060`) and `Mobile.OnDragDrop` / `Mobile.OnDragLift` (`Mobile.cs:10877,10949`). To observe these you must **override them on your own subclasses** or patch base classes — you can't subscribe globally from `Initialize`. Partial coverage exists via `EventSink.OnItemObtained`, `EventSink.ContainerDroppedTo`, and `EventSink.CorpseLoot`, but none of these is a universal "player moved item X from A to B" hook. **This is the biggest event-availability gap for the bridge.**
|
||||
- **Per-hit combat damage.** `AggressiveAction` marks aggression, not each swing/damage tick. For damage numbers you'd hook `Mobile.Damage` / weapon `OnHit` paths (virtual/override), not an EventSink.
|
||||
- **Equip/unequip of items generally.** `CheckEquipItem` exists (a *veto* hook), plus `EquipMacro`/`UnequipMacro` (macro-triggered only). No clean "item equipped" firehose via EventSink.
|
||||
- **Stat/hits/mana/stam changes.** No EventSink; these move through the delta/`ProcessDeltaQueue` system (§4). You'd poll or hook `Mobile` delta handling.
|
||||
|
||||
---
|
||||
|
||||
## 3. Timers — mechanism and which thread callbacks run on
|
||||
|
||||
**This is the crux, and the answer is unambiguous.** ServUO splits timers into a *scheduler thread* and *main-thread execution*:
|
||||
|
||||
- **Timer Thread** (`Main.cs:429-434`, named `"Timer Thread"`) runs `Timer.TimerThread.TimerMain` (`Timer.cs:314`). Its *only* job is bookkeeping: walk the priority buckets, decide which timers are due, and **enqueue** them into a shared `m_Queue` (`Timer.cs:354-357`). It **does not execute callbacks.** When anything becomes due it calls `Core.Set()` (`Timer.cs:374`) to wake the main loop.
|
||||
- **Core / main thread** runs `Timer.Slice()` (`Timer.cs:391`, called from `Core.Main` at `Main.cs:580`). This dequeues due timers and calls **`t.OnTick()` on the main thread** (`Timer.cs:409`).
|
||||
|
||||
**→ Every `Timer` / `Timer.DelayCall` callback executes on the Core (main) game thread.** The separate Timer Thread never touches game state; it's a scheduling clock. This is verifiable live via Appendix A (the probe logs `Thread.CurrentThread` from a Timer tick and from `Initialize` — they match, and match the network path shown in your crash log).
|
||||
|
||||
Other properties worth knowing:
|
||||
- Timers are bucketed by `TimerPriority` (`EveryTick`, `TenMS`, … `OneMinute`); priority is auto-computed from delay/interval (`Timer.cs:468`).
|
||||
- `Timer.Slice` has a `BreakCount` (default **20000**, `Timer.cs:383`) — if more than that many timers are due in one slice, the overflow waits for the next slice. Relevant if the bridge ever schedules a flood of one-shot timers.
|
||||
- **Timers do not fire during world save/load.** `TimerMain` early-continues while `World.Loading || World.Saving` (`Timer.cs:322`). See §5 — this directly affects inbound-command latency.
|
||||
|
||||
---
|
||||
|
||||
## 4. Object model & serialization — and a diff-friendly state shape
|
||||
|
||||
**Identity.** `Serial` (`Server/Serial.cs:7`) is a `struct` wrapping a single `int`. **Mobiles** get serials `< 0x40000000`; **items** start at `0x40000000` (`Serial.cs:11-12`); `IsItem`/`IsMobile` test that boundary. Serials are stable for an object's lifetime and are the natural **primary key** for any external mirror of state. `World.Mobiles` / `World.Items` are `Dictionary<Serial, …>` (`World.cs:19-20`) — O(1) lookup by serial from the main thread.
|
||||
|
||||
**ServUO's own persistence** (`Server/Serialization.cs`, `Server/World.cs`):
|
||||
- Every `Item`/`Mobile`/`SaveData` implements `Serialize(GenericWriter)` / `Deserialize(GenericReader)` plus a serial-taking ctor. `Core.VerifySerialization` (`Main.cs:679`) enforces this at boot.
|
||||
- `GenericWriter`/`GenericReader` are a **versioned, positional binary stream** of primitives (`ReadInt`, `ReadString`, `ReadMobile`, `ReadPoint3D`, …; `Serialization.cs:17+`). Each object writes an `int` version first, then fields in a fixed order. It is **compact but *not* diff-friendly**: it's a full positional snapshot with no field names, meaningless without the exact type+version that wrote it, and it encodes the *entire* object every save.
|
||||
- Saves are orchestrated by `World.Save` (`World.cs:1102`) on the main thread; a `SaveStrategy` may flush bytes to disk on a **background thread**, guarded by `m_DiskWriteHandle` (`ManualResetEvent`, `World.cs:29`). During a save `World.Saving` is true and object add/delete is deferred into `_addQueue`/`_deleteQueue` (`World.cs:1247-1280`).
|
||||
|
||||
**Recommendation for a diff-friendly representation (do NOT reuse the save system):**
|
||||
The internal serializer is the wrong tool for the bridge — it's full-snapshot, schema-coupled, and versioned per type. Instead, build an **event-sourced delta keyed by `Serial`**:
|
||||
|
||||
```jsonc
|
||||
// one line per change, main-thread produced, drained by background writer
|
||||
{ "t": 172..., "kind": "mob.move", "serial": "0x1A2B", "x": 1420, "y": 1631, "z": 0, "dir": "North" }
|
||||
{ "t": 172..., "kind": "mob.login", "serial": "0x1A2B", "name": "Thunderheat", "acct": "PerryAdimn" }
|
||||
{ "t": 172..., "kind": "item.gold", "serial": "0x1A2B", "delta": -500, "total": 12000 }
|
||||
```
|
||||
|
||||
- Derive fields from the **EventSink args + the live object** at event time (e.g. `m.X/Y/Z/Map/Serial`), not from `Serialize`.
|
||||
- Keyed by `Serial` so the external service maintains its own mirror and applies deltas.
|
||||
- Emit a periodic/`ServerStarted` **full snapshot** (iterate `World.Mobiles`/`World.Items` on the main thread) as a baseline the deltas layer onto; `AfterWorldSave` is a natural snapshot boundary.
|
||||
- Keep each record to primitives copied out **synchronously on the main thread** (pooled args, live objects mutate — see §5).
|
||||
|
||||
---
|
||||
|
||||
## 5. Thread-safety rules & marshaling onto the main thread
|
||||
|
||||
**Golden rule (RunUO/ServUO-wide):** the world — `World.Mobiles`, `World.Items`, every `Mobile`/`Item`/`Account`, the delta queues, packet sends — is **single-threaded and owned by the Core thread.** None of it is locked for general access. Reading or mutating any of it from another thread is a data race / heisenbug generator. The dictionaries aren't concurrent; `Mobile.ProcessDeltaQueue`/`Item.ProcessDeltaQueue` run on the main loop (`Main.cs:577-578`) with no cross-thread guard.
|
||||
|
||||
**What *is* safe from a non-main thread:**
|
||||
- `Core.Set()` — wake the main loop (`AutoResetEvent`, `Main.cs:324`).
|
||||
- **`Timer.DelayCall(...)`** — verified safe cross-thread. `DelayCall`→`Start`→`TimerThread.AddTimer`→`Change` takes `lock (m_Changed)` and signals the timer thread (`Timer.cs:243-251,883-892`). The scheduling call is lock-protected; the **callback then runs on the main thread.** This is the intended marshaling primitive.
|
||||
- Pushing onto a **`ConcurrentQueue`** you own, then letting the main thread drain it — this is literally how the network stack works: `MessagePump.m_Queue` is a `ConcurrentQueue<NetState>` (`MessagePump.cs:14`) filled by listener threads and drained by `MessagePump.Slice()` on the main thread (`MessagePump.cs:113`).
|
||||
|
||||
**The two marshaling patterns for inbound named-pipe commands** (pick one; pattern A is simplest):
|
||||
|
||||
- **A — `Timer.DelayCall` from the pipe thread.** On each inbound command, from the pipe read-callback thread call `Timer.DelayCall(TimeSpan.Zero, () => ApplyCommand(cmd))`. The lambda executes on the main thread on the next slice. Zero shared mutable state of your own. Caveat: a burst of commands = a burst of one-shot timers (mind `BreakCount`).
|
||||
- **B — your own `ConcurrentQueue` + `Core.Slice`.** Pipe thread enqueues; register a handler on the `Core.Slice` delegate (`Main.cs:41,586`) that drains the queue every loop iteration on the main thread. Mirrors the network design; better for high inbound rates.
|
||||
|
||||
**Pitfalls specific to this codebase:**
|
||||
1. **Pooled event args.** `MovementEventArgs` (and several others) are recycled via a plain `Queue` pool and `Free()`d immediately after the event (`EventSink.cs:802-834`). The pool itself is **not** thread-safe (main-thread-only). **Never** hand an args object to the pipe writer thread; copy primitives out first. Holding the reference = reading fields that belong to an unrelated later mobile.
|
||||
2. **Blocking the main thread = stalling the shard.** EventSink handlers and Timer ticks run on the Core thread. A synchronous named-pipe **write** that blocks (slow/absent reader, full pipe buffer) will freeze movement, combat, saves — everything. The writer *must* be fire-and-forget onto a background queue (see §6).
|
||||
3. **Timers pause during save/load.** Because `TimerMain` skips while `World.Saving`/`World.Loading` (`Timer.cs:322`), **inbound commands marshaled via `Timer.DelayCall` are deferred until the save finishes** (typically seconds; longer with background write). If commands must apply during a save window, prefer pattern B (Core.Slice) — but note the main loop also spends the save inside `World.Save`, so nothing script-side really runs mid-save regardless. Treat "commands don't apply during a save" as a design constraint, and have the external side tolerate the latency spike.
|
||||
4. **Reentrancy / world-mutation during save.** Adding/deleting entities during a save is deferred to safety queues and logs a warning (`World.cs:988,1247`). If a bridge command spawns/deletes, it may silently queue.
|
||||
5. **Crash path skips `Shutdown`.** Don't rely on graceful pipe teardown (§1).
|
||||
|
||||
---
|
||||
|
||||
## 6. Local ServUO↔sidecar transport (net48) — non-blocking bridge I/O
|
||||
|
||||
> **Superseded by [Part II.1](#ii1-transport-put-the-websocket-in-rust-keep-the-c-side-dumb).** For the Rust WS sidecar design the recommended C↔Rust link is **loopback TCP + newline-JSON**, not a named pipe, and **ServUO should not speak WebSocket**. The non-blocking principles below still apply verbatim to whichever local transport you pick.
|
||||
|
||||
Target is **net48** (`Scripts.csproj:3`), so you have `System.IO.Pipes` / `System.Net.Sockets` with `async`/`await` and `Begin/End` APIs, but **not** the newer `IAsyncEnumerable`/`CancellationToken` niceties of modern .NET. Design around that.
|
||||
|
||||
**Outbound (fire-and-forget writer) — the important one:**
|
||||
- The producer is the Core thread (event handlers). It must **never touch the pipe directly.** Producer does only: format the delta record → `ConcurrentQueue.Enqueue` → return. This is a non-blocking, allocation-only operation.
|
||||
- A **single dedicated background writer thread** (or a long-running `Task`) owns the `NamedPipeServerStream`/`ClientStream` and drains the queue, using `WriteAsync`/`FlushAsync`. One writer = writes stay ordered and you avoid interleaved frames on the pipe.
|
||||
- Use a **length-prefixed or newline-delimited framing** (`PipeTransmissionMode.Byte` is simplest and most portable; `Message` mode has size/OS quirks). Don't rely on message boundaries.
|
||||
- **Bound the queue.** If the external reader stalls, an unbounded queue is a memory leak that eventually OOMs the shard. Drop-oldest or drop-on-full with a dropped-count counter is the safe default for telemetry-style data.
|
||||
- Handle `IOException`/`Broken pipe` by reconnecting in the writer thread; the game keeps running, the queue keeps the newest N records.
|
||||
|
||||
**Inbound (command listener):**
|
||||
- A separate background thread/loop `WaitForConnectionAsync` → `ReadAsync` loop, parse a framed command, then **marshal to the main thread** via pattern A or B from §5. The read thread must not call any `World`/`Mobile`/`Item` API.
|
||||
- Server vs client: making ServUO the **`NamedPipeServerStream`** (external service connects in) is usually cleaner for lifecycle — the shard owns the pipe, survives external restarts, and you control `maxNumberOfServerInstances`. Two half-duplex pipes (one in, one out) are simpler to reason about than one duplex pipe shared across your writer and reader threads.
|
||||
- Set `PipeOptions.Asynchronous` at construction — required for the `*Async` methods to actually overlap I/O rather than block a thread-pool thread.
|
||||
|
||||
**Pitfalls:**
|
||||
- Don't `await` pipe I/O on the Core thread — there's no synchronization context that returns you to the Core thread anyway, and you'd risk resuming world access on a thread-pool thread. Keep all pipe `await`s on your dedicated background threads.
|
||||
- Named-pipe ACLs: if the external service runs as a different user/session, set a `PipeSecurity` explicitly or the connect will `UnauthorizedAccessException`.
|
||||
- First-chance `IOException` on client disconnect is normal; log-and-reconnect, don't crash the writer loop.
|
||||
|
||||
---
|
||||
|
||||
## 7. Flags against the bridge architecture
|
||||
|
||||
> **See [Part II.4](#ii4-revised-flags-for-this-architecture) for the flags that matter to the Rust WS sidecar + tracking/link design.** The list below is the original generic set (still valid background).
|
||||
|
||||
1. **⚑ Item pickup/drop has no EventSink (§2 gap).** If the spec assumes "subscribe to item move events" the way you subscribe to login/movement, that assumption is wrong. Pickup/drop/lift live on **virtual methods** (`Item.OnDragLift/OnDragDrop/OnDroppedInto`, `Mobile.OnDragDrop`). Exporting them cleanly requires base-class overrides/patching, not `Initialize`-time subscription. This is the item most likely to change the design.
|
||||
2. **⚑ `Movement` (and `Item/MobileCreated/Deleted`) are firehoses on the main thread (§2, §5).** Any spec that says "export all movement" must add player-filtering + aggregation, and the export path must be non-blocking. `Movement` args are **pooled** — copy-out-synchronously is mandatory, not optional.
|
||||
3. **⚑ Everything you'd export runs on the single Core thread (§3, §5).** The whole bridge stands or falls on the writer being fire-and-forget. If the spec has event handlers writing to the pipe synchronously, that's a shard-wide stall waiting to happen. Confirmed by your own crash log that even packet-triggered handlers run inline on `Core.Main`.
|
||||
4. **✔ Inbound commands *can* be safely marshaled to the main thread** via `Timer.DelayCall` (verified thread-safe) or a `ConcurrentQueue` drained on `Core.Slice`. The named-pipe approach is **not** blocked by threading — but:
|
||||
5. **⚑ Commands don't apply during world saves (§5 pitfall 3).** Timers pause and the main loop is inside `World.Save` (~seconds, every ~5 min by default). If the spec expects sub-second inbound command latency 100% of the time, it needs to tolerate periodic save-window spikes.
|
||||
6. **⚑ Don't mirror state via ServUO's serializer (§4).** If the spec imagined "reuse ServUO's save format to ship state," reconsider — it's full-snapshot, schema-versioned, and unnamed. Use event-derived deltas keyed by `Serial` + periodic snapshots.
|
||||
7. **⚑ Crash path skips graceful shutdown (§1).** The external service must treat pipe EOF as normal and re-handshake; don't assume a clean `Shutdown` teardown.
|
||||
8. **⚑ A compile error in the bridge plugin fails the whole shard boot (§1).** Keep the plugin small, wrap handler bodies in try/catch, and never let a bridge exception escape into a game code path.
|
||||
9. **(Environmental) The `zlibwapi64` native-load crash (§0)** already downed this shard once. Unrelated to the bridge, but resolve it before load-testing or it will confound results.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Drop-in empirical probe (run this yourself)
|
||||
|
||||
Save as `Scripts/Custom/BridgeThreadProbe.cs`, start the shard, watch the console. **No game client needed** — it proves the thread identity of `Initialize`, `ServerStarted`, a `Timer` tick, and `Core.Slice`. Delete the file afterward. (This is a throwaway diagnostic, not the bridge.)
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Server;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
public static class BridgeThreadProbe
|
||||
{
|
||||
private static void Log(string where)
|
||||
{
|
||||
var t = Thread.CurrentThread;
|
||||
Console.WriteLine("[PROBE] {0,-16} thread id={1} name=\"{2}\"",
|
||||
where, t.ManagedThreadId, t.Name);
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
Log("Initialize"); // expect: Core Thread
|
||||
|
||||
EventSink.ServerStarted += () => Log("ServerStarted"); // expect: Core Thread
|
||||
EventSink.Login += e => Log("Login (client)"); // needs a client login
|
||||
|
||||
// Timer tick — proves callbacks run on the main thread, not the Timer Thread.
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(3), () => Log("Timer.DelayCall")); // expect: Core Thread
|
||||
|
||||
// Cross-thread marshal test: schedule from a raw background thread,
|
||||
// confirm the callback still lands on Core Thread.
|
||||
new Thread(() =>
|
||||
{
|
||||
Log("raw bg thread"); // expect: some worker id, NOT Core Thread
|
||||
Timer.DelayCall(TimeSpan.Zero, () => Log("marshaled->main"));
|
||||
}).Start();
|
||||
|
||||
// Core.Slice runs every main-loop iteration; log once then detach.
|
||||
Slice one = null;
|
||||
one = () => { Log("Core.Slice"); Core.Slice -= one; };
|
||||
Core.Slice += one; // expect: Core Thread
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected result:** every line except `raw bg thread` reports `name="Core Thread"` with the same managed id as `Initialize` — confirming EventSink handlers, Timer ticks, and `Core.Slice` all execute on the one main thread, and that `Timer.DelayCall` from a background thread correctly hops work onto it. If you connect a client, `Login (client)` also reports `Core Thread`, matching the `MessagePump.Slice` evidence in your crash log.
|
||||
|
||||
---
|
||||
|
||||
## Key source references
|
||||
|
||||
| Topic | File:line |
|
||||
|-------|-----------|
|
||||
| Main game loop / thread setup | `Server/Main.cs:329,410-434,573-599` |
|
||||
| `Core.Slice` main-thread hook | `Server/Main.cs:41,586` |
|
||||
| `Core.Set` wake main loop | `Server/Main.cs:322-327` |
|
||||
| Shutdown / Crashed hooks | `Server/Main.cs:198,313` |
|
||||
| Script compile (`dotnet build`) | `Server/ScriptCompiler.cs:18-65` |
|
||||
| `Configure`/`Initialize` invoke + CallPriority | `Server/ScriptCompiler.cs:87-112`, `Server/Attributes.cs:27` |
|
||||
| EventSink event declarations | `Server/EventSink.cs:1692-1784` |
|
||||
| Movement raise (all mobiles, pooled, cancellable) | `Server/Mobile.cs:3020-3036`, `Server/EventSink.cs:792-834` |
|
||||
| Item pickup/drop = virtual, no EventSink | `Server/Item.cs:2157,4647,5060`, `Server/Mobile.cs:10877,10949` |
|
||||
| Timer scheduler thread (enqueue only) | `Server/Timer.cs:314-379` |
|
||||
| Timer execution on main thread | `Server/Timer.cs:391-419`, `Server/Main.cs:580` |
|
||||
| `Timer.DelayCall` cross-thread safety | `Server/Timer.cs:243-251,524-534,883-892` |
|
||||
| Network marshaling (ConcurrentQueue → main) | `Server/Network/MessagePump.cs:14,108,113` |
|
||||
| Serial identity | `Server/Serial.cs:7-33` |
|
||||
| Serialization API | `Server/Serialization.cs:17+` |
|
||||
| World save threading / safety queues | `Server/World.cs:29,1102-1208,1247-1280` |
|
||||
| Runtime evidence: EventSink on Core thread | `Crash 6-5-2026-22-38-3.log` |
|
||||
@@ -1,71 +0,0 @@
|
||||
# Shard prerequisites
|
||||
|
||||
Repairs the target shard (`C:\Users\colby\Desktop\servuo`, ServUO 57.4) required before the bridge could load. These are **deletions and edits of existing files**, so they cannot be expressed as an overlay copy. They are recorded here, and where practical as diffs under `patches/`.
|
||||
|
||||
Applied 2026-07-10. Backups on the Desktop: `servuo_saves_backup_2026-07-10_032608`, `servuo_bin_backup_2026-07-10_032608`, `servuo_removed_files_2026-07-10`.
|
||||
|
||||
---
|
||||
|
||||
## The symptom
|
||||
|
||||
`Scripts.dll` had not been rebuilt since **2026-05-30 17:01**. Every script change after that — including all of `Scripts/Custom/Named/`, `MyStats.cs`, and `SearchAdd.cs` — had never executed.
|
||||
|
||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) shells out to `dotnet build`, prints the output, ignores the exit code, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. A failing script build is invisible: the stale DLL simply reloads. The retry loop at `Main.cs:525` never trips.
|
||||
|
||||
Four independent breakages, all introduced between 17:14 and 21:55 on 2026-05-30.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stray `Server/Gumps/Gumps.cs`
|
||||
|
||||
A **byte-identical copy** of `Scripts/Services/Pet Training/Gumps.cs` (75,468 bytes), sitting in the Server project. It declares `namespace Server.Mobiles` and extends `BaseGump`, referencing `BaseCreature`, `PlayerMobile`, `TrainingPoint` — all defined in Scripts. Server cannot reference Scripts, so `Server.csproj` failed with 35 errors.
|
||||
|
||||
**Action:** deleted. The canonical copy under `Scripts/Services/Pet Training/` was edited 10 minutes later and is the one that matters.
|
||||
|
||||
## 2. Eleven duplicate creature classes
|
||||
|
||||
`Scripts/Custom/{Named,Legendary}/` redefined classes already present in `Scripts/Mobiles/Normal/`, producing `CS0111` / `CS0579`.
|
||||
|
||||
**Named** — `Eowmu`, `SkeletalCat`, `Windrunner`. The stock files each define **two** types: the mount *and* an `ICreatureStatuette` item (`EowmuStatue`, …) that `Scripts/Services/UltimaStore/UltimaStore.cs` references. Deleting the stock files outright would have re-broken the build.
|
||||
|
||||
**Action:** removed only the duplicate mount class from each stock file; kept the statues.
|
||||
|
||||
**Legendary** — `FireSteed`, `Kirin`, `Nightmare`, `OsseinRam`, `Phoenix`, `PolarBear`, `ShadowWyrm`, `TsukiWolf`. Clean 1:1 pairs. All custom versions sit in `namespace Server.Mobiles`, so the serialized type name is unchanged, and each `Deserialize` guards on `version` and migrates from 0 (`ShadowWyrm`: `if (version >= 1)`; `FireSteed`: `if (version < 1)` skill-cap migration; `Kirin`: `if (version == 0)` AI fixup).
|
||||
|
||||
**Action:** deleted the eight stock files. Custom wins.
|
||||
|
||||
## 3. `PolarBear` — a base-class change, not a version bump
|
||||
|
||||
Custom `PolarBear : BaseMount`; stock `PolarBear : BaseCreature`. The saved world contained a bear serialized through the `BaseCreature` chain, so loading it as a `BaseMount` misaligned the stream. World load aborted at `Server.Mobiles.PolarBear` serial `0x00000412` with `Delete the object? (y/n)`.
|
||||
|
||||
**Changing a saved type's base class is not version-migratable.** The custom class also carried `[TypeAlias("Server.Mobiles.Polarbear")]`, which would have hijacked the same records.
|
||||
|
||||
**Action:** restored stock `PolarBear : BaseCreature`; renamed the custom mount to `LegendaryPolarBear` and dropped the `TypeAlias`. Stock scripts referencing `typeof(PolarBear)` (`TalismanSlayer`, `SpeedInfo`, `RoyalZooDonationBox`, `SummonCreature`, `PetTrainingHelper`) continue to resolve to the `BaseCreature`.
|
||||
|
||||
Note: `Scripts/Custom/Legendary/PolarBear.cs` was renamed to `LegendaryPolarBear.cs`.
|
||||
|
||||
## 4. `AnimalLore.cs` referenced a package that does not exist
|
||||
|
||||
`Scripts/Skills/AnimalLore.cs` had `using ShrinkSystem;` and two `IShrinkItem` branches. No `ShrinkSystem` namespace exists anywhere in the repo, and `IShrinkItem` appears nowhere in the stale `Scripts.dll` — **the code had never compiled or run.** (`Scripts/Misc/ShrinkTable.cs` is unrelated stock: `namespace Server`, class `ShrinkTable`.)
|
||||
|
||||
**Action:** removed the `using` and collapsed the shrink branches back to the `BaseCreature` path. This restores exactly the behavior the shard was already running.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After the repairs, `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` succeeded with 0 warnings, 0 errors. Rebuilding `ServUO.exe` and `Ultima.dll` from current source produced **byte-identical** binaries (same SHA-256), confirming the core was never stale in content — only `Scripts.dll` was.
|
||||
|
||||
With Phase 0 applied, a plain boot shows:
|
||||
|
||||
```
|
||||
Core: Compiling scripts...
|
||||
Build succeeded.
|
||||
Core: Verified 6023 item and 1385 mobile types
|
||||
World: Loading...
|
||||
...done (206208 items, 42771 mobiles, 0 customs)
|
||||
```
|
||||
|
||||
## Unrelated, still open
|
||||
|
||||
`DllNotFoundException: zlibwapi64` crashed this shard once (`Crash 6-5-2026-22-38-3.log`) while sending a packed gump. `zlibwapi64.dll` is present in the repo root, so this is a working-directory / native-load-path problem. It will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
||||
@@ -1,63 +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
|
||||
|
||||
# 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
|
||||
|
||||
# Admin write plane (staff moderation from the website). OFF by default: the whole
|
||||
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
|
||||
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
|
||||
# loopback socket and applies a hard floor below.
|
||||
AdminWriteEnabled=false
|
||||
|
||||
# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
|
||||
# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
|
||||
# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
|
||||
# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
|
||||
AdminAccessFloor=CoOwner
|
||||
|
||||
# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
|
||||
AdminBroadcastMaxLength=300
|
||||
AdminReasonMaxLength=400
|
||||
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
|
||||
AdminBanMaxDurationSec=31536000
|
||||
|
||||
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||
# server never runs the scaffolding even if its .cs files are present.
|
||||
@@ -1,250 +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);
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- 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,362 +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.
|
||||
/// </summary>
|
||||
private static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
{
|
||||
var serialStr = BridgeJson.GetString(o, "serial");
|
||||
if (serialStr != null)
|
||||
{
|
||||
var m = ResolveSerial(serialStr);
|
||||
return m == null ? null : m.Account as Account;
|
||||
}
|
||||
|
||||
var acctName = BridgeJson.GetString(o, "account");
|
||||
return acctName == null ? null : Accounts.GetAccount(acctName) as Account;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one shard-side safety floor. Protects any account whose effective access level —
|
||||
/// the account's own or the highest of its characters' — is at or above the configured
|
||||
/// floor. Even under CoOwner authority the Owner is never reachable from the web.
|
||||
/// </summary>
|
||||
private static bool Protected(Account acct)
|
||||
{
|
||||
var lvl = acct.AccessLevel;
|
||||
|
||||
for (int i = 0; i < acct.Length; i++)
|
||||
{
|
||||
var m = acct[i];
|
||||
if (m != null && m.AccessLevel > lvl)
|
||||
lvl = m.AccessLevel;
|
||||
}
|
||||
|
||||
return lvl >= BridgeConfig.AdminAccessFloor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects every live NetState bound to this account. Enumerating NetState.Instances
|
||||
/// (rather than walking the account's characters) also catches a session parked at
|
||||
/// character-select, which has an account but no mobile yet. Snapshot first, since Dispose
|
||||
/// mutates the instance set.
|
||||
/// </summary>
|
||||
private static int KickAccountSessions(Account acct)
|
||||
{
|
||||
var doomed = new List<NetState>();
|
||||
|
||||
foreach (var ns in NetState.Instances)
|
||||
{
|
||||
if (ns != null && ns.Account == acct)
|
||||
doomed.Add(ns);
|
||||
}
|
||||
|
||||
foreach (var ns in doomed)
|
||||
ns.Dispose();
|
||||
|
||||
return doomed.Count;
|
||||
}
|
||||
|
||||
private static Mobile ResolveSerial(string serialStr)
|
||||
{
|
||||
try
|
||||
{
|
||||
var s = serialStr.Trim();
|
||||
int value;
|
||||
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
value = Convert.ToInt32(s.Substring(2), 16);
|
||||
else
|
||||
value = Convert.ToInt32(s, 10);
|
||||
|
||||
return World.FindMobile(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +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();
|
||||
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();
|
||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
break;
|
||||
|
||||
default:
|
||||
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}", 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,103 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <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 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; }
|
||||
|
||||
public static bool AdminWriteEnabled { get; private set; }
|
||||
public static AccessLevel AdminAccessFloor { get; private set; }
|
||||
public static int AdminBroadcastMaxLength { get; private set; }
|
||||
public static int AdminReasonMaxLength { get; private set; }
|
||||
public static int AdminBanMaxDurationSec { get; private set; }
|
||||
|
||||
public static bool Enabled { get; private set; }
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
|
||||
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
|
||||
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
|
||||
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
||||
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
||||
|
||||
if (QueueCap < 16)
|
||||
QueueCap = 16;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an AccessLevel name from config, case-insensitively, falling back to the given
|
||||
/// default on anything unrecognized so a typo can never open the floor wider than intended.
|
||||
/// </summary>
|
||||
private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
|
||||
{
|
||||
AccessLevel parsed;
|
||||
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
||||
Enum.IsDefined(typeof(AccessLevel), parsed))
|
||||
return parsed;
|
||||
|
||||
Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public static string Describe()
|
||||
{
|
||||
return String.Format(
|
||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})",
|
||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,198 +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>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,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,245 +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(']');
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
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,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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -41,10 +41,10 @@ So you can never accidentally run without auth. Rotate by editing the token and
|
||||
|
||||
## 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.
|
||||
- `/health` and the WebSocket `ws.hello` include `"protocol": 1`.
|
||||
- Every response carries an `X-UOLink-Version: 3` header.
|
||||
- `/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.
|
||||
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
@@ -20,7 +20,16 @@ use tracing_subscriber::EnvFilter;
|
||||
/// Wire-protocol version between the website and the sidecar. Bump this whenever an event or
|
||||
/// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
|
||||
/// of failing in confusing ways.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
///
|
||||
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
|
||||
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
|
||||
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
|
||||
///
|
||||
/// 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]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -75,6 +84,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let route_rpc = rpc.clone();
|
||||
let event_store = store.clone();
|
||||
let last_event_ts = last_event.clone();
|
||||
let replay_handle = handle.clone(); // re-push external news to the shard on (re)connect
|
||||
let mut total: u64 = 0;
|
||||
tokio::spawn(async move {
|
||||
while let Some(ev) = event_rx.recv().await {
|
||||
@@ -132,10 +142,160 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guild board (Protocol 2.0): guild.update folds in the latest roster (one row
|
||||
// per guild id); guild.remove drops a disbanded guild.
|
||||
"guild.update" => {
|
||||
if let Some(id) = ev.value.get("id").and_then(|v| v.as_i64()) {
|
||||
if let Err(e) = event_store
|
||||
.upsert_guild(
|
||||
id,
|
||||
ev.value.get("name").and_then(|n| n.as_str()),
|
||||
&text,
|
||||
t,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to upsert guild board");
|
||||
}
|
||||
}
|
||||
}
|
||||
"guild.remove" => {
|
||||
if let Some(id) = ev.value.get("id").and_then(|v| v.as_i64()) {
|
||||
if let Err(e) = event_store.delete_guild(id).await {
|
||||
tracing::warn!(error = %e, "failed to remove guild board row");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Governor board (Protocol 2.0): city.update folds in each city's latest
|
||||
// governance state (one row per city).
|
||||
"city.update" => {
|
||||
if let Some(city) = ev.value.get("city").and_then(|c| c.as_str()) {
|
||||
if let Err(e) = event_store.upsert_governor(city, &text, t).await {
|
||||
tracing::warn!(error = %e, "failed to upsert governor board");
|
||||
}
|
||||
}
|
||||
}
|
||||
// House registry (Protocol 2.0): house.update folds in each house's latest state
|
||||
// (one row per serial); house.remove drops a demolished/traded house.
|
||||
"house.update" => {
|
||||
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||
if let Err(e) = event_store
|
||||
.upsert_house(
|
||||
serial,
|
||||
ev.value.get("name").and_then(|n| n.as_str()),
|
||||
&text,
|
||||
t,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to upsert house registry");
|
||||
}
|
||||
}
|
||||
}
|
||||
"house.remove" => {
|
||||
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||
if let Err(e) = event_store.delete_house(serial).await {
|
||||
tracing::warn!(error = %e, "failed to remove house registry row");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// On a shard (re)connect, re-push the stored external news: the shard rebuilds
|
||||
// TownCryerSystem.NewsEntries from scratch each boot and does not persist ours. Replay
|
||||
// with announce=false so a restart does not re-proclaim every article at once. news.add
|
||||
// is idempotent by id, so replaying to a still-populated shard is harmless.
|
||||
if ev.kind == "server.hello" {
|
||||
match event_store.news_all().await {
|
||||
Ok(items) => {
|
||||
for mut item in items {
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("announce".to_string(), serde_json::json!(false));
|
||||
}
|
||||
if !replay_handle.send(item.to_string()).await {
|
||||
break; // shard went away mid-replay
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "news replay: could not read stored news"),
|
||||
}
|
||||
}
|
||||
|
||||
let _ = feed_tx.send(ev.value.to_string());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 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
|
||||
//! 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
|
||||
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
||||
|
||||
@@ -104,6 +104,16 @@ impl Store {
|
||||
Ok(row.map(|r| r.get::<String, _>("website_user_id")))
|
||||
}
|
||||
|
||||
/// Drops the mirrored link row so event attribution stops immediately, without waiting on the
|
||||
/// shard. Returns the number of rows removed (0 if the account was not linked here).
|
||||
pub async fn record_unlink(&self, account: &str) -> anyhow::Result<u64> {
|
||||
let res = sqlx::query("DELETE FROM links WHERE account = ?")
|
||||
.bind(account)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn cache_profile(
|
||||
&self,
|
||||
serial: &str,
|
||||
@@ -177,6 +187,310 @@ impl Store {
|
||||
.await?;
|
||||
Ok(parse_json_column(rows))
|
||||
}
|
||||
|
||||
// ---- guild board (Protocol 2.0) ----
|
||||
|
||||
/// Upserts one guild's latest state, keyed by guild id. Fed from `guild.update`; one row per
|
||||
/// guild, always the most recent snapshot. This is the board the website reads on load.
|
||||
pub async fn upsert_guild(
|
||||
&self,
|
||||
id: i64,
|
||||
name: Option<&str>,
|
||||
json: &str,
|
||||
t: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO guilds (id, name, json, updated_t) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(json)
|
||||
.bind(t)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops one guild from the board. Fed from `guild.remove` (a disband or a removed guild).
|
||||
pub async fn delete_guild(&self, id: i64) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM guilds WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The full guild board: every guild's latest snapshot, ordered by name.
|
||||
pub async fn guilds_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||
let rows = sqlx::query("SELECT json FROM guilds ORDER BY name, id")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(parse_json_column(rows))
|
||||
}
|
||||
|
||||
// ---- governor board (Protocol 2.0) ----
|
||||
|
||||
/// Upserts one city's latest governance state, keyed by city name. Fed from `city.update`.
|
||||
pub async fn upsert_governor(&self, city: &str, json: &str, t: i64) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO governors (city, json, updated_t) VALUES (?, ?, ?)
|
||||
ON CONFLICT(city) DO UPDATE SET json = excluded.json, updated_t = excluded.updated_t",
|
||||
)
|
||||
.bind(city)
|
||||
.bind(json)
|
||||
.bind(t)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The full governor board: every city's latest governance snapshot, ordered by city.
|
||||
pub async fn governors_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||
let rows = sqlx::query("SELECT json FROM governors ORDER BY city")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(parse_json_column(rows))
|
||||
}
|
||||
|
||||
// ---- house registry (Protocol 2.0) ----
|
||||
|
||||
/// Upserts one house's latest state, keyed by serial. Fed from `house.update`.
|
||||
pub async fn upsert_house(
|
||||
&self,
|
||||
serial: &str,
|
||||
name: Option<&str>,
|
||||
json: &str,
|
||||
t: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO houses (serial, name, json, updated_t) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(serial) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||
)
|
||||
.bind(serial)
|
||||
.bind(name)
|
||||
.bind(json)
|
||||
.bind(t)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops one house from the registry. Fed from `house.remove` (demolished / traded away).
|
||||
pub async fn delete_house(&self, serial: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM houses WHERE serial = ?")
|
||||
.bind(serial)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The full house registry: every house's latest snapshot, ordered by name then serial.
|
||||
pub async fn houses_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||
let rows = sqlx::query("SELECT json FROM houses ORDER BY name, serial")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(parse_json_column(rows))
|
||||
}
|
||||
|
||||
// ---- 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) ----
|
||||
|
||||
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
|
||||
/// website is the source of truth; this lets the sidecar replay the set to the shard on reconnect
|
||||
/// (the shard does not persist NewsEntries across a reboot).
|
||||
pub async fn upsert_news(&self, id: &str, json: &str, t: i64) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO news (id, json, updated_t) VALUES (?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET json = excluded.json, updated_t = excluded.updated_t",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(json)
|
||||
.bind(t)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes one external news article.
|
||||
pub async fn delete_news(&self, id: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM news WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every stored external news article (as its `news.add` command), oldest first so a replay
|
||||
/// re-inserts them in the same order the website added them.
|
||||
pub async fn news_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||
let rows = sqlx::query("SELECT json FROM news ORDER BY updated_t")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(parse_json_column(rows))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
|
||||
@@ -215,4 +529,65 @@ CREATE TABLE IF NOT EXISTS champs (
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guilds (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS governors (
|
||||
city TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS houses (
|
||||
serial TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS news (
|
||||
id TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 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
|
||||
);
|
||||
"#;
|
||||
|
||||
@@ -54,9 +54,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
.route("/vendors/:account", get(vendors))
|
||||
// Inbound commands (correlated by code / id).
|
||||
.route("/link/confirm", post(link_confirm))
|
||||
.route("/link/:account", get(link_lookup))
|
||||
// Account provisioning (Protocol 2.0). Create is correlated by reqId; the DELETE unlinks.
|
||||
.route("/accounts/create", post(account_create))
|
||||
.route("/link/:account", get(link_lookup).delete(link_delete))
|
||||
.route("/towncrier", post(towncrier_add))
|
||||
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
||||
// Town Cryer news gump (Protocol 2.1). Add/replace an article; delete one.
|
||||
.route("/news", post(news_add))
|
||||
.route("/news/:id", axum::routing::delete(news_remove))
|
||||
// Staff write plane (correlated by reqId). The shard enforces the real authorization;
|
||||
// the website must gate these behind admin/moderator roles before calling.
|
||||
.route("/admin/kick", post(admin_kick))
|
||||
@@ -71,6 +76,26 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
.route("/history", get(history))
|
||||
.route("/economy", get(economy))
|
||||
.route("/champs", get(champs))
|
||||
// World-state boards (Protocol 2.0), served from the store so they answer without the shard
|
||||
// and survive an outage with the last-known snapshot (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §12.2).
|
||||
.route("/guilds", get(guilds))
|
||||
.route("/governors", get(governors))
|
||||
.route("/online", get(online))
|
||||
.route("/houses", get(houses))
|
||||
// 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));
|
||||
|
||||
let app = Router::new()
|
||||
@@ -288,6 +313,138 @@ fn respond_admin(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `respond`, but for the account-provisioning plane. Maps an `account.error` reply to a
|
||||
/// status by its reason: a name clash is a 409, the per-IP cap is a 429, a disabled/protected/
|
||||
/// refused action is a 403, an unknown target or "not linked" is a 404, anything else a 400.
|
||||
fn respond_account(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
|
||||
if kind == "account.error" {
|
||||
let reason = value
|
||||
.get("reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("request rejected");
|
||||
let code = if reason.contains("already exists") {
|
||||
StatusCode::CONFLICT
|
||||
} else if reason.contains("ip account limit") {
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
} else if reason.contains("disabled")
|
||||
|| reason.contains("protected")
|
||||
|| reason.contains("refused")
|
||||
{
|
||||
StatusCode::FORBIDDEN
|
||||
} else if reason.contains("unknown") || reason.contains("not linked") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
(code, Json(value))
|
||||
} else {
|
||||
(StatusCode::OK, Json(value))
|
||||
}
|
||||
}
|
||||
Err(RpcError::NoShard) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({"error": "shard not connected"})),
|
||||
),
|
||||
Err(RpcError::Timeout) => (
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(json!({"error": "shard did not reply in time"})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- account-provisioning handlers ----
|
||||
|
||||
/// Body: {"actor","account","password","websiteUserId","ip"}. Creates and links a game account.
|
||||
/// Correlated on a fresh reqId. The password is forwarded to the shard (loopback) but never logged
|
||||
/// here and never appears in the reply; a successful create mirrors the link into the store.
|
||||
async fn account_create(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
let mut obj = match body {
|
||||
Value::Object(m) => m,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "body must be a JSON object"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Required, non-empty. `ip` is validated on the shard (which owns the cap), not here.
|
||||
for field in ["actor", "account", "password", "websiteUserId"] {
|
||||
let present = obj
|
||||
.get(field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if !present {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": format!("{field} is required") })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let req_id = st.rpc.next_req_id();
|
||||
obj.insert("kind".to_string(), json!("account.create"));
|
||||
obj.insert("reqId".to_string(), json!(req_id));
|
||||
|
||||
let result = st.rpc.call(&st.shard, Value::Object(obj), &req_id).await;
|
||||
|
||||
// Mirror a successful create's link into the store, so events are attributable without the
|
||||
// shard (same as link.confirm does).
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||
if let (Some(account), Some(web_id)) = (
|
||||
value.get("account").and_then(|a| a.as_str()),
|
||||
value.get("websiteUserId").and_then(|w| w.as_str()),
|
||||
) {
|
||||
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let _ = st.store.record_link(account, web_id, t).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
respond_account(result)
|
||||
}
|
||||
|
||||
/// Unlinks a game account from its website user. Body: {"actor"}. Correlated on reqId; a success
|
||||
/// also clears the sidecar's mirrored link row so attribution stops immediately.
|
||||
async fn link_delete(
|
||||
State(st): State<AppState>,
|
||||
Path(account): Path<String>,
|
||||
body: Option<Json<Value>>,
|
||||
) -> impl IntoResponse {
|
||||
let actor = body
|
||||
.as_ref()
|
||||
.and_then(|Json(b)| b.get("actor").and_then(|a| a.as_str()))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if actor.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "actor is required"})),
|
||||
);
|
||||
}
|
||||
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({
|
||||
"kind": "account.unlink", "reqId": req_id, "actor": actor, "account": account
|
||||
});
|
||||
let result = st.rpc.call(&st.shard, cmd, &req_id).await;
|
||||
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||
let _ = st.store.record_unlink(&account).await;
|
||||
}
|
||||
}
|
||||
|
||||
respond_account(result)
|
||||
}
|
||||
|
||||
// ---- admin write-plane handlers ----
|
||||
|
||||
/// Forwards a staff moderation command to the shard, correlated on a fresh reqId. Injects `kind`
|
||||
@@ -521,6 +678,50 @@ async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) ->
|
||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||
}
|
||||
|
||||
/// Body: {"id":"42","title":"...","body":"<html>","image":1614,"url":"...","announce":true}.
|
||||
/// Adds/replaces a Town Cryer news article. Correlated on `id`. A success is stored so the sidecar
|
||||
/// can replay the article to the shard on reconnect (NewsEntries is not persisted across a reboot).
|
||||
async fn news_add(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
let id = body.get("id").and_then(|i| i.as_str()).unwrap_or_default();
|
||||
let title_ok = body
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if id.is_empty() || !title_ok {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "id and title are required"})),
|
||||
);
|
||||
}
|
||||
|
||||
let mut cmd = body.clone();
|
||||
cmd["kind"] = json!("news.add");
|
||||
let id = id.to_string();
|
||||
let result = st.rpc.call(&st.shard, cmd.clone(), &id).await;
|
||||
|
||||
// Persist the article (as its news.add command) so it can be replayed on shard reconnect.
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("news.ok") {
|
||||
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let _ = st.store.upsert_news(&id, &cmd.to_string(), t).await;
|
||||
}
|
||||
}
|
||||
respond(result)
|
||||
}
|
||||
|
||||
async fn news_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||
let cmd = json!({"kind":"news.remove","id":id});
|
||||
let result = st.rpc.call(&st.shard, cmd, &id).await;
|
||||
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("news.ok") {
|
||||
let _ = st.store.delete_news(&id).await;
|
||||
}
|
||||
}
|
||||
respond(result)
|
||||
}
|
||||
|
||||
// ---- history (from SQLite) ----
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -566,6 +767,165 @@ async fn champs(State(st): State<AppState>) -> impl IntoResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// The guild board: every guild's latest roster snapshot (id/name/abbr/leader/members/alliance).
|
||||
/// Served from the local board table, so it hydrates a fresh page or a restarted sidecar without a
|
||||
/// shard round-trip. The live `guild.*` feed then keeps it current.
|
||||
async fn guilds(State(st): State<AppState>) -> impl IntoResponse {
|
||||
match st.store.guilds_all().await {
|
||||
Ok(guilds) => (StatusCode::OK, Json(json!({"guilds": guilds}))),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The governor board: each city's latest governance snapshot (governor/elect/election phase).
|
||||
/// Served from the local board table for the same reason as `/guilds`.
|
||||
async fn governors(State(st): State<AppState>) -> impl IntoResponse {
|
||||
match st.store.governors_all().await {
|
||||
Ok(cities) => (StatusCode::OK, Json(json!({"cities": cities}))),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The house registry: every house's latest snapshot (owner/region/location/decay/value). Served
|
||||
/// from the local board table, so it hydrates without the shard and survives an outage.
|
||||
async fn houses(State(st): State<AppState>) -> impl IntoResponse {
|
||||
match st.store.houses_all().await {
|
||||
Ok(houses) => (StatusCode::OK, Json(json!({"houses": houses}))),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 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
|
||||
/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the
|
||||
/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the
|
||||
/// population time series. Returns `count: 0` if the shard has not reported one yet.
|
||||
async fn online(State(st): State<AppState>) -> impl IntoResponse {
|
||||
match st.store.recent(Some("presence.online"), 1).await {
|
||||
Ok(mut events) => match events.pop() {
|
||||
Some(latest) => (StatusCode::OK, Json(latest)),
|
||||
None => (
|
||||
StatusCode::OK,
|
||||
Json(json!({"kind": "presence.online", "count": 0, "byFacet": {}, "byRegion": {}})),
|
||||
),
|
||||
},
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 ----
|
||||
|
||||
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