Compare commits
50 Commits
v0.1.0
...
05e192ca70
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4d21ef0b63 | |||
| 0550129f8e | |||
| 09c59b256e | |||
| 45227b1a74 | |||
| 78fcb7effb | |||
| ddf14bdab0 | |||
| 4badd15f91 | |||
| e30eec5e4d | |||
| b96a867691 | |||
| fa7f1f786b | |||
| b258ee3e60 | |||
| 9df337e186 | |||
| 359bb937b2 | |||
| 5562e09fb0 | |||
| c515a86a87 | |||
| 17e1c91fb3 | |||
| 11169c52a6 | |||
| a0dbb80e1e | |||
| 213f3fa2ac |
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()
|
||||||
258
.gitea/workflows/release.yml
Normal file
258
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# Automated build + release for the uo-link Rust sidecar.
|
||||||
|
#
|
||||||
|
# Trigger: every push to `main` (i.e. every merged PR).
|
||||||
|
#
|
||||||
|
# Flow (two conceptual halves, kept separate on purpose):
|
||||||
|
#
|
||||||
|
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
|
||||||
|
# │ reads: latest v* git tag + conventional-commit subjects │
|
||||||
|
# │ produces: next version, changelog, and (at the end) the release │
|
||||||
|
# └───────────────────────────────────────────────────────────────────┘
|
||||||
|
# ┌── RUST ADAPTER (the only Rust-specific part) ─────────────────────┐
|
||||||
|
# │ consumes: the version │
|
||||||
|
# │ produces: the artifacts (linux bin, windows exe, SHA256SUMS) │
|
||||||
|
# └───────────────────────────────────────────────────────────────────┘
|
||||||
|
#
|
||||||
|
# To retarget this engine at a C#/Node/Docker/static project later, only the
|
||||||
|
# "Rust adapter" steps change — the plan + release steps consume just
|
||||||
|
# {version, changelog, artifacts} and know nothing about Rust.
|
||||||
|
#
|
||||||
|
# Version bump (conventional commits since the last v* tag):
|
||||||
|
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
||||||
|
# nothing releasable -> no release is cut
|
||||||
|
# (first ever run, no tag) -> releases the current Cargo.toml version as-is
|
||||||
|
#
|
||||||
|
# Prerequisites (Settings → Actions → Secrets on 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
|
||||||
|
# `write:repository` so it can push the bump commit + tag
|
||||||
|
# and create the release. Grant that scope to the token.
|
||||||
|
# Also: `main` must accept a direct push from that user (disable branch
|
||||||
|
# protection for it, or add it as an exception) — the bump commit lands on main.
|
||||||
|
#
|
||||||
|
# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
|
||||||
|
|
||||||
|
name: Release sidecar
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-sidecar
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
|
REPO: RunicGateway/link
|
||||||
|
WORKDIR: sidecar
|
||||||
|
BIN: uo-link-sidecar
|
||||||
|
LINUX_TARGET: x86_64-unknown-linux-gnu
|
||||||
|
WINDOWS_TARGET: x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
|
||||||
|
# Quoted because the expression contains a colon (`chore(release):`), which an
|
||||||
|
# unquoted YAML scalar would misparse as a mapping value.
|
||||||
|
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
|
||||||
|
steps:
|
||||||
|
- name: Check out full history (need tags + commit log for the bump)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
|
||||||
|
- name: Plan the release (version + changelog)
|
||||||
|
id: plan
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p dist
|
||||||
|
git fetch --tags --force >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
CARGO_VERSION="$(grep -m1 '^version' "${WORKDIR}/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||||
|
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||||
|
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
|
||||||
|
|
||||||
|
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||||
|
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||||
|
|
||||||
|
BUMP=none
|
||||||
|
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
|
||||||
|
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
|
||||||
|
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
|
||||||
|
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
|
||||||
|
|
||||||
|
bump() { # <x.y.z> <major|minor|patch> -> bumped
|
||||||
|
IFS=. read -r MA MI PA <<< "$1"
|
||||||
|
case "$2" in
|
||||||
|
major) echo "$((MA+1)).0.0" ;;
|
||||||
|
minor) echo "${MA}.$((MI+1)).0" ;;
|
||||||
|
patch) echo "${MA}.${MI}.$((PA+1))" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
RELEASE=true
|
||||||
|
if [ -z "$LAST_TAG" ]; then
|
||||||
|
VERSION="$CARGO_VERSION" # first release: ship what's in Cargo.toml
|
||||||
|
elif [ "$BUMP" = none ]; then
|
||||||
|
RELEASE=false # no feat/fix/breaking since last tag
|
||||||
|
VERSION="${LAST_TAG#v}"
|
||||||
|
else
|
||||||
|
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||||
|
echo "Tag v${VERSION} already exists — nothing to release."
|
||||||
|
RELEASE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## ${BIN} v${VERSION}"
|
||||||
|
echo
|
||||||
|
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
||||||
|
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||||
|
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||||
|
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||||
|
echo "### All changes"
|
||||||
|
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||||
|
echo "$SUBJECTS" | sed 's/^/- /'
|
||||||
|
} > dist/CHANGELOG.md
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
|
||||||
|
|
||||||
|
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
|
||||||
|
- name: Install Rust toolchain, Windows target, and MinGW linker
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
|
build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq
|
||||||
|
|
||||||
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --profile minimal --default-toolchain stable
|
||||||
|
fi
|
||||||
|
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||||
|
rustup component add rustfmt
|
||||||
|
rustup target add "${WINDOWS_TARGET}"
|
||||||
|
|
||||||
|
- name: Set the crate version to match the release
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
# Replace only the [package] version (the first `version = "..."`).
|
||||||
|
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml"
|
||||||
|
grep -m1 '^version' "${WORKDIR}/Cargo.toml"
|
||||||
|
# Bumping the manifest version desyncs this crate's own entry in
|
||||||
|
# Cargo.lock, which would make the `--locked` fmt/test/build steps below
|
||||||
|
# fail ("cannot update the lock file ... --locked was passed"). Sync just
|
||||||
|
# the workspace member(s) into the lock — dependency pins are untouched.
|
||||||
|
cargo update --manifest-path "${WORKDIR}/Cargo.toml" --workspace
|
||||||
|
|
||||||
|
# ── RUST ADAPTER: gates ──────────────────────────────────────────────
|
||||||
|
- name: cargo fmt --check
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
working-directory: sidecar
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
working-directory: sidecar
|
||||||
|
run: cargo test --locked
|
||||||
|
|
||||||
|
# ── RUST ADAPTER: build both targets ─────────────────────────────────
|
||||||
|
- name: cargo build --release (Linux)
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
working-directory: sidecar
|
||||||
|
run: cargo build --release --locked --target "${LINUX_TARGET}"
|
||||||
|
|
||||||
|
- name: cargo build --release (Windows, cross via MinGW)
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
working-directory: sidecar
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
|
||||||
|
CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
|
||||||
|
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
|
||||||
|
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
|
||||||
|
|
||||||
|
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
|
||||||
|
- name: Package artifacts and SHA256SUMS
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
|
||||||
|
cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
|
||||||
|
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
|
||||||
|
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
||||||
|
|
||||||
|
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
|
||||||
|
- name: Commit version bump and push tag
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
# Secrets can arrive with a trailing newline (depending on how they were
|
||||||
|
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
|
||||||
|
# be parsed"). Strip line breaks before building the URL. Passing them via
|
||||||
|
# env (not inline ${{ }}) also keeps a newline from breaking this script.
|
||||||
|
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
git config user.name "uo-link-ci"
|
||||||
|
git config user.email "ci@whitlocktech.com"
|
||||||
|
git remote set-url origin \
|
||||||
|
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||||
|
|
||||||
|
git add "${WORKDIR}/Cargo.toml" "${WORKDIR}/Cargo.lock"
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
|
||||||
|
git push origin "HEAD:main"
|
||||||
|
else
|
||||||
|
echo "Version unchanged (first release) — no bump commit needed."
|
||||||
|
fi
|
||||||
|
git tag "${TAG}"
|
||||||
|
git push origin "${TAG}"
|
||||||
|
|
||||||
|
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
|
||||||
|
- name: Create Gitea release and upload assets
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||||
|
BODY="$(cat dist/CHANGELOG.md)"
|
||||||
|
# Same newline hygiene as the push step: a stray CR/LF in the token would
|
||||||
|
# corrupt the Authorization header.
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
|
||||||
|
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||||
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||||
|
| jq -r '.id')"
|
||||||
|
echo "Created release ${TAG} (id=${REL_ID})"
|
||||||
|
|
||||||
|
for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
|
||||||
|
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-F "attachment=@dist/${f}" >/dev/null
|
||||||
|
echo " uploaded ${f}"
|
||||||
|
done
|
||||||
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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ obj/
|
|||||||
*.dll
|
*.dll
|
||||||
*.exe
|
*.exe
|
||||||
*.pdb
|
*.pdb
|
||||||
|
*.log
|
||||||
|
|||||||
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
|
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
(RunicGateway/servuo-plugins) >>> THIS REPO <<<
|
||||||
```
|
```
|
||||||
|
|
||||||
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
|
The shard never speaks WebSocket and exposes no port of its own — the sidecar is the only
|
||||||
|
network-facing component, which is what keeps the game unreachable from the internet.
|
||||||
|
|
||||||
|
## Related repos
|
||||||
|
|
||||||
|
| Repo | What |
|
||||||
|
|------|------|
|
||||||
|
| **this** — `RunicGateway/link` | The Rust sidecar (`sidecar/`). |
|
||||||
|
| [RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) | The **C# ServUO plugin** — the shard side of the bridge (`overlay/`, `patches/`, `deploy.ps1`, test scaffolding). |
|
||||||
|
| [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation — design docs, protocol spec, integration guide, research. |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
|
| `sidecar/` | The Rust sidecar crate — terminates the loopback link to the shard, exposes WS + REST to the website. See [`sidecar/README.md`](sidecar/README.md). |
|
||||||
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
| `.gitea/workflows/release.yml` | Builds + releases the sidecar binary (Linux + Windows) on every merge to `main`. |
|
||||||
| `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. |
|
|
||||||
|
|
||||||
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
## Build & run
|
||||||
|
|
||||||
## Deploy
|
The sidecar is a standard cargo crate:
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify # show what would change
|
cd sidecar
|
||||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo # write
|
cargo build --release # binary at target/release/uo-link-sidecar
|
||||||
|
cp sidecar.toml.example sidecar.toml # then edit
|
||||||
|
cargo run --release
|
||||||
```
|
```
|
||||||
|
|
||||||
## Status
|
`.gitea/workflows/release.yml` cross-compiles Linux + Windows binaries and cuts a Gitea release on
|
||||||
|
every merge to `main` (conventional-commit versioning). See [`sidecar/README.md`](sidecar/README.md)
|
||||||
|
for configuration and the wire protocol.
|
||||||
|
|
||||||
| Phase | State |
|
## Deployment & compatibility
|
||||||
|------:|-------|
|
|
||||||
| 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** |
|
|
||||||
|
|
||||||
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
|
## License
|
||||||
- `TRACE;NEWTIMERS;ServUO` went undefined, so XmlSpawner compiled its non-ServUO branches.
|
|
||||||
|
|
||||||
Runtime script compilation therefore had no effect, silently. `overlay/Scripts/Scripts.csproj` conditions both property groups on `Configuration` alone.
|
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/`:
|
Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) (note
|
||||||
|
the **AI-usage disclosure** requirement) and our
|
||||||
| File | Responsibility |
|
[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately per
|
||||||
|------|----------------|
|
[SECURITY.md](SECURITY.md).
|
||||||
| `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`.
|
|
||||||
|
|||||||
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,371 +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. |
|
|
||||||
|
|
||||||
#### 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. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
|
|
||||||
### 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":...}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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,35 +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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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,188 +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();
|
|
||||||
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();
|
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.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());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +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 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 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);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (QueueCap < 16)
|
|
||||||
QueueCap = 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Describe()
|
|
||||||
{
|
|
||||||
return String.Format(
|
|
||||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s)",
|
|
||||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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,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,41 +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`.
|
|
||||||
|
|
||||||
## 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,66 +0,0 @@
|
|||||||
diff --git a/Server/EventSink.cs b/Server/EventSink.cs
|
|
||||||
index d30788f..1da2667 100644
|
|
||||||
--- a/Server/EventSink.cs
|
|
||||||
+++ b/Server/EventSink.cs
|
|
||||||
@@ -171,6 +171,8 @@ namespace Server
|
|
||||||
|
|
||||||
public delegate void ValidVendorSellEventHandler(ValidVendorSellEventArgs e);
|
|
||||||
|
|
||||||
+ public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);
|
|
||||||
+
|
|
||||||
public delegate void CorpseLootEventHandler(CorpseLootEventArgs e);
|
|
||||||
|
|
||||||
public delegate void RepairItemEventHandler(RepairItemEventArgs e);
|
|
||||||
@@ -1521,6 +1523,29 @@ namespace Server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+ // Player-vendor purchases raise no other EventSink. This fires at the committed sale in
|
|
||||||
+ // PlayerVendorBuyGump.OnResponse, where buyer, vendor owner, item, price, and commission
|
|
||||||
+ // are all in scope -- the data the bridge's cheat-detection feed needs.
|
|
||||||
+ public class PlayerVendorSaleEventArgs : EventArgs
|
|
||||||
+ {
|
|
||||||
+ public Mobile Buyer { get; set; }
|
|
||||||
+ public Mobile Vendor { get; set; }
|
|
||||||
+ public Mobile Owner { get; set; }
|
|
||||||
+ public Item Item { get; set; }
|
|
||||||
+ public int Price { get; set; }
|
|
||||||
+ public int Commission { get; set; }
|
|
||||||
+
|
|
||||||
+ public PlayerVendorSaleEventArgs(Mobile buyer, Mobile vendor, Mobile owner, Item item, int price, int commission)
|
|
||||||
+ {
|
|
||||||
+ Buyer = buyer;
|
|
||||||
+ Vendor = vendor;
|
|
||||||
+ Owner = owner;
|
|
||||||
+ Item = item;
|
|
||||||
+ Price = price;
|
|
||||||
+ Commission = commission;
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
public class CorpseLootEventArgs : EventArgs
|
|
||||||
{
|
|
||||||
public Mobile Mobile { get; set; }
|
|
||||||
@@ -1771,6 +1796,7 @@ namespace Server
|
|
||||||
public static event TameCreatureEventHandler TameCreature;
|
|
||||||
public static event ValidVendorPurchaseEventHandler ValidVendorPurchase;
|
|
||||||
public static event ValidVendorSellEventHandler ValidVendorSell;
|
|
||||||
+ public static event PlayerVendorSaleEventHandler PlayerVendorSale;
|
|
||||||
public static event CorpseLootEventHandler CorpseLoot;
|
|
||||||
public static event RepairItemEventHandler RepairItem;
|
|
||||||
public static event AlterItemEventHandler AlterItem;
|
|
||||||
@@ -2416,6 +2442,14 @@ namespace Server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+ public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)
|
|
||||||
+ {
|
|
||||||
+ if (PlayerVendorSale != null)
|
|
||||||
+ {
|
|
||||||
+ PlayerVendorSale(e);
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
public static void InvokeCorpseLoot(CorpseLootEventArgs e)
|
|
||||||
{
|
|
||||||
if (CorpseLoot != null)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
diff --git a/Scripts/Gumps/PlayerVendorGumps.cs b/Scripts/Gumps/PlayerVendorGumps.cs
|
|
||||||
index 049aae6..f1b30d2 100644
|
|
||||||
--- a/Scripts/Gumps/PlayerVendorGumps.cs
|
|
||||||
+++ b/Scripts/Gumps/PlayerVendorGumps.cs
|
|
||||||
@@ -95,6 +95,10 @@ namespace Server.Gumps
|
|
||||||
|
|
||||||
m_Vendor.HoldGold += m_VI.Price - commission;
|
|
||||||
|
|
||||||
+ // uo-link: the only committed-sale hook for player vendors (no EventSink exists).
|
|
||||||
+ EventSink.InvokePlayerVendorSale(
|
|
||||||
+ new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));
|
|
||||||
+
|
|
||||||
from.SendLocalizedMessage(503201); // You take the item.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ website ──WS (live feed) / REST (queries)──► sidecar ──loopback
|
|||||||
(this) newline-JSON, bidirectional
|
(this) newline-JSON, bidirectional
|
||||||
```
|
```
|
||||||
|
|
||||||
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See `../docs/PLAN.md` §2.
|
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §2.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -106,4 +106,4 @@ A shard `*.error` reply maps to HTTP 404 (unknown/not-found) or 400 (bad request
|
|||||||
|
|
||||||
## Wire protocol
|
## Wire protocol
|
||||||
|
|
||||||
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in `../docs/PLAN.md` (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
|
|||||||
let text = fs::read_to_string(path)?;
|
let text = fs::read_to_string(path)?;
|
||||||
let line = format!("auth_token = \"{token}\"");
|
let line = format!("auth_token = \"{token}\"");
|
||||||
|
|
||||||
if text.lines().any(|l| l.trim_start().starts_with("auth_token")) {
|
if text
|
||||||
|
.lines()
|
||||||
|
.any(|l| l.trim_start().starts_with("auth_token"))
|
||||||
|
{
|
||||||
let out: String = text
|
let out: String = text
|
||||||
.lines()
|
.lines()
|
||||||
.map(|l| {
|
.map(|l| {
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ use tracing_subscriber::EnvFilter;
|
|||||||
/// Wire-protocol version between the website and the sidecar. Bump this whenever an event or
|
/// 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
|
/// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
|
||||||
/// of failing in confusing ways.
|
/// of failing in confusing ways.
|
||||||
pub const PROTOCOL_VERSION: u32 = 1;
|
///
|
||||||
|
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
|
||||||
|
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
|
||||||
|
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 2;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@@ -75,6 +79,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let route_rpc = rpc.clone();
|
let route_rpc = rpc.clone();
|
||||||
let event_store = store.clone();
|
let event_store = store.clone();
|
||||||
let last_event_ts = last_event.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;
|
let mut total: u64 = 0;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(ev) = event_rx.recv().await {
|
while let Some(ev) = event_rx.recv().await {
|
||||||
@@ -96,11 +101,194 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
|
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
|
||||||
if ev.kind != "pong" {
|
if ev.kind != "pong" {
|
||||||
let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms);
|
let t = ev
|
||||||
|
.value
|
||||||
|
.get("t")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or_else(now_ms);
|
||||||
let text = ev.value.to_string();
|
let text = ev.value.to_string();
|
||||||
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
||||||
tracing::warn!(error = %e, "failed to persist event");
|
tracing::warn!(error = %e, "failed to persist event");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The champ board is a live projection: champ.update folds in the latest state (one
|
||||||
|
// row per spawn), champ.remove drops a spawn that despawned or was slain.
|
||||||
|
match ev.kind.as_str() {
|
||||||
|
"champ.update" => {
|
||||||
|
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||||
|
if let Err(e) = event_store
|
||||||
|
.upsert_champ(
|
||||||
|
serial,
|
||||||
|
ev.value.get("status").and_then(|s| s.as_str()),
|
||||||
|
ev.value.get("name").and_then(|n| n.as_str()),
|
||||||
|
&text,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "failed to upsert champ board");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"champ.remove" => {
|
||||||
|
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
|
||||||
|
if let Err(e) = event_store.delete_champ(serial).await {
|
||||||
|
tracing::warn!(error = %e, "failed to remove champ board row");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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());
|
let _ = feed_tx.send(ev.value.to_string());
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ impl Rpc {
|
|||||||
) -> Result<Value, RpcError> {
|
) -> Result<Value, RpcError> {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
self.pending
|
self.pending.lock().await.insert(corr_val.to_string(), tx);
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(corr_val.to_string(), tx);
|
|
||||||
|
|
||||||
if !shard.send(command.to_string()).await {
|
if !shard.send(command.to_string()).await {
|
||||||
self.pending.lock().await.remove(corr_val);
|
self.pending.lock().await.remove(corr_val);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
|
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
|
||||||
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
|
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
|
||||||
//! is never directly reachable from the website — it exposes no port. See docs/PLAN.md §2.
|
//! is never directly reachable from the website — it exposes no port. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2.
|
||||||
//!
|
//!
|
||||||
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
|
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
|
||||||
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ pub struct Store {
|
|||||||
impl Store {
|
impl Store {
|
||||||
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
|
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
|
||||||
pub async fn open(path: &str) -> anyhow::Result<Self> {
|
pub async fn open(path: &str) -> anyhow::Result<Self> {
|
||||||
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?
|
let opts =
|
||||||
.create_if_missing(true);
|
SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?.create_if_missing(true);
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(4)
|
.max_connections(4)
|
||||||
@@ -78,7 +78,12 @@ impl Store {
|
|||||||
self.recent(Some("economy.supply"), limit).await
|
self.recent(Some("economy.supply"), limit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn record_link(&self, account: &str, website_user_id: &str, t: i64) -> anyhow::Result<()> {
|
pub async fn record_link(
|
||||||
|
&self,
|
||||||
|
account: &str,
|
||||||
|
website_user_id: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?)
|
"INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t",
|
ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t",
|
||||||
@@ -99,6 +104,16 @@ impl Store {
|
|||||||
Ok(row.map(|r| r.get::<String, _>("website_user_id")))
|
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(
|
pub async fn cache_profile(
|
||||||
&self,
|
&self,
|
||||||
serial: &str,
|
serial: &str,
|
||||||
@@ -128,6 +143,354 @@ impl Store {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upserts one champion-spawn's latest state, keyed by serial. Fed from the `champ.update`
|
||||||
|
/// stream; this table is the live board the website reads, so there is exactly one row per
|
||||||
|
/// spawn and it always holds the most recent snapshot.
|
||||||
|
pub async fn upsert_champ(
|
||||||
|
&self,
|
||||||
|
serial: &str,
|
||||||
|
status: Option<&str>,
|
||||||
|
name: Option<&str>,
|
||||||
|
json: &str,
|
||||||
|
t: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO champs (serial, status, name, json, updated_t) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(serial) DO UPDATE SET status = excluded.status, name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
|
||||||
|
)
|
||||||
|
.bind(serial)
|
||||||
|
.bind(status)
|
||||||
|
.bind(name)
|
||||||
|
.bind(json)
|
||||||
|
.bind(t)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops one spawn from the board. Fed from the `champ.remove` stream: a controller that was
|
||||||
|
/// deleted, or a transient sea boss that was slain, leaves the board this way.
|
||||||
|
pub async fn delete_champ(&self, serial: &str) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM champs WHERE serial = ?")
|
||||||
|
.bind(serial)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full champion-spawn board: every spawn's latest snapshot. Ordered by name so the site
|
||||||
|
/// gets a stable list.
|
||||||
|
pub async fn champs_all(&self) -> anyhow::Result<Vec<Value>> {
|
||||||
|
let rows = sqlx::query("SELECT json FROM champs ORDER BY name, serial")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(parse_json_column(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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> {
|
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
|
||||||
@@ -158,4 +521,73 @@ CREATE TABLE IF NOT EXISTS profiles (
|
|||||||
json TEXT NOT NULL,
|
json TEXT NOT NULL,
|
||||||
updated_t INTEGER NOT NULL
|
updated_t INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS champs (
|
||||||
|
serial TEXT PRIMARY KEY,
|
||||||
|
status TEXT,
|
||||||
|
name TEXT,
|
||||||
|
json TEXT NOT NULL,
|
||||||
|
updated_t INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
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,12 +54,48 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
.route("/vendors/:account", get(vendors))
|
.route("/vendors/:account", get(vendors))
|
||||||
// Inbound commands (correlated by code / id).
|
// Inbound commands (correlated by code / id).
|
||||||
.route("/link/confirm", post(link_confirm))
|
.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", post(towncrier_add))
|
||||||
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
||||||
|
// Town Cryer news gump (Protocol 2.1). Add/replace an article; delete one.
|
||||||
|
.route("/news", post(news_add))
|
||||||
|
.route("/news/:id", axum::routing::delete(news_remove))
|
||||||
|
// Staff write plane (correlated by reqId). The shard enforces the real authorization;
|
||||||
|
// the website must gate these behind admin/moderator roles before calling.
|
||||||
|
.route("/admin/kick", post(admin_kick))
|
||||||
|
.route("/admin/ban", post(admin_ban))
|
||||||
|
.route("/admin/unban", post(admin_unban))
|
||||||
|
.route("/admin/broadcast", post(admin_broadcast))
|
||||||
|
// Help-page (support) queue: snapshot the open queue, respond to / close a page.
|
||||||
|
.route("/pages", get(pages_list))
|
||||||
|
.route("/pages/:id/respond", post(page_respond))
|
||||||
|
.route("/pages/:id/close", post(page_close))
|
||||||
// History, read from SQLite rather than the shard.
|
// History, read from SQLite rather than the shard.
|
||||||
.route("/history", get(history))
|
.route("/history", get(history))
|
||||||
.route("/economy", get(economy))
|
.route("/economy", get(economy))
|
||||||
|
.route("/champs", get(champs))
|
||||||
|
// 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));
|
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -113,7 +149,8 @@ fn iso_ms(ms: i64) -> Option<String> {
|
|||||||
if ms <= 0 {
|
if ms <= 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
chrono::DateTime::from_timestamp_millis(ms)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- gate: protocol check + auth ----
|
// ---- gate: protocol check + auth ----
|
||||||
@@ -164,8 +201,15 @@ async fn gate(State(st): State<AppState>, req: Request, next: Next) -> Response
|
|||||||
|
|
||||||
fn extract_token(req: &Request) -> Option<String> {
|
fn extract_token(req: &Request) -> Option<String> {
|
||||||
// Authorization: Bearer <token>
|
// Authorization: Bearer <token>
|
||||||
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) {
|
if let Some(v) = req
|
||||||
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) {
|
.headers()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
{
|
||||||
|
if let Some(rest) = v
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| v.strip_prefix("bearer "))
|
||||||
|
{
|
||||||
return Some(rest.trim().to_string());
|
return Some(rest.trim().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +275,273 @@ fn respond(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `respond`, but for the admin write plane, where a rejection is not a not-found. Maps an
|
||||||
|
/// `admin.error` reply to a status by its reason: an unknown target is a 404, a floor/authorization
|
||||||
|
/// refusal (protected target, or the write plane being disabled) is a 403, anything else a 400.
|
||||||
|
fn respond_admin(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||||
|
match result {
|
||||||
|
Ok(value) => {
|
||||||
|
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
|
||||||
|
if kind == "admin.error" {
|
||||||
|
let reason = value
|
||||||
|
.get("reason")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.unwrap_or("request rejected");
|
||||||
|
let code = if reason.contains("unknown") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else if reason.contains("protected")
|
||||||
|
|| reason.contains("refused")
|
||||||
|
|| reason.contains("disabled")
|
||||||
|
{
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
} else {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
};
|
||||||
|
(code, Json(value))
|
||||||
|
} else {
|
||||||
|
(StatusCode::OK, Json(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RpcError::NoShard) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(json!({"error": "shard not connected"})),
|
||||||
|
),
|
||||||
|
Err(RpcError::Timeout) => (
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
Json(json!({"error": "shard did not reply in time"})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `respond`, but for the account-provisioning plane. Maps an `account.error` reply to a
|
||||||
|
/// status by its reason: a name clash is a 409, the per-IP cap is a 429, a disabled/protected/
|
||||||
|
/// refused action is a 403, an unknown target or "not linked" is a 404, anything else a 400.
|
||||||
|
fn respond_account(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||||
|
match result {
|
||||||
|
Ok(value) => {
|
||||||
|
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
|
||||||
|
if kind == "account.error" {
|
||||||
|
let reason = value
|
||||||
|
.get("reason")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.unwrap_or("request rejected");
|
||||||
|
let code = if reason.contains("already exists") {
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
} else if reason.contains("ip account limit") {
|
||||||
|
StatusCode::TOO_MANY_REQUESTS
|
||||||
|
} else if reason.contains("disabled")
|
||||||
|
|| reason.contains("protected")
|
||||||
|
|| reason.contains("refused")
|
||||||
|
{
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
} else if reason.contains("unknown") || reason.contains("not linked") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
};
|
||||||
|
(code, Json(value))
|
||||||
|
} else {
|
||||||
|
(StatusCode::OK, Json(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RpcError::NoShard) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(json!({"error": "shard not connected"})),
|
||||||
|
),
|
||||||
|
Err(RpcError::Timeout) => (
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
Json(json!({"error": "shard did not reply in time"})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account-provisioning handlers ----
|
||||||
|
|
||||||
|
/// Body: {"actor","account","password","websiteUserId","ip"}. Creates and links a game account.
|
||||||
|
/// Correlated on a fresh reqId. The password is forwarded to the shard (loopback) but never logged
|
||||||
|
/// here and never appears in the reply; a successful create mirrors the link into the store.
|
||||||
|
async fn account_create(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
let mut obj = match body {
|
||||||
|
Value::Object(m) => m,
|
||||||
|
_ => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "body must be a JSON object"})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Required, non-empty. `ip` is validated on the shard (which owns the cap), not here.
|
||||||
|
for field in ["actor", "account", "password", "websiteUserId"] {
|
||||||
|
let present = obj
|
||||||
|
.get(field)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !present {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "error": format!("{field} is required") })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
obj.insert("kind".to_string(), json!("account.create"));
|
||||||
|
obj.insert("reqId".to_string(), json!(req_id));
|
||||||
|
|
||||||
|
let result = st.rpc.call(&st.shard, Value::Object(obj), &req_id).await;
|
||||||
|
|
||||||
|
// Mirror a successful create's link into the store, so events are attributable without the
|
||||||
|
// shard (same as link.confirm does).
|
||||||
|
if let Ok(value) = &result {
|
||||||
|
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||||
|
if let (Some(account), Some(web_id)) = (
|
||||||
|
value.get("account").and_then(|a| a.as_str()),
|
||||||
|
value.get("websiteUserId").and_then(|w| w.as_str()),
|
||||||
|
) {
|
||||||
|
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let _ = st.store.record_link(account, web_id, t).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respond_account(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlinks a game account from its website user. Body: {"actor"}. Correlated on reqId; a success
|
||||||
|
/// also clears the sidecar's mirrored link row so attribution stops immediately.
|
||||||
|
async fn link_delete(
|
||||||
|
State(st): State<AppState>,
|
||||||
|
Path(account): Path<String>,
|
||||||
|
body: Option<Json<Value>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let actor = body
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|Json(b)| b.get("actor").and_then(|a| a.as_str()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if actor.is_empty() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "actor is required"})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({
|
||||||
|
"kind": "account.unlink", "reqId": req_id, "actor": actor, "account": account
|
||||||
|
});
|
||||||
|
let result = st.rpc.call(&st.shard, cmd, &req_id).await;
|
||||||
|
|
||||||
|
if let Ok(value) = &result {
|
||||||
|
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||||
|
let _ = st.store.record_unlink(&account).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respond_account(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admin write-plane handlers ----
|
||||||
|
|
||||||
|
/// Forwards a staff moderation command to the shard, correlated on a fresh reqId. Injects `kind`
|
||||||
|
/// and `reqId`, requiring the caller-supplied `actor` up front (the shard enforces it too). The
|
||||||
|
/// body's remaining fields (account/serial/durationSec/reason/text/hue) pass straight through.
|
||||||
|
async fn admin_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json<Value>) {
|
||||||
|
let mut obj = match body {
|
||||||
|
Value::Object(m) => m,
|
||||||
|
_ => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "body must be a JSON object"})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let actor_ok = obj
|
||||||
|
.get("actor")
|
||||||
|
.and_then(|a| a.as_str())
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !actor_ok {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "actor is required"})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
obj.insert("kind".to_string(), json!(kind));
|
||||||
|
obj.insert("reqId".to_string(), json!(req_id));
|
||||||
|
|
||||||
|
respond_admin(st.rpc.call(&st.shard, Value::Object(obj), &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"actor":"...","account":"..."|"serial":"0x.."}. Disconnects the target's live sessions.
|
||||||
|
async fn admin_kick(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
admin_call(&st, "admin.kick", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"actor":"...","account":"...","durationSec":<opt>,"reason":<opt>}. 0/absent = indefinite.
|
||||||
|
async fn admin_ban(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
admin_call(&st, "admin.ban", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"actor":"...","account":"..."}.
|
||||||
|
async fn admin_unban(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
admin_call(&st, "admin.unban", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"actor":"...","text":"...","hue":<opt>}. Announces a system message to everyone online.
|
||||||
|
async fn admin_broadcast(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
admin_call(&st, "admin.broadcast", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- help-page queue handlers ----
|
||||||
|
|
||||||
|
/// The open help-page queue, correlated on reqId. Returns a pages.list.
|
||||||
|
async fn pages_list(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({"kind": "pages.snapshot", "reqId": req_id});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"message":"...","close":<bool, optional>}. Delivers a staff response to the player.
|
||||||
|
async fn page_respond(
|
||||||
|
State(st): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let message = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if message.trim().is_empty() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({"error": "message is required"})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let close = body.get("close").and_then(|c| c.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({
|
||||||
|
"kind": "page.respond", "reqId": req_id,
|
||||||
|
"pageId": id, "message": message, "close": close
|
||||||
|
});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a page from the queue.
|
||||||
|
async fn page_close(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||||
|
let req_id = st.rpc.next_req_id();
|
||||||
|
let cmd = json!({"kind": "page.close", "reqId": req_id, "pageId": id});
|
||||||
|
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- query handlers ----
|
// ---- query handlers ----
|
||||||
|
|
||||||
async fn char_by_slot(
|
async fn char_by_slot(
|
||||||
@@ -297,7 +608,10 @@ async fn vendors(State(st): State<AppState>, Path(account): Path<String>) -> imp
|
|||||||
|
|
||||||
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
|
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
|
||||||
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
let code = body.get("code").and_then(|c| c.as_str()).unwrap_or_default();
|
let code = body
|
||||||
|
.get("code")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
let web_id = body
|
let web_id = body
|
||||||
.get("websiteUserId")
|
.get("websiteUserId")
|
||||||
.and_then(|w| w.as_str())
|
.and_then(|w| w.as_str())
|
||||||
@@ -359,14 +673,55 @@ async fn towncrier_add(State(st): State<AppState>, Json(body): Json<Value>) -> i
|
|||||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn towncrier_remove(
|
async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
|
||||||
State(st): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let cmd = json!({"kind":"towncrier.remove","id":id});
|
let cmd = json!({"kind":"towncrier.remove","id":id});
|
||||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) ----
|
// ---- history (from SQLite) ----
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -399,6 +754,178 @@ async fn economy(State(st): State<AppState>, Query(q): Query<HistoryQuery>) -> i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The champion-spawn board: every spawn's latest state (status/level/kills/boss/location and, when
|
||||||
|
/// relevant, the cooldown ETA). Served from the local board table, so it answers without touching
|
||||||
|
/// the shard and survives a shard outage with the last-known snapshot.
|
||||||
|
async fn champs(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.champs_all().await {
|
||||||
|
Ok(spawns) => (StatusCode::OK, Json(json!({"spawns": spawns}))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The guild board: every guild's latest roster snapshot (id/name/abbr/leader/members/alliance).
|
||||||
|
/// Served from the local board table, so it hydrates a fresh page or a restarted sidecar without a
|
||||||
|
/// shard round-trip. The live `guild.*` feed then keeps it current.
|
||||||
|
async fn guilds(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.guilds_all().await {
|
||||||
|
Ok(guilds) => (StatusCode::OK, Json(json!({"guilds": guilds}))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The governor board: each city's latest governance snapshot (governor/elect/election phase).
|
||||||
|
/// Served from the local board table for the same reason as `/guilds`.
|
||||||
|
async fn governors(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.governors_all().await {
|
||||||
|
Ok(cities) => (StatusCode::OK, Json(json!({"cities": cities}))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The house registry: every house's latest snapshot (owner/region/location/decay/value). Served
|
||||||
|
/// from the local board table, so it hydrates without the shard and survives an outage.
|
||||||
|
async fn houses(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
match st.store.houses_all().await {
|
||||||
|
Ok(houses) => (StatusCode::OK, Json(json!({"houses": houses}))),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": e.to_string()})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 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 ----
|
// ---- websocket ----
|
||||||
|
|
||||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
|||||||
25
sonar-project.properties
Normal file
25
sonar-project.properties
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# SonarQube analysis config for the link (uo-link sidecar) repo.
|
||||||
|
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
|
||||||
|
# The project key must match the one created in SonarQube (dashboard URL
|
||||||
|
# ?id=Runic-Gateway-link).
|
||||||
|
|
||||||
|
sonar.projectKey=Runic-Gateway-link
|
||||||
|
sonar.projectName=runic gateway link
|
||||||
|
|
||||||
|
# Analysed application code. The Rust sidecar crate lives under sidecar/src.
|
||||||
|
# Rust unit tests live inline (#[cfg(test)] modules) rather than in a separate
|
||||||
|
# tree, so there is no distinct sonar.tests path to declare.
|
||||||
|
sonar.sources=sidecar/src
|
||||||
|
|
||||||
|
# Never analyse build output, the vendored lockfile, or generated config.
|
||||||
|
sonar.exclusions=**/target/**,**/*.lock
|
||||||
|
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
# ── Optional enrichment (enable if your SonarQube edition/version supports it) ──
|
||||||
|
# SonarQube imports Clippy findings when given a JSON report. To turn this on:
|
||||||
|
# 1. In sonarqube.yml, add a step before the scan that runs:
|
||||||
|
# cargo clippy --message-format=json > sidecar/clippy-report.json
|
||||||
|
# (needs the Rust toolchain + `rustup component add clippy` on the runner).
|
||||||
|
# 2. Uncomment the line below.
|
||||||
|
# sonar.rust.clippy.reportPaths=sidecar/clippy-report.json
|
||||||
@@ -1,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,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,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