Compare commits
59 Commits
048ee000f5
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f23a08d449 | |||
| a740365131 | |||
| 6061fe39ce | |||
| 41ac2ccaaa | |||
| f6a86ff8c2 | |||
| 54149fb481 | |||
| 0182732d63 | |||
| b9a27a2de5 | |||
| 63a7dc4374 | |||
| d2a12c46e2 | |||
| 7aa7bc8032 | |||
| 827de04471 | |||
| 28b878c850 | |||
| 1dd490b483 | |||
| a144c12c46 | |||
| b818f6cf37 | |||
| badc1702de | |||
| fc4ebf0f5a | |||
| 8cf995f27f | |||
| 158c0596d8 | |||
| 0d9ac7fda8 | |||
| 8195454201 | |||
| 79cc611ee0 | |||
| d57d9aad84 | |||
| 8c6db9f0d5 | |||
| 65562eea40 | |||
| cc4f58317e | |||
| 0eda2d3a97 | |||
| 48b16dc70e | |||
| c045bdd566 | |||
| 8828382e41 | |||
| 7fa8953ffa | |||
| 4720a214a2 | |||
| 3a52abbd77 | |||
| eebc74ac8d | |||
| 724262548b | |||
| ebbfab51fc | |||
| 968b526fac | |||
| 7215ae5fe1 | |||
| 48d57e6278 | |||
| a38afe4c90 | |||
| ed8f568d94 | |||
| a7a383e6d9 | |||
| 3fb4b7dc9f | |||
| 64a89f97af | |||
| c72ed6619e | |||
| 76b0b3e66a | |||
|
|
4e8be1a086 | ||
|
|
5c6b1bb299 | ||
|
|
ea12ac3f94 | ||
| e9856212ce | |||
| 0fd6b91f22 | |||
| 5cade1aa9d | |||
| 5e0b42b948 | |||
| be442d36a0 | |||
| a837edd5ee | |||
| 1244eb6c4f | |||
| dd39d524c6 | |||
| 5816c29c67 |
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/servuo-plugins/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.
|
||||
623
.gitea/workflows/release.yml
Normal file
623
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,623 @@
|
||||
# Automated release for the deployable ServUO overlay.
|
||||
#
|
||||
# Trigger: every push to `main` (i.e. every merged PR).
|
||||
#
|
||||
# Why this exists: the Runic Gateway installer deploys the plugin from a release
|
||||
# tarball, not from git — the shard host gets no git and no Gitea credentials
|
||||
# (docs/installer/PLAN.md §1, §5 Phase 0.1). Until this workflow, `link` was the
|
||||
# only repo that published releases, so there was nothing for the installer to
|
||||
# fetch. This is Phase 0 item 1.
|
||||
#
|
||||
# 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 │
|
||||
# └───────────────────────────────────────────────────────────────────┘
|
||||
# ┌── OVERLAY ADAPTER (the only repo-specific part) ──────────────────┐
|
||||
# │ consumes: the version │
|
||||
# │ produces: runicgateway-overlay-<ver>.tar.gz + SHA256SUMS │
|
||||
# └───────────────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# The engine is `link/.gitea/workflows/release.yml`'s, reused as its own header
|
||||
# anticipated — the plan and release steps consume only {version, changelog,
|
||||
# artifacts} and know nothing about what is inside the artifacts.
|
||||
#
|
||||
# ── Three differences from link's copy, all forced by this repo ──────────────
|
||||
#
|
||||
# 1. NO BUILD. The plugin ships as C# source and ServUO compiles it at boot; it
|
||||
# needs ServUO reference assemblies, so there is no way to compile it here.
|
||||
# The build gates are replaced by the structural gates below, which is the
|
||||
# most this repo can honestly assert about an artifact.
|
||||
#
|
||||
# 2. NO BUMP COMMIT, and so no push to `main`. link has to write the version
|
||||
# into Cargo.toml because the binary embeds it; a tarball embeds nothing but
|
||||
# the manifest.json this job generates, so the git tag IS the version. That
|
||||
# removes a failure mode outright: this workflow never needs `main` to accept
|
||||
# a direct push, so no branch-protection exception is required for it.
|
||||
#
|
||||
# 3. A MANIFEST. The tarball carries manifest.json — version, commit, declared
|
||||
# protocol version, ServUO compatibility, and a SHA256 for every file. The
|
||||
# installer needs it because the plugin announces no version on the wire and
|
||||
# none is queryable before ServUO boots (PLAN.md §2.6): the manifest is the
|
||||
# only thing that lets the bundle CI verify sidecar/overlay protocol
|
||||
# agreement BEFORE an operator installs the pair (PLAN.md §7.1, gate 1).
|
||||
#
|
||||
# Version bump (conventional commits since the last v* tag):
|
||||
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
||||
# nothing releasable -> no release is cut (a docs:/chore:-only merge
|
||||
# deliberately does NOT cut one — PLAN.md §7.3)
|
||||
# (first ever run, no tag) -> releases SEED_VERSION below
|
||||
#
|
||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/servuo-plugins):
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
|
||||
# tag and create the release. The final step also dispatches
|
||||
# RunicGateway/installer's bundle workflow, so the token
|
||||
# ideally has write there too — a nicety, not a requirement:
|
||||
# without it the step warns and that repo's nightly cron
|
||||
# picks the release up instead.
|
||||
# REGISTRY_USER — the Gitea username that token belongs to.
|
||||
#
|
||||
# These are checked by an explicit preflight step rather than left to fail
|
||||
# wherever they happen to be used first — see the comment on that step for why
|
||||
# an absent token does NOT simply fail the tag push.
|
||||
#
|
||||
# The final step POSTs to the installer repo's bundle workflow, so a new overlay
|
||||
# release recomposes the compat matrix immediately instead of waiting for that
|
||||
# repo's nightly cron (PLAN.md §7.2). It was deliberately absent until Phase 0
|
||||
# item 3 landed something to dispatch — a step that 404s on every release is
|
||||
# worse than no step.
|
||||
|
||||
name: Release overlay
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: release-overlay
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/servuo-plugins
|
||||
# Artifact naming per PLAN.md §3.
|
||||
ARTIFACT: runicgateway-overlay
|
||||
# Used only for the very first release, when no v* tag exists yet. Matches the
|
||||
# house style set by link (pre-1.0; the release version is independent of the
|
||||
# protocol version, which lives in overlay.toml).
|
||||
SEED_VERSION: "0.1.0"
|
||||
# Notified after a release so the installer's compat matrix picks up this
|
||||
# overlay immediately rather than at its next nightly run (PLAN.md §7.2).
|
||||
INSTALLER_REPO: RunicGateway/installer
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
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="$SEED_VERSION" # first release: seed
|
||||
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
|
||||
|
||||
# An existing tag is NOT automatically "nothing to do". A tag with no
|
||||
# release behind it means a previous run tagged and then died before
|
||||
# publishing — which is exactly what happened on the first run here,
|
||||
# when the missing REGISTRY_* secrets took the release API call to 401
|
||||
# after the tag had already been pushed. Standing down on the tag alone
|
||||
# would make that state permanent: every later run would see the tag,
|
||||
# set RELEASE=false, and the release would never appear. So distinguish
|
||||
# the two cases and finish the job the earlier run started.
|
||||
# Note this OVERRIDES the RELEASE=false decided just above. With the tag
|
||||
# already in place there are no releasable commits after it, so the
|
||||
# normal path stands down — which is precisely why the stuck state
|
||||
# could never clear itself. Recovery has to be able to say "yes,
|
||||
# publish" for a version the bump logic considers already done.
|
||||
REUSE_TAG=false
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||
if [ "$REL_HTTP" = "200" ]; then
|
||||
echo "Tag v${VERSION} already has a release — nothing to do."
|
||||
RELEASE=false
|
||||
elif [ "$REL_HTTP" = "404" ]; then
|
||||
echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
|
||||
REUSE_TAG=true
|
||||
RELEASE=true
|
||||
else
|
||||
# Anything else (000 from a network failure, 401/403 from a bad
|
||||
# token) is not evidence of absence. Guessing "no release" here
|
||||
# would re-publish over a good one, so refuse instead.
|
||||
echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Orphan sweep ────────────────────────────────────────────────
|
||||
#
|
||||
# The check above is VERSION-SCOPED: it only ever asks about the one
|
||||
# version this run computed. That is enough to recover an orphan on
|
||||
# the very next run, and useless afterwards — once any releasable
|
||||
# commit lands, the next run computes a NEW version, never looks at
|
||||
# the old tag again, and the orphan becomes permanent and silent.
|
||||
#
|
||||
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
|
||||
# commit that ADDED the recovery above was itself typed
|
||||
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
|
||||
# v0.1.1 — and the run that introduced the recovery stepped straight
|
||||
# past the tag it was written to rescue. That tag is still orphaned.
|
||||
#
|
||||
# So every v* tag is checked, and anything missing a release is
|
||||
# WARNED about. Deliberately not recovered: publishing an old version
|
||||
# would mean building today's tree and shipping it under a tag whose
|
||||
# tree it is not, which is worse than the inconsistency it fixes.
|
||||
# A human decides whether to recover or drop it.
|
||||
#
|
||||
# Never fails the run. A sweep that can break a good release is a
|
||||
# sweep someone will delete.
|
||||
ORPHANS=""
|
||||
for T in $(git tag -l 'v*' --sort=-v:refname); do
|
||||
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
|
||||
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
|
||||
done
|
||||
if [ -n "${ORPHANS}" ]; then
|
||||
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
|
||||
fi
|
||||
|
||||
# Changelog range. A recovery run has nothing after the tag, so
|
||||
# summarize what the tag itself contains rather than emitting an empty
|
||||
# list: the range that produced it, i.e. previous-tag..this-tag.
|
||||
if [ "$REUSE_TAG" = true ]; then
|
||||
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi
|
||||
SINCE="$PREV_TAG"
|
||||
else
|
||||
CL_RANGE="$RANGE"
|
||||
SINCE="$LAST_TAG"
|
||||
fi
|
||||
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
|
||||
|
||||
{
|
||||
echo "## ${ARTIFACT} v${VERSION}"
|
||||
echo
|
||||
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
|
||||
FIXES="$(echo "$CL_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 "$SINCE" ]; then echo "Since ${SINCE}:"; fi
|
||||
echo "$CL_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 "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-<none>}"
|
||||
|
||||
# ── Credential preflight ─────────────────────────────────────────────
|
||||
# Runs BEFORE anything is built or pushed, and only when this run intends
|
||||
# to publish, so a docs:/chore:-only merge stays green on a repo that has
|
||||
# no secrets.
|
||||
#
|
||||
# This exists because of how the first run failed. REGISTRY_USER and
|
||||
# REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway:
|
||||
# actions/checkout leaves an `http.<host>.extraheader` credential in the
|
||||
# local git config, so `git remote set-url` to a URL with empty
|
||||
# credentials still authenticated through that leftover header. The
|
||||
# release API call had no such fallback and returned 401 — so the run
|
||||
# tagged the repo and then failed, which is the worst of both outcomes.
|
||||
# Checking the secrets up front turns that into an immediate, legible
|
||||
# failure instead of a half-published release.
|
||||
- name: Verify release credentials are configured
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MISSING=""
|
||||
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
|
||||
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs the write:repository scope to push the tag and create the release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release credentials present."
|
||||
|
||||
- name: Install jq
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v jq >/dev/null 2>&1 && exit 0
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq --no-install-recommends jq
|
||||
|
||||
# ── OVERLAY ADAPTER: gates ───────────────────────────────────────────
|
||||
# There is no compiler to run, so these assert the things that CAN be
|
||||
# checked without a ServUO tree — and each one has actually been a way to
|
||||
# ship a broken overlay:
|
||||
#
|
||||
# • overlay/ mirrors the server root; if Bridge.cfg or the Bridge scripts
|
||||
# go missing the deploy silently no-ops (PLAN.md §2.1).
|
||||
# • overlay/Scripts/Scripts.csproj is Phase 0 of the plugin itself — it
|
||||
# overwrites a stock file to fix ServUO's silent script-build bug. An
|
||||
# overlay shipped without it installs code that never compiles, and
|
||||
# ServUO reports success anyway.
|
||||
# • a malformed .patch is invisible until an operator runs the patch tier
|
||||
# on their live shard. `git apply --stat` parses the diff without
|
||||
# needing the target files present.
|
||||
# • each patch's companion .cs must exist, since it references symbols
|
||||
# the patch introduces and is meaningless without it (PLAN.md §2.2).
|
||||
# • patches/tier.json must describe every .patch and nothing but. That
|
||||
# table is what tells the installer which patches form one unit, which
|
||||
# companion follows which, and whether a CORE rebuild is needed — a
|
||||
# patch added without it would be shipped and silently never offered.
|
||||
- name: Validate the overlay and patch tier
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail() { echo "::error::$*"; exit 1; }
|
||||
|
||||
[ -f overlay/Config/Bridge.cfg ] || fail "overlay/Config/Bridge.cfg is missing"
|
||||
[ -f overlay/Scripts/Scripts.csproj ] || fail "overlay/Scripts/Scripts.csproj is missing (the silent-build-bug fix)"
|
||||
[ -d overlay/Scripts/Custom/Bridge ] || fail "overlay/Scripts/Custom/Bridge/ is missing"
|
||||
|
||||
CS_COUNT="$(find overlay/Scripts/Custom/Bridge -name '*.cs' | wc -l)"
|
||||
[ "$CS_COUNT" -gt 0 ] || fail "overlay/Scripts/Custom/Bridge/ contains no .cs files"
|
||||
echo "overlay: ${CS_COUNT} bridge script(s)"
|
||||
|
||||
for p in patches/*.patch; do
|
||||
[ -e "$p" ] || fail "patches/ contains no .patch files"
|
||||
echo "--- ${p}"
|
||||
git apply --stat "$p" || fail "${p} is not a parseable unified diff"
|
||||
done
|
||||
|
||||
# The tier table, checked in BOTH directions. A patch missing from
|
||||
# tier.json ships but is never offered to an operator; a tier.json
|
||||
# entry naming a file that is not there makes the installer report a
|
||||
# feature it cannot apply. Neither surfaces until someone runs the
|
||||
# tier on a live shard, so both fail the release here instead.
|
||||
[ -f patches/tier.json ] || fail "patches/tier.json is missing (the patch-tier declaration)"
|
||||
jq -e . patches/tier.json >/dev/null || fail "patches/tier.json is not valid JSON"
|
||||
|
||||
DESCRIBED="$(jq -r '.features[].patches[].file' patches/tier.json | LC_ALL=C sort)"
|
||||
PRESENT="$(cd patches && ls *.patch | LC_ALL=C sort)"
|
||||
if [ "$DESCRIBED" != "$PRESENT" ]; then
|
||||
echo "described by tier.json:"; echo "$DESCRIBED" | sed 's/^/ /'
|
||||
echo "present in patches/:"; echo "$PRESENT" | sed 's/^/ /'
|
||||
fail "patches/tier.json and patches/*.patch disagree — every patch must be described by exactly one feature"
|
||||
fi
|
||||
|
||||
# Each patch's declared target must be the file its diff actually
|
||||
# edits. The installer cross-checks the same pair at install time and
|
||||
# refuses on a mismatch, so catching it here saves an operator the run.
|
||||
while IFS=$'\t' read -r PFILE PTARGET; do
|
||||
DIFF_TARGET="$(sed -n 's|^+++ b/||p' "patches/${PFILE}" | head -1 | tr -d '\r')"
|
||||
[ "$DIFF_TARGET" = "$PTARGET" ] \
|
||||
|| fail "patches/${PFILE} edits ${DIFF_TARGET} but tier.json declares ${PTARGET}"
|
||||
done < <(jq -r '.features[].patches[] | [.file, .target] | @tsv' patches/tier.json)
|
||||
|
||||
# Companions can only be copied after their feature's patches land, so
|
||||
# they live here rather than in overlay/ — and a missing one turns a
|
||||
# successfully patched shard into one that does not compile.
|
||||
for f in $(jq -r '.features[].companions[].file' patches/tier.json); do
|
||||
[ -f "patches/${f}" ] || fail "patches/${f} is missing (a feature's companion source)"
|
||||
done
|
||||
|
||||
for r in $(jq -r '.features[].rebuild' patches/tier.json); do
|
||||
case "$r" in
|
||||
core|scripts) ;;
|
||||
*) fail "tier.json declares rebuild=\"${r}\"; only \"core\" or \"scripts\" are understood" ;;
|
||||
esac
|
||||
done
|
||||
echo "patch tier: $(jq -r '.features | length' patches/tier.json) feature(s), $(echo "$PRESENT" | wc -l) patch(es)"
|
||||
|
||||
[ -f overlay.toml ] || fail "overlay.toml is missing (protocol + ServUO declarations)"
|
||||
|
||||
# ── OVERLAY ADAPTER: stage, manifest, package ────────────────────────
|
||||
# The tarball has a FIXED top-level directory (runicgateway-overlay/), not a
|
||||
# versioned one: the installer extracts and then looks for overlay/,
|
||||
# patches/ and manifest.json at known paths, and a version-dependent prefix
|
||||
# would make it parse the very version it is trying to read.
|
||||
#
|
||||
# tar flags pin ownership, mtime and member order so the same tree produces
|
||||
# a byte-identical tarball — a checksum that changes only when content
|
||||
# changes is worth more than one that changes every run.
|
||||
- name: Build manifest.json and the release tarball
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
STAGE="dist/stage/${ARTIFACT}"
|
||||
|
||||
mkdir -p "${STAGE}"
|
||||
cp -r overlay "${STAGE}/overlay"
|
||||
cp -r patches "${STAGE}/patches"
|
||||
|
||||
# tier.json is folded into manifest.json below, so the staged copy is
|
||||
# removed: shipping it twice would give the tarball two statements of
|
||||
# the same table, one of which nothing reads and both of which are
|
||||
# free to drift.
|
||||
rm -f "${STAGE}/patches/tier.json"
|
||||
|
||||
# Declarations from overlay.toml. Read, don't hardcode — the point of
|
||||
# that file is that the protocol number lives in one place.
|
||||
PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')"
|
||||
MIN_SERVUO="$(grep -m1 -E '^min_servuo_version[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
PATCHED_AGAINST="$(grep -m1 -E '^patches_verified_against[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
[ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; }
|
||||
[ -n "$MIN_SERVUO" ] || { echo "::error::could not read min_servuo_version from overlay.toml"; exit 1; }
|
||||
[ -n "$PATCHED_AGAINST" ] || { echo "::error::could not read patches_verified_against from overlay.toml"; exit 1; }
|
||||
echo "==> protocol=${PROTOCOL} min_servuo=${MIN_SERVUO} patches_verified_against=${PATCHED_AGAINST}"
|
||||
|
||||
# The patch tier, folded in verbatim minus its comment block. Paths are
|
||||
# rewritten to be relative to the tarball root (`patches/<file>`), which
|
||||
# is where the installer will find them after extraction — tier.json
|
||||
# names them relative to patches/ because that is where a maintainer
|
||||
# editing it is looking.
|
||||
TIER="$(jq '
|
||||
del(._comment)
|
||||
| .features |= map(
|
||||
.patches |= map(.file |= "patches/" + .)
|
||||
| .companions |= map(.file |= "patches/" + .)
|
||||
)' patches/tier.json)"
|
||||
|
||||
# Per-file SHA256 of everything shipped, as a {path: sha} object. The
|
||||
# installer records these in install.json so a later `doctor` can tell
|
||||
# "operator edited a deployed file" from "the overlay drifted".
|
||||
# The `\*?` is not paranoia: sha256sum marks binary mode by prefixing the
|
||||
# path with `*` (`<hash> *path`) instead of the two-space text-mode
|
||||
# separator. Coreutils on Linux defaults to text mode, but a build host
|
||||
# that doesn't would otherwise put a leading `*` on EVERY key here and
|
||||
# silently produce a manifest whose paths match nothing.
|
||||
FILES="$(cd "${STAGE}" \
|
||||
&& find overlay patches -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum \
|
||||
| jq -R -s '
|
||||
split("\n") | map(select(length > 0))
|
||||
| map(capture("^(?<sha>[0-9a-f]+)[ \t]+\\*?(?<path>.+)$"))
|
||||
| map({ (.path): .sha }) | add')"
|
||||
|
||||
jq -n \
|
||||
--arg component "servuo-plugins-overlay" \
|
||||
--arg version "${VERSION}" \
|
||||
--arg commit "${GITHUB_SHA}" \
|
||||
--arg repo "${REPO}" \
|
||||
--argjson protocol "${PROTOCOL}" \
|
||||
--arg min_servuo "${MIN_SERVUO}" \
|
||||
--arg patched_against "${PATCHED_AGAINST}" \
|
||||
--argjson tier "${TIER}" \
|
||||
--argjson files "${FILES}" \
|
||||
'{
|
||||
component: $component,
|
||||
version: $version,
|
||||
commit: $commit,
|
||||
repo: $repo,
|
||||
protocol: $protocol,
|
||||
servuo: {
|
||||
min_version: $min_servuo,
|
||||
patches_verified_against: $patched_against
|
||||
},
|
||||
patch_tier: $tier,
|
||||
files: $files
|
||||
}' > "${STAGE}/manifest.json"
|
||||
|
||||
echo "----- manifest.json (files elided) -----"
|
||||
jq 'del(.files) + {file_count: (.files | length)}' "${STAGE}/manifest.json"
|
||||
|
||||
TARBALL="${ARTIFACT}-${VERSION}.tar.gz"
|
||||
tar --sort=name --mtime='UTC 1970-01-01' \
|
||||
--owner=0 --group=0 --numeric-owner \
|
||||
-czf "dist/${TARBALL}" -C dist/stage "${ARTIFACT}"
|
||||
|
||||
( cd dist && sha256sum "${TARBALL}" > SHA256SUMS )
|
||||
echo "tarball=${TARBALL}" >> "$GITHUB_OUTPUT"
|
||||
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
||||
id: package
|
||||
|
||||
# ── RELEASE ENGINE: tag ──────────────────────────────────────────────
|
||||
# Tag only — no bump commit, so `main` is never pushed to (see header).
|
||||
- name: Push the release tag
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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.
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
git config user.name "servuo-plugins-ci"
|
||||
git config user.email "ci@whitlocktech.com"
|
||||
git remote set-url origin \
|
||||
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||
|
||||
# The tag may already exist when we are finishing a run that died after
|
||||
# tagging (see the plan step). `git tag` on an existing name fails under
|
||||
# `set -e`, and pushing an identical existing tag is a harmless no-op —
|
||||
# so create it only if it is new, then push either way. A push that
|
||||
# fails here means the remote tag points somewhere else, which SHOULD
|
||||
# stop the run.
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "Tag ${TAG} already exists — reusing it."
|
||||
else
|
||||
git tag "${TAG}"
|
||||
fi
|
||||
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 }}"
|
||||
TARBALL="${{ steps.package.outputs.tarball }}"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
BODY="$(cat dist/CHANGELOG.md)"
|
||||
# Same newline hygiene as the tag step: a stray CR/LF in the token would
|
||||
# corrupt the Authorization header.
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
|
||||
|
||||
# installer#22's release run failed exactly here: it landed one second
|
||||
# after the tag push and Gitea answered 500, having not finished
|
||||
# processing the pushed tag. Re-running published the same artifacts
|
||||
# untouched, so it was a race, not a bad request — but the tag sat
|
||||
# orphaned until a human noticed.
|
||||
#
|
||||
# Two things made that worse than it needed to be.
|
||||
#
|
||||
# 1. `curl -sSf` prints NO response body on an error status, so all the
|
||||
# log carried was "curl: (22) ... error: 500" and the cause had to be
|
||||
# inferred from timestamps. Capture the body and print it.
|
||||
# 2. Nothing retried, so a transient 5xx became a permanent orphan.
|
||||
#
|
||||
# 4xx is deliberately NOT retried: a bad token or a malformed body does
|
||||
# not improve by being sent again, and retrying only turns a clear
|
||||
# failure into a slow one.
|
||||
REL_ID=""
|
||||
for attempt in 1 2 3 4 5; do
|
||||
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" || echo 000)"
|
||||
|
||||
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
|
||||
REL_ID="$(jq -r '.id' /tmp/rel.json)"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
|
||||
echo "--- response body ---"
|
||||
cat /tmp/rel.json || true
|
||||
echo
|
||||
echo "---------------------"
|
||||
|
||||
case "$HTTP" in
|
||||
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ "$attempt" = 5 ]; then
|
||||
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
|
||||
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
|
||||
exit 1
|
||||
fi
|
||||
sleep $(( attempt * 5 ))
|
||||
done
|
||||
|
||||
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
|
||||
echo "::error::Release created but no id came back; refusing to upload assets blind."
|
||||
exit 1
|
||||
fi
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "${TARBALL}" SHA256SUMS; do
|
||||
# Same treatment. An upload that fails quietly leaves a release whose
|
||||
# SHA256SUMS does not cover every artifact it advertises, which is
|
||||
# worse than no release at all -- that file is the trust anchor.
|
||||
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" || echo 000)"
|
||||
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
|
||||
echo "::error::uploading ${f} returned HTTP ${HTTP}"
|
||||
cat /tmp/asset.json || true
|
||||
exit 1
|
||||
fi
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
|
||||
# ── Recompose the installer's bundle manifest ────────────────────────
|
||||
# The installer does not resolve "latest" at run time — it deploys the
|
||||
# exact overlay named by a published bundle (docs/installer/PLAN.md §7.1).
|
||||
# An overlay release that nobody recomposes around is therefore a release
|
||||
# no operator will ever be offered. This tells the installer repo to
|
||||
# rebuild that manifest now rather than leaving the new version invisible
|
||||
# until its nightly cron.
|
||||
#
|
||||
# That job re-reads this tarball's manifest.json and checks its declared
|
||||
# `protocol` against the sidecar's PROTOCOL_VERSION before publishing
|
||||
# anything (PLAN.md §7.1, gate 1) — which is the check this repo cannot
|
||||
# perform for itself, since the C# plugin announces no version on the wire.
|
||||
#
|
||||
# DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint
|
||||
# returns no run handle, so there is nothing to poll: a waiting step would
|
||||
# have to guess which run is its own and hold a runner idle to do it.
|
||||
#
|
||||
# A failure here is a WARNING, never a failure of this job. The release is
|
||||
# already published and correct by this point, and failing the run would
|
||||
# misreport that. The installer's nightly cron recomposes from whatever the
|
||||
# latest releases actually are, so a dropped dispatch costs latency, not
|
||||
# correctness.
|
||||
- name: Ask the installer repo to recompose its bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref":"main"}' \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)"
|
||||
case "$HTTP" in
|
||||
20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;;
|
||||
403|404)
|
||||
echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;;
|
||||
*)
|
||||
echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;;
|
||||
esac
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
*.log
|
||||
dist/
|
||||
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
|
||||
102
CONTRIBUTING.md
Normal file
102
CONTRIBUTING.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Contributing to Runic Gateway — ServUO Plugin
|
||||
|
||||
Thanks for your interest in contributing! This repo is the **C# ServUO side** of
|
||||
the game bridge. The shard emits newline-delimited JSON over a loopback TCP
|
||||
socket to the Rust sidecar
|
||||
([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)).
|
||||
|
||||
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/servuo-plugins/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).
|
||||
|
||||
## Development setup
|
||||
|
||||
This plugin is deployed as **source** and compiled by ServUO at boot — there is
|
||||
no standalone build artifact and no CI build (it needs the ServUO reference
|
||||
assemblies to compile). See the [README](README.md) for the full model.
|
||||
|
||||
**Key rule:** anything under `overlay/` is authoritative and mirrors the ServUO
|
||||
server root. **Do not edit files in a deployed server tree directly** — edit here
|
||||
under `overlay/` (or `patches/` for changes to stock ServUO files) and deploy:
|
||||
|
||||
```powershell
|
||||
# Show what would change, then write it into a ServUO install:
|
||||
.\deploy.ps1 -ServerPath C:\path\to\servuo -Verify
|
||||
.\deploy.ps1 -ServerPath C:\path\to\servuo
|
||||
```
|
||||
|
||||
`deploy.ps1` deploys from *this working tree*, which is what you want while
|
||||
developing. It is not how a shard is set up: operators run the
|
||||
[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer),
|
||||
which syncs the released overlay tarball and installs the sidecar alongside it.
|
||||
Changes here reach shards through a [release](README.md#releases), so a change
|
||||
that only works when `deploy.ps1` copies it is a change that does not ship.
|
||||
|
||||
- `overlay/` — copied over an install (the only thing `deploy.ps1` deploys).
|
||||
- `patches/` — unified diffs against stock ServUO for files we must modify.
|
||||
- `tools/` — never deployed: test scaffolding and stub sidecars.
|
||||
|
||||
### Testing
|
||||
|
||||
`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard
|
||||
sends — run it, boot the shard, and watch events arrive:
|
||||
|
||||
```powershell
|
||||
.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log
|
||||
```
|
||||
|
||||
`tools/stub_sidecar_request.ps1` additionally sends inbound requests to exercise
|
||||
the request/response handlers. For real end-to-end testing, run against the Rust
|
||||
sidecar rather than the throwaway PowerShell stubs.
|
||||
|
||||
### Protocol compatibility
|
||||
|
||||
The loopback JSON protocol is a **compatibility contract** shared with the
|
||||
sidecar. The canonical event/command catalog lives in the
|
||||
[docs repo](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link)
|
||||
(`PLAN.md` §5/§7 and `INTEGRATION.md`). If you add or change an event or command,
|
||||
keep the plugin, the sidecar, and the spec in sync.
|
||||
|
||||
## 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` (e.g. `feat(bridge): add vendor.sale event`).
|
||||
|
||||
## 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>.
|
||||
216
README.md
Normal file
216
README.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Runic Gateway — ServUO Plugin
|
||||
|
||||
The **C# ServUO side** of the Runic Gateway bridge. The shard emits newline-delimited JSON
|
||||
over a loopback TCP socket to the Rust **sidecar** ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)),
|
||||
which owns the WebSocket the website consumes.
|
||||
|
||||
```
|
||||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
||||
>>> THIS REPO <<< (RunicGateway/link)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Documentation
|
||||
|
||||
All project documentation lives in the central **[RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs)** repo,
|
||||
under [`link/`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link) (design docs,
|
||||
integration guide, protocol spec, research — with full history preserved).
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|------|------|
|
||||
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
|
||||
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
||||
| `tools/` | Never deployed. Test scaffolding (C# probes + PowerShell stub sidecars) and anything else that must not reach a server. |
|
||||
| `deploy.ps1` | **Developer tool** — copies `overlay/` from this working tree into a server root. `-Verify` diffs instead of writing. Operators use the [installer](https://gitea.whitlocktech.com/RunicGateway/installer); see [Deploy](#deploy). |
|
||||
| `overlay.toml` | Release metadata: the wire-protocol version this overlay speaks, and its ServUO compatibility. Read by CI into the release manifest — see [Releases](#releases). |
|
||||
| `.gitea/workflows/release.yml` | Publishes `runicgateway-overlay-<ver>.tar.gz` on every merge to `main`. |
|
||||
| [INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md) | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. |
|
||||
| [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) | Implementation plan, measured performance budget, and the full data catalog. |
|
||||
| [RESEARCH.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/RESEARCH.md) | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
|
||||
| [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) | Repairs the target shard needed before any of this could load. |
|
||||
|
||||
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
||||
|
||||
## Sidecar & deployment
|
||||
|
||||
The Rust sidecar is the other half of the bridge and lives in **[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**.
|
||||
The two are deployed **together** — by the
|
||||
[installer](https://gitea.whitlocktech.com/RunicGateway/installer), in one run — but built
|
||||
**independently**:
|
||||
|
||||
- **This plugin** is deployed as *source*: `overlay/` is copied into the ServUO server root and
|
||||
ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)). There is
|
||||
**no CI build** — it cannot be compiled standalone without the ServUO reference assemblies. CI
|
||||
publishes a *source* tarball, which is what the installer fetches and syncs; see
|
||||
[Releases](#releases).
|
||||
- **The sidecar** is a standalone Rust binary, released from its own repo and installed from that
|
||||
release.
|
||||
|
||||
The **only** coupling is the loopback JSON protocol (the shard dials out to the sidecar on
|
||||
`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)).
|
||||
A wedged or absent sidecar cannot stall the shard, so the plugin can be deployed before, after, or
|
||||
without the sidecar running.
|
||||
|
||||
## Deploy
|
||||
|
||||
**On a shard, use the [Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer).**
|
||||
One binary syncs this overlay from the release tarball below, offers the patch tier, installs the
|
||||
uo-link sidecar as a service, and prints the values your website needs — cross-platform, with a
|
||||
`doctor` afterwards to tell a copied file from a working bridge:
|
||||
|
||||
```bash
|
||||
sudo ./runicgateway-installer-linux-x86_64 install
|
||||
```
|
||||
|
||||
Guide: [installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md).
|
||||
To place the overlay yourself instead — a host that cannot run the binary, or you want to see every
|
||||
file land — [Appendix A2](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#a2-deploy-the-plugin-overlay)
|
||||
is the same copy done by hand, and stays supported.
|
||||
|
||||
### `deploy.ps1` — the developer path
|
||||
|
||||
`deploy.ps1` deploys from a **working tree**, which is what you want while writing plugin code and
|
||||
is the one thing the installer cannot do (it deploys from a release):
|
||||
|
||||
```powershell
|
||||
.\deploy.ps1 -ServerPath <servuo> -Verify # show what would change
|
||||
.\deploy.ps1 -ServerPath <servuo> # write
|
||||
```
|
||||
|
||||
It is Windows-only and stays developer-facing; it never installs the sidecar, registers a service,
|
||||
or checks the protocol pairing. Nothing shipped to an operator depends on it.
|
||||
|
||||
## Releases
|
||||
|
||||
Every merge to `main` that carries a releasable conventional commit (`feat:`, `fix:`, `perf:`, or a
|
||||
breaking change — a `docs:`/`chore:`-only merge deliberately cuts nothing) publishes a Gitea release:
|
||||
|
||||
```
|
||||
runicgateway-overlay-<ver>.tar.gz
|
||||
└── runicgateway-overlay/
|
||||
├── manifest.json
|
||||
├── overlay/ # exactly what deploy.ps1 would copy
|
||||
└── patches/ # the opt-in stock-file diffs + their companion sources
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
This is a **source** tarball, not a build — nothing here is compiled. It exists so the installer can
|
||||
deploy the plugin onto a shard host that has no git and no Gitea credentials.
|
||||
|
||||
`manifest.json` is what makes the tarball self-describing:
|
||||
|
||||
```json
|
||||
{
|
||||
"component": "servuo-plugins-overlay",
|
||||
"version": "0.1.0",
|
||||
"commit": "968b526…",
|
||||
"protocol": 3,
|
||||
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
|
||||
"files": { "overlay/Config/Bridge.cfg": "32718424…", … }
|
||||
}
|
||||
```
|
||||
|
||||
- **`protocol`** comes from `overlay.toml` and is the plugin half of the compatibility contract. The
|
||||
plugin announces no version on the wire and none is queryable before ServUO boots, so this
|
||||
declaration is the only way the installer can check it against the sidecar's `PROTOCOL_VERSION`
|
||||
*before* an operator installs the pair. **When the protocol changes, bump it in the same PR that
|
||||
changes the emitters.**
|
||||
- **`files`** carries a SHA256 per shipped file, so a deployment can later tell "an operator edited
|
||||
this" from "the overlay moved on".
|
||||
|
||||
The version is derived from git tags — there is no version to maintain by hand and no bump commit,
|
||||
so this workflow never pushes to `main`.
|
||||
|
||||
The tarball is byte-reproducible for a given tree (`tar --sort=name`, pinned mtime and ownership), so
|
||||
its checksum changes only when its contents do.
|
||||
|
||||
## Status
|
||||
|
||||
| Phase | State |
|
||||
|------:|-------|
|
||||
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
||||
| 1 — transport (`BridgeLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §11** |
|
||||
| 2 — event streams (`BridgeEvents`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §12** |
|
||||
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §13** |
|
||||
| 4 — request/response (`BridgeRequests`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §14** |
|
||||
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §15** |
|
||||
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §16** |
|
||||
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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/`.
|
||||
|
||||
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).
|
||||
|
||||
## Phase 0 — what it fixes
|
||||
|
||||
`ScriptCompiler.Compile()` runs `dotnet build Scripts/Scripts.csproj -c Release`, prints the output, and **never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Because that build passed no `Platform`, MSBuild defaulted to `AnyCPU`, and `Scripts.csproj` gated both `OutputPath` and `DefineConstants` on `Configuration|Platform == Release|x64`. So:
|
||||
|
||||
- the DLL landed in `Scripts/bin/Release/` while the core loads `Scripts.dll` from the base directory, and
|
||||
- `TRACE;NEWTIMERS;ServUO` went undefined, so XmlSpawner compiled its non-ServUO branches.
|
||||
|
||||
Runtime script compilation therefore had no effect, silently. `overlay/Scripts/Scripts.csproj` conditions both property groups on `Configuration` alone.
|
||||
|
||||
`Server.csproj` is deliberately left alone: nothing under `Server/` uses those symbols, and giving it `OutputPath=..\` would make the boot-time build try to overwrite the running `ServUO.exe`.
|
||||
|
||||
## The plugin (Phase 1)
|
||||
|
||||
`overlay/Scripts/Custom/Bridge/`:
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `BridgeConfig.cs` | Reads `Config/Bridge.cfg` in `Configure()`, before `World.Load`. |
|
||||
| `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. |
|
||||
| `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. |
|
||||
| `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. |
|
||||
| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
|
||||
| `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. |
|
||||
| `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. |
|
||||
| `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. |
|
||||
| `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. |
|
||||
| `BridgeTownCrier.cs` | Town-crier news (Phase 6): inbound `towncrier.add` / `remove` into the global crier list, with abuse caps. |
|
||||
|
||||
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
|
||||
|
||||
## Testing
|
||||
|
||||
`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error.
|
||||
|
||||
```powershell
|
||||
.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log
|
||||
```
|
||||
|
||||
`tools/stub_sidecar_request.ps1` additionally *sends* inbound requests (`char.request`, `account.roster`, `vendor.snapshot`, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4.
|
||||
|
||||
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)) 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 [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. See `tools/scaffolding/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Runic Gateway is free software, licensed under the **GNU General Public License
|
||||
v3.0 or later** — see [LICENSE.md](LICENSE.md).
|
||||
|
||||
Copyright (C) 2026 Runic Gateway
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later
|
||||
version. It is distributed WITHOUT ANY WARRANTY; without even the implied
|
||||
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) (note
|
||||
the **AI-usage disclosure** requirement) and our
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately per
|
||||
[SECURITY.md](SECURITY.md).
|
||||
50
SECURITY.md
Normal file
50
SECURITY.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Security Policy
|
||||
|
||||
Thank you for helping keep Runic Gateway and its users safe.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public issues, pull
|
||||
requests, or the wiki.** A public report tips off attackers before a fix is
|
||||
available.
|
||||
|
||||
Instead, report privately by email to:
|
||||
|
||||
**whitlocktech@gmail.com**
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
- The repository and component affected.
|
||||
- The type of issue (e.g. authentication bypass, injection, secret exposure,
|
||||
remote code execution, denial of service).
|
||||
- Step-by-step instructions to reproduce, and a proof-of-concept if you have one.
|
||||
- The impact — what an attacker could do with it.
|
||||
- Any suggested remediation.
|
||||
|
||||
You will receive an acknowledgement of your report, typically within a few days.
|
||||
We will keep you informed as we investigate and work toward a fix, and we are
|
||||
happy to credit you in the release notes once the issue is resolved (let us know
|
||||
if you would prefer to remain anonymous).
|
||||
|
||||
## Scope
|
||||
|
||||
Runic Gateway is a self-hosted platform made up of several components:
|
||||
|
||||
| Component | Repo | Network exposure |
|
||||
|---|---|---|
|
||||
| Website (site + admin + API) | `RunicGateway/website` | Internet-facing (behind a reverse proxy) |
|
||||
| uo-link sidecar | `RunicGateway/link` | The only network-facing part of the game bridge |
|
||||
| ServUO plugin | `RunicGateway/servuo-plugins` | Loopback only — dials the sidecar on `127.0.0.1` |
|
||||
| Documentation | `RunicGateway/docs` | Content only |
|
||||
|
||||
Because instances are self-hosted, the security of any given deployment also
|
||||
depends on how it is configured and operated — strong secrets (`JWT_SECRET`,
|
||||
`SECRET_ENC_KEY`, database and admin passwords), a correctly configured reverse
|
||||
proxy and `TRUST_PROXY`, and keeping the shard itself unreachable from the
|
||||
internet (only the sidecar should be exposed). See each repo's README for the
|
||||
security model.
|
||||
|
||||
## Supported versions
|
||||
|
||||
This project is developed continuously and does not maintain long-term release
|
||||
branches. Security fixes land on `main`; please run a recent build.
|
||||
@@ -9,8 +9,8 @@
|
||||
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
|
||||
.\deploy.ps1 -ServerPath <servuo> -Verify
|
||||
.\deploy.ps1 -ServerPath <servuo>
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
|
||||
42
overlay.toml
Normal file
42
overlay.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Release metadata for the deployable overlay.
|
||||
#
|
||||
# Consumed by .gitea/workflows/release.yml, which folds these values into the
|
||||
# manifest.json shipped inside runicgateway-overlay-<ver>.tar.gz. The Runic
|
||||
# Gateway installer reads that manifest to decide what it is deploying and
|
||||
# whether it is compatible with the sidecar it is about to install
|
||||
# (docs/installer/PLAN.md §5 Phase 0, §7.1).
|
||||
#
|
||||
# There is deliberately NO version key here. The release version is derived from
|
||||
# git tags and conventional commits by the release workflow, so there is no bump
|
||||
# commit to keep in sync and no way for this file to disagree with the tag.
|
||||
|
||||
# ── The loopback wire-protocol version this overlay speaks ───────────────────
|
||||
#
|
||||
# This is the plugin half of the compatibility contract. It MUST equal the
|
||||
# sidecar's PROTOCOL_VERSION (link/sidecar/src/main.rs) for a deployment to
|
||||
# work: the sidecar rejects a mismatch with 409 rather than mis-parsing.
|
||||
#
|
||||
# The C# plugin has no queryable version before ServUO boots — it does not
|
||||
# announce one on the wire — so this declaration is the only thing that lets the
|
||||
# installer's bundle CI check the pair BEFORE an operator installs them
|
||||
# (docs/installer/PLAN.md §2.6, §7.1 gate 1). Keeping it honest is therefore a
|
||||
# manual duty: when the protocol changes, bump it here in the same PR that
|
||||
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
|
||||
#
|
||||
# Current: 6 — see docs/link/v6.md (idempotency keys on inbound commands, champ.boss.killed).
|
||||
protocol = 7
|
||||
|
||||
# ── ServUO compatibility ─────────────────────────────────────────────────────
|
||||
#
|
||||
# The base overlay (Config/Bridge.cfg + Scripts/Custom/Bridge/*.cs) only ADDS
|
||||
# files and is expected to work on any reasonably current ServUO. This is the
|
||||
# oldest version it is known good on.
|
||||
min_servuo_version = "57.4"
|
||||
|
||||
# The patches/ tier is a different matter: those are unified diffs against STOCK
|
||||
# ServUO files, so they are verified against exactly one version and nothing
|
||||
# else. On any other version the installer skips the whole tier with a warning
|
||||
# and completes the base install (docs/installer/PLAN.md §1, §2.2) — losing
|
||||
# vendor.sale events and in-game moderation-audit forwarding, but never
|
||||
# half-patching an unknown tree.
|
||||
patches_verified_against = "57.4"
|
||||
@@ -14,7 +14,7 @@ Port=7788
|
||||
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.
|
||||
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
|
||||
StatSweepSeconds=30
|
||||
DecaySweepSeconds=60
|
||||
EconomySweepSeconds=300
|
||||
@@ -29,6 +29,114 @@ ChampSweepSeconds=10
|
||||
# support queue; the full open queue is also available on demand via pages.snapshot.
|
||||
PageSweepSeconds=5
|
||||
|
||||
# Guild roster poll (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
|
||||
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
|
||||
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
|
||||
GuildSweepSeconds=60
|
||||
|
||||
# Members per guild.roster frame (Protocol 4). A roster is the only fat frame the bridge emits
|
||||
# (~69 bytes per member) and the sidecar reads a line with no length bound, so this caps it; a
|
||||
# guild over the cap is split across continuation frames carrying seq/more. 500 members is ~35 KB,
|
||||
# past any realistic guild, so the split path is an edge case rather than the norm.
|
||||
GuildRosterMembersPerLine=500
|
||||
|
||||
# Guilds that may emit a roster in one sweep. Every guild looks changed right after a sidecar
|
||||
# reconnect, and building hundreds of fat frames in a single Core-thread pass is exactly the stall
|
||||
# the bridge exists to avoid. The sweep re-arms itself every 2s while a baseline is draining, so
|
||||
# lowering this slows the catch-up without making the site wait a full sweep interval per batch.
|
||||
GuildRosterGuildsPerTick=25
|
||||
|
||||
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
|
||||
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
|
||||
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
|
||||
CitySweepSeconds=300
|
||||
|
||||
# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
|
||||
# interval and emitted as presence.online only when it changes. Region transitions come
|
||||
# through separately in real time as region.enter (EventSink.OnEnterRegion).
|
||||
PresenceSweepSeconds=30
|
||||
|
||||
# Housing registry poll. Every house is diffed on this interval to emit house.update /
|
||||
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
|
||||
HousingSweepSeconds=300
|
||||
|
||||
# Points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7). ServUO carries ~25 point
|
||||
# currencies (Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city loyalties,
|
||||
# the Doom/Khaldun/Kotl treasure systems, …). Each is diffed on this interval and emitted as
|
||||
# one points.board frame per system when its top N moves.
|
||||
#
|
||||
# Slow on purpose: these are month-scale standings, and ten of the systems keep a row for
|
||||
# every character ever created, so the pass is the widest read in the bridge. It is still
|
||||
# cheap — a single bounded pass, never a sort — but there is nothing to gain by hurrying it.
|
||||
PointsSweepSeconds=300
|
||||
|
||||
# Master switch for the boards. Off leaves char.profile points alone (see below).
|
||||
PointsLeaderboardEnabled=true
|
||||
|
||||
# How many players per board. Clamped to 1..100 — the frame is emitted PER SYSTEM, so a big
|
||||
# N is multiplied by ~25.
|
||||
PointsTopN=10
|
||||
|
||||
# Which systems to publish, as a comma-separated list of PointsType names, e.g.
|
||||
# PointsSystems=QueensLoyalty,CleanUpBritannia,VoidPool
|
||||
# Blank (the default) publishes whatever the shard itself shows on the in-game loyalty gump
|
||||
# (ShowOnLoyaltyGump), so a subsystem you add later gets a board without an edit here.
|
||||
# An unrecognized name is logged and ignored, never silently dropped.
|
||||
PointsSystems=
|
||||
|
||||
# Include a per-character "points" block in char.profile (the website character sheet). This
|
||||
# is a lookup across every published system's table, so it is the dominant cost of building a
|
||||
# profile; turn it off on a very large shard that does not want the sheet paying for it.
|
||||
PointsProfileEnabled=true
|
||||
|
||||
# Also compute each system's rank in that block. OFF by default and worth leaving off: a
|
||||
# points lookup stops at the character's own row, but a rank must count every row that beats
|
||||
# them, in every system, on every profile build. The website already derives rank from the
|
||||
# board for anyone in the top N.
|
||||
PointsProfileRank=false
|
||||
|
||||
# Player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8). Every player vendor's shop name,
|
||||
# owner, location and priced inventory, published as one vendor.listing frame per vendor so the
|
||||
# website can offer the search the in-game Vendor Search gump offers. Honours each player's own
|
||||
# in-game opt-out (the vendor's VendorSearch flag) — hide your vendor in game and it is hidden
|
||||
# on the site too.
|
||||
MarketEnabled=true
|
||||
|
||||
# Sweep interval. UNLIKE every other sweep here, a tick does NOT walk the whole world: it
|
||||
# inventories at most MarketSweepBatch vendors and a persistent cursor round-robins through the
|
||||
# rest, so the per-tick cost is bounded by the batch rather than by how many vendors exist. Full
|
||||
# coverage takes ceil(vendors / batch) x MarketSweepSeconds — 500 vendors at the defaults is one
|
||||
# complete pass every 20 minutes, and the site labels the data with how stale it may be.
|
||||
#
|
||||
# Lower this (or raise the batch) for faster coverage; both trade directly against per-tick cost,
|
||||
# and the expensive part is the item walk, which recurses into every container a vendor is selling.
|
||||
MarketSweepSeconds=60
|
||||
MarketSweepBatch=25
|
||||
|
||||
# Per-vendor listing cap, after which the frame carries "truncated": true. A commodity reseller
|
||||
# with thousands of stacked resources is a real thing, and an uncapped frame for one is measured
|
||||
# in megabytes. Clamped to 1..5000.
|
||||
MarketMaxListings=250
|
||||
|
||||
# Shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5). One world.ruleset frame — expansion, which
|
||||
# systems are on, skill/stat caps, account and house limits, champion scroll rules —
|
||||
# emitted on every sidecar connect (and on [bridge reload), so the website's rules page
|
||||
# cannot drift from the server. Not a sweep: it changes only when you edit a .cfg.
|
||||
#
|
||||
# The frame is built from an explicit allowlist of keys in BridgeRuleset.cs. Server.cfg,
|
||||
# Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg, Compiler.cfg, Reports.cfg and Client.cfg
|
||||
# are never read.
|
||||
RulesetEnabled=true
|
||||
|
||||
# The one connection detail the bridge will publish, e.g. play.myshard.com,2593. Blank
|
||||
# (the default) omits it entirely. Server.cfg's Address/Listen/Port are NEVER published —
|
||||
# if you want a connect string on the site, put it here deliberately.
|
||||
PublicConnectAddress=
|
||||
|
||||
# Include the save/restart schedule (AutoSave frequency, AutoRestart hour) in the frame.
|
||||
# Turn off if you would rather not advertise a predictable restart window.
|
||||
RulesetIncludeSchedule=true
|
||||
|
||||
# Shown to a player when they run [link. The website page where they enter the code.
|
||||
LinkUrl=https://yoursite/link
|
||||
|
||||
@@ -39,6 +147,15 @@ TownCrierMaxLineLength=200
|
||||
TownCrierMaxActive=20
|
||||
TownCrierMaxDurationSec=86400
|
||||
|
||||
# Town Cryer news gump. Website articles (news.add) become entries in the modern Town
|
||||
# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines
|
||||
# above. The article title is also proclaimed by the criers (announce defaults on). Caps
|
||||
# are defense in depth on top of the loopback trust boundary.
|
||||
NewsMaxTitleLength=100
|
||||
NewsMaxBodyLength=2000
|
||||
NewsMaxExternal=20
|
||||
NewsAnnounceDurationSec=300
|
||||
|
||||
# Admin write plane (staff moderation from the website). OFF by default: the whole
|
||||
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
|
||||
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
|
||||
@@ -57,6 +174,127 @@ AdminReasonMaxLength=400
|
||||
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
|
||||
AdminBanMaxDurationSec=31536000
|
||||
|
||||
# Account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A). Which side may mint game accounts:
|
||||
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
|
||||
# (else an in-game login of any new name still mints an account).
|
||||
# game — the game server is the authority; website account.create is refused.
|
||||
# hybrid — either side may create (the default).
|
||||
# The bridge governs only the account.create verb; the in-game first-login auto-create is
|
||||
# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
|
||||
# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
|
||||
# (the safest — no website creation).
|
||||
SignupMode=hybrid
|
||||
|
||||
# Master switch for the account.create verb. Absent, it follows the mode (on unless
|
||||
# SignupMode=game). Set explicitly to force it on or off regardless of mode.
|
||||
AccountCreateEnabled=true
|
||||
|
||||
# Fail closed if account.create omits a usable browser IP. The per-IP cap
|
||||
# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
|
||||
# than waved through. Turn off only for a deployment that deliberately does not cap website
|
||||
# signups by IP (MaxAccountsPerIP still applies in-game either way).
|
||||
RequireIpForCreate=true
|
||||
|
||||
# Length caps on a website-supplied username / password, checked before the account is made.
|
||||
AccountNameMaxLength=16
|
||||
AccountPasswordMaxLength=30
|
||||
|
||||
# ── The event plane (docs/link/v6.md 8) ──────────────────────────────────────
|
||||
#
|
||||
# Leases and the participation ledger: the website holding a live config value for a bounded
|
||||
# time, and this shard counting who took part in a run. Both are driven on a SCHEDULE, by an
|
||||
# event the website starts unattended.
|
||||
#
|
||||
# This is deliberately NOT AdminWriteEnabled. Turning the admin plane on is consenting to
|
||||
# staff moderation driven from a screen a human is looking at; turning this on is consenting
|
||||
# to the website changing and watching your world at four in the morning. One switch could
|
||||
# not honestly express both.
|
||||
#
|
||||
# A lease always carries its own deadline and this shard restores the baseline when it
|
||||
# passes, whether or not the website is ever heard from again -- and a lease is never written
|
||||
# to disk, so a restart puts every leased value back too.
|
||||
EventsEnabled=false
|
||||
|
||||
# The longest this shard will hold a lease, whatever the website asks for. Thirty days.
|
||||
# A longer request is REFUSED rather than shortened: a silently-clamped lease would leave the
|
||||
# two halves disagreeing about when the world comes back.
|
||||
LeaseMaxDurationSec=2592000
|
||||
|
||||
# How long a finished lease stays listed after its deadline restored it, so a teardown that
|
||||
# arrives late still gets a definite verdict instead of finding nothing.
|
||||
LeaseGraceSec=86400
|
||||
|
||||
# How often the participation sweep credits everyone standing in a run's area, and what one
|
||||
# kill inside it is worth against one minute of being there.
|
||||
ParticipationSweepSeconds=30
|
||||
ParticipationKillWeight=5.0
|
||||
|
||||
# Bounds. Runs counted at once, members per run, and the widest area an event may declare.
|
||||
ParticipationMaxRuns=8
|
||||
ParticipationMaxMembers=2000
|
||||
ParticipationMaxRadius=300
|
||||
|
||||
# How long a closed run's tally stays readable before this shard forgets it, and how many
|
||||
# members one snapshot resolves before yielding the Core thread.
|
||||
ParticipationGraceSec=86400
|
||||
ParticipationSnapshotChunk=100
|
||||
|
||||
# ---- The world verbs (protocol 7) ----------------------------------------------------
|
||||
# What an event may PLACE in the world, all of it owned by the run that placed it and
|
||||
# deleted when the run tears down. Every ceiling here REFUSES rather than clamps: this
|
||||
# shard's bound exists for the case where the website is wrong, and a quiet clamp would
|
||||
# leave the two halves disagreeing about what was actually placed.
|
||||
#
|
||||
# The defaults are the EM Program's published quotas, because they are the only numbers
|
||||
# anyone has defended in public.
|
||||
|
||||
# Per CALL: creatures, enhanced "boss" variants, oracle NPCs and decoration items.
|
||||
EventsMaxCreatures=30
|
||||
EventsMaxBosses=4
|
||||
EventsMaxNpcs=5
|
||||
EventsMaxDecor=60
|
||||
|
||||
# The longest a temporary gate may stand. The shard closes it on its own when the time
|
||||
# passes, whether or not the website is ever heard from again.
|
||||
EventsMaxGateMinutes=240
|
||||
|
||||
# Per RUN, across every verb above. The per-call ceilings bound one request; this bounds
|
||||
# a run that calls a verb in a loop, which is the shape a runaway schedule takes.
|
||||
EventsMaxOwnedPerRun=200
|
||||
|
||||
# How far from the chosen spot things may be scattered.
|
||||
EventsMaxSpread=40
|
||||
|
||||
# How much harder than normal a "boss" may be made. EVENTS.md calls it an enhanced
|
||||
# regular mob, so this is low enough that the result is still the creature that was
|
||||
# picked.
|
||||
EventsMaxBossMultiplier=10.0
|
||||
|
||||
# The oracle NPC: how many keyword lines it answers to, how close a player must be to be
|
||||
# greeted and to be heard, and how often it will speak to the same player.
|
||||
EventsOracleMaxLines=5
|
||||
EventsOracleGreetRange=4
|
||||
EventsOracleSpeechRange=8
|
||||
EventsOracleGreetCooldownSec=60
|
||||
EventsOracleAnswerCooldownSec=5
|
||||
|
||||
# How often expired gates are collected and rows for objects the world has already lost
|
||||
# are pruned.
|
||||
EventsSweepSeconds=30
|
||||
|
||||
# Item grants (Phase 12b). The first bounds how many characters one grant may reach --
|
||||
# the run's participation ledger is the recipient list, so this is a bound on the size of
|
||||
# an event rather than on a number somebody typed. The second bounds one hand.
|
||||
# Both REFUSE rather than clamp: the website records what was handed out.
|
||||
EventsMaxGrantPerRun=200
|
||||
EventsMaxGrantStack=1000
|
||||
|
||||
# The shortest gap between world saves, counted from the last save by anybody --
|
||||
# ServUO's own autosave included. A save stops the world, so this is a rate limit rather
|
||||
# than a cap, and a save asked for too soon is refused rather than queued: a queued save
|
||||
# would land at a moment nobody chose. Set to 0 to allow a save at any time.
|
||||
EventsMinSaveIntervalSec=300
|
||||
|
||||
# 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
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Server.Custom.Bridge
|
||||
/// 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
|
||||
/// link.confirm is trusted only because the socket is loopback-only (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2); if the
|
||||
/// sidecar ever moves off-host, gate it behind a shared secret.
|
||||
/// </summary>
|
||||
public static class BridgeAccountLink
|
||||
@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
|
||||
return;
|
||||
|
||||
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
|
||||
CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
|
||||
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
|
||||
|
||||
// Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
|
||||
@@ -127,6 +128,53 @@ namespace Server.Custom.Bridge
|
||||
url, (int)CodeTtl.TotalMinutes);
|
||||
}
|
||||
|
||||
// ---- [unlink ----
|
||||
|
||||
[Usage("unlink")]
|
||||
[Description("Unlinks this game account from your website account.")]
|
||||
private static void OnUnlinkCommand(CommandEventArgs e)
|
||||
{
|
||||
Unlink(e.Mobile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so
|
||||
/// the website can reconcile a player-initiated unlink. Player-scoped (own account only),
|
||||
/// so it needs no access floor. After unlinking, [link works again.
|
||||
/// </summary>
|
||||
public static void Unlink(Mobile m)
|
||||
{
|
||||
if (m == null)
|
||||
return;
|
||||
|
||||
var acct = m.Account as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
m.SendMessage("Bridge: no account on this character.");
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = acct.GetTag(Tag);
|
||||
if (existing == null)
|
||||
{
|
||||
m.SendMessage("Your account is not linked to a website account.");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.RemoveTag(Tag);
|
||||
DropCodesFor(acct.Username); // drop any pending codes so nothing dangles
|
||||
|
||||
BridgeLink.Emit(BridgeJson.Begin("account.unlinked")
|
||||
.Str("origin", "in-game")
|
||||
.Str("account", acct.Username)
|
||||
.Str("websiteUserId", existing)
|
||||
.Str("char", m.Name)
|
||||
.End());
|
||||
|
||||
m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing);
|
||||
}
|
||||
|
||||
// ---- inbound link.confirm ----
|
||||
|
||||
private static void OnLinkConfirm(Dictionary<string, object> o)
|
||||
|
||||
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Misc;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The account provisioning plane (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A): website-driven account
|
||||
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
|
||||
/// is unchanged.
|
||||
///
|
||||
/// account.create — mint a game account and link it to a website user in one step.
|
||||
/// account.unlink — sever the WebsiteUserId tie from the website side.
|
||||
///
|
||||
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
|
||||
/// Timer.DelayCall first), so they touch accounts freely.
|
||||
///
|
||||
/// Trust model matches the admin plane (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization lives on
|
||||
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
|
||||
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
|
||||
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
|
||||
/// AccountCreateEnabled.
|
||||
/// </summary>
|
||||
public static class BridgeAccounts
|
||||
{
|
||||
private const string Tag = "WebsiteUserId";
|
||||
|
||||
// Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an
|
||||
// in-game one (AccountHandler.cs). Kept local because that array is private.
|
||||
private static readonly char[] ForbiddenChars =
|
||||
{
|
||||
'<', '>', ':', '"', '/', '\\', '|', '?', '*', ' '
|
||||
};
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("account.create", OnCreate);
|
||||
BridgeBoot.RegisterHandler("account.unlink", OnUnlink);
|
||||
}
|
||||
|
||||
// ---- account.create ----
|
||||
|
||||
/// <summary>
|
||||
/// Creates a game account and links it to the given website user. Refused unless the
|
||||
/// signup mode allows website creation. Enforces the same username/password character
|
||||
/// safety and per-IP cap as ServUO's in-game create path; the password never leaves the
|
||||
/// process in any reply, audit, or log.
|
||||
/// </summary>
|
||||
private static void OnCreate(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var actor = BridgeJson.GetString(o, "actor");
|
||||
const string action = "create";
|
||||
|
||||
if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game)
|
||||
{
|
||||
Err(reqId, action, "signups disabled for this mode");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||
{
|
||||
Err(reqId, action, "missing actor");
|
||||
return;
|
||||
}
|
||||
|
||||
var account = BridgeJson.GetString(o, "account");
|
||||
var password = BridgeJson.GetString(o, "password");
|
||||
var webId = BridgeJson.GetString(o, "websiteUserId");
|
||||
var ipStr = BridgeJson.GetString(o, "ip");
|
||||
|
||||
if (String.IsNullOrEmpty(account))
|
||||
{
|
||||
Err(reqId, action, "missing account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(password))
|
||||
{
|
||||
Err(reqId, action, "missing password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(webId))
|
||||
{
|
||||
Err(reqId, action, "missing websiteUserId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.Length > BridgeConfig.AccountNameMaxLength ||
|
||||
password.Length > BridgeConfig.AccountPasswordMaxLength)
|
||||
{
|
||||
Err(reqId, action, "username or password too long");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsSafeUsername(account) || !IsSafePassword(password))
|
||||
{
|
||||
Err(reqId, action, "invalid username/password");
|
||||
return;
|
||||
}
|
||||
|
||||
// Collision: the only correct resolution of a website/in-game race for a name.
|
||||
if (Accounts.GetAccount(account) != null)
|
||||
{
|
||||
Err(reqId, action, "account already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is
|
||||
// exempt in IPLimiter, so accepting it would silently bypass the cap.
|
||||
IPAddress ip;
|
||||
bool haveIp = TryParseIp(ipStr, out ip);
|
||||
|
||||
if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip)))
|
||||
{
|
||||
Err(reqId, action, "client ip required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (haveIp && !AccountHandler.CanCreate(ip))
|
||||
{
|
||||
Err(reqId, action, "ip account limit reached");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create + link. new Account self-registers (Accounts.Add) and hashes the password per
|
||||
// the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an
|
||||
// in-game first-login does; the tag persists on the next world save.
|
||||
var acct = new Account(account, password);
|
||||
|
||||
if (haveIp)
|
||||
acct.LogAccess(ip);
|
||||
|
||||
acct.SetTag(Tag, webId);
|
||||
|
||||
Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}",
|
||||
actor, account, webId, haveIp ? ip.ToString() : "-");
|
||||
|
||||
BridgeLink.Emit(AuditBegin(action, actor, account)
|
||||
.Str("websiteUserId", webId)
|
||||
.End());
|
||||
|
||||
var sb = BridgeJson.Begin("account.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action).Str("account", account).Str("websiteUserId", webId);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- account.unlink ----
|
||||
|
||||
/// <summary>
|
||||
/// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the
|
||||
/// Owner floor keeps a protected staff account unreachable from the web.
|
||||
/// </summary>
|
||||
private static void OnUnlink(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var actor = BridgeJson.GetString(o, "actor");
|
||||
const string action = "unlink";
|
||||
|
||||
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
|
||||
{
|
||||
Err(reqId, action, "missing actor");
|
||||
return;
|
||||
}
|
||||
|
||||
var acct = BridgeAdmin.ResolveTargetAccount(o);
|
||||
if (acct == null)
|
||||
{
|
||||
Err(reqId, action, "unknown or accountless target");
|
||||
return;
|
||||
}
|
||||
|
||||
if (BridgeAdmin.Protected(acct))
|
||||
{
|
||||
Err(reqId, action, "target is protected staff; refused");
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = acct.GetTag(Tag);
|
||||
if (existing == null)
|
||||
{
|
||||
Err(reqId, action, "not linked");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.RemoveTag(Tag);
|
||||
|
||||
Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})",
|
||||
actor, acct.Username, existing);
|
||||
|
||||
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
|
||||
.Str("websiteUserId", existing)
|
||||
.End());
|
||||
|
||||
var sb = BridgeJson.Begin("account.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action).Str("account", acct.Username);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static void Err(string reqId, string action, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("account.error");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
if (action != null) sb.Str("action", action);
|
||||
sb.Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
|
||||
/// admin.audit. Never carries the password.
|
||||
/// </summary>
|
||||
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
||||
{
|
||||
return BridgeJson.Begin("account.audit")
|
||||
.Str("origin", "web")
|
||||
.Str("action", action)
|
||||
.Str("actor", "web:" + actor)
|
||||
.Str("target", target);
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the username safety rules in AccountHandler.CreateAccount.</summary>
|
||||
private static bool IsSafeUsername(string un)
|
||||
{
|
||||
if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith("."))
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < un.Length; i++)
|
||||
{
|
||||
char c = un[i];
|
||||
if (c < 0x20 || c >= 0x7F || IsForbidden(c))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the password safety rules in AccountHandler.CreateAccount.</summary>
|
||||
private static bool IsSafePassword(string pw)
|
||||
{
|
||||
for (int i = 0; i < pw.Length; i++)
|
||||
{
|
||||
char c = pw[i];
|
||||
if (c < 0x20 || c >= 0x7F)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsForbidden(char c)
|
||||
{
|
||||
for (int i = 0; i < ForbiddenChars.Length; i++)
|
||||
if (c == ForbiddenChars[i])
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseIp(string s, out IPAddress ip)
|
||||
{
|
||||
ip = null;
|
||||
|
||||
if (String.IsNullOrEmpty(s))
|
||||
return false;
|
||||
|
||||
return IPAddress.TryParse(s.Trim(), out ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace Server.Custom.Bridge
|
||||
/// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
|
||||
/// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
|
||||
///
|
||||
/// Trust model (docs/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
|
||||
/// Trust model (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
|
||||
/// these commands are gated there behind admin/moderator roles. The shard trusts the
|
||||
/// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
|
||||
/// CoOwner authority. Its one hard floor is <see cref="Protected"/>: a command refuses any
|
||||
@@ -248,7 +248,7 @@ namespace Server.Custom.Bridge
|
||||
/// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
|
||||
/// connected dashboard so the website's moderation log stays complete regardless of which
|
||||
/// client issued the action. The in-game counterpart (origin=in-game) is emitted from
|
||||
/// BridgeEvents; see docs/ADMIN_CONTROLS.md §5.5.
|
||||
/// BridgeEvents; see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
|
||||
/// </summary>
|
||||
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
||||
{
|
||||
@@ -283,9 +283,10 @@ namespace Server.Custom.Bridge
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the command's target account, by "serial" (a player mobile's account) or by
|
||||
/// "account" (username). Returns null if neither resolves to a real account.
|
||||
/// "account" (username). Returns null if neither resolves to a real account. Public so the
|
||||
/// account plane (unlink) resolves targets the same way the moderation plane does.
|
||||
/// </summary>
|
||||
private static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
public static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
{
|
||||
var serialStr = BridgeJson.GetString(o, "serial");
|
||||
if (serialStr != null)
|
||||
@@ -301,9 +302,10 @@ namespace Server.Custom.Bridge
|
||||
/// <summary>
|
||||
/// The one shard-side safety floor. Protects any account whose effective access level —
|
||||
/// the account's own or the highest of its characters' — is at or above the configured
|
||||
/// floor. Even under CoOwner authority the Owner is never reachable from the web.
|
||||
/// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
|
||||
/// so the account plane (unlink) enforces the identical floor.
|
||||
/// </summary>
|
||||
private static bool Protected(Account acct)
|
||||
public static bool Protected(Account acct)
|
||||
{
|
||||
var lvl = acct.AccessLevel;
|
||||
|
||||
|
||||
@@ -133,7 +133,42 @@ namespace Server.Custom.Bridge
|
||||
return;
|
||||
}
|
||||
|
||||
// Protocol 6. A command may carry an `idempotencyKey`, and one that does is executed at
|
||||
// most once: a repeat is answered with the original reply rather than re-run. The gate
|
||||
// is here rather than in each handler so it covers every inbound kind — including the
|
||||
// ones a later protocol adds, which is the half that is easy to forget. A command with
|
||||
// no key behaves exactly as it did before, which is what keeps the admin screens (which
|
||||
// send none) unchanged.
|
||||
var idempotencyKey = BridgeJson.GetString(obj, "idempotencyKey");
|
||||
|
||||
if (idempotencyKey == null)
|
||||
{
|
||||
handler(obj);
|
||||
return;
|
||||
}
|
||||
|
||||
if (BridgeIdempotency.Intercept(idempotencyKey, obj))
|
||||
return; // already answered: a replay of the original reply, or bridge.busy
|
||||
|
||||
string error = null;
|
||||
|
||||
try
|
||||
{
|
||||
handler(obj);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallowed deliberately, and only on the keyed path: the key must be closed out
|
||||
// with a definite answer (see BridgeIdempotency's header) rather than left in
|
||||
// flight by an exception unwinding past Finish. Unkeyed commands still throw the
|
||||
// way they always have.
|
||||
error = ex.Message;
|
||||
Console.WriteLine("[Bridge] handler for '{0}' threw: {1}", kind, ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BridgeIdempotency.Finish(idempotencyKey, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPing(Dictionary<string, object> o)
|
||||
@@ -161,8 +196,20 @@ namespace Server.Custom.Bridge
|
||||
BridgeSweeps.Rearm();
|
||||
BridgePages.Rearm();
|
||||
BridgeChamps.Rearm();
|
||||
BridgeSocial.Rearm();
|
||||
BridgeGovernance.Rearm();
|
||||
BridgePresence.Rearm();
|
||||
BridgeHousing.Rearm();
|
||||
BridgePoints.Rearm();
|
||||
BridgeMarket.Rearm();
|
||||
BridgeParticipation.Rearm();
|
||||
BridgeLeases.Rearm();
|
||||
BridgeWorld.Rearm();
|
||||
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
|
||||
// .cfg wants the change on the site now, not after a shard restart.
|
||||
BridgeRuleset.Emit();
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; ruleset re-emitted; endpoint changes take effect on reconnect.");
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
@@ -173,9 +220,23 @@ namespace Server.Custom.Bridge
|
||||
case "sweepnow":
|
||||
BridgeSweeps.SweepOnce();
|
||||
BridgeChamps.SweepOnce();
|
||||
BridgeSocial.SweepOnce();
|
||||
BridgeGovernance.SweepOnce();
|
||||
BridgePresence.SweepOnce();
|
||||
BridgeHousing.SweepOnce();
|
||||
BridgePoints.SweepOnce();
|
||||
BridgeMarket.SweepOnce();
|
||||
BridgeParticipation.SweepOnce();
|
||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -186,7 +247,19 @@ namespace Server.Custom.Bridge
|
||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,17 @@ namespace Server.Custom.Bridge
|
||||
// Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
|
||||
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
||||
|
||||
private static long _sweeps, _emitted, _removed;
|
||||
// Protocol 6. Which spawn a live champion belongs to, refreshed by the sweep. The kill itself
|
||||
// is detected by TYPE (see OnCreatureDeath), so this map only ever supplies CONTEXT — which
|
||||
// altar, at what level. A boss that popped and died inside one sweep interval is still
|
||||
// reported; it simply arrives without its spawn.
|
||||
private static readonly Dictionary<Serial, Serial> _bossOf = new Dictionary<Serial, Serial>();
|
||||
|
||||
// How many damage entries a kill reports. Deep enough that a real champion fight's meaningful
|
||||
// contributors are all present, shallow enough that the frame stays one line on the wire.
|
||||
private const int MaxDamagers = 20;
|
||||
|
||||
private static long _sweeps, _emitted, _removed, _bossKills;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
@@ -56,12 +66,24 @@ namespace Server.Custom.Bridge
|
||||
// Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
|
||||
// independently of the shard rebuilds its state within one sweep.
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
|
||||
// Protocol 6. A boss defeat was previously only INFERABLE — champ.update going bossUp
|
||||
// true then false, correlated against a mob.killed nearby — and that inference is both
|
||||
// fragile and silent about who did the work. It is a real moment in a shard's week and
|
||||
// an event's phase condition wants to name it, so it becomes a kind of its own.
|
||||
EventSink.CreatureDeath += OnCreatureDeath;
|
||||
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
_last.Clear();
|
||||
|
||||
// _bossOf is deliberately NOT cleared. It is a fact about the world, not a diff cache:
|
||||
// dropping it on a sidecar reconnect would lose the spawn attribution for a boss that is
|
||||
// up right now, and it refills from the sweep only if that boss's record happens to
|
||||
// change again before it dies.
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
@@ -82,8 +104,160 @@ namespace Server.Custom.Bridge
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})",
|
||||
_sweeps, _emitted, _removed, _last.Count);
|
||||
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3} bossKills={4} bossesUp={5})",
|
||||
_sweeps, _emitted, _removed, _last.Count, _bossKills, _bossOf.Count);
|
||||
}
|
||||
|
||||
// ---- champ.boss.killed (Protocol 6) ----
|
||||
|
||||
/// <summary>
|
||||
/// Fires for every creature death on the shard, so the first thing it does is decide
|
||||
/// this is not one. Detection is by TYPE — <c>BaseChampion</c>, which
|
||||
/// <c>BaseSeaChampion</c> derives from, so one check covers both families — with the
|
||||
/// sweep's map used only to name the altar. A boss that popped and died between two
|
||||
/// sweeps is therefore still reported; it simply arrives without a spawn.
|
||||
///
|
||||
/// The damage table is read here and nowhere else, because it exists here and nowhere
|
||||
/// else: ServUO discards a creature's damage entries with the creature, and the shard is
|
||||
/// the only party that ever sees them. Entries are reported whether or not ServUO
|
||||
/// considers them expired — expiry governs LOOTING RIGHTS, and someone who fought the
|
||||
/// first two thirds of a champion fight and then died took part in it regardless of what
|
||||
/// they are owed from the corpse.
|
||||
/// </summary>
|
||||
private static void OnCreatureDeath(CreatureDeathEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var boss = e.Creature;
|
||||
|
||||
if (boss == null)
|
||||
return;
|
||||
|
||||
Serial spawnSerial;
|
||||
bool attributed = _bossOf.TryGetValue(boss.Serial, out spawnSerial);
|
||||
|
||||
if (!(boss is BaseChampion) && !attributed)
|
||||
return;
|
||||
|
||||
_bossOf.Remove(boss.Serial);
|
||||
_bossKills++;
|
||||
|
||||
var spawn = attributed ? World.FindItem(spawnSerial) as ChampionSpawn : null;
|
||||
var name = String.IsNullOrEmpty(boss.Name) ? boss.GetType().Name : boss.Name;
|
||||
|
||||
var sb = BridgeJson.Begin("champ.boss.killed")
|
||||
.Str("category", boss is BaseSeaChampion ? "sea" : "champion")
|
||||
.Ser("bossSerial", boss.Serial)
|
||||
.Str("boss", name)
|
||||
.Str("bossType", boss.GetType().Name)
|
||||
.Str("map", boss.Map == null ? null : boss.Map.Name)
|
||||
.Num("x", boss.X).Num("y", boss.Y).Num("z", boss.Z);
|
||||
|
||||
// The altar's own record, when the kill could be attributed to one. `serial` is the
|
||||
// SPAWN here, matching champ.update, so a consumer can join the two without a rule
|
||||
// about which of two serials on the frame means what.
|
||||
if (spawn != null)
|
||||
{
|
||||
sb.Ser("serial", spawn.Serial)
|
||||
.Str("type", spawn.Type.ToString())
|
||||
.Num("level", spawn.Level);
|
||||
}
|
||||
|
||||
// A named region is what a phase condition can actually match on ("the boss in
|
||||
// Yew"); coordinates are not. Emitted alongside the coordinates rather than
|
||||
// instead, because large stretches of the map belong to no named region at all.
|
||||
//
|
||||
// **The innermost region here is ANONYMOUS, and the rig is the only thing that was
|
||||
// ever going to say so.** A champion killed in the middle of Britain produced a
|
||||
// frame with no region at all, because an active `ChampionSpawn` registers a
|
||||
// `ChampionSpawnRegion` over its own spawn area — constructed with a null name and
|
||||
// with the town region as its PARENT (`ChampionSpawn.cs`, its constructor). So the
|
||||
// most specific region containing a champion boss is, by construction, the one
|
||||
// region on the map guaranteed to have no name.
|
||||
//
|
||||
// It also explains why this looked fine for twenty seconds: region registration is
|
||||
// deferred, so a lookup immediately after the altar is placed still answers
|
||||
// "Britain" and one at the kill does not. A first read at spawn time would have
|
||||
// confirmed a bug into the design.
|
||||
//
|
||||
// Walking to the nearest NAMED ancestor is the general answer rather than a special
|
||||
// case for champions: a house region, a dungeon sub-region and a guarded-zone
|
||||
// overlay are all anonymous children of somewhere a player would name.
|
||||
var region = NamedRegionAt(boss.Location, boss.Map);
|
||||
|
||||
if (region != null)
|
||||
sb.Str("region", region);
|
||||
|
||||
if (e.Killer != null)
|
||||
sb.Actor("killer", e.Killer);
|
||||
|
||||
sb.Damagers("damagers", TopDamagers(boss), MaxDamagers);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A death handler must never be the thing that breaks a death.
|
||||
Console.WriteLine("[Bridge] champ.boss.killed threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player damage against this creature, highest first. Totals are summed per damager
|
||||
/// rather than trusted to be one entry each: ServUO's own registration folds repeat
|
||||
/// damage into an existing entry, but an entry that expired and was re-created leaves
|
||||
/// two, and a table that listed the same player twice would be read as two participants.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// The nearest NAMED region containing a point, walking outward from the most specific
|
||||
/// one, or null when nothing on the way out has a name.
|
||||
///
|
||||
/// Null rather than "" so the caller can leave the field off the frame entirely: a
|
||||
/// consumer reading `region: ""` cannot tell "outdoors, nowhere in particular" from
|
||||
/// "somewhere, but the shard would not say", and only one of those is true here.
|
||||
///
|
||||
/// The map's own default region terminates the walk with its parentless empty name, so
|
||||
/// a point in open countryside answers null without a special case.
|
||||
/// </summary>
|
||||
private static string NamedRegionAt(Point3D p, Map map)
|
||||
{
|
||||
if (map == null)
|
||||
return null;
|
||||
|
||||
for (var region = Region.Find(p, map); region != null; region = region.Parent)
|
||||
{
|
||||
if (!String.IsNullOrEmpty(region.Name))
|
||||
return region.Name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<KeyValuePair<Mobile, int>> TopDamagers(Mobile boss)
|
||||
{
|
||||
var totals = new Dictionary<Mobile, int>();
|
||||
|
||||
var entries = boss.DamageEntries;
|
||||
|
||||
if (entries != null)
|
||||
{
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var de = entries[i];
|
||||
|
||||
if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player)
|
||||
continue;
|
||||
|
||||
int running;
|
||||
totals.TryGetValue(de.Damager, out running);
|
||||
totals[de.Damager] = running + de.DamageGiven;
|
||||
}
|
||||
}
|
||||
|
||||
var ranked = totals.ToList();
|
||||
ranked.Sort((a, b) => b.Value.CompareTo(a.Value));
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
@@ -107,6 +281,15 @@ namespace Server.Custom.Bridge
|
||||
{
|
||||
if (s.Deleted)
|
||||
continue;
|
||||
|
||||
// Protocol 6. Remember which altar a live champion belongs to so its death can
|
||||
// name one. Recorded here rather than looked up at death because the lookup
|
||||
// would be a scan of World.Items on every creature death on the shard.
|
||||
var champion = s.Champion;
|
||||
|
||||
if (champion != null && !champion.Deleted)
|
||||
_bossOf[champion.Serial] = s.Serial;
|
||||
|
||||
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
|
||||
}
|
||||
|
||||
@@ -133,6 +316,19 @@ namespace Server.Custom.Bridge
|
||||
BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
|
||||
_removed++;
|
||||
}
|
||||
|
||||
// A defeated champion's attribution is consumed by OnCreatureDeath, but one deleted
|
||||
// by a GM or lost to a world reload never dies, so the map is swept too. Cheap: it
|
||||
// holds at most one entry per altar with a boss currently up.
|
||||
if (_bossOf.Count > 0)
|
||||
{
|
||||
var vanished = _bossOf.Keys
|
||||
.Where(k => { var m = World.FindMobile(k); return m == null || m.Deleted; })
|
||||
.ToList();
|
||||
|
||||
foreach (var k in vanished)
|
||||
_bossOf.Remove(k);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,18 @@ using System;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
|
||||
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
|
||||
/// the operator pairs with this (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §2).
|
||||
/// </summary>
|
||||
public enum SignupMode
|
||||
{
|
||||
Website, // website is the account authority; in-game auto-create should be off
|
||||
Game, // game server is the authority; account.create is refused
|
||||
Hybrid // either side may create
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
|
||||
/// reads as "Bridge.Port" here.
|
||||
@@ -19,6 +31,33 @@ namespace Server.Custom.Bridge
|
||||
public static int EconomySweepSeconds { get; private set; }
|
||||
public static int PageSweepSeconds { get; private set; }
|
||||
public static int ChampSweepSeconds { get; private set; }
|
||||
public static int GuildSweepSeconds { get; private set; }
|
||||
public static int CitySweepSeconds { get; private set; }
|
||||
public static int PresenceSweepSeconds { get; private set; }
|
||||
public static int HousingSweepSeconds { get; private set; }
|
||||
public static int PointsSweepSeconds { get; private set; }
|
||||
public static int MarketSweepSeconds { get; private set; }
|
||||
|
||||
// ---- guild rosters (Protocol 4) ----
|
||||
public static int GuildRosterMembersPerLine { get; private set; }
|
||||
public static int GuildRosterGuildsPerTick { get; private set; }
|
||||
|
||||
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
|
||||
public static bool MarketEnabled { get; private set; }
|
||||
public static int MarketSweepBatch { get; private set; }
|
||||
public static int MarketMaxListings { get; private set; }
|
||||
|
||||
// ---- points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7) ----
|
||||
public static bool PointsLeaderboardEnabled { get; private set; }
|
||||
public static int PointsTopN { get; private set; }
|
||||
public static string PointsSystems { get; private set; }
|
||||
public static bool PointsProfileEnabled { get; private set; }
|
||||
public static bool PointsProfileRank { get; private set; }
|
||||
|
||||
// ---- shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5) ----
|
||||
public static bool RulesetEnabled { get; private set; }
|
||||
public static string PublicConnectAddress { get; private set; }
|
||||
public static bool RulesetIncludeSchedule { get; private set; }
|
||||
|
||||
public static string LinkUrl { get; private set; }
|
||||
|
||||
@@ -27,12 +66,68 @@ namespace Server.Custom.Bridge
|
||||
public static int TownCrierMaxActive { get; private set; }
|
||||
public static int TownCrierMaxDurationSec { get; private set; }
|
||||
|
||||
// Town Cryer news gump (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16).
|
||||
public static int NewsMaxTitleLength { get; private set; }
|
||||
public static int NewsMaxBodyLength { get; private set; }
|
||||
public static int NewsMaxExternal { get; private set; }
|
||||
public static int NewsAnnounceDurationSec { get; private set; }
|
||||
|
||||
public static bool AdminWriteEnabled { get; private set; }
|
||||
public static AccessLevel AdminAccessFloor { get; private set; }
|
||||
public static int AdminBroadcastMaxLength { get; private set; }
|
||||
public static int AdminReasonMaxLength { get; private set; }
|
||||
public static int AdminBanMaxDurationSec { get; private set; }
|
||||
|
||||
// ---- the event plane (docs/link/v6.md §8, EVENTS_PLAN.md Phase 11b) ----
|
||||
//
|
||||
// **Its own gate, deliberately not AdminWriteEnabled** (org lead, 2026-09-04). Enabling the
|
||||
// admin plane is an operator consenting to staff moderation driven from the website - a
|
||||
// human pressing kick or ban on a screen. A lease and a participation ledger are the
|
||||
// website changing and watching the world on a SCHEDULE, unattended, at four in the
|
||||
// morning. Those are different consents, and one switch cannot express both.
|
||||
public static bool EventsEnabled { get; private set; }
|
||||
|
||||
public static int LeaseMaxDurationSec { get; private set; }
|
||||
public static int LeaseGraceSec { get; private set; }
|
||||
|
||||
public static int ParticipationSweepSeconds { get; private set; }
|
||||
public static double ParticipationKillWeight { get; private set; }
|
||||
public static int ParticipationMaxRuns { get; private set; }
|
||||
public static int ParticipationMaxMembers { get; private set; }
|
||||
public static int ParticipationMaxRadius { get; private set; }
|
||||
public static int ParticipationGraceSec { get; private set; }
|
||||
public static int ParticipationSnapshotChunk { get; private set; }
|
||||
|
||||
// The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a). Each of these is the shard's
|
||||
// OWN ceiling rather than a mirror of the module's budget dimension, and each REFUSES
|
||||
// rather than clamps -- BridgeLeases' argument for LeaseMaxDurationSec, unchanged: the
|
||||
// bound exists for the case where the website is wrong, and a quiet clamp would leave the
|
||||
// two halves disagreeing about what was actually placed.
|
||||
public static int EventsMaxCreatures { get; private set; }
|
||||
public static int EventsMaxBosses { get; private set; }
|
||||
public static int EventsMaxNpcs { get; private set; }
|
||||
public static int EventsMaxDecor { get; private set; }
|
||||
public static int EventsMaxGateMinutes { get; private set; }
|
||||
public static int EventsMaxOwnedPerRun { get; private set; }
|
||||
public static int EventsMaxSpread { get; private set; }
|
||||
public static double EventsMaxBossMultiplier { get; private set; }
|
||||
public static int EventsOracleMaxLines { get; private set; }
|
||||
public static int EventsOracleGreetRange { get; private set; }
|
||||
public static int EventsOracleSpeechRange { get; private set; }
|
||||
public static int EventsOracleGreetCooldownSec { get; private set; }
|
||||
public static int EventsOracleAnswerCooldownSec { get; private set; }
|
||||
public static int EventsSweepSeconds { get; private set; }
|
||||
public static int EventsMaxGrantPerRun { get; private set; }
|
||||
public static int EventsMaxGrantStack { get; private set; }
|
||||
public static int EventsMinSaveIntervalSec { get; private set; }
|
||||
|
||||
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
|
||||
public static SignupMode Signup { get; private set; }
|
||||
public static bool AccountCreateEnabled { get; private set; }
|
||||
public static bool RequireIpForCreate { get; private set; }
|
||||
public static int AccountNameMaxLength { get; private set; }
|
||||
public static int AccountPasswordMaxLength { get; private set; }
|
||||
|
||||
public static bool Enabled { get; private set; }
|
||||
|
||||
public static void Configure()
|
||||
@@ -60,6 +155,109 @@ namespace Server.Custom.Bridge
|
||||
if (ChampSweepSeconds < 1)
|
||||
ChampSweepSeconds = 1;
|
||||
|
||||
// Social/political sweeps (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Both change slowly, so the
|
||||
// defaults are unhurried; the pass is a handful of field reads over a small set.
|
||||
GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
|
||||
if (GuildSweepSeconds < 1)
|
||||
GuildSweepSeconds = 1;
|
||||
|
||||
// A roster line is the only fat frame this plugin emits — measured at roughly 69 bytes
|
||||
// per member — and the sidecar reads a line with no length bound. The cap turns an
|
||||
// unbounded frame into a bounded one; a guild above it is split across continuation
|
||||
// lines. 500 members is ~35 KB, comfortably past any real guild, so the split path is
|
||||
// an edge case rather than the norm.
|
||||
GuildRosterMembersPerLine = Config.Get("Bridge.GuildRosterMembersPerLine", 500);
|
||||
if (GuildRosterMembersPerLine < 16)
|
||||
GuildRosterMembersPerLine = 16;
|
||||
|
||||
// How many guilds may emit a roster in a single sweep. Every guild re-emits after a
|
||||
// reconnect (the diff caches are cleared), and building a few hundred fat JSON frames in
|
||||
// one Core-thread pass is exactly the stall this bridge exists to avoid. The sweep
|
||||
// re-arms itself promptly while a baseline is still draining, so this throttles the work
|
||||
// without making the site wait a full sweep interval per batch.
|
||||
GuildRosterGuildsPerTick = Config.Get("Bridge.GuildRosterGuildsPerTick", 25);
|
||||
if (GuildRosterGuildsPerTick < 1)
|
||||
GuildRosterGuildsPerTick = 1;
|
||||
|
||||
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
|
||||
if (CitySweepSeconds < 1)
|
||||
CitySweepSeconds = 1;
|
||||
|
||||
PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
|
||||
if (PresenceSweepSeconds < 1)
|
||||
PresenceSweepSeconds = 1;
|
||||
|
||||
HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
|
||||
if (HousingSweepSeconds < 1)
|
||||
HousingSweepSeconds = 1;
|
||||
|
||||
// Points/loyalty boards. The sweep touches every point entry on the shard, and ten of
|
||||
// the ~25 systems keep a row per character ever created, so the default interval is
|
||||
// deliberately slow — these are month-scale standings, not live state.
|
||||
PointsSweepSeconds = Config.Get("Bridge.PointsSweepSeconds", 300);
|
||||
if (PointsSweepSeconds < 1)
|
||||
PointsSweepSeconds = 1;
|
||||
|
||||
PointsLeaderboardEnabled = Config.Get("Bridge.PointsLeaderboardEnabled", true);
|
||||
|
||||
// Board size. Bounded below at 1 because the selection indexes the Nth slot directly,
|
||||
// and above at 100 because the frame is emitted per system — a large N multiplied by
|
||||
// ~25 systems is how a "board" turns into a bandwidth problem.
|
||||
PointsTopN = Config.Get("Bridge.PointsTopN", 10);
|
||||
if (PointsTopN < 1)
|
||||
PointsTopN = 1;
|
||||
if (PointsTopN > 100)
|
||||
PointsTopN = 100;
|
||||
|
||||
// Blank (the default) means "publish whatever the shard itself shows on the loyalty
|
||||
// gump", so a shard that adds a subsystem gets its board without an edit here.
|
||||
PointsSystems = Config.Get("Bridge.PointsSystems", "");
|
||||
|
||||
PointsProfileEnabled = Config.Get("Bridge.PointsProfileEnabled", true);
|
||||
|
||||
// Off by default, and the default is the point: a rank cannot early-exit the way a
|
||||
// points lookup can — it must count every row that beats the player, in every system,
|
||||
// on every profile build. See BridgeProfile.WritePoints.
|
||||
PointsProfileRank = Config.Get("Bridge.PointsProfileRank", false);
|
||||
|
||||
// Player-vendor market index. Unlike every other sweep, this one does NOT walk its whole
|
||||
// collection per tick: MarketSweepBatch caps how many vendors are inventoried, and a
|
||||
// persistent cursor round-robins through the rest, so the per-tick cost is bounded by
|
||||
// the batch rather than by how many vendors the world holds.
|
||||
MarketEnabled = Config.Get("Bridge.MarketEnabled", true);
|
||||
|
||||
MarketSweepSeconds = Config.Get("Bridge.MarketSweepSeconds", 60);
|
||||
if (MarketSweepSeconds < 1)
|
||||
MarketSweepSeconds = 1;
|
||||
|
||||
// Bounded below at 1 (a batch of 0 would advance the cursor nowhere and publish nothing,
|
||||
// silently) and above at 500, past which the batch stops bounding anything on any
|
||||
// realistic shard and the tick is a whole-world pass by another name.
|
||||
MarketSweepBatch = Config.Get("Bridge.MarketSweepBatch", 25);
|
||||
if (MarketSweepBatch < 1)
|
||||
MarketSweepBatch = 1;
|
||||
if (MarketSweepBatch > 500)
|
||||
MarketSweepBatch = 500;
|
||||
|
||||
// Per-vendor listing cap. BridgeJson.Parse caps INBOUND frames at 1 MB; outbound is
|
||||
// uncapped and the sidecar's read_line will allocate whatever arrives, so the cap here
|
||||
// is what keeps one commodity reseller with 8,000 stacked resources from emitting a
|
||||
// multi-megabyte frame. Over the cap the frame carries "truncated": true and the site
|
||||
// says so.
|
||||
MarketMaxListings = Config.Get("Bridge.MarketMaxListings", 250);
|
||||
if (MarketMaxListings < 1)
|
||||
MarketMaxListings = 1;
|
||||
if (MarketMaxListings > 5000)
|
||||
MarketMaxListings = 5000;
|
||||
|
||||
// The ruleset frame is not a sweep — it is emitted once per sidecar connect (and on
|
||||
// `[bridge reload`), so it has no interval. PublicConnectAddress is the ONE connection
|
||||
// detail the bridge will publish, and only because an operator typed it here for that
|
||||
// purpose; Server.cfg's Address/Port are never read (see BridgeRuleset's allowlist note).
|
||||
RulesetEnabled = Config.Get("Bridge.RulesetEnabled", true);
|
||||
PublicConnectAddress = Config.Get("Bridge.PublicConnectAddress", "");
|
||||
RulesetIncludeSchedule = Config.Get("Bridge.RulesetIncludeSchedule", true);
|
||||
|
||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||
|
||||
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
||||
@@ -67,14 +265,218 @@ namespace Server.Custom.Bridge
|
||||
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
||||
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
|
||||
|
||||
NewsMaxTitleLength = Config.Get("Bridge.NewsMaxTitleLength", 100);
|
||||
NewsMaxBodyLength = Config.Get("Bridge.NewsMaxBodyLength", 2000);
|
||||
NewsMaxExternal = Config.Get("Bridge.NewsMaxExternal", 20);
|
||||
NewsAnnounceDurationSec = Config.Get("Bridge.NewsAnnounceDurationSec", 300);
|
||||
if (NewsAnnounceDurationSec < 1)
|
||||
NewsAnnounceDurationSec = 1;
|
||||
|
||||
AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
|
||||
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
|
||||
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
|
||||
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
||||
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
||||
|
||||
// The event plane. Off until an operator says otherwise - see the field block above for
|
||||
// why this is not AdminWriteEnabled.
|
||||
EventsEnabled = Config.Get("Bridge.EventsEnabled", false);
|
||||
|
||||
// Thirty days, matching core's own MAX_LEASE_MS. This is the shard's INDEPENDENT
|
||||
// ceiling rather than a mirror of it: the website bounds what it will ask for, and a
|
||||
// shard that trusted the asking would have no bound of its own at the one moment it
|
||||
// matters, which is when the website is wrong.
|
||||
LeaseMaxDurationSec = Config.Get("Bridge.LeaseMaxDurationSec", 2592000);
|
||||
if (LeaseMaxDurationSec < 1)
|
||||
LeaseMaxDurationSec = 1;
|
||||
|
||||
// How long a finished lease stays listed after its deadline restored it, so teardown
|
||||
// still gets a definite verdict rather than finding nothing and having to guess.
|
||||
LeaseGraceSec = Config.Get("Bridge.LeaseGraceSec", 86400);
|
||||
if (LeaseGraceSec < 0)
|
||||
LeaseGraceSec = 0;
|
||||
|
||||
ParticipationSweepSeconds = Config.Get("Bridge.ParticipationSweepSeconds", 30);
|
||||
if (ParticipationSweepSeconds < 1)
|
||||
ParticipationSweepSeconds = 1;
|
||||
|
||||
// What one kill inside the area is worth against one minute of standing in it. Both
|
||||
// halves live on the shard because the score IS the shard's number: core stores an
|
||||
// opaque decimal it never interprets, so a weight core could edit would be a weight
|
||||
// nobody could explain from either side.
|
||||
ParticipationKillWeight = Config.Get("Bridge.ParticipationKillWeight", 5.0);
|
||||
if (ParticipationKillWeight < 0.0)
|
||||
ParticipationKillWeight = 0.0;
|
||||
|
||||
ParticipationMaxRuns = Config.Get("Bridge.ParticipationMaxRuns", 8);
|
||||
if (ParticipationMaxRuns < 1)
|
||||
ParticipationMaxRuns = 1;
|
||||
|
||||
ParticipationMaxMembers = Config.Get("Bridge.ParticipationMaxMembers", 2000);
|
||||
if (ParticipationMaxMembers < 1)
|
||||
ParticipationMaxMembers = 1;
|
||||
|
||||
// A radius, not a rectangle, and bounded: an area big enough to cover a facet makes
|
||||
// "took part" meaningless and the sweep expensive in the same stroke.
|
||||
ParticipationMaxRadius = Config.Get("Bridge.ParticipationMaxRadius", 300);
|
||||
if (ParticipationMaxRadius < 1)
|
||||
ParticipationMaxRadius = 1;
|
||||
|
||||
ParticipationGraceSec = Config.Get("Bridge.ParticipationGraceSec", 86400);
|
||||
if (ParticipationGraceSec < 0)
|
||||
ParticipationGraceSec = 0;
|
||||
|
||||
// How many members one snapshot resolves before yielding the Core thread. See
|
||||
// BridgeParticipation: this is what makes the handler DEFER, which is what makes
|
||||
// `bridge.busy` reachable at all.
|
||||
ParticipationSnapshotChunk = Config.Get("Bridge.ParticipationSnapshotChunk", 100);
|
||||
if (ParticipationSnapshotChunk < 1)
|
||||
ParticipationSnapshotChunk = 1;
|
||||
|
||||
// The world verbs. PEC's published quotas are the defaults, because they are the only
|
||||
// numbers anyone has ever defended in public: 30 creatures, a handful of bosses, five
|
||||
// NPCs of five lines each, a four-hour gate. See EVENTS.md's PEC section.
|
||||
EventsMaxCreatures = Config.Get("Bridge.EventsMaxCreatures", 30);
|
||||
if (EventsMaxCreatures < 1)
|
||||
EventsMaxCreatures = 1;
|
||||
|
||||
EventsMaxBosses = Config.Get("Bridge.EventsMaxBosses", 4);
|
||||
if (EventsMaxBosses < 1)
|
||||
EventsMaxBosses = 1;
|
||||
|
||||
EventsMaxNpcs = Config.Get("Bridge.EventsMaxNpcs", 5);
|
||||
if (EventsMaxNpcs < 1)
|
||||
EventsMaxNpcs = 1;
|
||||
|
||||
EventsMaxDecor = Config.Get("Bridge.EventsMaxDecor", 60);
|
||||
if (EventsMaxDecor < 1)
|
||||
EventsMaxDecor = 1;
|
||||
|
||||
EventsMaxGateMinutes = Config.Get("Bridge.EventsMaxGateMinutes", 240);
|
||||
if (EventsMaxGateMinutes < 1)
|
||||
EventsMaxGateMinutes = 1;
|
||||
|
||||
// The whole run, across every verb. The per-verb ceilings above bound one CALL; this
|
||||
// bounds a run that calls a verb in a loop, which is the shape a runaway schedule
|
||||
// actually takes.
|
||||
EventsMaxOwnedPerRun = Config.Get("Bridge.EventsMaxOwnedPerRun", 200);
|
||||
if (EventsMaxOwnedPerRun < 1)
|
||||
EventsMaxOwnedPerRun = 1;
|
||||
|
||||
EventsMaxSpread = Config.Get("Bridge.EventsMaxSpread", 40);
|
||||
if (EventsMaxSpread < 0)
|
||||
EventsMaxSpread = 0;
|
||||
|
||||
// "An enhanced regular mob", per EVENTS.md's boss row -- so a ceiling low enough that
|
||||
// the result is still recognisably the creature the author picked.
|
||||
EventsMaxBossMultiplier = Config.Get("Bridge.EventsMaxBossMultiplier", 10.0);
|
||||
if (EventsMaxBossMultiplier < 1.0)
|
||||
EventsMaxBossMultiplier = 1.0;
|
||||
|
||||
EventsOracleMaxLines = Config.Get("Bridge.EventsOracleMaxLines", 5);
|
||||
if (EventsOracleMaxLines < 1)
|
||||
EventsOracleMaxLines = 1;
|
||||
|
||||
EventsOracleGreetRange = Config.Get("Bridge.EventsOracleGreetRange", 4);
|
||||
if (EventsOracleGreetRange < 1)
|
||||
EventsOracleGreetRange = 1;
|
||||
|
||||
EventsOracleSpeechRange = Config.Get("Bridge.EventsOracleSpeechRange", 8);
|
||||
if (EventsOracleSpeechRange < 1)
|
||||
EventsOracleSpeechRange = 1;
|
||||
|
||||
EventsOracleGreetCooldownSec = Config.Get("Bridge.EventsOracleGreetCooldownSec", 60);
|
||||
if (EventsOracleGreetCooldownSec < 0)
|
||||
EventsOracleGreetCooldownSec = 0;
|
||||
|
||||
EventsOracleAnswerCooldownSec = Config.Get("Bridge.EventsOracleAnswerCooldownSec", 5);
|
||||
if (EventsOracleAnswerCooldownSec < 0)
|
||||
EventsOracleAnswerCooldownSec = 0;
|
||||
|
||||
// How often expired gates are collected and dead ownership rows pruned. Gates are a
|
||||
// minutes-scale deadline, so one slow sweep beats a timer per object.
|
||||
EventsSweepSeconds = Config.Get("Bridge.EventsSweepSeconds", 30);
|
||||
if (EventsSweepSeconds < 1)
|
||||
EventsSweepSeconds = 1;
|
||||
|
||||
// Phase 12b. How many characters one grant may reach, and how many of one item may go
|
||||
// into one hand. Both refuse rather than clamp, on `LeaseMaxDurationSec`'s argument:
|
||||
// the website records what was handed out, and a silent clamp would make its ledger a
|
||||
// description of a grant that did not happen.
|
||||
EventsMaxGrantPerRun = Config.Get("Bridge.EventsMaxGrantPerRun", 200);
|
||||
if (EventsMaxGrantPerRun < 0)
|
||||
EventsMaxGrantPerRun = 0;
|
||||
|
||||
EventsMaxGrantStack = Config.Get("Bridge.EventsMaxGrantStack", 1000);
|
||||
if (EventsMaxGrantStack < 1)
|
||||
EventsMaxGrantStack = 1;
|
||||
|
||||
// A save stops the world, so this one is a rate limit rather than a cap. It counts from
|
||||
// the last save by ANYBODY -- ServUO's own autosave included -- because an event save
|
||||
// thirty seconds after the hourly one is the same freeze twice, and this shard is the
|
||||
// only half that can see both.
|
||||
EventsMinSaveIntervalSec = Config.Get("Bridge.EventsMinSaveIntervalSec", 300);
|
||||
if (EventsMinSaveIntervalSec < 0)
|
||||
EventsMinSaveIntervalSec = 0;
|
||||
|
||||
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
|
||||
// unrecognized* value falls back to Game (the safest — no website creation), so a
|
||||
// typo can never accidentally open provisioning.
|
||||
Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game);
|
||||
// Default follows the mode: creation is on unless the shard is game-authority.
|
||||
AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game);
|
||||
RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true);
|
||||
AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16);
|
||||
AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30);
|
||||
if (AccountNameMaxLength < 1)
|
||||
AccountNameMaxLength = 1;
|
||||
if (AccountPasswordMaxLength < 1)
|
||||
AccountPasswordMaxLength = 1;
|
||||
|
||||
if (QueueCap < 16)
|
||||
QueueCap = 16;
|
||||
|
||||
WarnOnSignupMismatch();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bridge governs only the account.create verb; ServUO's in-game first-login
|
||||
/// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves
|
||||
/// disagree is quietly broken (website-only that still auto-creates in game, or a mode
|
||||
/// that expects in-game creation with it switched off), so surface the contradiction
|
||||
/// loudly rather than silently doing the permissive thing.
|
||||
/// </summary>
|
||||
private static void WarnOnSignupMismatch()
|
||||
{
|
||||
var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true);
|
||||
|
||||
if (Signup == SignupMode.Website && autoCreate)
|
||||
Console.WriteLine(
|
||||
"[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; "
|
||||
+ "an in-game login of any new name still mints an account. Set it false for website-only.");
|
||||
else if (Signup == SignupMode.Game && !autoCreate)
|
||||
Console.WriteLine(
|
||||
"[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; "
|
||||
+ "in-game creation is off and account.create is refused, so no account can be created.");
|
||||
else if (Signup == SignupMode.Hybrid && !autoCreate)
|
||||
Console.WriteLine(
|
||||
"[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; "
|
||||
+ "in-game first-login creation is off. Only website account.create will work.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a SignupMode name, case-insensitively, falling back to <paramref name="fallback"/>
|
||||
/// on anything unrecognized so a typo can never open provisioning wider than intended.
|
||||
/// </summary>
|
||||
private static SignupMode ParseSignupMode(string value, SignupMode fallback)
|
||||
{
|
||||
SignupMode parsed;
|
||||
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
||||
Enum.IsDefined(typeof(SignupMode), parsed))
|
||||
return parsed;
|
||||
|
||||
Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,9 +497,10 @@ namespace Server.Custom.Bridge
|
||||
public static string Describe()
|
||||
{
|
||||
return String.Format(
|
||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})",
|
||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11}) events={12}",
|
||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor);
|
||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled,
|
||||
EventsEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,9 +170,53 @@ namespace Server.Custom.Bridge
|
||||
.Str("acct", e.Username)
|
||||
.Str("ip", address)
|
||||
.End());
|
||||
|
||||
EmitLoginResult(e, address);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protocol 5. The RESULT of the login above, which the attempt itself cannot carry.
|
||||
///
|
||||
/// Why a second kind rather than two more fields: PacketHandlers.AccountLogin invokes this
|
||||
/// sink and only THEN branches on e.Accepted, and the decision is made by the handlers
|
||||
/// themselves -- Server.Misc.AccountHandler is the one that validates the password and
|
||||
/// sets Accepted/RejectReason. Inside our own handler the verdict therefore does not exist
|
||||
/// yet: Accepted is still its constructor default of `true` for a password that is about
|
||||
/// to be rejected. Anything built on the attempt alone fires on every SUCCESSFUL login
|
||||
/// too, which is the wrong way round for a security notice -- it would tell a player
|
||||
/// "someone tried to get into your account" every time they logged in themselves.
|
||||
///
|
||||
/// Reading it one Core slice later, via DelayCall(Zero), is what makes the verdict final
|
||||
/// without a core patch and without depending on handler subscription ORDER, which
|
||||
/// ServUO does not define and which a shard's own scripts can change.
|
||||
///
|
||||
/// On holding the args object: it carries the plaintext Password, so it is deliberately
|
||||
/// alive for one extra slice and no longer, and exactly two properties are read off it.
|
||||
/// The password is never read, never logged and never emitted -- the same rule the
|
||||
/// attempt emitter above states.
|
||||
/// </summary>
|
||||
private static void EmitLoginResult(AccountLoginEventArgs e, string address)
|
||||
{
|
||||
// The NetState is disposed by AccountLogin_ReplyRej before this runs, which is why the
|
||||
// address is passed in already resolved rather than re-read from e.State.
|
||||
Timer.DelayCall(TimeSpan.Zero, () =>
|
||||
Guard("account.login.result", () =>
|
||||
{
|
||||
var sb = BridgeJson.Begin("account.login.result")
|
||||
.Str("acct", e.Username)
|
||||
.Str("ip", address)
|
||||
.Bool("accepted", e.Accepted);
|
||||
|
||||
// ALRReason is only meaningful on a rejection; on an accept it is still the
|
||||
// enum's zero value (Invalid), which would read as a failure reason if emitted.
|
||||
if (!e.Accepted)
|
||||
sb.Str("reason", e.RejectReason.ToString());
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- economy ----
|
||||
|
||||
private static void OnGoldChange(AccountGoldChangeEventArgs e)
|
||||
|
||||
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Engines.CityLoyalty;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The town-governor stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a
|
||||
/// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of
|
||||
/// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises
|
||||
/// an EventSink, so — like <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — the set
|
||||
/// is polled and each city emits `city.update` only when its signature changes. Governors turn
|
||||
/// over on the order of weeks, so a slow sweep (default 5 min) is ample.
|
||||
///
|
||||
/// The wire model is uniform with the rest of Part B: a full-state `city.update` upsert, with
|
||||
/// "the governor changed" derived sidecar-side by comparing to the stored board — rather than a
|
||||
/// discrete from→to event, which a sidecar reconnect (cache cleared, full re-emit) would
|
||||
/// otherwise fire spuriously for every city.
|
||||
///
|
||||
/// Gated on CityLoyaltySystem.Enabled: a shard running its own town system emits nothing here.
|
||||
/// </summary>
|
||||
public static class BridgeGovernance
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// City enum value -> last-emitted signature.
|
||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
||||
|
||||
private static long _sweeps, _emitted;
|
||||
private static bool _warnedDisabled;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
_last.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
Stop();
|
||||
|
||||
_timer = Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
|
||||
CitySweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("cities(enabled={0} sweeps={1} emitted={2} tracked={3})",
|
||||
CityLoyaltySystem.Enabled, _sweeps, _emitted, _last.Count);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
CitySweep();
|
||||
}
|
||||
|
||||
private static void CitySweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (!CityLoyaltySystem.Enabled || CityLoyaltySystem.Cities == null)
|
||||
{
|
||||
if (!_warnedDisabled)
|
||||
{
|
||||
Console.WriteLine("[Bridge] city loyalty disabled; governor stream idle.");
|
||||
_warnedDisabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
foreach (var city in CityLoyaltySystem.Cities)
|
||||
{
|
||||
if (city == null)
|
||||
continue;
|
||||
|
||||
var sig = Signature(city);
|
||||
|
||||
int key = (int)city.City;
|
||||
|
||||
string prior;
|
||||
if (_last.TryGetValue(key, out prior) && prior == sig)
|
||||
continue; // unchanged since last emit
|
||||
|
||||
_last[key] = sig;
|
||||
BridgeLink.Emit(WriteCity(city));
|
||||
_emitted++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] city sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// The volatile fields: governor, governor-elect, and the election phase / candidate count.
|
||||
private static string Signature(CityLoyaltySystem city)
|
||||
{
|
||||
var gov = city.Governor == null ? 0 : city.Governor.Serial.Value;
|
||||
var elect = city.GovernorElect == null ? 0 : city.GovernorElect.Serial.Value;
|
||||
|
||||
var e = city.Election;
|
||||
var phase = ElectionPhase(e);
|
||||
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
|
||||
|
||||
return String.Concat(
|
||||
gov.ToString(), "|", elect.ToString(), "|", phase, "|", candidates.ToString());
|
||||
}
|
||||
|
||||
private static string WriteCity(CityLoyaltySystem city)
|
||||
{
|
||||
var e = city.Election;
|
||||
var phase = ElectionPhase(e);
|
||||
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
|
||||
|
||||
var sb = BridgeJson.Begin("city.update")
|
||||
.Str("city", city.City.ToString())
|
||||
.Str("electionPhase", phase)
|
||||
.Num("candidates", candidates);
|
||||
|
||||
sb.Actor("governor", city.Governor);
|
||||
sb.Actor("governorElect", city.GovernorElect);
|
||||
|
||||
if (e != null && e.Ongoing)
|
||||
sb.Str("autoPickAt", e.AutoPickGovernor.ToUniversalTime().ToString("o"));
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>Folds the election state into one of: none / nominate / vote / pending.</summary>
|
||||
private static string ElectionPhase(CityElection e)
|
||||
{
|
||||
if (e == null)
|
||||
return "none";
|
||||
|
||||
if (e.CanNominate())
|
||||
return "nominate";
|
||||
|
||||
if (e.CanVote())
|
||||
return "vote";
|
||||
|
||||
if (e.Ongoing)
|
||||
return "pending";
|
||||
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
165
overlay/Scripts/Custom/Bridge/BridgeHousing.cs
Normal file
165
overlay/Scripts/Custom/Bridge/BridgeHousing.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using Server.Multis;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The housing registry (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay
|
||||
/// *transitions*; this is the complementary *board*: one row per house with owner, location,
|
||||
/// region, co-owners, value, and current decay level, so the website can render an owner→houses
|
||||
/// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit
|
||||
/// house.update only when a house's signature changes, and house.remove when a house is gone.
|
||||
///
|
||||
/// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the
|
||||
/// registry is owner→houses; `price` is the house's placement value, not a sale listing.
|
||||
/// </summary>
|
||||
public static class BridgeHousing
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// house serial -> last-emitted signature.
|
||||
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
||||
|
||||
private static long _sweeps, _emitted, _removed;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
_last.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
Stop();
|
||||
|
||||
_timer = Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
|
||||
HouseSweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("housing(sweeps={0} emitted={1} removed={2} tracked={3})",
|
||||
_sweeps, _emitted, _removed, _last.Count);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
HouseSweep();
|
||||
}
|
||||
|
||||
private static void HouseSweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
var seen = new HashSet<Serial>();
|
||||
|
||||
foreach (var house in BaseHouse.AllHouses)
|
||||
{
|
||||
if (house == null || house.Deleted)
|
||||
continue;
|
||||
|
||||
seen.Add(house.Serial);
|
||||
|
||||
var level = house.DecayLevel; // computed getter — read once
|
||||
var sig = Signature(house, level);
|
||||
|
||||
string prior;
|
||||
if (_last.TryGetValue(house.Serial, out prior) && prior == sig)
|
||||
continue; // unchanged since last emit
|
||||
|
||||
_last[house.Serial] = sig;
|
||||
BridgeLink.Emit(WriteHouse(house, level));
|
||||
_emitted++;
|
||||
}
|
||||
|
||||
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
foreach (var serial in gone)
|
||||
{
|
||||
_last.Remove(serial);
|
||||
BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End());
|
||||
_removed++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Signature(BaseHouse house, DecayLevel level)
|
||||
{
|
||||
var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value;
|
||||
var region = house.Region;
|
||||
var regionName = region == null ? "" : (region.Name ?? "");
|
||||
var sign = house.Sign;
|
||||
var name = sign == null ? "" : (sign.GetName() ?? "");
|
||||
var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count;
|
||||
|
||||
return String.Concat(
|
||||
ownerSerial.ToString(), "|",
|
||||
level.ToString(), "|",
|
||||
regionName, "|",
|
||||
name, "|",
|
||||
coOwners.ToString(), "|",
|
||||
house.Price.ToString());
|
||||
}
|
||||
|
||||
private static string WriteHouse(BaseHouse house, DecayLevel level)
|
||||
{
|
||||
var sb = BridgeJson.Begin("house.update")
|
||||
.Ser("serial", house.Serial)
|
||||
.Str("decay", level.ToString())
|
||||
.Num("price", house.Price)
|
||||
.Str("map", house.Map == null ? null : house.Map.Name)
|
||||
.Num("x", house.X).Num("y", house.Y).Num("z", house.Z);
|
||||
|
||||
var sign = house.Sign;
|
||||
if (sign != null)
|
||||
sb.Str("name", sign.GetName());
|
||||
|
||||
var region = house.Region;
|
||||
if (region != null)
|
||||
sb.Str("region", region.Name);
|
||||
|
||||
sb.Actor("owner", house.Owner);
|
||||
|
||||
sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count);
|
||||
sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count);
|
||||
|
||||
sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o"));
|
||||
sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o"));
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
}
|
||||
}
|
||||
541
overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
Normal file
541
overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
Normal file
@@ -0,0 +1,541 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Protocol 6. Makes a repeated command safe.
|
||||
///
|
||||
/// The website's event runner retries a step that did not come back, and until now a command
|
||||
/// whose acknowledgement was lost was indistinguishable from one that never applied. There
|
||||
/// was no way to tell the difference from either end, so every world-writing verb had to be
|
||||
/// declared un-retryable — a lost announcement being cheaper than a doubled one. That is not
|
||||
/// a position you can hold once an event can spawn creatures or lease a config value.
|
||||
///
|
||||
/// So a command may now carry an `idempotencyKey`, and the shard promises: **a key is
|
||||
/// executed at most once.** A repeat is never re-run. It is answered with the original
|
||||
/// reply — the same acknowledgement the caller lost — under the repeat's own correlation id.
|
||||
///
|
||||
/// ── Reserve on receipt, not on completion ──────────────────────────────────────────────
|
||||
///
|
||||
/// The key is recorded BEFORE the handler is dispatched, not after it returns. A handler that
|
||||
/// finishes inside its own inbound call can never see a repeat (the Core thread processes one
|
||||
/// line at a time), but a handler that defers — a lease that arms a timer, a spawn that
|
||||
/// waits for a save — completes long after `OnInboundLine` has returned, and that is exactly
|
||||
/// the window a lost ack opens. Reserving late would leave it uncovered.
|
||||
///
|
||||
/// A repeat of a key that is still in flight is answered `bridge.busy`: it runs nothing and
|
||||
/// tells the caller to come back. `bridge.busy` is deliberately not an error — the work is
|
||||
/// happening, and the module classifies it retryable.
|
||||
///
|
||||
/// ── A key that has begun is never released — EXCEPT on a refusal ──────────────────────
|
||||
///
|
||||
/// Not when the handler throws. Releasing it would let a retry re-run a command that may have
|
||||
/// applied half of itself, which is precisely the failure this file exists to prevent. A
|
||||
/// handler that throws stores a `bridge.error` reply instead, so the retry gets a definite
|
||||
/// answer and the step fails once rather than looping.
|
||||
///
|
||||
/// A REFUSAL is the third case, and it was missing until the Phase 16 acceptance walk. A
|
||||
/// handler that ran to completion and answered `*.error` did not do anything: every refusal
|
||||
/// on this plane is a guard — a missing runId, an unknown item, a cap, a rate limit, a write
|
||||
/// that failed and left the value alone. Remembering it froze the answer for ever, so a
|
||||
/// refusal that WAITING FIXES could never be retried past. `uo.world.save` is the case that
|
||||
/// found it: the shard saves at most every 300 seconds, the module says in as many words that
|
||||
/// this is "the one refusal on this plane that waiting fixes", and six attempts over four
|
||||
/// minutes all replayed one frozen sentence — "the last save was 227 seconds ago" — because
|
||||
/// the number was the first reply's, not the clock's.
|
||||
///
|
||||
/// So a refusal releases the key: nothing happened, and the caller is free to ask again. The
|
||||
/// refusal is still EMITTED to the caller, which is what ends the attempt; it is simply not
|
||||
/// remembered as this key's answer. The safety argument is that "nothing happened" is a
|
||||
/// property of every `*.error` reply here, and it is a property this file cannot verify — so
|
||||
/// it is a rule handlers must keep: **do not answer `*.error` after changing the world.**
|
||||
/// Report a partial change in an `ok` reply, as the item grant does with `granted`/`missed`
|
||||
/// and the despawn with `removed`/`gone`/`refused`.
|
||||
///
|
||||
/// ── The one hole, and why it is loud ──────────────────────────────────────────────────
|
||||
///
|
||||
/// The set is bounded, so an evicted key's repeat WOULD be applied a second time. The bounds
|
||||
/// are chosen to put that far outside reach — an hour, against core's fifteen-minute step
|
||||
/// lease — and an eviction that drops a key which had not yet expired prints a warning naming
|
||||
/// the count. If the guarantee is ever actually breached, an operator sees it here rather
|
||||
/// than discovering a doubled spawn in the world.
|
||||
/// </summary>
|
||||
public static class BridgeIdempotency
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a key is remembered. Core's step lease is 15 minutes and its retry backoff
|
||||
/// is bounded well inside that, so an hour is not a tuned number — it is a margin wide
|
||||
/// enough that expiry should never be the thing that ends a key's life.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan Ttl = TimeSpan.FromHours(1.0);
|
||||
|
||||
/// <summary>
|
||||
/// Hard bound on remembered keys, in the same spirit as BridgeLink's outbound queue cap:
|
||||
/// the Core thread never holds an unbounded collection. At command rates this is days of
|
||||
/// traffic, so reaching it means something is wrong — hence the warning on eviction.
|
||||
/// </summary>
|
||||
private const int Cap = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// The correlation fields the sidecar routes replies on, in the order `rpc.rs` tries
|
||||
/// them. A command carries exactly one; the reply echoes it. A replay must be stamped
|
||||
/// with the REPEAT's value, not the original's — the sidecar's `reqId` is a fresh
|
||||
/// per-process counter, so the retry is waiting on an id the first attempt never used.
|
||||
/// </summary>
|
||||
private static readonly string[] CorrFields = { "reqId", "code", "id" };
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public DateTime Reserved; // when the key was first seen
|
||||
public bool Done; // the handler has finished (successfully or not)
|
||||
public string Reply; // the correlated reply line, verbatim; null if there was none
|
||||
public string Corr; // the correlation value the original reply carries
|
||||
public string CorrField; // which of CorrFields that value sits in
|
||||
public string Kind; // for diagnostics only
|
||||
public bool Refused; // the reply was a `*.error`: nothing happened, so do not keep the key
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Is this reply a refusal — a handler that ran and deliberately did nothing?
|
||||
///
|
||||
/// Every refusal on this plane is emitted as a `kind` ending in `.error`
|
||||
/// (`world.error`, `lease.error`, `oneshot.error`, `participation.error`, …). Matched on
|
||||
/// the suffix rather than a list, so a handler family added later is covered without
|
||||
/// anyone remembering to extend an enumeration here.
|
||||
///
|
||||
/// `bridge.error` is deliberately EXCLUDED: that is the reply this file writes itself
|
||||
/// when a handler THREW, and a throw is exactly the case whose key must be kept.
|
||||
/// </summary>
|
||||
private static bool IsRefusal(string replyLine)
|
||||
{
|
||||
if (replyLine == null)
|
||||
return false;
|
||||
|
||||
var parsed = BridgeJson.Parse(replyLine);
|
||||
|
||||
if (parsed == null)
|
||||
return false;
|
||||
|
||||
var kind = BridgeJson.GetString(parsed, "kind");
|
||||
|
||||
if (kind == null || String.Equals(kind, "bridge.error", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
return kind.EndsWith(".error", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, Entry> _byKey =
|
||||
new Dictionary<string, Entry>(StringComparer.Ordinal);
|
||||
|
||||
// Insertion order, so the cap evicts oldest-first without sorting the dictionary.
|
||||
private static readonly Queue<string> _order = new Queue<string>();
|
||||
|
||||
// ---- capture state; Core thread only, one keyed command at a time ----
|
||||
|
||||
private static Entry _open;
|
||||
private static string _openCorr;
|
||||
private static string _openCorrField;
|
||||
|
||||
private static long _seen, _replayed, _busy, _evicted, _uncorrelated, _refusals;
|
||||
|
||||
/// <summary>
|
||||
/// True while a keyed command's handler is running. BridgeLink.Emit checks this on every
|
||||
/// emit, so it is a plain bool read rather than anything that costs the sweep path.
|
||||
/// </summary>
|
||||
public static bool Capturing
|
||||
{
|
||||
get { return _open != null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format(
|
||||
"idem(keys={0} seen={1} replayed={2} busy={3} evicted={4} uncorrelated={5} refused={6})",
|
||||
_byKey.Count, _seen, _replayed, _busy, _evicted, _uncorrelated, _refusals);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by BridgeBoot for every inbound command that carries an `idempotencyKey`,
|
||||
/// before the handler runs.
|
||||
///
|
||||
/// Returns TRUE when the command must not be executed — this call has already emitted the
|
||||
/// answer (a replay of the original reply, or `bridge.busy`). Returns FALSE when the key
|
||||
/// is new: the key is now reserved and capture is open, and the caller MUST pair this
|
||||
/// with <see cref="Finish"/> in a finally.
|
||||
/// </summary>
|
||||
public static bool Intercept(string key, Dictionary<string, object> command)
|
||||
{
|
||||
_seen++;
|
||||
Sweep();
|
||||
|
||||
string corrField = null;
|
||||
string corr = null;
|
||||
|
||||
for (int i = 0; i < CorrFields.Length; i++)
|
||||
{
|
||||
var v = BridgeJson.GetString(command, CorrFields[i]);
|
||||
|
||||
if (v != null)
|
||||
{
|
||||
corrField = CorrFields[i];
|
||||
corr = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Entry prior;
|
||||
|
||||
if (_byKey.TryGetValue(key, out prior))
|
||||
{
|
||||
if (prior.Done)
|
||||
Replay(key, prior, corrField, corr);
|
||||
else
|
||||
Busy(key, prior, corrField, corr);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
Reserved = DateTime.UtcNow,
|
||||
Done = false,
|
||||
Kind = BridgeJson.GetString(command, "kind"),
|
||||
};
|
||||
|
||||
Remember(key, entry);
|
||||
|
||||
_open = entry;
|
||||
_openCorr = corr;
|
||||
_openCorrField = corrField;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by BridgeBoot in a finally, once the handler has returned. Closes capture and
|
||||
/// marks the key done. `error` is non-null when the handler threw.
|
||||
///
|
||||
/// A handler that deferred its work calls <see cref="Hold"/> first; this then leaves the
|
||||
/// key reserved and in flight, and the handler completes it later.
|
||||
/// </summary>
|
||||
public static void Finish(string key, string error)
|
||||
{
|
||||
var entry = _open;
|
||||
|
||||
_open = null;
|
||||
var corr = _openCorr;
|
||||
var corrField = _openCorrField;
|
||||
_openCorr = null;
|
||||
_openCorrField = null;
|
||||
|
||||
if (entry == null || entry.Done)
|
||||
return; // Hold() released it to its own completion, or there was nothing open
|
||||
|
||||
if (error != null)
|
||||
{
|
||||
// The handler threw. The key stays claimed — see the class header — and the stored
|
||||
// answer is the failure, so the retry ends the step instead of re-running a command
|
||||
// that may have applied part of itself.
|
||||
var sb = BridgeJson.Begin("bridge.error");
|
||||
|
||||
if (corrField != null)
|
||||
sb.Str(corrField, corr);
|
||||
|
||||
sb.Str("reason", "handler threw: " + error)
|
||||
.Str("idempotencyKey", key);
|
||||
|
||||
entry.Reply = sb.End();
|
||||
entry.Corr = corr;
|
||||
entry.CorrField = corrField;
|
||||
entry.Done = true;
|
||||
|
||||
Console.WriteLine("[Bridge] idempotency: {0} threw under key {1}; the retry will be answered with the failure",
|
||||
entry.Kind, key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Reply == null)
|
||||
{
|
||||
// Nothing the sidecar could have correlated was emitted. That is a defect in the
|
||||
// handler rather than a state to model: the FIRST attempt has already timed out at
|
||||
// the sidecar, and the retry would time out identically forever. Store a definite
|
||||
// answer so the retry terminates, and say so.
|
||||
_uncorrelated++;
|
||||
|
||||
var sb = BridgeJson.Begin("bridge.error");
|
||||
|
||||
if (corrField != null)
|
||||
sb.Str(corrField, corr);
|
||||
|
||||
sb.Str("reason", "the original command produced no correlated reply")
|
||||
.Str("idempotencyKey", key);
|
||||
|
||||
entry.Reply = sb.End();
|
||||
entry.Corr = corr;
|
||||
entry.CorrField = corrField;
|
||||
|
||||
Console.WriteLine("[Bridge] idempotency: {0} under key {1} emitted no reply the sidecar could correlate",
|
||||
entry.Kind, key);
|
||||
}
|
||||
else if (entry.Refused)
|
||||
{
|
||||
// The handler ran and refused, so nothing happened and this key is not spent. The
|
||||
// refusal has already gone out to the caller; it just is not remembered as the
|
||||
// answer. Without this, a refusal that waiting fixes could never be retried past —
|
||||
// see the class header.
|
||||
Release(key);
|
||||
_refusals++;
|
||||
return;
|
||||
}
|
||||
|
||||
entry.Done = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For a handler that finishes AFTER its inbound call returns. It keeps the key reserved
|
||||
/// (so a repeat is answered `bridge.busy` rather than executed) and takes on the duty of
|
||||
/// calling <see cref="Complete"/> with the reply it eventually emits.
|
||||
///
|
||||
/// 11a built this door and had nothing to walk through it. `participation.snapshot` is
|
||||
/// the first: above a threshold it walks its members in chunks across Core ticks, so it
|
||||
/// completes long after its inbound call returned, and a repeat arriving in between is
|
||||
/// the first `bridge.busy` this shard can actually produce.
|
||||
/// </summary>
|
||||
public static void Hold(string key)
|
||||
{
|
||||
var entry = _open;
|
||||
|
||||
if (entry == null)
|
||||
return;
|
||||
|
||||
// The caller must be holding the key it was dispatched under. A mismatch would leave
|
||||
// the OPEN key marked done by Finish while the named one stayed in flight forever, so
|
||||
// it is refused rather than honoured: capture stays open and the ordinary path runs.
|
||||
if (key == null || !_byKey.ContainsKey(key))
|
||||
{
|
||||
Console.WriteLine("[Bridge] idempotency: Hold called with an unknown key '{0}'; ignoring", key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close capture without marking done: the key stays in flight until Complete.
|
||||
_open = null;
|
||||
_openCorr = null;
|
||||
_openCorrField = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a key a handler previously held. `replyLine` is the line the handler emits
|
||||
/// as its answer; it is stored so a later repeat replays it.
|
||||
/// </summary>
|
||||
public static void Complete(string key, string replyLine)
|
||||
{
|
||||
Entry entry;
|
||||
|
||||
if (key == null || !_byKey.TryGetValue(key, out entry) || entry.Done)
|
||||
return;
|
||||
|
||||
var parsed = replyLine == null ? null : BridgeJson.Parse(replyLine);
|
||||
|
||||
if (parsed != null)
|
||||
{
|
||||
for (int i = 0; i < CorrFields.Length; i++)
|
||||
{
|
||||
var v = BridgeJson.GetString(parsed, CorrFields[i]);
|
||||
|
||||
if (v != null)
|
||||
{
|
||||
entry.CorrField = CorrFields[i];
|
||||
entry.Corr = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A deferred handler can refuse too — a lease whose target vanished while the timer was
|
||||
// armed answers `lease.error` here rather than from inside the inbound call. Same rule:
|
||||
// nothing happened, so the key is not spent.
|
||||
if (IsRefusal(replyLine))
|
||||
{
|
||||
Release(key);
|
||||
_refusals++;
|
||||
return;
|
||||
}
|
||||
|
||||
entry.Reply = replyLine;
|
||||
entry.Done = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Give a key back, as though it had never been seen.
|
||||
///
|
||||
/// Only ever called for a refusal — see the class header. It removes the entry from the
|
||||
/// lookup; the stale key left in `_order` is harmless, because eviction re-reads
|
||||
/// `_byKey` and skips what is no longer there.
|
||||
/// </summary>
|
||||
private static void Release(string key)
|
||||
{
|
||||
if (key != null)
|
||||
_byKey.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every line a keyed handler emits passes through here. Only the one the sidecar would
|
||||
/// correlate with THIS command is kept: an `admin.audit` broadcast that happens to be
|
||||
/// emitted alongside the reply is a fact about the world and must not be replayed, while
|
||||
/// the reply is an answer to a caller and must be.
|
||||
/// </summary>
|
||||
public static void Observe(string line)
|
||||
{
|
||||
var entry = _open;
|
||||
|
||||
if (entry == null || line == null || _openCorrField == null || _openCorr == null)
|
||||
return;
|
||||
|
||||
// Cheap reject before parsing: the correlation value is a string field on the reply, so
|
||||
// if it does not appear in the line at all this cannot be the reply.
|
||||
if (line.IndexOf(_openCorr, StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
|
||||
var parsed = BridgeJson.Parse(line);
|
||||
|
||||
if (parsed == null)
|
||||
return;
|
||||
|
||||
if (!String.Equals(BridgeJson.GetString(parsed, _openCorrField), _openCorr, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
entry.Reply = line;
|
||||
entry.Corr = _openCorr;
|
||||
entry.CorrField = _openCorrField;
|
||||
entry.Refused = IsRefusal(line);
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
private static void Replay(string key, Entry prior, string corrField, string corr)
|
||||
{
|
||||
_replayed++;
|
||||
|
||||
// A repeat with no correlation field is nobody's outstanding call. Re-emitting the
|
||||
// original reply would put a stale answer on the event feed, where a subscriber would
|
||||
// read it as a fresh one, so the repeat is absorbed silently instead.
|
||||
if (corrField == null || corr == null)
|
||||
{
|
||||
Console.WriteLine("[Bridge] idempotency: absorbed an uncorrelated repeat of key {0} ({1})",
|
||||
key, prior.Kind);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stamp the repeat's correlation id over the original's. The sidecar is waiting on the
|
||||
// id IT sent this time; replaying the first attempt's id would leave the call hanging
|
||||
// until the reply timeout, which is the very failure being answered.
|
||||
string line = null;
|
||||
|
||||
if (prior.Reply != null && String.Equals(corrField, prior.CorrField, StringComparison.Ordinal))
|
||||
line = BridgeJson.RewriteStringField(prior.Reply, corrField, corr);
|
||||
|
||||
if (line == null)
|
||||
{
|
||||
// Either the original produced no reply to replay, or the repeat correlates on a
|
||||
// different field than the original did. Nothing sensible can be replayed under an
|
||||
// id the caller is not waiting on, so answer plainly rather than hang the call.
|
||||
BridgeLink.Emit(BridgeJson.Begin("bridge.error")
|
||||
.Str(corrField, corr)
|
||||
.Str("reason", "the original reply for this idempotency key cannot be replayed")
|
||||
.Str("idempotencyKey", key)
|
||||
.End());
|
||||
return;
|
||||
}
|
||||
|
||||
line = BridgeJson.WithTrueFlag(line, "replayed");
|
||||
|
||||
Console.WriteLine("[Bridge] idempotency: replaying the original reply for key {0} ({1})",
|
||||
key, prior.Kind);
|
||||
|
||||
BridgeLink.Emit(line);
|
||||
}
|
||||
|
||||
private static void Busy(string key, Entry prior, string corrField, string corr)
|
||||
{
|
||||
_busy++;
|
||||
|
||||
var sb = BridgeJson.Begin("bridge.busy");
|
||||
|
||||
if (corrField != null)
|
||||
sb.Str(corrField, corr);
|
||||
|
||||
// **`busyKind`, not `kind`, and the name is the whole bug.** `Begin` has already
|
||||
// written this frame's own `kind` as `bridge.busy`, so a second `kind` field made the
|
||||
// object carry two -- and every JSON parser worth the name takes the LAST. The sidecar
|
||||
// matches `bridge.busy` to decide on a 425, read `participation.snapshot` instead, and
|
||||
// answered an ordinary 200 with a body saying nothing had happened.
|
||||
//
|
||||
// It shipped in 11a and could not be seen there: with only synchronous handlers a
|
||||
// repeat can never arrive mid-flight, so this arm was unreachable on a live shard and
|
||||
// the unit test that covers the sidecar's mapping was, correctly, feeding it a frame
|
||||
// built by hand. The first deferring handler produced it on its first collision.
|
||||
sb.Str("idempotencyKey", key)
|
||||
.Str("busyKind", prior.Kind)
|
||||
.Str("reason", "a command with this idempotency key is still in flight");
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Remember(string key, Entry entry)
|
||||
{
|
||||
_byKey[key] = entry;
|
||||
_order.Enqueue(key);
|
||||
|
||||
while (_order.Count > Cap)
|
||||
{
|
||||
var oldest = _order.Dequeue();
|
||||
|
||||
Entry dropped;
|
||||
|
||||
if (!_byKey.TryGetValue(oldest, out dropped))
|
||||
continue;
|
||||
|
||||
_byKey.Remove(oldest);
|
||||
|
||||
// Expired keys leave silently; they are supposed to. A key evicted while still
|
||||
// inside its TTL is the guarantee's one hole, so it never leaves quietly.
|
||||
if (DateTime.UtcNow - dropped.Reserved < Ttl)
|
||||
{
|
||||
_evicted++;
|
||||
Console.WriteLine(
|
||||
"[Bridge] idempotency: evicted key {0} ({1}) while still live — the cap of {2} was reached, so a repeat of it WOULD be applied again ({3} so far)",
|
||||
oldest, dropped.Kind, Cap, _evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops keys past their TTL. Runs on the command path, which is human-rate.</summary>
|
||||
private static void Sweep()
|
||||
{
|
||||
if (_order.Count == 0)
|
||||
return;
|
||||
|
||||
var cutoff = DateTime.UtcNow - Ttl;
|
||||
|
||||
while (_order.Count > 0)
|
||||
{
|
||||
var oldest = _order.Peek();
|
||||
|
||||
Entry entry;
|
||||
|
||||
if (!_byKey.TryGetValue(oldest, out entry))
|
||||
{
|
||||
_order.Dequeue();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Reserved > cutoff)
|
||||
return; // insertion-ordered, so nothing behind this is older
|
||||
|
||||
_order.Dequeue();
|
||||
_byKey.Remove(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ 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
|
||||
/// every emitted event, and the measured budget in https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md assumes this cost, not a
|
||||
/// reflection serializer's.
|
||||
///
|
||||
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
|
||||
@@ -76,6 +76,230 @@ namespace Server.Custom.Bridge
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a nested actor object: serial, name, account (when there is one), the linked
|
||||
/// webId (when the account is linked), and the player flag. A `null` mobile writes null.
|
||||
/// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a
|
||||
/// guild leader / joiner / governor can be attributed to a site user without a lookup.
|
||||
/// </summary>
|
||||
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
|
||||
{
|
||||
sb.Append(",\"").Append(name).Append("\":");
|
||||
WriteActor(sb, m);
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a named array of actor objects — a guild roster (Protocol 4) being the first
|
||||
/// caller. Every other outbound helper here emits a leading `,"name":`, so an array
|
||||
/// element needs the bare object; that is why <see cref="WriteActor"/> exists separately
|
||||
/// rather than <see cref="Actor"/> being reused.
|
||||
///
|
||||
/// `count` bounds how many are written, because a roster frame must stay a bounded line
|
||||
/// (Bridge.GuildRosterMembersPerLine). A null entry in the sequence is skipped rather
|
||||
/// than written as null, so the array is always a list of real members and a caller can
|
||||
/// trust its length.
|
||||
///
|
||||
/// `withGuildRank` adds each member's guild rank to their object. It is a parameter
|
||||
/// rather than always-on because rank is a property of a mobile's membership of THIS
|
||||
/// guild, not of the mobile — every other actor this bridge writes is a bystander,
|
||||
/// a killer, a governor, and guild rank is meaningless on all of them.
|
||||
/// </summary>
|
||||
public static StringBuilder Actors(
|
||||
this StringBuilder sb, string name, IList<Mobile> mobiles, int start, int count,
|
||||
bool withGuildRank = false)
|
||||
{
|
||||
sb.Append(",\"").Append(name).Append("\":[");
|
||||
|
||||
if (mobiles != null)
|
||||
{
|
||||
var end = Math.Min(start + count, mobiles.Count);
|
||||
bool first = true;
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
var m = mobiles[i];
|
||||
|
||||
if (m == null)
|
||||
continue;
|
||||
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
|
||||
if (withGuildRank)
|
||||
WriteGuildMember(sb, m);
|
||||
else
|
||||
WriteActor(sb, m);
|
||||
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named array of actor objects each carrying a damage total — a boss kill's damage
|
||||
/// table (Protocol 6), and the first actor array whose entries are ranked rather than
|
||||
/// merely listed.
|
||||
///
|
||||
/// The pairs are written in the order given, so the CALLER owns the sort. That is
|
||||
/// deliberate: "the top damagers" is a judgement about a fight, and the shard's job is
|
||||
/// to report the numbers it holds rather than to decide what counts as a contribution.
|
||||
///
|
||||
/// Each entry is the standard actor object plus `damage`, which means it carries `acct`
|
||||
/// and `webId` and is therefore governed by the website's locked-field rule exactly as
|
||||
/// every other actor is. A shard that considers the whole table too revealing hides it
|
||||
/// with one field rule rather than by dropping the kind.
|
||||
/// </summary>
|
||||
public static StringBuilder Damagers(
|
||||
this StringBuilder sb, string name, IList<KeyValuePair<Mobile, int>> pairs, int count)
|
||||
{
|
||||
sb.Append(",\"").Append(name).Append("\":[");
|
||||
|
||||
if (pairs != null)
|
||||
{
|
||||
var end = Math.Min(count, pairs.Count);
|
||||
bool first = true;
|
||||
|
||||
for (int i = 0; i < end; i++)
|
||||
{
|
||||
var m = pairs[i].Key;
|
||||
|
||||
if (m == null)
|
||||
continue;
|
||||
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
|
||||
sb.Append('{');
|
||||
WriteActorFields(sb, m);
|
||||
sb.Append(",\"damage\":").Append(pairs[i].Value);
|
||||
sb.Append('}');
|
||||
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A roster member: the standard actor object plus the member's rank in their guild.
|
||||
///
|
||||
/// **Only the raw rank is emitted, never a resolved label.** ServUO names the five
|
||||
/// standard ranks with cliloc ids (1062959–1062963) and ships no text for them, so the
|
||||
/// shard cannot produce "Warlord" without a client-file table it does not have. The
|
||||
/// website module does have one, and resolving a game term is its job in any case.
|
||||
///
|
||||
/// `rank` is the numeric rank, 0–4, with 4 being Leader (`RankDefinition.Ranks`). A
|
||||
/// custom rank definition may carry a literal string instead of a cliloc, so `rankName`
|
||||
/// is written when there is one and `rankCliloc` when there is not; a shard that has
|
||||
/// replaced the rank table therefore keeps its own naming rather than being flattened
|
||||
/// into the stock five.
|
||||
///
|
||||
/// A member with no readable rank — a mobile that is not a PlayerMobile, or one whose
|
||||
/// GuildRank is null — is written with no rank fields at all rather than a fabricated
|
||||
/// default. Absent means "not known", and a consumer that treated a missing rank as 0
|
||||
/// would silently demote them.
|
||||
///
|
||||
/// **Staff are deliberately written with no rank, and this is not a rounding error.**
|
||||
/// `PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at GameMaster or
|
||||
/// above, whatever their actual rank — a gameplay convenience so staff can operate a
|
||||
/// guild stone, and emphatically not a claim about who leads the guild. The true value
|
||||
/// is in a private field with no accessor, so the only honest options are "Leader" and
|
||||
/// "not known", and publishing a staff member as a guild leader on a public roster is
|
||||
/// the worse of the two by a wide margin. A staff account that genuinely leads its guild
|
||||
/// shows as an unranked member, which is a visible gap rather than a false claim.
|
||||
/// </summary>
|
||||
private static void WriteGuildMember(StringBuilder sb, Mobile m)
|
||||
{
|
||||
if (m == null)
|
||||
{
|
||||
sb.Append("null");
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append('{');
|
||||
WriteActorFields(sb, m);
|
||||
|
||||
var pm = m as Server.Mobiles.PlayerMobile;
|
||||
var rank = pm == null || pm.AccessLevel >= AccessLevel.GameMaster ? null : pm.GuildRank;
|
||||
|
||||
if (rank != null)
|
||||
{
|
||||
sb.Append(",\"rank\":").Append(rank.Rank);
|
||||
|
||||
if (!string.IsNullOrEmpty(rank.Name.String))
|
||||
{
|
||||
sb.Append(",\"rankName\":");
|
||||
Escape(sb, rank.Name.String);
|
||||
}
|
||||
else if (rank.Name.Number > 0)
|
||||
{
|
||||
sb.Append(",\"rankCliloc\":").Append(rank.Name.Number);
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One bare actor object, with no leading field name: serial, name, account (when there
|
||||
/// is one), the linked webId (when the account is linked), and the player flag. A `null`
|
||||
/// mobile writes null.
|
||||
///
|
||||
/// `acct` and `webId` are the site-identity fields, and they are emitted here
|
||||
/// unconditionally by design — the sidecar is a forwarder, and deciding who may see them
|
||||
/// is the website's job (it projects per the shard visibility rungs). Note that `acct` is
|
||||
/// genuinely optional: a PlayerMobile can have no Account at all.
|
||||
/// </summary>
|
||||
private static void WriteActor(StringBuilder sb, Mobile m)
|
||||
{
|
||||
if (m == null)
|
||||
{
|
||||
sb.Append("null");
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append('{');
|
||||
WriteActorFields(sb, m);
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The actor fields, with no braces, so a caller can add its own.
|
||||
///
|
||||
/// Split out for <see cref="WriteGuildMember"/>, which is the same object plus guild
|
||||
/// rank. Note the first field is written WITHOUT a leading comma and every later one
|
||||
/// with, so this must be the first thing inside its object.
|
||||
/// </summary>
|
||||
private static void WriteActorFields(StringBuilder sb, Mobile m)
|
||||
{
|
||||
sb.Append("\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||
|
||||
sb.Append(",\"name\":");
|
||||
Escape(sb, m.Name ?? "");
|
||||
|
||||
var acct = m.Account as Accounting.Account;
|
||||
if (acct != null)
|
||||
{
|
||||
sb.Append(",\"acct\":");
|
||||
Escape(sb, acct.Username);
|
||||
|
||||
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||
if (webId != null)
|
||||
{
|
||||
sb.Append(",\"webId\":");
|
||||
Escape(sb, webId);
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
|
||||
}
|
||||
|
||||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
||||
public static string End(this StringBuilder sb)
|
||||
{
|
||||
@@ -83,6 +307,19 @@ namespace Server.Custom.Bridge
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a bare JSON string value, or `null`, with no leading comma and no field name.
|
||||
/// For the hand-built arrays the event plane emits, where <see cref="Escape"/> would
|
||||
/// throw on the null a nullable field is entitled to be.
|
||||
/// </summary>
|
||||
public static void Text(StringBuilder sb, string value)
|
||||
{
|
||||
if (value == null)
|
||||
sb.Append("null");
|
||||
else
|
||||
Escape(sb, value);
|
||||
}
|
||||
|
||||
public static void Escape(StringBuilder sb, string value)
|
||||
{
|
||||
sb.Append('"');
|
||||
@@ -112,6 +349,75 @@ namespace Server.Custom.Bridge
|
||||
sb.Append('"');
|
||||
}
|
||||
|
||||
// ---- rewriting an already-built line (protocol 6) ----
|
||||
//
|
||||
// BridgeIdempotency replays a stored reply under the REPEAT's correlation id. It could
|
||||
// parse the line, edit the dictionary and re-serialize, but a round trip through
|
||||
// JavaScriptSerializer would silently renormalise every number and string in a reply this
|
||||
// file went to the trouble of writing by hand. These two edit the text instead, so a
|
||||
// replayed reply is byte-for-byte the original apart from the field that had to change.
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the value of a top-level STRING field, honouring backslash escapes when
|
||||
/// finding the value's end. Returns null if the field is not present as a string —
|
||||
/// never a half-rewritten line.
|
||||
/// </summary>
|
||||
public static string RewriteStringField(string line, string name, string value)
|
||||
{
|
||||
if (line == null || name == null || value == null)
|
||||
return null;
|
||||
|
||||
// The leading comma is part of the needle: every top-level field is written by Str()
|
||||
// after Begin() has already emitted `t` and `kind`, so a real one always has one. It
|
||||
// is the cheapest thing that stops the search matching the same text inside a value.
|
||||
var needle = ",\"" + name + "\":\"";
|
||||
int at = line.IndexOf(needle, StringComparison.Ordinal);
|
||||
|
||||
if (at < 0)
|
||||
return null;
|
||||
|
||||
int valueStart = at + needle.Length;
|
||||
int i = valueStart;
|
||||
|
||||
while (i < line.Length)
|
||||
{
|
||||
char c = line[i];
|
||||
|
||||
if (c == '\\')
|
||||
{
|
||||
i += 2; // an escape consumes the next character, whatever it is
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
break;
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i >= line.Length)
|
||||
return null; // unterminated: refuse rather than guess
|
||||
|
||||
var sb = new StringBuilder(line.Length + value.Length);
|
||||
sb.Append(line, 0, valueStart - 1); // up to and excluding the opening quote
|
||||
Escape(sb, value);
|
||||
sb.Append(line, i + 1, line.Length - i - 1);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends `"name":true` to an already-closed object. Returns the line unchanged if it
|
||||
/// is not one, so a malformed reply is passed through rather than corrupted further.
|
||||
/// </summary>
|
||||
public static string WithTrueFlag(string line, string name)
|
||||
{
|
||||
if (String.IsNullOrEmpty(line) || line[line.Length - 1] != '}')
|
||||
return line;
|
||||
|
||||
return line.Substring(0, line.Length - 1) + ",\"" + name + "\":true}";
|
||||
}
|
||||
|
||||
// ---- inbound ----
|
||||
|
||||
/// <summary>
|
||||
@@ -178,6 +484,42 @@ namespace Server.Custom.Bridge
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a JSON array of OBJECTS, as a list of dictionaries.
|
||||
///
|
||||
/// `JavaScriptSerializer` already materializes a nested object as another
|
||||
/// `Dictionary<string, object>` when the target is `object`, so this needs no
|
||||
/// parser work -- only the same defensive walk `GetStringList` does. Anything in the
|
||||
/// array that is not an object is skipped rather than failing the whole field: a
|
||||
/// malformed row in an oracle's dialogue should cost that row, not the NPC.
|
||||
///
|
||||
/// Returns an empty list for a missing or non-array value, never null.
|
||||
/// </summary>
|
||||
public static List<Dictionary<string, object>> GetObjectList(
|
||||
Dictionary<string, object> o, string key)
|
||||
{
|
||||
var result = new List<Dictionary<string, object>>();
|
||||
|
||||
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)
|
||||
{
|
||||
var row = item as Dictionary<string, object>;
|
||||
|
||||
if (row != null)
|
||||
result.Add(row);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int GetInt(Dictionary<string, object> o, string key, int fallback)
|
||||
{
|
||||
object v;
|
||||
@@ -194,5 +536,50 @@ namespace Server.Custom.Bridge
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Epoch milliseconds and lease durations do not fit an int, and JavaScriptSerializer
|
||||
/// hands a large JSON number back as a long or a decimal depending on its magnitude, so
|
||||
/// the conversion is done rather than the cast attempted.
|
||||
/// </summary>
|
||||
public static long GetLong(Dictionary<string, object> o, string key, long fallback)
|
||||
{
|
||||
object v;
|
||||
|
||||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||||
return fallback;
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.ToInt64(v, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A lease VALUE arrives as text on the wire whatever its declared type (see
|
||||
/// BridgeLeases), so this exists for the numbers that are genuinely numbers - a radius,
|
||||
/// a weight. InvariantCulture throughout: a shard running under a comma-decimal locale
|
||||
/// must read the same bytes the same way as one that is not.
|
||||
/// </summary>
|
||||
public static double GetDouble(Dictionary<string, object> o, string key, double fallback)
|
||||
{
|
||||
object v;
|
||||
|
||||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||||
return fallback;
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.ToDouble(v, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
708
overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs
Normal file
708
overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs
Normal file
@@ -0,0 +1,708 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
using Server.Engines.SeasonalEvents;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Protocol 7, part b. The two lease planes whose value lives on something that is
|
||||
/// <b>already in the world</b> — a property on an existing object, and a seasonal event's
|
||||
/// status.
|
||||
///
|
||||
/// `BridgeLeases` owns the wire, the deadline, the compare-and-set and the bookkeeping;
|
||||
/// this file owns everything that is specific to ServUO, which is the same split the config
|
||||
/// plane has had since 11b. What is new is that both planes here are <b>targeted</b>: a
|
||||
/// lease names a key AND the thing it applies to, because `Spawner.MaxCount` is one
|
||||
/// capability over thousands of spawners rather than one value.
|
||||
///
|
||||
/// ── Why a lease here must be PERSISTED, and the config plane's must not ────────────────
|
||||
///
|
||||
/// 11b's config lease is deliberately memory-only, and its header states the reason: a lease
|
||||
/// that never reaches disk makes a shard restart a *free* restore. That argument depends
|
||||
/// entirely on the leased value being memory-only too, and here it is not.
|
||||
///
|
||||
/// A spawner is an `Item`. It is in the world save. A seasonal entry is written to
|
||||
/// `Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a restart does
|
||||
/// not put either of them back — it puts the CHANGE back and throws away the deadline timer
|
||||
/// that was going to undo it. The world is then stuck at the leased value with nothing on
|
||||
/// this shard remembering that it is borrowed, which is the exact failure the lease framing
|
||||
/// exists to make impossible.
|
||||
///
|
||||
/// So the hold is persisted, in the Bridge's <b>third</b> save file, beside 11b's
|
||||
/// `Participation.bin` and 12a's `Owned.bin` — and, like both of those, written by the same
|
||||
/// `EventSink.WorldSave` that writes what it describes, so it cannot get out of step with
|
||||
/// it. The deadline is re-armed on load, from the stored absolute time.
|
||||
///
|
||||
/// **A deadline that has already passed while the shard was down fires at once**, rather
|
||||
/// than being dropped or extended. The promise the website was given is "back at baseline by
|
||||
/// then"; a shard that was off for the whole hold has not kept it, and restoring immediately
|
||||
/// is the only reading of it that is still true.
|
||||
///
|
||||
/// ── Reflection, bounded by an allowlist ───────────────────────────────────────────────
|
||||
///
|
||||
/// Properties are read and written through reflection, and the allowlist below is what makes
|
||||
/// that defensible rather than `[set` with extra steps. A pair not named here does not exist
|
||||
/// as far as this plane is concerned, whatever a caller sends; every entry additionally
|
||||
/// requires the property to carry `CommandProperty`, so nothing internal is reachable even
|
||||
/// if a pair were added carelessly. Reflection rather than a hand-written switch is what
|
||||
/// lets the boot self-check (§N10) actually verify a pair — a switch would compile happily
|
||||
/// against a property ServUO had renamed.
|
||||
/// </summary>
|
||||
public static class BridgeLeaseTargets
|
||||
{
|
||||
// ---- the object-property allowlist ----
|
||||
|
||||
/// <summary>
|
||||
/// One allowlisted property, and every type it may be applied to.
|
||||
///
|
||||
/// **`Spawner` and `XmlSpawner` share all four names**, which is a fact about this tree
|
||||
/// rather than a convenience: the shard's own `Spawns/*.xml` load as XmlSpawners and
|
||||
/// `[add spawner` makes the native one, so a catalog that named only one of them would
|
||||
/// work on a shard until the day it did not. They also share the semantics — `MaxCount`
|
||||
/// is the ceiling the next tick spawns up to on both.
|
||||
/// </summary>
|
||||
private sealed class PropEntry
|
||||
{
|
||||
public string Key;
|
||||
public string Label;
|
||||
public string Property;
|
||||
public string[] Types;
|
||||
public BridgeLeases.LeaseType Type;
|
||||
public double Min;
|
||||
public double Max;
|
||||
|
||||
/// <summary>
|
||||
/// True when the CLR property is a `TimeSpan` and the wire carries seconds.
|
||||
///
|
||||
/// The lease type vocabulary is int/float/bool/string and there is no duration in
|
||||
/// it, so a respawn window has to cross as a number. Seconds rather than minutes
|
||||
/// because the spawn files' own `DelayInSec` flag proves both are in use, and a unit
|
||||
/// that cannot express five seconds cannot express the shard's own data.
|
||||
/// </summary>
|
||||
public bool Seconds;
|
||||
}
|
||||
|
||||
private static readonly string[] SpawnerTypes =
|
||||
{
|
||||
"Server.Mobiles.Spawner",
|
||||
"Server.Mobiles.XmlSpawner",
|
||||
};
|
||||
|
||||
private static readonly PropEntry[] Props =
|
||||
{
|
||||
new PropEntry
|
||||
{
|
||||
Key = "Spawner.MaxCount",
|
||||
Label = "Spawner: how many at once",
|
||||
Property = "MaxCount",
|
||||
Types = SpawnerTypes,
|
||||
Type = BridgeLeases.LeaseType.Int,
|
||||
Min = 0.0,
|
||||
Max = 100.0,
|
||||
},
|
||||
new PropEntry
|
||||
{
|
||||
Key = "Spawner.MinDelay",
|
||||
Label = "Spawner: shortest respawn wait",
|
||||
Property = "MinDelay",
|
||||
Types = SpawnerTypes,
|
||||
Type = BridgeLeases.LeaseType.Int,
|
||||
Min = 0.0,
|
||||
Max = 86400.0,
|
||||
Seconds = true,
|
||||
},
|
||||
new PropEntry
|
||||
{
|
||||
Key = "Spawner.MaxDelay",
|
||||
Label = "Spawner: longest respawn wait",
|
||||
Property = "MaxDelay",
|
||||
Types = SpawnerTypes,
|
||||
Type = BridgeLeases.LeaseType.Int,
|
||||
Min = 0.0,
|
||||
Max = 86400.0,
|
||||
Seconds = true,
|
||||
},
|
||||
new PropEntry
|
||||
{
|
||||
Key = "Spawner.Running",
|
||||
Label = "Spawner: running",
|
||||
Property = "Running",
|
||||
Types = SpawnerTypes,
|
||||
Type = BridgeLeases.LeaseType.Bool,
|
||||
Min = 0.0,
|
||||
Max = 0.0,
|
||||
},
|
||||
};
|
||||
|
||||
// ---- the seasonal allowlist ----
|
||||
|
||||
/// <summary>
|
||||
/// The seasonal events an event may hold, and the one it may not.
|
||||
///
|
||||
/// **`TreasuresOfTokuno` is excluded, and its exclusion is the whole argument for §N10's
|
||||
/// self-check made concrete.** `SeasonalEventEntry.IsActive()` special-cases it and reads
|
||||
/// `TreasuresOfTokuno.DropEra` instead of `Status`, so setting its status writes a field
|
||||
/// that nothing consults. The write succeeds, the value reads back, a compare-and-set
|
||||
/// restore would pass — every mechanism in this file would report a working lease over a
|
||||
/// capability that does nothing at all. That is the failure N10 names ("a capability that
|
||||
/// lies"), and no runtime probe can catch this one, so it is caught by reading the source
|
||||
/// and excluded here by name.
|
||||
///
|
||||
/// The remaining eight are real, and six of them do MORE than flip a flag:
|
||||
/// `OnStatusChange()` calls a `CheckEnabled()` that generates or removes world content
|
||||
/// for Doom, Khaldun, Sorcerer's Dungeon, Krampus, Rising Tide and Fellowship. §G called
|
||||
/// this toggle "small and safe"; it is safe, because ServUO does it to itself from a
|
||||
/// staff gump, but it is not small, and an author scheduling one should be told so. The
|
||||
/// label says it.
|
||||
/// </summary>
|
||||
private static readonly EventType[] SeasonalExcluded =
|
||||
{
|
||||
EventType.TreasuresOfTokuno,
|
||||
};
|
||||
|
||||
/// <summary>The status values a seasonal lease may hold. `EventStatus` has exactly three.</summary>
|
||||
public static readonly string[] SeasonalValues = { "Inactive", "Active", "Seasonal" };
|
||||
|
||||
public const string SeasonalKey = "Seasonal.Status";
|
||||
|
||||
// ---- what the catalog offers ----
|
||||
|
||||
/// <summary>Every targeted key this shard offers, in `lease.list` order.</summary>
|
||||
public static IEnumerable<BridgeLeases.Catalog> Catalog()
|
||||
{
|
||||
for (int i = 0; i < Props.Length; i++)
|
||||
{
|
||||
var p = Props[i];
|
||||
|
||||
if (_dropped.Contains(p.Key))
|
||||
continue;
|
||||
|
||||
yield return new BridgeLeases.Catalog
|
||||
{
|
||||
Key = p.Key,
|
||||
Label = p.Label,
|
||||
Kind = BridgeLeases.LeaseKind.ObjectProperty,
|
||||
Type = p.Type,
|
||||
Min = p.Min,
|
||||
Max = p.Max,
|
||||
Default = p.Type == BridgeLeases.LeaseType.Bool ? "true" : "0",
|
||||
TargetLabel = "Which spawner",
|
||||
};
|
||||
}
|
||||
|
||||
if (!_dropped.Contains(SeasonalKey))
|
||||
{
|
||||
yield return new BridgeLeases.Catalog
|
||||
{
|
||||
Key = SeasonalKey,
|
||||
Label = "Seasonal event status",
|
||||
Kind = BridgeLeases.LeaseKind.Seasonal,
|
||||
Type = BridgeLeases.LeaseType.Text,
|
||||
Default = "Inactive",
|
||||
Values = SeasonalValues,
|
||||
TargetLabel = "Which seasonal event",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The seasonal events an author may name, for the module's option source.</summary>
|
||||
public static IEnumerable<string> SeasonalTargets()
|
||||
{
|
||||
foreach (EventType type in Enum.GetValues(typeof(EventType)))
|
||||
{
|
||||
if (Array.IndexOf(SeasonalExcluded, type) >= 0)
|
||||
continue;
|
||||
|
||||
if (SeasonalEventSystem.GetEntry(type) == null)
|
||||
continue;
|
||||
|
||||
yield return type.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- reading and writing ----
|
||||
|
||||
/// <summary>
|
||||
/// Reads a targeted key, or answers null when the target cannot be resolved.
|
||||
///
|
||||
/// **Null is "I could not find it", never a value**, and the caller turns it into a
|
||||
/// refusal. A missing spawner answered as `0` would let a lease be taken over nothing,
|
||||
/// record `0` as the baseline, and restore that baseline onto whatever object later
|
||||
/// claimed the serial.
|
||||
/// </summary>
|
||||
public static string Read(BridgeLeases.Catalog entry, string target, out string why)
|
||||
{
|
||||
why = null;
|
||||
|
||||
if (entry.Kind == BridgeLeases.LeaseKind.Seasonal)
|
||||
{
|
||||
var seasonal = SeasonalEntry(target, out why);
|
||||
return seasonal == null ? null : seasonal.Status.ToString();
|
||||
}
|
||||
|
||||
var prop = Lookup(entry.Key);
|
||||
|
||||
if (prop == null)
|
||||
{
|
||||
why = "no lease is offered for key '" + entry.Key + "'";
|
||||
return null;
|
||||
}
|
||||
|
||||
object obj = Resolve(target, prop, out why);
|
||||
|
||||
if (obj == null)
|
||||
return null;
|
||||
|
||||
var info = Info(obj.GetType(), prop, out why);
|
||||
|
||||
if (info == null)
|
||||
return null;
|
||||
|
||||
var raw = info.GetValue(obj, null);
|
||||
return Render(prop, raw);
|
||||
}
|
||||
|
||||
/// <summary>Writes a targeted key. Answers false with a reason rather than throwing.</summary>
|
||||
public static bool Write(BridgeLeases.Catalog entry, string target, string canonical, out string why)
|
||||
{
|
||||
why = null;
|
||||
|
||||
if (entry.Kind == BridgeLeases.LeaseKind.Seasonal)
|
||||
{
|
||||
var seasonal = SeasonalEntry(target, out why);
|
||||
|
||||
if (seasonal == null)
|
||||
return false;
|
||||
|
||||
EventStatus status;
|
||||
|
||||
if (!TryParseStatus(canonical, out status))
|
||||
{
|
||||
why = "'" + canonical + "' is not one of " + String.Join(", ", SeasonalValues);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The setter fires `OnStatusChange()`, which for six of the eight generates or
|
||||
// removes world content. That is ServUO's own behaviour from its own staff gump and
|
||||
// is exactly what makes the toggle worth having; it is noted here so nobody reads
|
||||
// this line as a field assignment.
|
||||
seasonal.Status = status;
|
||||
return true;
|
||||
}
|
||||
|
||||
var prop = Lookup(entry.Key);
|
||||
|
||||
if (prop == null)
|
||||
{
|
||||
why = "no lease is offered for key '" + entry.Key + "'";
|
||||
return false;
|
||||
}
|
||||
|
||||
object obj = Resolve(target, prop, out why);
|
||||
|
||||
if (obj == null)
|
||||
return false;
|
||||
|
||||
var info = Info(obj.GetType(), prop, out why);
|
||||
|
||||
if (info == null)
|
||||
return false;
|
||||
|
||||
object value;
|
||||
|
||||
if (!Parse(prop, canonical, out value, out why))
|
||||
return false;
|
||||
|
||||
info.SetValue(obj, value, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- target resolution ----
|
||||
|
||||
/// <summary>
|
||||
/// Finds the object a target names.
|
||||
///
|
||||
/// **Two ways to name one, and both are needed.** A serial is what `[props` shows a GM
|
||||
/// and what a rig can type; an `XmlSpawner.UniqueId` is what the shard's own
|
||||
/// `Spawns/*.xml` carry, which is the only naming the website can offer from the atlas
|
||||
/// without the shard being up. A dropdown built from serials is impossible — they are
|
||||
/// assigned when the world is built, and nothing off-shard knows them.
|
||||
///
|
||||
/// The UniqueId lookup is a scan of `World.Items`, and it stays a scan on purpose: it
|
||||
/// runs once per lease apply, which is a rare, human-scheduled operation, and a cache
|
||||
/// would be a second copy of the world to keep correct across `[add` and deletion.
|
||||
/// </summary>
|
||||
private static object Resolve(string target, PropEntry prop, out string why)
|
||||
{
|
||||
why = null;
|
||||
|
||||
if (String.IsNullOrEmpty(target))
|
||||
{
|
||||
why = "this lease needs a target";
|
||||
return null;
|
||||
}
|
||||
|
||||
Item item = null;
|
||||
int serial;
|
||||
|
||||
if (TryParseSerial(target, out serial))
|
||||
{
|
||||
item = World.FindItem((Serial)serial);
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
why = "nothing on this shard has serial " + target;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
item = World.Items.Values
|
||||
.OfType<XmlSpawner>()
|
||||
.FirstOrDefault(s => String.Equals(s.UniqueId, target, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
why = "no spawner on this shard carries the id '" + target + "'";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.Deleted)
|
||||
{
|
||||
why = "that object has been deleted";
|
||||
return null;
|
||||
}
|
||||
|
||||
// **The allowlist is checked against the object's OWN type, not against what was
|
||||
// asked for.** This is the sentence the whole plane rests on: a serial is a number a
|
||||
// caller chooses, so the only thing standing between `Spawner.MaxCount` and any item on
|
||||
// the shard is this check.
|
||||
var name = item.GetType().FullName;
|
||||
var ok = false;
|
||||
|
||||
for (int i = 0; i < prop.Types.Length && !ok; i++)
|
||||
{
|
||||
// Assignable rather than equal, so a shard's own subclass of Spawner is leasable —
|
||||
// an operator who derived from it has not changed what `MaxCount` means.
|
||||
var declared = ScriptCompiler.FindTypeByFullName(prop.Types[i]);
|
||||
ok = declared != null && declared.IsInstanceOfType(item);
|
||||
}
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
why = String.Format("{0} is a {1}, and this lease applies to {2}",
|
||||
target, name, String.Join(" or ", prop.Types));
|
||||
return null;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private static SeasonalEventEntry SeasonalEntry(string target, out string why)
|
||||
{
|
||||
why = null;
|
||||
|
||||
if (String.IsNullOrEmpty(target))
|
||||
{
|
||||
why = "this lease needs a target";
|
||||
return null;
|
||||
}
|
||||
|
||||
EventType type;
|
||||
|
||||
try
|
||||
{
|
||||
type = (EventType)Enum.Parse(typeof(EventType), target, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
why = "'" + target + "' is not a seasonal event on this shard";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.IndexOf(SeasonalExcluded, type) >= 0)
|
||||
{
|
||||
why = target + " reads its own era rather than this status, so leasing it would do nothing";
|
||||
return null;
|
||||
}
|
||||
|
||||
var entry = SeasonalEventSystem.GetEntry(type);
|
||||
|
||||
if (entry == null)
|
||||
{
|
||||
why = "this shard has no entry for " + target;
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---- the boot self-check (EVENTS.md N10) ----
|
||||
|
||||
private static readonly HashSet<string> _dropped = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Drops any targeted key that cannot possibly work, and says so on the console.
|
||||
///
|
||||
/// **It cannot be the config plane's check, and that is a property of the thing rather
|
||||
/// than a shortcut.** A config key is probed by writing to it and reading it back,
|
||||
/// because there is exactly one of it. A property has thousands of instances and no
|
||||
/// canonical one; probing would mean picking somebody's spawner at boot and writing to
|
||||
/// it. So what is verified here is everything that can be verified without touching the
|
||||
/// world: the type still resolves, the property still exists on it, it is still public
|
||||
/// and settable, it still carries `CommandProperty`, and its CLR type is still the one
|
||||
/// this file knows how to render. That is precisely the failure N10 was written for — a
|
||||
/// property that a later ServUO renamed or made read-only — and it catches it at boot
|
||||
/// rather than at 3am inside an unattended run.
|
||||
/// </summary>
|
||||
public static void SelfCheck()
|
||||
{
|
||||
_dropped.Clear();
|
||||
|
||||
for (int i = 0; i < Props.Length; i++)
|
||||
{
|
||||
var prop = Props[i];
|
||||
string why;
|
||||
|
||||
if (Verify(prop, out why))
|
||||
continue;
|
||||
|
||||
_dropped.Add(prop.Key);
|
||||
Console.WriteLine("[Bridge] lease {0}: DROPPED from the catalog -- {1}", prop.Key, why);
|
||||
}
|
||||
|
||||
// The seasonal plane's own check is the one thing it can verify without writing: that
|
||||
// this shard has entries at all. `SeasonalEventSystem.LoadEntries()` runs in
|
||||
// `Configure()`, so an empty list here means an operator has removed the system rather
|
||||
// than that the check ran too early.
|
||||
if (!SeasonalTargets().Any())
|
||||
{
|
||||
_dropped.Add(SeasonalKey);
|
||||
Console.WriteLine("[Bridge] lease {0}: DROPPED from the catalog -- this shard has no seasonal events", SeasonalKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Verify(PropEntry prop, out string why)
|
||||
{
|
||||
why = null;
|
||||
var found = 0;
|
||||
|
||||
for (int i = 0; i < prop.Types.Length; i++)
|
||||
{
|
||||
var type = ScriptCompiler.FindTypeByFullName(prop.Types[i]);
|
||||
|
||||
if (type == null)
|
||||
continue;
|
||||
|
||||
string detail;
|
||||
var info = Info(type, prop, out detail);
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
why = prop.Types[i] + ": " + detail;
|
||||
return false;
|
||||
}
|
||||
|
||||
found++;
|
||||
}
|
||||
|
||||
if (found == 0)
|
||||
{
|
||||
why = "none of " + String.Join(", ", prop.Types) + " exists on this shard";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The property, if it is one this plane may touch. Null with a reason otherwise.
|
||||
///
|
||||
/// `CommandProperty` is required and is not decoration: it is ServUO's own marker for
|
||||
/// "a staff member may set this", so requiring it means this plane can never reach
|
||||
/// further into an object than `[set` could — which is the bound §G draws, kept even
|
||||
/// though the allowlist already makes it unreachable.
|
||||
/// </summary>
|
||||
private static PropertyInfo Info(Type type, PropEntry prop, out string why)
|
||||
{
|
||||
why = null;
|
||||
|
||||
var info = type.GetProperty(prop.Property, BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
why = "no property named " + prop.Property;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!info.CanRead || !info.CanWrite)
|
||||
{
|
||||
why = prop.Property + " is not both readable and writable";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (info.GetCustomAttributes(typeof(CommandPropertyAttribute), true).Length == 0)
|
||||
{
|
||||
why = prop.Property + " is not a CommandProperty";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Matches(prop, info.PropertyType))
|
||||
{
|
||||
why = prop.Property + " is a " + info.PropertyType.Name + ", which this lease cannot carry";
|
||||
return null;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static bool Matches(PropEntry prop, Type clr)
|
||||
{
|
||||
if (prop.Seconds)
|
||||
return clr == typeof(TimeSpan);
|
||||
|
||||
switch (prop.Type)
|
||||
{
|
||||
case BridgeLeases.LeaseType.Int: return clr == typeof(int);
|
||||
case BridgeLeases.LeaseType.Float: return clr == typeof(double);
|
||||
case BridgeLeases.LeaseType.Bool: return clr == typeof(bool);
|
||||
default: return clr == typeof(string);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- value rendering ----
|
||||
|
||||
private static string Render(PropEntry prop, object raw)
|
||||
{
|
||||
if (raw == null)
|
||||
return "";
|
||||
|
||||
if (prop.Seconds)
|
||||
return ((long)((TimeSpan)raw).TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
switch (prop.Type)
|
||||
{
|
||||
case BridgeLeases.LeaseType.Int:
|
||||
return Convert.ToInt64(raw, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
case BridgeLeases.LeaseType.Float:
|
||||
return Convert.ToDouble(raw, CultureInfo.InvariantCulture).ToString("R", CultureInfo.InvariantCulture);
|
||||
|
||||
case BridgeLeases.LeaseType.Bool:
|
||||
return ((bool)raw) ? "true" : "false";
|
||||
|
||||
default:
|
||||
return Convert.ToString(raw, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Parse(PropEntry prop, string canonical, out object value, out string why)
|
||||
{
|
||||
value = null;
|
||||
why = null;
|
||||
|
||||
if (prop.Seconds)
|
||||
{
|
||||
double seconds;
|
||||
|
||||
if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out seconds))
|
||||
{
|
||||
why = "'" + canonical + "' is not a number of seconds";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = TimeSpan.FromSeconds(seconds);
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (prop.Type)
|
||||
{
|
||||
case BridgeLeases.LeaseType.Int:
|
||||
{
|
||||
double n;
|
||||
|
||||
if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
|
||||
{
|
||||
why = "'" + canonical + "' is not a number";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = (int)n;
|
||||
return true;
|
||||
}
|
||||
|
||||
case BridgeLeases.LeaseType.Bool:
|
||||
{
|
||||
value = String.Equals(canonical, "true", StringComparison.OrdinalIgnoreCase) || canonical == "1";
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
double d;
|
||||
|
||||
if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out d))
|
||||
{
|
||||
why = "'" + canonical + "' is not a number";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = d;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static PropEntry Lookup(string key)
|
||||
{
|
||||
for (int i = 0; i < Props.Length; i++)
|
||||
{
|
||||
if (String.Equals(Props[i].Key, key, StringComparison.Ordinal))
|
||||
return Props[i];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseSerial(string raw, out int serial)
|
||||
{
|
||||
serial = 0;
|
||||
|
||||
if (String.IsNullOrEmpty(raw))
|
||||
return false;
|
||||
|
||||
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Int32.TryParse(raw.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out serial);
|
||||
}
|
||||
|
||||
// A bare decimal is a serial too, but a UniqueId is a GUID and never all digits, so
|
||||
// there is nothing to disambiguate.
|
||||
return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out serial);
|
||||
}
|
||||
|
||||
private static bool TryParseStatus(string raw, out EventStatus status)
|
||||
{
|
||||
status = EventStatus.Inactive;
|
||||
|
||||
for (int i = 0; i < SeasonalValues.Length; i++)
|
||||
{
|
||||
if (!String.Equals(SeasonalValues[i], raw, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
status = (EventStatus)Enum.Parse(typeof(EventStatus), SeasonalValues[i], false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
1405
overlay/Scripts/Custom/Bridge/BridgeLeases.cs
Normal file
1405
overlay/Scripts/Custom/Bridge/BridgeLeases.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,17 @@ namespace Server.Custom.Bridge
|
||||
/// </summary>
|
||||
public static void Emit(string line)
|
||||
{
|
||||
if (!_running || line == null)
|
||||
if (line == null)
|
||||
return;
|
||||
|
||||
// Protocol 6. While a keyed command's handler runs — Core thread, one at a time — every
|
||||
// line it emits is offered to the recent-key store so the correlated reply can be
|
||||
// replayed to a retry later. Deliberately BEFORE the `_running` check: a reply the link
|
||||
// was too dead to deliver is precisely the one a retry will come back for.
|
||||
if (BridgeIdempotency.Capturing)
|
||||
BridgeIdempotency.Observe(line);
|
||||
|
||||
if (!_running)
|
||||
return;
|
||||
|
||||
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over
|
||||
|
||||
705
overlay/Scripts/Custom/Bridge/BridgeMarket.cs
Normal file
705
overlay/Scripts/Custom/Bridge/BridgeMarket.cs
Normal file
@@ -0,0 +1,705 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
using Server.Engines.VendorSearching;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The shard-wide player-vendor index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8). Every player vendor's
|
||||
/// shop name, owner, location and priced inventory, published as one authoritative
|
||||
/// <c>vendor.listing</c> frame per vendor, so the website can offer the search the in-game
|
||||
/// Vendor Search gump offers — from outside the game.
|
||||
///
|
||||
/// ---- Why this is a sweep and not an RPC ----
|
||||
///
|
||||
/// The obvious shape is a <c>market.snapshot</c> request/reply like vendor.snapshot next
|
||||
/// door. It cannot work: the sidecar's rpc router correlates on the FIRST frame carrying a
|
||||
/// matching reqId and resolves a single oneshot, so a chunked reply sharing one reqId would
|
||||
/// deliver chunk 1 to the HTTP caller and LEAK chunks 2..N onto the broadcast feed. A
|
||||
/// whole-world snapshot in one frame is not an option either — the reply timeout is 10 s and
|
||||
/// 40,000 listings do not serialize in time.
|
||||
///
|
||||
/// So it is a diff sweep on the broadcast stream, shaped like <see cref="BridgeHousing"/>:
|
||||
/// one frame per vendor, authoritative for that vendor, plus vendor.listing.remove when one
|
||||
/// goes away. The per-account <c>vendor.snapshot</c> RPC is untouched; the player portal
|
||||
/// keeps using it.
|
||||
///
|
||||
/// ---- The two perf traps, and what this does about them ----
|
||||
///
|
||||
/// 1. **VendorSearch.GetItemName is a packet builder, not a field read.** It constructs an
|
||||
/// ObjectPropertyList, calls GetProperties, serialises it and then byte-parses the
|
||||
/// resulting packet — PER ITEM. Across a full pass that is a multi-hundred-millisecond
|
||||
/// stall on the Core thread. It is never called here. The frame carries `itemId`, `hue`,
|
||||
/// `amount`, `price`, the plain `item.Name` field (null for most items) and
|
||||
/// `item.LabelNumber`; the website resolves display names against its own cliloc table,
|
||||
/// exactly as char.profile.equipment already does.
|
||||
///
|
||||
/// (On any modern client the call would not even work: every current client ships its
|
||||
/// Cliloc.* files compressed, ServUO's bundled Ultima.StringList reads only the old plain
|
||||
/// layout, so VendorSearch.StringList is null and GetItemName returns item.Name anyway.
|
||||
/// The in-game gump has the same gap.)
|
||||
///
|
||||
/// 2. **A full pass is unbounded in world size.** 500 vendors × 80 listings is ~40,000 item
|
||||
/// reads, and the reusable public GetItems(Container, List<Item>) recurses into
|
||||
/// sub-containers, so the real count runs ABOVE the top-level pack.Items a naive estimate
|
||||
/// would use. So the sweep is amortized: a persistent round-robin cursor over
|
||||
/// PlayerVendor.PlayerVendors advances at most MarketSweepBatch vendors per tick, which
|
||||
/// makes the PER-TICK cost bounded independently of how many vendors exist. Full coverage
|
||||
/// takes ceil(vendors / batch) × MarketSweepSeconds. This is the one genuinely new pattern
|
||||
/// versus the other sweeps, which all walk their whole collection every tick.
|
||||
///
|
||||
/// ---- Privacy ----
|
||||
///
|
||||
/// `pv.VendorSearch` is ServUO's own per-vendor opt-out and DoSearch filters on it, so a
|
||||
/// player who hid their vendor in game is hidden on the website too: an opted-out vendor is
|
||||
/// skipped entirely and the seen-set removal then drops it from the board. Map.Internal and
|
||||
/// a null Backpack are skipped for the same reason DoSearch skips them.
|
||||
///
|
||||
/// Owner is written as flat `ownerSerial`/`ownerName` — never through BridgeJson.Actor,
|
||||
/// which would add `acct` and `webId`. Same argument BridgePoints makes: this is the widest-
|
||||
/// audience surface the bridge has, and the site resolves serial → user from its own
|
||||
/// shard_account_links mirror when staff need it.
|
||||
/// </summary>
|
||||
public static class BridgeMarket
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// vendor serial -> last-emitted signature.
|
||||
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
||||
|
||||
// Round-robin cursor: an INDEX into PlayerVendor.PlayerVendors, not a serial. The list is
|
||||
// mutated by placement/deletion between ticks, so the cursor is a hint, not a promise — it
|
||||
// is wrapped and clamped every tick, and a shifted list at worst re-visits or defers a
|
||||
// vendor by one cycle. Tracking a serial instead would cost a lookup to find "where was I"
|
||||
// and buy nothing: the sweep is idempotent per vendor.
|
||||
private static int _cursor;
|
||||
|
||||
private static long _sweeps, _emitted, _removed, _scanned, _skipped, _truncated;
|
||||
|
||||
// Per-tick cost, in milliseconds. Reported by `[bridge status` because the
|
||||
// whole design of this sweep is a claim about that number — the batch cap is what makes it
|
||||
// independent of world size — and an operator tuning MarketSweepBatch is otherwise tuning
|
||||
// blind. `_maxMs` is the one that matters: the Core thread runs this between frames, so the
|
||||
// worst tick is the budget, not the average.
|
||||
private static double _lastMs, _maxMs;
|
||||
private static readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
|
||||
|
||||
// Reused across ticks. The item walk is single-threaded (Core thread) and the list is
|
||||
// cleared before each vendor, so one buffer serves the whole sweep — the alternative is a
|
||||
// fresh List<Item> per vendor per tick, which at 25 vendors × every 60 s is pure garbage.
|
||||
private static readonly List<Item> _items = new List<Item>();
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
// A new sidecar knows nothing, so drop the diff state and start the round-robin from
|
||||
// the top. The re-emit of the whole world is self-throttled by the batch window — this
|
||||
// is the one place the amortized sweep pays for itself twice, because a reconnect on a
|
||||
// whole-world sweep would otherwise be the biggest burst the bridge ever produces.
|
||||
_last.Clear();
|
||||
_cursor = 0;
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
Stop();
|
||||
|
||||
_timer = Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(BridgeConfig.MarketSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.MarketSweepSeconds),
|
||||
MarketSweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare (key-less) string value, or JSON null.
|
||||
///
|
||||
/// <see cref="BridgeJson.Escape"/> takes a non-null string — it dereferences
|
||||
/// <c>value.Length</c> immediately — and <see cref="BridgeJson.Str"/> writes the `,"key":`
|
||||
/// prefix itself, so neither serves a value written inside a hand-built object. Most of
|
||||
/// what this frame writes is legitimately null (an item's plain Name is null for nearly
|
||||
/// every item, a vendor standing in the street has no house), so this is the common path
|
||||
/// rather than an edge case.
|
||||
/// </summary>
|
||||
private static void Text(StringBuilder sb, string value)
|
||||
{
|
||||
if (value == null)
|
||||
sb.Append("null");
|
||||
else
|
||||
BridgeJson.Escape(sb, value);
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
var all = PlayerVendor.PlayerVendors;
|
||||
|
||||
return String.Format(
|
||||
"market(enabled={0} sweeps={1} scanned={2} emitted={3} removed={4} skipped={5} truncated={6} tracked={7} vendors={8} cursor={9} batch={10} lastMs={11:F2} maxMs={12:F2})",
|
||||
BridgeConfig.MarketEnabled, _sweeps, _scanned, _emitted, _removed, _skipped,
|
||||
_truncated, _last.Count, all == null ? 0 : all.Count, _cursor,
|
||||
BridgeConfig.MarketSweepBatch, _lastMs, _maxMs);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
MarketSweep();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One tick: at most <c>MarketSweepBatch</c> vendors starting at the cursor, then the
|
||||
/// removal pass.
|
||||
///
|
||||
/// The removal pass is the part the batching makes subtle. `_last` holds every vendor
|
||||
/// seen in ANY previous tick, but this tick only visited a window — so "not in this
|
||||
/// tick's seen set" does NOT mean gone. Removals are therefore decided against the
|
||||
/// CURRENT vendor list (plus the opt-out/validity rules), not against the window, which
|
||||
/// is a cheap pass over serials rather than a second inventory walk.
|
||||
/// </summary>
|
||||
private static void MarketSweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!BridgeConfig.MarketEnabled)
|
||||
return;
|
||||
|
||||
_sweeps++;
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
_clock.Restart();
|
||||
|
||||
var all = PlayerVendor.PlayerVendors;
|
||||
|
||||
if (all == null || all.Count == 0)
|
||||
{
|
||||
Reap(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// A live set of every serial that SHOULD be on the board right now, built as the
|
||||
// window is walked plus a cheap pass over the rest. Built here rather than reusing
|
||||
// a field so a throwing vendor cannot leave a half-built set behind.
|
||||
var present = new HashSet<Serial>();
|
||||
|
||||
var count = all.Count;
|
||||
var batch = Math.Min(BridgeConfig.MarketSweepBatch, count);
|
||||
|
||||
if (_cursor >= count)
|
||||
_cursor = 0;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var vendor = all[i];
|
||||
|
||||
if (Eligible(vendor))
|
||||
present.Add(vendor.Serial);
|
||||
}
|
||||
|
||||
for (int n = 0; n < batch; n++)
|
||||
{
|
||||
var index = (_cursor + n) % count;
|
||||
var vendor = all[index];
|
||||
|
||||
if (!Eligible(vendor))
|
||||
{
|
||||
_skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// One bad vendor must not cost the rest of the window: the item walk touches
|
||||
// arbitrary Item subclasses on a shard running modified scripts.
|
||||
try
|
||||
{
|
||||
SweepVendor(vendor);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] market sweep threw for 0x{0:X}: {1}",
|
||||
vendor.Serial.Value, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
_cursor = count == 0 ? 0 : (_cursor + batch) % count;
|
||||
|
||||
Reap(present);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] market sweep threw: {0}", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// In `finally` so a throwing tick still records what it cost — a sweep that blows
|
||||
// the budget and then throws is exactly the one worth seeing in the status line.
|
||||
if (_clock.IsRunning)
|
||||
{
|
||||
_clock.Stop();
|
||||
_lastMs = _clock.Elapsed.TotalMilliseconds;
|
||||
if (_lastMs > _maxMs)
|
||||
_maxMs = _lastMs;
|
||||
|
||||
WarnIfSlow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-tick budget, milliseconds. The batch cap exists to hold a tick under this
|
||||
/// regardless of world size, so exceeding it means MarketSweepBatch is too large for
|
||||
/// this shard's shops — the one thing an operator needs told, and the one thing
|
||||
/// `[bridge status` cannot tell them unprompted. Generous: a tick is off the frame
|
||||
/// budget, and the alternative to a rare 50 ms tick is a permanently stale market.
|
||||
/// </summary>
|
||||
private const double SlowTickMs = 50.0;
|
||||
|
||||
// At most one warning a minute. A shard whose batch is genuinely too big would otherwise
|
||||
// print every MarketSweepSeconds forever, and a log nobody can read is a log nobody reads.
|
||||
private static DateTime _lastWarn = DateTime.MinValue;
|
||||
|
||||
private static void WarnIfSlow()
|
||||
{
|
||||
if (_lastMs <= SlowTickMs)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (now - _lastWarn < TimeSpan.FromMinutes(1))
|
||||
return;
|
||||
|
||||
_lastWarn = now;
|
||||
|
||||
Console.WriteLine(
|
||||
// ASCII only. The ServUO console writes in the OS code page, so an em dash here
|
||||
// renders as "???" in the log an operator would paste into an issue.
|
||||
"[Bridge] market sweep took {0:F1} ms (budget {1:F0} ms) - lower Bridge.MarketSweepBatch (now {2}) if this persists",
|
||||
_lastMs, SlowTickMs, BridgeConfig.MarketSweepBatch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same filter DoSearch applies, so the website's index is the in-game index.
|
||||
/// <c>VendorSearch</c> is the player's own opt-out toggle and is honoured first.
|
||||
/// </summary>
|
||||
private static bool Eligible(PlayerVendor vendor)
|
||||
{
|
||||
return vendor != null
|
||||
&& !vendor.Deleted
|
||||
&& vendor.VendorSearch
|
||||
&& vendor.Map != null
|
||||
&& vendor.Map != Map.Internal
|
||||
&& vendor.Backpack != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops from the board every tracked vendor that is no longer eligible.
|
||||
/// <paramref name="present"/> null means "there are no vendors at all", which clears it.
|
||||
/// </summary>
|
||||
private static void Reap(HashSet<Serial> present)
|
||||
{
|
||||
if (_last.Count == 0)
|
||||
return;
|
||||
|
||||
List<Serial> gone = null;
|
||||
|
||||
foreach (var serial in _last.Keys)
|
||||
{
|
||||
if (present != null && present.Contains(serial))
|
||||
continue;
|
||||
|
||||
if (gone == null)
|
||||
gone = new List<Serial>();
|
||||
|
||||
gone.Add(serial);
|
||||
}
|
||||
|
||||
if (gone == null)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < gone.Count; i++)
|
||||
{
|
||||
_last.Remove(gone[i]);
|
||||
BridgeLink.Emit(BridgeJson.Begin("vendor.listing.remove").Ser("serial", gone[i]).End());
|
||||
_removed++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SweepVendor(PlayerVendor vendor)
|
||||
{
|
||||
_scanned++;
|
||||
|
||||
CollectItems(vendor);
|
||||
|
||||
var sig = Signature(vendor);
|
||||
|
||||
string prior;
|
||||
if (_last.TryGetValue(vendor.Serial, out prior) && prior == sig)
|
||||
return; // nothing about this shop changed since it was last published
|
||||
|
||||
_last[vendor.Serial] = sig;
|
||||
BridgeLink.Emit(WriteVendor(vendor));
|
||||
_emitted++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every sellable item on one vendor, into the shared buffer.
|
||||
///
|
||||
/// Mirrors VendorSearch's own private GetItems(PlayerVendor): the vendor's own movable
|
||||
/// equipment (minus the backpack itself and hair layers, which are not merchandise)
|
||||
/// followed by a recursive walk of the backpack. The recursion uses the PUBLIC
|
||||
/// GetItems(Container, List<Item>) rather than a hand-rolled one so that ServUO's
|
||||
/// rule about which containers are sold whole (quivers, seed boxes, jewelry boxes, …)
|
||||
/// stays ServUO's to define — the predicate that decides it is private, and a copy here
|
||||
/// would silently diverge the first time that list changes.
|
||||
/// </summary>
|
||||
private static void CollectItems(PlayerVendor vendor)
|
||||
{
|
||||
_items.Clear();
|
||||
|
||||
var own = vendor.Items;
|
||||
|
||||
if (own != null)
|
||||
{
|
||||
for (int i = 0; i < own.Count; i++)
|
||||
{
|
||||
var item = own[i];
|
||||
|
||||
if (item == null || !item.Movable || item == vendor.Backpack)
|
||||
continue;
|
||||
|
||||
if (item.Layer == Layer.Hair || item.Layer == Layer.FacialHair)
|
||||
continue;
|
||||
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (vendor.Backpack != null)
|
||||
VendorSearch.GetItems(vendor.Backpack, _items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A listing's price, and whether it was priced by an enclosing container.
|
||||
///
|
||||
/// ServUO prices a container as a unit: an item inside a priced bag has no VendorItem of
|
||||
/// its own and inherits the bag's price, which DoSearch surfaces as `isChild`. Reproduced
|
||||
/// exactly, because a website that priced every item in a 40k bag at 40k would be lying
|
||||
/// about the shard.
|
||||
/// </summary>
|
||||
private static int PriceOf(PlayerVendor vendor, Item item, out bool child)
|
||||
{
|
||||
child = false;
|
||||
|
||||
var vi = vendor.GetVendorItem(item);
|
||||
|
||||
if (vi != null)
|
||||
return vi.Price;
|
||||
|
||||
var parent = item.Parent as Container;
|
||||
|
||||
while (parent != null)
|
||||
{
|
||||
vi = vendor.GetVendorItem(parent);
|
||||
|
||||
if (vi != null)
|
||||
{
|
||||
child = true;
|
||||
return vi.Price;
|
||||
}
|
||||
|
||||
parent = parent.Parent as Container;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The diff key. Location, shop name and owner are in it because they move a vendor's
|
||||
/// row on the site; every listing's serial, price and amount are in it because those are
|
||||
/// what a shopper searches on.
|
||||
///
|
||||
/// Built over the SAME buffer the frame is written from, in the same order, so a
|
||||
/// signature match really does mean an identical frame — a cheaper hash (count + a sum
|
||||
/// of serial^price, as §8.3 first proposed) collides on the common case of two items
|
||||
/// swapping prices, which is exactly what re-pricing a shop looks like.
|
||||
/// </summary>
|
||||
private static string Signature(PlayerVendor vendor)
|
||||
{
|
||||
var sb = new StringBuilder(256);
|
||||
|
||||
sb.Append(vendor.ShopName ?? "").Append('|');
|
||||
sb.Append(vendor.Owner == null ? 0 : vendor.Owner.Serial.Value).Append('|');
|
||||
sb.Append(vendor.Map == null ? "" : vendor.Map.Name).Append('|');
|
||||
sb.Append(vendor.X).Append(',').Append(vendor.Y).Append('|');
|
||||
|
||||
// The FEE STATE, and it belongs here for a reason found on a live rig: a vendor
|
||||
// quietly running out of gold changes none of the fields above, so without this the
|
||||
// sweep sees no change, emits nothing, and `uo.vendor.expiring` -- the warning whose
|
||||
// entire subject is a vendor running out of gold -- can only fire by coincidence,
|
||||
// when somebody happens to reprice an item on a shop that is already broke.
|
||||
//
|
||||
// The DERIVED values, not the raw ones. `periodsRemaining` is an integer division, so
|
||||
// it moves only when the shard's own answer to "is this vendor in danger" moves --
|
||||
// near-zero extra frame volume -- while `HoldGold` changes on every sale and
|
||||
// `NextPayTime` on every tick, which would re-emit a fat listing frame for a shop
|
||||
// whose listings did not change. Protocol-neutral: the fields already ship in
|
||||
// `AppendFees`, and this changes only WHEN a frame is sent.
|
||||
AppendFeeSignature(sb, vendor);
|
||||
|
||||
var limit = Math.Min(_items.Count, BridgeConfig.MarketMaxListings);
|
||||
|
||||
sb.Append(_items.Count).Append('|');
|
||||
|
||||
for (int i = 0; i < limit; i++)
|
||||
{
|
||||
var item = _items[i];
|
||||
|
||||
if (item == null || item.Deleted)
|
||||
continue;
|
||||
|
||||
bool child;
|
||||
var price = PriceOf(vendor, item, out child);
|
||||
|
||||
if (price <= 0)
|
||||
continue;
|
||||
|
||||
sb.Append(item.Serial.Value.ToString("X")).Append(':')
|
||||
.Append(price).Append(':')
|
||||
.Append(item.Amount).Append(';');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One vendor frame — authoritative for that vendor, so the website replaces its whole
|
||||
/// listing set from it rather than merging.
|
||||
///
|
||||
/// `location` is one nested object rather than flat map/x/y/region because it is ONE
|
||||
/// admin-configurable field on the site (`market.location`): the visibility projection
|
||||
/// matches literal JSON keys, so a nested object is what lets a single rule hide a
|
||||
/// vendor's whereabouts on both the live frame and the stored read model. Flat keys
|
||||
/// would need five rules that could drift apart.
|
||||
///
|
||||
/// `count` is the number of listings PUBLISHED, and `truncated` says the shop holds
|
||||
/// more. A shop over the cap is a real thing (commodity resellers run thousands of
|
||||
/// stacks) and the site says so rather than quietly showing a partial shop as complete.
|
||||
/// </summary>
|
||||
private static string WriteVendor(PlayerVendor vendor)
|
||||
{
|
||||
var sb = BridgeJson.Begin("vendor.listing")
|
||||
.Ser("serial", vendor.Serial)
|
||||
.Str("shopName", vendor.ShopName);
|
||||
|
||||
var owner = vendor.Owner;
|
||||
|
||||
if (owner != null)
|
||||
{
|
||||
sb.Ser("ownerSerial", owner.Serial);
|
||||
sb.Str("ownerName", owner.Name);
|
||||
|
||||
// Protocol 5. Without this the listing names an owner the website cannot resolve to
|
||||
// a person: ownerName is a character name, and only the account is the link key.
|
||||
var acct = owner.Account as Account;
|
||||
if (acct != null)
|
||||
sb.Str("ownerAcct", acct.Username);
|
||||
}
|
||||
|
||||
AppendFees(sb, vendor);
|
||||
|
||||
sb.Append(",\"location\":{\"map\":");
|
||||
Text(sb, vendor.Map == null ? null : vendor.Map.Name);
|
||||
sb.Append(",\"x\":").Append(vendor.X);
|
||||
sb.Append(",\"y\":").Append(vendor.Y);
|
||||
sb.Append(",\"z\":").Append(vendor.Z);
|
||||
|
||||
var region = vendor.Region;
|
||||
sb.Append(",\"region\":");
|
||||
Text(sb, region == null ? null : region.Name);
|
||||
|
||||
// The house name is the sign's, which is what a player would be told to look for
|
||||
// ("Bob's Villa"), not the house type. Null for a vendor standing outside one.
|
||||
var house = vendor.House;
|
||||
var sign = house == null ? null : house.Sign;
|
||||
sb.Append(",\"house\":");
|
||||
Text(sb, sign == null ? null : sign.GetName());
|
||||
|
||||
sb.Append('}');
|
||||
|
||||
var max = BridgeConfig.MarketMaxListings;
|
||||
var published = 0;
|
||||
var considered = 0;
|
||||
|
||||
var items = new StringBuilder(512);
|
||||
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var item = _items[i];
|
||||
|
||||
if (item == null || item.Deleted)
|
||||
continue;
|
||||
|
||||
bool child;
|
||||
var price = PriceOf(vendor, item, out child);
|
||||
|
||||
// Unpriced items are inventory, not listings — DoSearch drops them the same way.
|
||||
if (price <= 0)
|
||||
continue;
|
||||
|
||||
considered++;
|
||||
|
||||
if (published >= max)
|
||||
continue;
|
||||
|
||||
if (published > 0)
|
||||
items.Append(',');
|
||||
|
||||
items.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
|
||||
items.Append(",\"itemId\":").Append(item.ItemID);
|
||||
items.Append(",\"hue\":").Append(item.Hue);
|
||||
items.Append(",\"amount\":").Append(item.Amount);
|
||||
items.Append(",\"price\":").Append(price);
|
||||
|
||||
// The PLAIN Name field, which is null for most items — never GetItemName, which
|
||||
// builds and parses a property packet per item. LabelNumber is the cliloc the
|
||||
// website resolves against its own table.
|
||||
items.Append(",\"name\":");
|
||||
Text(items, item.Name);
|
||||
items.Append(",\"cliloc\":").Append(item.LabelNumber);
|
||||
|
||||
if (child)
|
||||
items.Append(",\"child\":true");
|
||||
|
||||
items.Append('}');
|
||||
|
||||
published++;
|
||||
}
|
||||
|
||||
sb.Num("count", published);
|
||||
sb.Num("total", considered);
|
||||
sb.Bool("truncated", considered > published);
|
||||
|
||||
if (considered > published)
|
||||
_truncated++;
|
||||
|
||||
sb.Append(",\"items\":[").Append(items).Append(']');
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protocol 5. The vendor's fee state, which is what makes "your vendor is about to be
|
||||
/// dismissed" a thing the website can say BEFORE it happens instead of after.
|
||||
///
|
||||
/// The dismissal rule is PlayerVendor.PayTimer.OnTick: at every tick the charge is
|
||||
/// compared with the funds, and `if (pay > totalGold) Destroy()`. Both halves of that
|
||||
/// comparison differ between ServUO's two vendor systems, so both are resolved here
|
||||
/// rather than left for the sidecar or the website to guess at:
|
||||
///
|
||||
/// | charge | funds | interval
|
||||
/// NewVendorSystem | ChargePerRealWorldDay | HoldGold | 1 real day
|
||||
/// old system | ChargePerDay | BankAccount + HoldGold | 1 UO day
|
||||
///
|
||||
/// Two consequences worth stating, because both are easy to get wrong downstream:
|
||||
///
|
||||
/// * A field called `daysRemaining` would be WRONG on an old-system shard, where a pay
|
||||
/// period is a UO day (Clock.MinutesPerUODay, roughly two real hours) rather than a
|
||||
/// real one. So this emits `periodsRemaining` plus the interval that gives it meaning,
|
||||
/// and resolves the arithmetic into `dismissalAt` -- an instant, which needs no units.
|
||||
/// * A commission vendor (IsCommission) has no PayTimer at all and is never dismissed
|
||||
/// for fees. It reports exempt:true and no schedule, rather than a misleading
|
||||
/// "infinite days".
|
||||
///
|
||||
/// `dismissalAt` assumes no further sales or deposits, exactly as a bank balance
|
||||
/// projection does. Unlike a dynamic-decay house, though, there is no randomness in it:
|
||||
/// given the current funds it is the exact tick the vendor is destroyed on.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// The fee state as the change-detector sees it: exempt, and how many pay ticks the
|
||||
/// vendor survives. Kept beside `AppendFees` so the two cannot drift -- a fee field
|
||||
/// that becomes decision-relevant has to be added in both places, and this comment is
|
||||
/// where the next person is told so.
|
||||
/// </summary>
|
||||
private static void AppendFeeSignature(StringBuilder sb, PlayerVendor vendor)
|
||||
{
|
||||
if (vendor == null || vendor.IsCommission)
|
||||
{
|
||||
sb.Append("exempt|");
|
||||
return;
|
||||
}
|
||||
|
||||
int charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
|
||||
int funds = BaseHouse.NewVendorSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
|
||||
|
||||
// Mirrors AppendFees: a free vendor never runs out, and reports no periods at all.
|
||||
sb.Append(charge > 0 ? (funds / charge).ToString() : "free").Append('|');
|
||||
}
|
||||
|
||||
private static void AppendFees(StringBuilder sb, PlayerVendor vendor)
|
||||
{
|
||||
sb.Append(",\"fees\":{");
|
||||
|
||||
if (vendor.IsCommission)
|
||||
{
|
||||
sb.Append("\"exempt\":true}");
|
||||
return;
|
||||
}
|
||||
|
||||
bool newSystem = BaseHouse.NewVendorSystem;
|
||||
|
||||
int charge = newSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
|
||||
int funds = newSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
|
||||
|
||||
sb.Append("\"exempt\":false");
|
||||
sb.Append(",\"newVendorSystem\":").Append(newSystem ? "true" : "false");
|
||||
sb.Append(",\"chargePerPeriod\":").Append(charge);
|
||||
sb.Append(",\"funds\":").Append(funds);
|
||||
sb.Append(",\"holdGold\":").Append(vendor.HoldGold);
|
||||
sb.Append(",\"bankAccount\":").Append(vendor.BankAccount);
|
||||
|
||||
var interval = newSystem ? TimeSpan.FromDays(1.0) : TimeSpan.FromMinutes(Clock.MinutesPerUODay);
|
||||
sb.Append(",\"payIntervalSec\":").Append((long)interval.TotalSeconds);
|
||||
|
||||
var nextPay = vendor.NextPayTime.ToUniversalTime();
|
||||
sb.Append(",\"nextPayAt\":");
|
||||
Text(sb, nextPay.ToString("o"));
|
||||
|
||||
// A free vendor (no priced stock under the old system can reach charge 0) never runs out.
|
||||
if (charge > 0)
|
||||
{
|
||||
// Ticks it survives before the one that finds pay > totalGold.
|
||||
long periods = funds / charge;
|
||||
sb.Append(",\"periodsRemaining\":").Append(periods);
|
||||
|
||||
sb.Append(",\"dismissalAt\":");
|
||||
Text(sb, nextPay.AddSeconds(periods * interval.TotalSeconds).ToString("o"));
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Mobiles;
|
||||
using Server.Services.TownCryer;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Website news articles pushed into the modern Town Cryer News gump
|
||||
/// (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier
|
||||
/// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML),
|
||||
/// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries,
|
||||
/// which the stock news gumps already render (they branch on TextDefinition.Number, so string
|
||||
/// content needs no gump change).
|
||||
///
|
||||
/// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep
|
||||
/// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just
|
||||
/// the title through the existing crier say path (default on), so players hear it in-world.
|
||||
///
|
||||
/// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall),
|
||||
/// which is required to touch the shared news list and to send crier packets.
|
||||
/// </summary>
|
||||
public static class BridgeNews
|
||||
{
|
||||
// A neutral scroll gump when the website supplies no image.
|
||||
private const int DefaultImage = 0x64E;
|
||||
|
||||
// Website id -> the news entry we created for it, so a later remove/replace can find it.
|
||||
private static readonly Dictionary<string, TownCryerNewsEntry> _ours =
|
||||
new Dictionary<string, TownCryerNewsEntry>(StringComparer.Ordinal);
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("news.add", OnAdd);
|
||||
BridgeBoot.RegisterHandler("news.remove", OnRemove);
|
||||
}
|
||||
|
||||
private static void OnAdd(Dictionary<string, object> o)
|
||||
{
|
||||
var id = BridgeJson.GetString(o, "id");
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
Reply("news.error", null, "missing id");
|
||||
return;
|
||||
}
|
||||
|
||||
var list = TownCryerSystem.NewsEntries;
|
||||
if (list == null)
|
||||
{
|
||||
Reply("news.error", id, "town cryer unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var title = BridgeJson.GetString(o, "title");
|
||||
if (String.IsNullOrEmpty(title))
|
||||
{
|
||||
Reply("news.error", id, "missing title");
|
||||
return;
|
||||
}
|
||||
|
||||
var body = BridgeJson.GetString(o, "body") ?? "";
|
||||
var url = BridgeJson.GetString(o, "url");
|
||||
int image = BridgeJson.GetInt(o, "image", DefaultImage);
|
||||
|
||||
// announce defaults to true (proclaim the title in-world); "announce":false suppresses it.
|
||||
bool announce = true;
|
||||
object rawAnnounce;
|
||||
if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool)
|
||||
announce = (bool)rawAnnounce;
|
||||
|
||||
if (title.Length > BridgeConfig.NewsMaxTitleLength)
|
||||
title = title.Substring(0, BridgeConfig.NewsMaxTitleLength);
|
||||
if (body.Length > BridgeConfig.NewsMaxBodyLength)
|
||||
body = body.Substring(0, BridgeConfig.NewsMaxBodyLength);
|
||||
|
||||
try
|
||||
{
|
||||
// Replace an existing id in place: drop the old entry first.
|
||||
TownCryerNewsEntry old;
|
||||
if (_ours.TryGetValue(id, out old) && old != null)
|
||||
{
|
||||
list.Remove(old);
|
||||
_ours.Remove(id);
|
||||
}
|
||||
else if (_ours.Count >= BridgeConfig.NewsMaxExternal)
|
||||
{
|
||||
Reply("news.error", id, "too many news entries");
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new TownCryerNewsEntry(
|
||||
new TextDefinition(title),
|
||||
new TextDefinition(body),
|
||||
image,
|
||||
null,
|
||||
url);
|
||||
|
||||
list.Insert(0, entry); // newest first, as the gump reads top-down
|
||||
_ours[id] = entry;
|
||||
|
||||
if (announce)
|
||||
Announce(title);
|
||||
|
||||
Reply("news.ok", id, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message);
|
||||
Reply("news.error", id, "internal error");
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnRemove(Dictionary<string, object> o)
|
||||
{
|
||||
var id = BridgeJson.GetString(o, "id");
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
Reply("news.error", null, "missing id");
|
||||
return;
|
||||
}
|
||||
|
||||
TownCryerNewsEntry entry;
|
||||
if (!_ours.TryGetValue(id, out entry))
|
||||
{
|
||||
Reply("news.error", id, "unknown id");
|
||||
return;
|
||||
}
|
||||
|
||||
_ours.Remove(id);
|
||||
|
||||
try
|
||||
{
|
||||
var list = TownCryerSystem.NewsEntries;
|
||||
if (list != null && entry != null)
|
||||
list.Remove(entry);
|
||||
|
||||
Reply("news.ok", id, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message);
|
||||
Reply("news.error", id, "internal error");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Proclaims a single line — the article title — through the town criers.</summary>
|
||||
private static void Announce(string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
GlobalTownCrierEntryList.Instance.AddEntry(
|
||||
new[] { title },
|
||||
TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failed proclamation must not fail the news add — the article is already posted.
|
||||
Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Reply(string kind, string id, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin(kind);
|
||||
if (id != null) sb.Str("id", id);
|
||||
if (reason != null) sb.Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
}
|
||||
}
|
||||
459
overlay/Scripts/Custom/Bridge/BridgeOneShots.cs
Normal file
459
overlay/Scripts/Custom/Bridge/BridgeOneShots.cs
Normal file
@@ -0,0 +1,459 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
using Server.Items;
|
||||
using Server.Misc;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Protocol 7, part b. The two verbs that are neither owned nor borrowed: an item put into
|
||||
/// someone's hands, and a world save.
|
||||
///
|
||||
/// EVENTS_PLAN.md Phase 12b. Everything else the event plane does is a thing this shard can
|
||||
/// take back — a creature it deletes, a value it restores. These two are not, and they are
|
||||
/// in the same file because that is what they have in common: <b>done is done</b>.
|
||||
///
|
||||
/// ── The grant, and why §8's exclusion of it was reopened separately ────────────────────
|
||||
///
|
||||
/// `ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both —
|
||||
/// deliberately as two reversals rather than one, because permitting an event to create a
|
||||
/// creature says nothing about permitting it to hand out loot. What makes this grant a
|
||||
/// different proposition from the one §8 refused is four properties it did not have then,
|
||||
/// and all four are visible in this file:
|
||||
///
|
||||
/// - **Declared, not typed.** The allowlist below is the shard's, and an item not on it
|
||||
/// cannot be granted however the request is spelled. There is no free-text type name
|
||||
/// reaching `Activator.CreateInstance` — that is `[add`, which §G excludes.
|
||||
/// - **Bounded.** `EventsMaxGrantPerRun` bounds the whole run and
|
||||
/// `EventsMaxGrantStack` bounds one hand; both refuse rather than clamp.
|
||||
/// - **Attributable.** The run id rides on every grant and is logged with it.
|
||||
/// - **Idempotent.** Protocol 6's key means a lost acknowledgement cannot double a
|
||||
/// reward, which is the failure that made §G call the grant un-retryable when it was
|
||||
/// written. It is retryable now, and 11a is the whole reason.
|
||||
///
|
||||
/// ── Who receives it is answered HERE, and that is the interesting decision ─────────────
|
||||
///
|
||||
/// A grant needs a list of people, and the website has one — `event_run_participants`. It
|
||||
/// would have had to reach through core to get it, because a module cannot read core's
|
||||
/// tables, so the alternative was a new core surface handing participants to a module's
|
||||
/// `perform()`.
|
||||
///
|
||||
/// It is not needed: **this shard already has the list**, in 11b's run-scoped participation
|
||||
/// ledger, keyed by the same character serials the website's `member_key` holds. So the
|
||||
/// grant names a run and the recipients are resolved from the ledger the run has been
|
||||
/// keeping all along — no new core surface, no participant list crossing the wire twice,
|
||||
/// and no window in which the two disagree.
|
||||
///
|
||||
/// A run with no open ledger grants to nobody and says so, rather than granting to
|
||||
/// everybody online. "Everyone present" is not a thing this file will guess at.
|
||||
///
|
||||
/// ── The save ──────────────────────────────────────────────────────────────────────────
|
||||
///
|
||||
/// `ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a
|
||||
/// phase boundary — the point in an event after which what has happened is safe from a
|
||||
/// crash — and `world.save.before` / `world.save.after` are already on the wire, so the
|
||||
/// acknowledgement it needs exists.
|
||||
///
|
||||
/// **A save stops the world**, so unlike every other verb here it is rate-limited by the
|
||||
/// shard rather than only capped: `EventsMinSaveIntervalSec` refuses a save that comes too
|
||||
/// soon after the last one, whether the last one was an event's or ServUO's own autosave.
|
||||
/// Refuses, never queues — a queued save would arrive at a moment nobody chose.
|
||||
/// </summary>
|
||||
public static class BridgeOneShots
|
||||
{
|
||||
// ---- the grant allowlist ----
|
||||
|
||||
/// <summary>
|
||||
/// One grantable item: what an author names it, and what this shard builds.
|
||||
///
|
||||
/// **The list is short and boring on purpose.** Every entry is a thing an event
|
||||
/// plausibly hands out and nothing here is equipment with rolled properties — an
|
||||
/// artifact generator behind an unattended schedule is a different proposition and one
|
||||
/// nobody has asked for. An operator who wants more edits this array, which is a
|
||||
/// deployment they control rather than a field on a web form.
|
||||
/// </summary>
|
||||
private sealed class GrantEntry
|
||||
{
|
||||
public string Key;
|
||||
public string Label;
|
||||
public string Type;
|
||||
}
|
||||
|
||||
private static readonly GrantEntry[] Grants =
|
||||
{
|
||||
new GrantEntry { Key = "gold", Label = "Gold", Type = "Server.Items.Gold" },
|
||||
new GrantEntry { Key = "cloak", Label = "Cloak", Type = "Server.Items.Cloak" },
|
||||
new GrantEntry { Key = "sandals", Label = "Sandals", Type = "Server.Items.Sandals" },
|
||||
new GrantEntry { Key = "candle", Label = "Candle", Type = "Server.Items.Candle" },
|
||||
new GrantEntry { Key = "earrings", Label = "Silver earrings", Type = "Server.Items.SilverEarrings" },
|
||||
new GrantEntry { Key = "fireworks", Label = "Fireworks wand", Type = "Server.Items.FireworksWand" },
|
||||
new GrantEntry { Key = "bottle", Label = "Message in a bottle", Type = "Server.Items.MessageInABottle" },
|
||||
};
|
||||
|
||||
private static long _granted, _saves, _refused;
|
||||
|
||||
private static long _lastSaveMs;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("item.grant", OnGrant);
|
||||
BridgeBoot.RegisterHandler("item.catalog", OnCatalog);
|
||||
BridgeBoot.RegisterHandler("world.save", OnSave);
|
||||
|
||||
// Counted whoever asked for it, so the interval below also covers ServUO's own
|
||||
// autosave. An event save landing thirty seconds after the hourly one is the same
|
||||
// freeze twice, and the shard is the only half that can see both.
|
||||
EventSink.WorldSave += (e) => { _lastSaveMs = BridgeJson.NowMs(); };
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("oneshots(granted={0} saves={1} refused={2})", _granted, _saves, _refused);
|
||||
}
|
||||
|
||||
// ---- item.catalog ----
|
||||
|
||||
/// <summary>
|
||||
/// What this shard is willing to grant.
|
||||
///
|
||||
/// A read, so the website's option source can offer real choices — and the module holds
|
||||
/// the same list, so the dropdown still works with the shard down. Two copies of a
|
||||
/// short allowlist, exactly like the lease bounds: the module's is what makes a bad
|
||||
/// value a refusal on a form, and this one is what is true when the website is wrong.
|
||||
/// </summary>
|
||||
private static void OnCatalog(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "catalog"))
|
||||
return;
|
||||
|
||||
var sb = BridgeJson.Begin("item.catalog.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
|
||||
sb.Append(",\"items\":[");
|
||||
|
||||
for (int i = 0; i < Grants.Length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
sb.Append(',');
|
||||
|
||||
sb.Append("{\"key\":");
|
||||
BridgeJson.Text(sb, Grants[i].Key);
|
||||
sb.Append(",\"label\":");
|
||||
BridgeJson.Text(sb, Grants[i].Label);
|
||||
sb.Append(",\"stackable\":").Append(Stackable(Grants[i]) ? "true" : "false");
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
sb.Append(",\"maxPerRun\":").Append(BridgeConfig.EventsMaxGrantPerRun);
|
||||
sb.Append(",\"maxStack\":").Append(BridgeConfig.EventsMaxGrantStack);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- item.grant ----
|
||||
|
||||
private static void OnGrant(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "grant"))
|
||||
return;
|
||||
|
||||
var runId = BridgeJson.GetString(o, "runId");
|
||||
|
||||
if (String.IsNullOrEmpty(runId))
|
||||
{
|
||||
Err(reqId, "grant", "a grant needs a runId");
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = LookupGrant(BridgeJson.GetString(o, "item"));
|
||||
|
||||
if (entry == null)
|
||||
{
|
||||
Err(reqId, "grant", "this shard does not grant '" + BridgeJson.GetString(o, "item") + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
var amount = (int)BridgeJson.GetLong(o, "amount", 1L);
|
||||
|
||||
if (amount < 1)
|
||||
{
|
||||
Err(reqId, "grant", "a grant needs a positive amount");
|
||||
return;
|
||||
}
|
||||
|
||||
// Refused rather than clamped, on `LeaseMaxDurationSec`'s argument from 11b: the
|
||||
// website is the half that records what was handed out, and a silent clamp would make
|
||||
// its ledger a description of a grant that did not happen.
|
||||
if (amount > BridgeConfig.EventsMaxGrantStack)
|
||||
{
|
||||
Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture,
|
||||
"this shard grants at most {0} at a time, and {1} were asked for",
|
||||
BridgeConfig.EventsMaxGrantStack, amount));
|
||||
return;
|
||||
}
|
||||
|
||||
var toBank = String.Equals(BridgeJson.GetString(o, "where"), "bank", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var serials = BridgeParticipation.MemberSerials(runId);
|
||||
|
||||
if (serials == null)
|
||||
{
|
||||
Err(reqId, "grant", "run " + runId + " has no participation ledger open on this shard");
|
||||
return;
|
||||
}
|
||||
|
||||
if (serials.Count == 0)
|
||||
{
|
||||
// Not a refusal: a run whose event nobody attended is a real outcome, and the
|
||||
// website needs to record a grant that reached nobody rather than a failed step it
|
||||
// will retry against the same empty ledger.
|
||||
var none = BridgeJson.Begin("item.grant.ok");
|
||||
if (reqId != null) none.Str("reqId", reqId);
|
||||
none.Str("runId", runId).Str("item", entry.Key);
|
||||
none.Append(",\"granted\":0,\"missed\":[]");
|
||||
BridgeLink.Emit(none.End());
|
||||
return;
|
||||
}
|
||||
|
||||
if (serials.Count > BridgeConfig.EventsMaxGrantPerRun)
|
||||
{
|
||||
Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture,
|
||||
"that run has {0} participants and this shard grants to at most {1}",
|
||||
serials.Count, BridgeConfig.EventsMaxGrantPerRun));
|
||||
return;
|
||||
}
|
||||
|
||||
var hue = (int)BridgeJson.GetLong(o, "hue", 0L);
|
||||
var name = BridgeJson.GetString(o, "name");
|
||||
|
||||
if (name != null && name.Length > 40)
|
||||
name = name.Substring(0, 40);
|
||||
|
||||
var granted = 0;
|
||||
var missed = new List<string>();
|
||||
|
||||
for (int i = 0; i < serials.Count; i++)
|
||||
{
|
||||
var mobile = World.FindMobile((Serial)serials[i]) as PlayerMobile;
|
||||
|
||||
if (mobile == null || mobile.Deleted)
|
||||
{
|
||||
missed.Add(Hex(serials[i]) + ": no such character");
|
||||
continue;
|
||||
}
|
||||
|
||||
string why;
|
||||
|
||||
if (Give(mobile, entry, amount, hue, name, toBank, out why))
|
||||
granted++;
|
||||
else
|
||||
missed.Add(Hex(serials[i]) + ": " + why);
|
||||
}
|
||||
|
||||
_granted += granted;
|
||||
|
||||
Console.WriteLine("[Bridge] grant {0} x{1} to run {2}: {3} of {4}",
|
||||
entry.Key, amount, runId, granted, serials.Count);
|
||||
|
||||
var sb = BridgeJson.Begin("item.grant.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("runId", runId).Str("item", entry.Key);
|
||||
sb.Append(",\"granted\":").Append(granted);
|
||||
sb.Append(",\"missed\":[");
|
||||
|
||||
for (int i = 0; i < missed.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
BridgeJson.Text(sb, missed[i]);
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and hands over one grant, or says why it could not.
|
||||
///
|
||||
/// **A grant that cannot be delivered is deleted rather than dropped on the floor.**
|
||||
/// `AddItem` failing on a full backpack would otherwise leave the item in the world at
|
||||
/// (0,0) — a real ServUO trap — and an event that quietly littered the map with
|
||||
/// undeliverable rewards would be worse than one that reported a miss.
|
||||
/// </summary>
|
||||
private static bool Give(PlayerMobile mobile, GrantEntry entry, int amount, int hue, string name, bool toBank, out string why)
|
||||
{
|
||||
why = null;
|
||||
|
||||
var container = toBank ? (Container)mobile.BankBox : mobile.Backpack;
|
||||
|
||||
if (container == null || container.Deleted)
|
||||
{
|
||||
why = toBank ? "no bank box" : "no backpack";
|
||||
return false;
|
||||
}
|
||||
|
||||
Item item;
|
||||
|
||||
try
|
||||
{
|
||||
item = Build(entry);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
why = "could not be created (" + e.Message + ")";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
why = "could not be created";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.Stackable)
|
||||
{
|
||||
item.Amount = amount;
|
||||
}
|
||||
else if (amount > 1)
|
||||
{
|
||||
// A non-stackable granted in quantity would be N items, and N items is N chances to
|
||||
// overflow a backpack halfway through with no way to report which half landed.
|
||||
// One is what an event means by "a commemorative cloak" anyway.
|
||||
item.Delete();
|
||||
why = "is not stackable, so it can only be granted one at a time";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hue > 0)
|
||||
item.Hue = hue;
|
||||
|
||||
if (!String.IsNullOrEmpty(name))
|
||||
item.Name = name;
|
||||
|
||||
if (!container.TryDropItem(mobile, item, false))
|
||||
{
|
||||
item.Delete();
|
||||
why = toBank ? "bank box is full" : "backpack is full";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Item Build(GrantEntry entry)
|
||||
{
|
||||
var type = ScriptCompiler.FindTypeByFullName(entry.Type);
|
||||
|
||||
if (type == null)
|
||||
return null;
|
||||
|
||||
return Activator.CreateInstance(type) as Item;
|
||||
}
|
||||
|
||||
private static bool Stackable(GrantEntry entry)
|
||||
{
|
||||
Item probe = null;
|
||||
|
||||
try
|
||||
{
|
||||
probe = Build(entry);
|
||||
return probe != null && probe.Stackable;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (probe != null)
|
||||
probe.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- world.save ----
|
||||
|
||||
private static void OnSave(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "save"))
|
||||
return;
|
||||
|
||||
var since = BridgeJson.NowMs() - _lastSaveMs;
|
||||
var minimum = (long)BridgeConfig.EventsMinSaveIntervalSec * 1000L;
|
||||
|
||||
if (_lastSaveMs > 0L && since < minimum)
|
||||
{
|
||||
// **Refused, not queued.** A queued save would land at a moment nobody chose, in the
|
||||
// middle of whatever the next step is doing. Refusing tells the website exactly what
|
||||
// happened, and a save skipped because one just happened has cost nothing.
|
||||
Err(reqId, "save", String.Format(CultureInfo.InvariantCulture,
|
||||
"this shard saves at most every {0} seconds, and the last save was {1} seconds ago",
|
||||
BridgeConfig.EventsMinSaveIntervalSec, since / 1000L));
|
||||
return;
|
||||
}
|
||||
|
||||
// `world.save.before` and `world.save.after` are emitted by `BridgeEvents` from ServUO's
|
||||
// own hooks, so the acknowledgement of what actually happened rides those rather than
|
||||
// being asserted here. This reply says only that the save was STARTED.
|
||||
_saves++;
|
||||
AutoSave.Save();
|
||||
|
||||
var sb = BridgeJson.Begin("world.save.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Bool("started", true);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static GrantEntry LookupGrant(string key)
|
||||
{
|
||||
if (key == null)
|
||||
return null;
|
||||
|
||||
for (int i = 0; i < Grants.Length; i++)
|
||||
{
|
||||
if (String.Equals(Grants[i].Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
return Grants[i];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Hex(int serial)
|
||||
{
|
||||
return "0x" + serial.ToString("X", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static bool Ready(string reqId, string action)
|
||||
{
|
||||
if (!BridgeConfig.EventsEnabled)
|
||||
{
|
||||
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Err(string reqId, string action, string reason)
|
||||
{
|
||||
_refused++;
|
||||
|
||||
var sb = BridgeJson.Begin("oneshot.error");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action).Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
}
|
||||
}
|
||||
293
overlay/Scripts/Custom/Bridge/BridgeOracle.cs
Normal file
293
overlay/Scripts/Custom/Bridge/BridgeOracle.cs
Normal file
@@ -0,0 +1,293 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Network;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// An event-owned NPC that answers questions. Protocol 7, EVENTS_PLAN.md Phase 12a.
|
||||
///
|
||||
/// EVENTS.md §G rates this the capability most worth having and gives the reason in one
|
||||
/// line: *"this is literally a web form"*. An oracle is a greeting and a handful of
|
||||
/// "when a player says X, reply Y" rows, and that is a form an event author can fill in
|
||||
/// without knowing anything about Ultima Online — which is more than can be said for
|
||||
/// choosing a spawn point.
|
||||
///
|
||||
/// ── Why this is ours and not `XmlDialog` ───────────────────────────────────────────────
|
||||
///
|
||||
/// ServUO already ships a complete dialogue engine in `XmlSpawner2.XmlDialog`, and its
|
||||
/// `SpeechEntry` is the evidence that the shape below is right rather than invented: `Text`
|
||||
/// plus a comma-separated `Keywords` list, an entry with no keywords being the one that
|
||||
/// fires automatically, a proximity range (`defProximityRange = 3`), a conversation lock so
|
||||
/// two players cannot talk over each other.
|
||||
///
|
||||
/// It is also exactly why this verb must not be built on it. `SpeechEntry` carries an
|
||||
/// `Action` string — XmlSpawner's command-scripting language — and routing authored
|
||||
/// dialogue through XmlDialog would leave an arbitrary-command field one field away from
|
||||
/// an event author on the website. That is the `[set` that §G excludes, arriving through
|
||||
/// the back door, in a subsystem this overlay does not own and an operator can switch off.
|
||||
///
|
||||
/// What the verb actually needs are two native virtuals on `Server.Mobile`:
|
||||
/// `OnMovement`, which is delivered to **every** mobile in range (the `HandlesOnMovement`
|
||||
/// filter applies only to Items — `Server/Mobile.cs:3369` against `:3375`), and
|
||||
/// `HandlesOnSpeech`/`OnSpeech`. Nothing on the wire is executable: keywords and text.
|
||||
///
|
||||
/// ── It cannot be killed, moved or looted ───────────────────────────────────────────────
|
||||
///
|
||||
/// `CanBeDamaged()` is false, as `TownCrier`'s is, and it is `Blessed`, `Frozen` and
|
||||
/// `CantWalk`. An event NPC that a player can drag out of the venue or kill for its robe is
|
||||
/// an event NPC that stops being where the run's ledger says it is, and teardown deleting
|
||||
/// something that has wandered two screens away is a worse outcome than it not moving.
|
||||
/// </summary>
|
||||
public class BridgeOracle : Mobile
|
||||
{
|
||||
/// <summary>One row of the form: what a player has to say, and what it answers.</summary>
|
||||
public sealed class Line
|
||||
{
|
||||
/// <summary>Lower-cased, already trimmed. Matched as substrings of what was said.</summary>
|
||||
public string[] Keywords;
|
||||
|
||||
public string Text;
|
||||
}
|
||||
|
||||
private string m_Greeting;
|
||||
private List<Line> m_Lines;
|
||||
|
||||
/// <summary>
|
||||
/// When each player was last spoken to, so an oracle cannot be farmed for spam.
|
||||
///
|
||||
/// Deliberately not serialized. It is a rate limiter, not state anybody is owed across
|
||||
/// a restart, and a restart is exactly the moment a fresh greeting is *correct* — the
|
||||
/// player is arriving at the venue again as far as the world is concerned.
|
||||
/// </summary>
|
||||
private readonly Dictionary<Mobile, DateTime> m_Greeted = new Dictionary<Mobile, DateTime>();
|
||||
|
||||
private readonly Dictionary<Mobile, DateTime> m_Answered = new Dictionary<Mobile, DateTime>();
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster, true)]
|
||||
public string Greeting { get { return m_Greeting; } set { m_Greeting = value; } }
|
||||
|
||||
public List<Line> Lines
|
||||
{
|
||||
get { return m_Lines ?? (m_Lines = new List<Line>()); }
|
||||
set { m_Lines = value; }
|
||||
}
|
||||
|
||||
public BridgeOracle()
|
||||
: this(null, null, 0, false)
|
||||
{
|
||||
}
|
||||
|
||||
public BridgeOracle(string name, string title, int hue, bool female)
|
||||
{
|
||||
Name = String.IsNullOrEmpty(name) ? "the oracle" : name;
|
||||
Title = title;
|
||||
Female = female;
|
||||
Body = female ? 0x191 : 0x190;
|
||||
Hue = hue > 0 ? hue : Utility.RandomSkinHue();
|
||||
|
||||
InitStats(100, 100, 25);
|
||||
|
||||
AddItem(new Robe(Utility.RandomNeutralHue()));
|
||||
AddItem(new Sandals());
|
||||
|
||||
// See the class header. A run's ledger records where this NPC is; letting the world
|
||||
// move it would make that record a lie within a minute of a curious player arriving.
|
||||
Blessed = true;
|
||||
Frozen = true;
|
||||
CantWalk = true;
|
||||
}
|
||||
|
||||
public BridgeOracle(Serial serial)
|
||||
: base(serial)
|
||||
{
|
||||
}
|
||||
|
||||
public override bool CanBeDamaged()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public override bool ClickTitle { get { return false; } }
|
||||
|
||||
// ---- the greeting ----
|
||||
|
||||
/// <summary>
|
||||
/// Greet a player who has just come into range.
|
||||
///
|
||||
/// "Just come into range" rather than "is in range": `OnMovement` fires on every step,
|
||||
/// so greeting on proximity alone would have the oracle shouting at anyone who walked
|
||||
/// past it. The old location is compared as well as the new one, which makes this fire
|
||||
/// once per approach, and the per-player cooldown catches the player who paces the line.
|
||||
/// </summary>
|
||||
public override void OnMovement(Mobile m, Point3D oldLocation)
|
||||
{
|
||||
base.OnMovement(m, oldLocation);
|
||||
|
||||
if (String.IsNullOrEmpty(m_Greeting) || m == this || m.Deleted || Deleted)
|
||||
return;
|
||||
|
||||
if (!(m is PlayerMobile) || !m.Alive || m.Map != Map)
|
||||
return;
|
||||
|
||||
var range = BridgeConfig.EventsOracleGreetRange;
|
||||
|
||||
if (!InRange(m, range) || InRange(oldLocation, range))
|
||||
return;
|
||||
|
||||
if (!Recent(m_Greeted, m, BridgeConfig.EventsOracleGreetCooldownSec))
|
||||
return;
|
||||
|
||||
m_Greeted[m] = DateTime.UtcNow;
|
||||
Direction = GetDirectionTo(m);
|
||||
Say(m_Greeting);
|
||||
}
|
||||
|
||||
// ---- the keyword lines ----
|
||||
|
||||
public override bool HandlesOnSpeech(Mobile from)
|
||||
{
|
||||
return m_Lines != null && m_Lines.Count > 0 && from.Alive && from is PlayerMobile &&
|
||||
InRange(from, BridgeConfig.EventsOracleSpeechRange);
|
||||
}
|
||||
|
||||
public override void OnSpeech(SpeechEventArgs e)
|
||||
{
|
||||
base.OnSpeech(e);
|
||||
|
||||
if (e.Handled || m_Lines == null || m_Lines.Count == 0 || Deleted)
|
||||
return;
|
||||
|
||||
var from = e.Mobile;
|
||||
|
||||
if (from == null || !from.Alive || !(from is PlayerMobile) ||
|
||||
!InRange(from, BridgeConfig.EventsOracleSpeechRange))
|
||||
return;
|
||||
|
||||
var said = (e.Speech ?? "").ToLower(CultureInfo.InvariantCulture);
|
||||
|
||||
if (said.Length == 0)
|
||||
return;
|
||||
|
||||
var line = Match(said);
|
||||
|
||||
if (line == null)
|
||||
return;
|
||||
|
||||
// The cooldown is consulted only once something actually matched. Checking it first
|
||||
// would let a player burn their own cooldown on an unrelated sentence and then find
|
||||
// the oracle mute when they finally said the word.
|
||||
if (!Recent(m_Answered, from, BridgeConfig.EventsOracleAnswerCooldownSec))
|
||||
return;
|
||||
|
||||
m_Answered[from] = DateTime.UtcNow;
|
||||
Direction = GetDirectionTo(from);
|
||||
Say(line.Text);
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private Line Match(string said)
|
||||
{
|
||||
for (int i = 0; i < m_Lines.Count; i++)
|
||||
{
|
||||
var line = m_Lines[i];
|
||||
|
||||
if (line == null || line.Keywords == null || String.IsNullOrEmpty(line.Text))
|
||||
continue;
|
||||
|
||||
for (int j = 0; j < line.Keywords.Length; j++)
|
||||
{
|
||||
var keyword = line.Keywords[j];
|
||||
|
||||
if (!String.IsNullOrEmpty(keyword) && said.IndexOf(keyword, StringComparison.Ordinal) >= 0)
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether enough time has passed to speak to this player again, pruning as it goes.
|
||||
///
|
||||
/// The prune matters: without it a busy venue leaves one dictionary entry per player
|
||||
/// who ever walked past, held by a strong reference to a `Mobile` that may since have
|
||||
/// been deleted, for as long as the NPC exists.
|
||||
/// </summary>
|
||||
private static bool Recent(Dictionary<Mobile, DateTime> seen, Mobile m, int cooldownSec)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var cooldown = TimeSpan.FromSeconds(cooldownSec);
|
||||
|
||||
if (seen.Count > 64)
|
||||
{
|
||||
List<Mobile> stale = null;
|
||||
|
||||
foreach (var pair in seen)
|
||||
{
|
||||
if (pair.Key == null || pair.Key.Deleted || now - pair.Value > cooldown)
|
||||
(stale ?? (stale = new List<Mobile>())).Add(pair.Key);
|
||||
}
|
||||
|
||||
if (stale != null)
|
||||
{
|
||||
for (int i = 0; i < stale.Count; i++)
|
||||
seen.Remove(stale[i]);
|
||||
}
|
||||
}
|
||||
|
||||
DateTime last;
|
||||
|
||||
return !seen.TryGetValue(m, out last) || now - last >= cooldown;
|
||||
}
|
||||
|
||||
// ---- persistence ----
|
||||
|
||||
public override void Serialize(GenericWriter writer)
|
||||
{
|
||||
base.Serialize(writer);
|
||||
|
||||
writer.Write(0); // version
|
||||
|
||||
writer.Write(m_Greeting ?? "");
|
||||
|
||||
var lines = m_Lines ?? new List<Line>();
|
||||
writer.Write(lines.Count);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
writer.Write(String.Join(",", line.Keywords ?? new string[0]));
|
||||
writer.Write(line.Text ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public override void Deserialize(GenericReader reader)
|
||||
{
|
||||
base.Deserialize(reader);
|
||||
|
||||
reader.ReadInt(); // version
|
||||
|
||||
m_Greeting = reader.ReadString();
|
||||
|
||||
var count = reader.ReadInt();
|
||||
m_Lines = new List<Line>(count);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var keywords = reader.ReadString() ?? "";
|
||||
var text = reader.ReadString();
|
||||
|
||||
m_Lines.Add(new Line
|
||||
{
|
||||
Keywords = keywords.Length == 0 ? new string[0] : keywords.Split(','),
|
||||
Text = text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
968
overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
Normal file
968
overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
Normal file
@@ -0,0 +1,968 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Protocol 6, part b. The run-scoped participation ledger: who took part in an event, and
|
||||
/// how much.
|
||||
///
|
||||
/// EVENTS.md §G rates participation attribution as the largest remaining piece of new UO
|
||||
/// work, and says why nothing composed out of the existing streams can stand in for it:
|
||||
/// `region.enter` plus `mob.killed` is loosely composable and **not trustworthy enough to
|
||||
/// publish results on**. Nothing scopes a kill or an arrival to a run, nothing separates a
|
||||
/// passer-by from an attendee, and nothing survives a relog. Results and a leaderboard on
|
||||
/// top of that would be a table of confident numbers that were not true.
|
||||
///
|
||||
/// So participation is measured here, where the world is, and reported as one opaque number
|
||||
/// per member. **The plugin computes the score; core stores a decimal it never interprets.**
|
||||
/// That split is what keeps the event engine game-agnostic: "one minute present plus five a
|
||||
/// kill" is a sentence about Ultima Online, and the sentence has to live on the Ultima
|
||||
/// Online side of the seam.
|
||||
///
|
||||
/// ── Keyed by character serial ──────────────────────────────────────────────────────────
|
||||
///
|
||||
/// Which matches `module-uo`'s existing Teams `memberKey` (`teamProvider.model.js`), so one
|
||||
/// module speaks one member vocabulary and a participant can be joined to a roster without a
|
||||
/// translation table. A player who attends on two characters is two members, and that is the
|
||||
/// same answer Teams already gives.
|
||||
///
|
||||
/// ── Persisted in the world save, which is a first ──────────────────────────────────────
|
||||
///
|
||||
/// Nothing in this bridge has ever persisted anything. A ledger has to, because a run spans
|
||||
/// hours and a restart mid-event is an ordinary Tuesday: an in-memory tally would silently
|
||||
/// regress every attendee's score to whatever they earned after the restart. The only ways
|
||||
/// to paper over that from the other side are a high-water rule in core — which must stay
|
||||
/// game-agnostic and cannot have one — or a per-run offset in the module, which is the same
|
||||
/// bug with more moving parts.
|
||||
///
|
||||
/// `Server.Persistence` plus `EventSink.WorldSave` writes a companion file beside the world
|
||||
/// save rather than a persistence ITEM. No world object, no serial, nothing for a GM to find
|
||||
/// and delete by accident, and a wipe of custom items leaves the ledger intact.
|
||||
///
|
||||
/// **The save/load hooks are attached unconditionally**, before the enabled gate is
|
||||
/// consulted. An operator who switches the plane off for an afternoon must not come back to
|
||||
/// a truncated file where a run's tally used to be.
|
||||
///
|
||||
/// ── The first handler that defers ──────────────────────────────────────────────────────
|
||||
///
|
||||
/// `participation.snapshot` resolves every member serial to a mobile and an account, so a
|
||||
/// well-attended run is hundreds of world lookups in one inbound call — exactly the kind of
|
||||
/// work the Core thread must not be handed in one piece. Above
|
||||
/// `Bridge.ParticipationSnapshotChunk` members it walks in chunks across ticks.
|
||||
///
|
||||
/// That makes it the first handler in the bridge to complete AFTER its inbound call returns,
|
||||
/// and therefore the first that can genuinely answer `bridge.busy` — protocol 6 built the
|
||||
/// door in 11a with `BridgeIdempotency.Hold`/`Complete` and had nothing to walk through it.
|
||||
/// </summary>
|
||||
public static class BridgeParticipation
|
||||
{
|
||||
private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Participation.bin");
|
||||
|
||||
private const int SaveVersion = 1;
|
||||
|
||||
/// <summary>One character's part in one run.</summary>
|
||||
private sealed class Member
|
||||
{
|
||||
public int Serial;
|
||||
|
||||
/// <summary>
|
||||
/// Last seen name, kept only so the console and the snapshot can say something
|
||||
/// useful about a character that has since been deleted. The website resolves its
|
||||
/// own names from the serial and never reads this.
|
||||
/// </summary>
|
||||
public string Name;
|
||||
|
||||
/// <summary>
|
||||
/// Accrued presence in SECONDS, not in sample counts.
|
||||
///
|
||||
/// A sample count would have to be multiplied by the sweep interval to mean
|
||||
/// anything, and the interval is a config key an operator may change halfway
|
||||
/// through a five-hour run — which would silently rewrite the first half of the
|
||||
/// tally. Accruing the interval as it is actually used makes history immutable.
|
||||
/// </summary>
|
||||
public long Seconds;
|
||||
|
||||
public int Kills;
|
||||
public long FirstMs;
|
||||
public long LastMs;
|
||||
}
|
||||
|
||||
/// <summary>One run's declared area and its members.</summary>
|
||||
private sealed class Run
|
||||
{
|
||||
public string RunId;
|
||||
public string MapName;
|
||||
public int MapIndex;
|
||||
public int X;
|
||||
public int Y;
|
||||
public int Radius;
|
||||
|
||||
public long OpenedMs;
|
||||
public long UntilMs;
|
||||
public long ClosedMs;
|
||||
public bool Closed;
|
||||
|
||||
/// <summary>
|
||||
/// Frozen at open, for the same reason presence is accrued in seconds: a weight the
|
||||
/// operator retunes mid-run must not retroactively re-score the kills that already
|
||||
/// happened under the old one.
|
||||
/// </summary>
|
||||
public double KillWeight;
|
||||
|
||||
/// <summary>Members the cap turned away. Reported, because a truncated tally that says so is usable and one that does not is a lie.</summary>
|
||||
public long Refused;
|
||||
|
||||
public Dictionary<int, Member> Members = new Dictionary<int, Member>();
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, Run> _runs = new Dictionary<string, Run>(StringComparer.Ordinal);
|
||||
|
||||
private static Timer _timer;
|
||||
|
||||
private static long _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad`
|
||||
/// fires, so this cannot be deferred to Initialize.
|
||||
/// </summary>
|
||||
[CallPriority(900)]
|
||||
public static void Configure()
|
||||
{
|
||||
EventSink.WorldSave += OnWorldSave;
|
||||
EventSink.WorldLoad += OnWorldLoad;
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("participation.open", OnOpen);
|
||||
BridgeBoot.RegisterHandler("participation.snapshot", OnSnapshot);
|
||||
BridgeBoot.RegisterHandler("participation.close", OnClose);
|
||||
|
||||
EventSink.CreatureDeath += OnCreatureDeath;
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
Rearm();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the sweep timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
Stop();
|
||||
|
||||
_timer = Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
|
||||
Sweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null)
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
int members = 0;
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
members += run.Members.Count;
|
||||
|
||||
return String.Format(
|
||||
"participation(runs={0} members={1} sweeps={2} opened={3} closed={4} snapshots={5} kills={6} deferred={7} refused={8})",
|
||||
_runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every character serial this run has recorded, or null when the run is unknown here.
|
||||
///
|
||||
/// Added by Phase 12b for the item grant, which needs a list of people and would
|
||||
/// otherwise have had to reach through core for one — the website's
|
||||
/// `event_run_participants` holds the same serials, but a module cannot read core's
|
||||
/// tables and adding a core surface to hand them over would have been a second copy of
|
||||
/// a list this shard has been keeping all along.
|
||||
///
|
||||
/// **Null and empty are different answers.** Null is "no ledger is open for that run",
|
||||
/// which is a refusal; empty is "the ledger is open and nobody came", which is a real
|
||||
/// outcome a grant has to be able to report rather than retry.
|
||||
///
|
||||
/// A closed run still answers: closing stops the counting, and a reward handed out
|
||||
/// after the event has ended is the ordinary case rather than an edge one.
|
||||
/// </summary>
|
||||
public static List<int> MemberSerials(string runId)
|
||||
{
|
||||
Run run;
|
||||
|
||||
if (runId == null || !_runs.TryGetValue(runId, out run))
|
||||
return null;
|
||||
|
||||
var serials = new List<int>(run.Members.Count);
|
||||
|
||||
foreach (var member in run.Members.Values)
|
||||
serials.Add(member.Serial);
|
||||
|
||||
return serials;
|
||||
}
|
||||
|
||||
// ---- participation.open ----
|
||||
|
||||
/// <summary>
|
||||
/// Declares a run's area and starts counting.
|
||||
///
|
||||
/// The area is a map, a point and a radius (org lead, 2026-09-04). Not a region name:
|
||||
/// protocol 6's own live walk established that the most specific region containing an
|
||||
/// event is routinely anonymous, so a region-named area would be undeclarable for
|
||||
/// exactly the venues events use. Not a rectangle either — an author picks the spot the
|
||||
/// event happens at, not two opposite corners of it.
|
||||
/// </summary>
|
||||
private static void OnOpen(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "open"))
|
||||
return;
|
||||
|
||||
var runId = BridgeJson.GetString(o, "runId");
|
||||
|
||||
if (String.IsNullOrEmpty(runId))
|
||||
{
|
||||
Err(reqId, "open", "a run id is required");
|
||||
return;
|
||||
}
|
||||
|
||||
var mapName = BridgeJson.GetString(o, "map");
|
||||
var map = MapByName(mapName);
|
||||
|
||||
if (map == null)
|
||||
{
|
||||
Err(reqId, "open", "unknown map '" + (mapName ?? "") + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
var radius = BridgeJson.GetInt(o, "radius", 0);
|
||||
|
||||
if (radius < 1 || radius > BridgeConfig.ParticipationMaxRadius)
|
||||
{
|
||||
Err(reqId, "open",
|
||||
String.Format(CultureInfo.InvariantCulture,
|
||||
"radius must be 1 to {0} tiles, and {1} was asked for",
|
||||
BridgeConfig.ParticipationMaxRadius, radius));
|
||||
return;
|
||||
}
|
||||
|
||||
var x = BridgeJson.GetInt(o, "x", -1);
|
||||
var y = BridgeJson.GetInt(o, "y", -1);
|
||||
|
||||
if (x < 0 || y < 0)
|
||||
{
|
||||
Err(reqId, "open", "an area needs an x and a y");
|
||||
return;
|
||||
}
|
||||
|
||||
Run existing;
|
||||
|
||||
if (_runs.TryGetValue(runId, out existing))
|
||||
{
|
||||
// Re-opening the same area is the ordinary consequence of a step being re-authored
|
||||
// or a run being resumed, and answering it as an error would fail a run for doing
|
||||
// nothing. Re-opening a DIFFERENT area is an authoring mistake, and silently
|
||||
// moving the venue mid-run would make the tally describe two places at once.
|
||||
if (existing.MapIndex != map.MapIndex || existing.X != x || existing.Y != y ||
|
||||
existing.Radius != radius)
|
||||
{
|
||||
Err(reqId, "open", "run " + runId + " is already counting a different area");
|
||||
return;
|
||||
}
|
||||
|
||||
existing.Closed = false;
|
||||
Ok(reqId, "open", existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_runs.Count >= BridgeConfig.ParticipationMaxRuns)
|
||||
{
|
||||
Err(reqId, "open",
|
||||
String.Format(CultureInfo.InvariantCulture,
|
||||
"this shard counts at most {0} runs at once", BridgeConfig.ParticipationMaxRuns));
|
||||
return;
|
||||
}
|
||||
|
||||
var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
|
||||
var now = BridgeJson.NowMs();
|
||||
|
||||
var run = new Run
|
||||
{
|
||||
RunId = runId,
|
||||
MapName = map.Name,
|
||||
MapIndex = map.MapIndex,
|
||||
X = x,
|
||||
Y = y,
|
||||
Radius = radius,
|
||||
OpenedMs = now,
|
||||
UntilMs = holdMs > 0L ? now + holdMs : 0L,
|
||||
KillWeight = BridgeConfig.ParticipationKillWeight,
|
||||
};
|
||||
|
||||
_runs[runId] = run;
|
||||
_opened++;
|
||||
|
||||
Console.WriteLine("[Bridge] participation: run {0} counting {1} tiles around {2} ({3}, {4})",
|
||||
runId, radius, map.Name, x, y);
|
||||
|
||||
Ok(reqId, "open", run);
|
||||
}
|
||||
|
||||
// ---- participation.close ----
|
||||
|
||||
/// <summary>
|
||||
/// Stops counting. The tally stays readable through the grace window, because the run
|
||||
/// that closes an event and the step that collects its results are two different steps
|
||||
/// and either can be retried.
|
||||
/// </summary>
|
||||
private static void OnClose(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "close"))
|
||||
return;
|
||||
|
||||
var runId = BridgeJson.GetString(o, "runId");
|
||||
|
||||
Run run;
|
||||
|
||||
if (runId == null || !_runs.TryGetValue(runId, out run))
|
||||
{
|
||||
// Not an error. A close of a run this shard has already forgotten — a restart, a
|
||||
// second teardown attempt — has the same meaning as one it honoured: nothing is
|
||||
// being counted for that run any more.
|
||||
var gone = BridgeJson.Begin("participation.ok");
|
||||
if (reqId != null) gone.Str("reqId", reqId);
|
||||
gone.Str("action", "close").Str("runId", runId).Bool("closed", true).Bool("known", false);
|
||||
BridgeLink.Emit(gone.End());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!run.Closed)
|
||||
{
|
||||
// One last sweep before the books shut, so the people standing there when the event
|
||||
// ended are credited for the interval they were standing there in.
|
||||
SweepRun(run, BridgeConfig.ParticipationSweepSeconds);
|
||||
|
||||
run.Closed = true;
|
||||
run.ClosedMs = BridgeJson.NowMs();
|
||||
_closed++;
|
||||
|
||||
Console.WriteLine("[Bridge] participation: run {0} closed with {1} member(s)",
|
||||
run.RunId, run.Members.Count);
|
||||
}
|
||||
|
||||
Ok(reqId, "close", run);
|
||||
}
|
||||
|
||||
// ---- participation.snapshot ----
|
||||
|
||||
/// <summary>One snapshot in progress. See the class header for why this exists at all.</summary>
|
||||
private sealed class Job
|
||||
{
|
||||
public string ReqId;
|
||||
public string IdempotencyKey;
|
||||
public Run Run;
|
||||
public List<Member> Members;
|
||||
public int Index;
|
||||
public StringBuilder Sb;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this job took the key out of the inbound call's hands.
|
||||
///
|
||||
/// Recorded rather than re-derived from the chunk size, because the chunk size is a
|
||||
/// config key an operator may change between the Hold and the Complete — and a
|
||||
/// Complete that did not happen leaves every retry answered `bridge.busy` until the
|
||||
/// store evicts the key an hour later.
|
||||
/// </summary>
|
||||
public bool Held;
|
||||
}
|
||||
|
||||
private static void OnSnapshot(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "snapshot"))
|
||||
return;
|
||||
|
||||
var runId = BridgeJson.GetString(o, "runId");
|
||||
|
||||
Run run;
|
||||
|
||||
if (runId == null || !_runs.TryGetValue(runId, out run))
|
||||
{
|
||||
Err(reqId, "snapshot", "this shard is not counting run '" + (runId ?? "") + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
// **Copied, not iterated in place.** A sweep or a kill landing between two chunks would
|
||||
// otherwise mutate the dictionary the walk is enumerating, and a snapshot is a
|
||||
// point-in-time answer in any case: the run it describes is the run as it was when the
|
||||
// question was asked.
|
||||
var members = new List<Member>(run.Members.Values);
|
||||
|
||||
var job = new Job
|
||||
{
|
||||
ReqId = reqId,
|
||||
IdempotencyKey = BridgeJson.GetString(o, "idempotencyKey"),
|
||||
Run = run,
|
||||
Members = members,
|
||||
Index = 0,
|
||||
Sb = OpenSnapshot(reqId, run, members.Count),
|
||||
};
|
||||
|
||||
_snapshots++;
|
||||
|
||||
if (members.Count <= BridgeConfig.ParticipationSnapshotChunk)
|
||||
{
|
||||
// Small enough to answer in the inbound call. Deliberately NOT deferred anyway: the
|
||||
// idempotency store captures a reply emitted inside the handler for free, and
|
||||
// holding a key we did not need to hold would put an ordinary command through the
|
||||
// in-flight path for no reason.
|
||||
Step(job);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferring. The key must be HELD before this call returns, or a repeat arriving while
|
||||
// the walk is still running would be executed a second time rather than answered
|
||||
// `bridge.busy` — which is the entire failure protocol 6 exists to prevent, and it is
|
||||
// reachable for the first time right here.
|
||||
if (job.IdempotencyKey != null)
|
||||
{
|
||||
BridgeIdempotency.Hold(job.IdempotencyKey);
|
||||
job.Held = true;
|
||||
}
|
||||
|
||||
_deferred++;
|
||||
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
|
||||
}
|
||||
|
||||
/// <summary>One chunk of a snapshot. Re-arms itself until the walk is done.</summary>
|
||||
private static void Step(Job job)
|
||||
{
|
||||
try
|
||||
{
|
||||
var end = Math.Min(job.Index + BridgeConfig.ParticipationSnapshotChunk, job.Members.Count);
|
||||
|
||||
for (; job.Index < end; job.Index++)
|
||||
WriteMember(job.Sb, job.Run, job.Members[job.Index], job.Index > 0);
|
||||
|
||||
if (job.Index < job.Members.Count)
|
||||
{
|
||||
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
|
||||
return;
|
||||
}
|
||||
|
||||
job.Sb.Append(']');
|
||||
var line = job.Sb.End();
|
||||
|
||||
BridgeLink.Emit(line);
|
||||
|
||||
// Only a HELD key needs completing. An inline snapshot was captured by the
|
||||
// idempotency store on its way through Emit, and completing it twice would replace
|
||||
// a correlated reply with one this method has no correlation information for.
|
||||
if (job.Held)
|
||||
BridgeIdempotency.Complete(job.IdempotencyKey, line);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] participation snapshot threw: {0}", ex.Message);
|
||||
|
||||
// A held key whose walk died must still be closed out, or every retry of this step
|
||||
// gets `bridge.busy` until the store's TTL evicts it an hour later.
|
||||
if (job.Held)
|
||||
{
|
||||
var sb = BridgeJson.Begin("participation.error");
|
||||
if (job.ReqId != null) sb.Str("reqId", job.ReqId);
|
||||
sb.Str("action", "snapshot").Str("reason", "the snapshot failed: " + ex.Message);
|
||||
var line = sb.End();
|
||||
|
||||
BridgeLink.Emit(line);
|
||||
BridgeIdempotency.Complete(job.IdempotencyKey, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static StringBuilder OpenSnapshot(string reqId, Run run, int count)
|
||||
{
|
||||
var sb = BridgeJson.Begin("participation.snapshot.ok");
|
||||
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Str("runId", run.RunId)
|
||||
.Str("map", run.MapName)
|
||||
.Num("x", run.X)
|
||||
.Num("y", run.Y)
|
||||
.Num("radius", run.Radius)
|
||||
.Bool("closed", run.Closed)
|
||||
.Num("openedMs", run.OpenedMs)
|
||||
.Num("killWeight", run.KillWeight)
|
||||
.Num("members", count)
|
||||
.Num("refused", run.Refused);
|
||||
|
||||
sb.Append(",\"participants\":[");
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One member, with the score this shard computed and the two components it came from.
|
||||
///
|
||||
/// The components ride along because core stores the score opaquely and could never
|
||||
/// explain it: a results table that can say "forty minutes and three kills" beside a
|
||||
/// number is a table an operator can argue with, and one that shows only the number is
|
||||
/// one they can only believe or not.
|
||||
/// </summary>
|
||||
private static void WriteMember(StringBuilder sb, Run run, Member member, bool comma)
|
||||
{
|
||||
if (comma)
|
||||
sb.Append(',');
|
||||
|
||||
var minutes = member.Seconds / 60.0;
|
||||
var score = minutes + run.KillWeight * member.Kills;
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(((uint)member.Serial).ToString("X")).Append('"');
|
||||
|
||||
// Resolved now rather than at sweep time, and the mobile is looked up whether or not
|
||||
// its owner is online: a character that took part and logged out is still in the world,
|
||||
// so its account — and the linked website user with it — is still readable.
|
||||
var mobile = World.FindMobile((Serial)member.Serial);
|
||||
|
||||
sb.Append(",\"name\":");
|
||||
BridgeJson.Text(sb, mobile != null && !String.IsNullOrEmpty(mobile.Name) ? mobile.Name : member.Name);
|
||||
|
||||
var acct = mobile == null ? null : mobile.Account as Accounting.Account;
|
||||
|
||||
if (acct != null)
|
||||
{
|
||||
sb.Append(",\"acct\":");
|
||||
BridgeJson.Text(sb, acct.Username);
|
||||
|
||||
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||
|
||||
if (webId != null)
|
||||
{
|
||||
sb.Append(",\"webId\":");
|
||||
BridgeJson.Text(sb, webId);
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(",\"seconds\":").Append(member.Seconds);
|
||||
sb.Append(",\"minutes\":").Append(minutes.ToString("F2", CultureInfo.InvariantCulture));
|
||||
sb.Append(",\"kills\":").Append(member.Kills);
|
||||
sb.Append(",\"score\":").Append(score.ToString("F4", CultureInfo.InvariantCulture));
|
||||
sb.Append(",\"firstMs\":").Append(member.FirstMs);
|
||||
sb.Append(",\"lastMs\":").Append(member.LastMs);
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
// ---- counting ----
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
Sweep();
|
||||
}
|
||||
|
||||
private static void Sweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (_runs.Count == 0)
|
||||
return;
|
||||
|
||||
var seconds = BridgeConfig.ParticipationSweepSeconds;
|
||||
var now = BridgeJson.NowMs();
|
||||
List<string> expired = null;
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
{
|
||||
if (run.Closed)
|
||||
continue;
|
||||
|
||||
// The run's own deadline, honoured here for the reason a lease's is honoured on
|
||||
// the shard: a website that stopped talking must not leave this shard counting
|
||||
// an event that ended days ago.
|
||||
if (run.UntilMs > 0L && now >= run.UntilMs)
|
||||
{
|
||||
SweepRun(run, seconds);
|
||||
run.Closed = true;
|
||||
run.ClosedMs = now;
|
||||
_closed++;
|
||||
|
||||
Console.WriteLine("[Bridge] participation: run {0} passed its deadline and stopped counting",
|
||||
run.RunId);
|
||||
continue;
|
||||
}
|
||||
|
||||
SweepRun(run, seconds);
|
||||
}
|
||||
|
||||
var cutoff = now - (long)BridgeConfig.ParticipationGraceSec * 1000L;
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
{
|
||||
if (!run.Closed || run.ClosedMs > cutoff)
|
||||
continue;
|
||||
|
||||
if (expired == null)
|
||||
expired = new List<string>();
|
||||
|
||||
expired.Add(run.RunId);
|
||||
}
|
||||
|
||||
if (expired == null)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < expired.Count; i++)
|
||||
{
|
||||
Console.WriteLine("[Bridge] participation: forgetting run {0}, closed longer than the grace window",
|
||||
expired[i]);
|
||||
|
||||
_runs.Remove(expired[i]);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] participation sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Credits every online player standing in one run's area with one interval.</summary>
|
||||
private static void SweepRun(Run run, int seconds)
|
||||
{
|
||||
var map = Map.Maps[run.MapIndex];
|
||||
|
||||
if (map == null)
|
||||
return;
|
||||
|
||||
var now = BridgeJson.NowMs();
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var pm = m as PlayerMobile;
|
||||
|
||||
if (pm == null || pm.NetState == null || pm.Deleted)
|
||||
continue;
|
||||
|
||||
if (!Inside(run, pm))
|
||||
continue;
|
||||
|
||||
var member = Touch(run, pm, now);
|
||||
|
||||
if (member == null)
|
||||
continue;
|
||||
|
||||
member.Seconds += seconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kill credit, and it goes to every damager standing in the area rather than to the
|
||||
/// killer alone.
|
||||
///
|
||||
/// A last hit is a poor description of who fought something: the player who held it for
|
||||
/// four minutes and died to it took part more than the one who happened to land the blow
|
||||
/// that finished it. `Mobile.DamageEntries` is already populated and is readable here
|
||||
/// because a `CreatureDeath` handler runs before the creature is disposed of — the same
|
||||
/// fact protocol 6's damage table rests on.
|
||||
///
|
||||
/// The presence check is applied to the DAMAGER, not only to the corpse. Someone
|
||||
/// shooting into the venue from outside it is not attending the event, and someone who
|
||||
/// fought there and has since walked away is no longer accruing anything either.
|
||||
/// </summary>
|
||||
private static void OnCreatureDeath(CreatureDeathEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_runs.Count == 0 || e == null || e.Creature == null)
|
||||
return;
|
||||
|
||||
var creature = e.Creature;
|
||||
|
||||
if (creature.Player)
|
||||
return; // a player death is not a kill anybody is credited for
|
||||
|
||||
var now = BridgeJson.NowMs();
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
{
|
||||
if (run.Closed || !Inside(run, creature))
|
||||
continue;
|
||||
|
||||
var entries = creature.DamageEntries;
|
||||
|
||||
if (entries == null)
|
||||
continue;
|
||||
|
||||
// Summed into a set first: ServUO folds repeat damage into an existing entry,
|
||||
// but an entry that expired and was re-created leaves two, and crediting per
|
||||
// entry would pay a long fight twice. Expiry governs looting rights, not
|
||||
// whether somebody was there.
|
||||
var credited = new HashSet<Mobile>();
|
||||
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var de = entries[i];
|
||||
|
||||
if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player)
|
||||
continue;
|
||||
|
||||
if (!credited.Add(de.Damager))
|
||||
continue;
|
||||
|
||||
if (!Inside(run, de.Damager))
|
||||
continue;
|
||||
|
||||
var member = Touch(run, de.Damager, now);
|
||||
|
||||
if (member == null)
|
||||
continue;
|
||||
|
||||
member.Kills++;
|
||||
_kills++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A death handler must never be the thing that breaks a death.
|
||||
Console.WriteLine("[Bridge] participation kill credit threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Inside(Run run, Mobile m)
|
||||
{
|
||||
if (m == null || m.Map == null || m.Map.MapIndex != run.MapIndex)
|
||||
return false;
|
||||
|
||||
// A circle, and squared so the check costs no square root. `Radius` is in tiles and the
|
||||
// z axis is deliberately ignored: a venue is a place on the map, and a player one floor
|
||||
// up in a tower over the square is at the event.
|
||||
var dx = m.X - run.X;
|
||||
var dy = m.Y - run.Y;
|
||||
|
||||
return (dx * dx) + (dy * dy) <= run.Radius * run.Radius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds or creates a member row, or answers null when the cap turned it away.
|
||||
///
|
||||
/// The cap counts a refusal rather than swallowing it, and the count rides on every
|
||||
/// snapshot: a truncated tally that says it is truncated is usable, and one that does
|
||||
/// not is a leaderboard with people missing from it for no stated reason.
|
||||
/// </summary>
|
||||
private static Member Touch(Run run, Mobile m, long now)
|
||||
{
|
||||
var serial = m.Serial.Value;
|
||||
|
||||
Member member;
|
||||
|
||||
if (run.Members.TryGetValue((int)serial, out member))
|
||||
{
|
||||
member.LastMs = now;
|
||||
member.Name = m.Name ?? member.Name;
|
||||
return member;
|
||||
}
|
||||
|
||||
if (run.Members.Count >= BridgeConfig.ParticipationMaxMembers)
|
||||
{
|
||||
run.Refused++;
|
||||
_refused++;
|
||||
return null;
|
||||
}
|
||||
|
||||
member = new Member
|
||||
{
|
||||
Serial = (int)serial,
|
||||
Name = m.Name ?? "",
|
||||
FirstMs = now,
|
||||
LastMs = now,
|
||||
};
|
||||
|
||||
run.Members[member.Serial] = member;
|
||||
return member;
|
||||
}
|
||||
|
||||
// ---- persistence ----
|
||||
|
||||
private static void OnWorldSave(WorldSaveEventArgs e)
|
||||
{
|
||||
Persistence.Serialize(
|
||||
SavePath,
|
||||
writer =>
|
||||
{
|
||||
writer.Write(SaveVersion);
|
||||
writer.Write(_runs.Count);
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
{
|
||||
writer.Write(run.RunId ?? "");
|
||||
writer.Write(run.MapName ?? "");
|
||||
writer.Write(run.MapIndex);
|
||||
writer.Write(run.X);
|
||||
writer.Write(run.Y);
|
||||
writer.Write(run.Radius);
|
||||
writer.Write(run.OpenedMs);
|
||||
writer.Write(run.UntilMs);
|
||||
writer.Write(run.ClosedMs);
|
||||
writer.Write(run.Closed);
|
||||
writer.Write(run.KillWeight);
|
||||
writer.Write(run.Refused);
|
||||
|
||||
writer.Write(run.Members.Count);
|
||||
|
||||
foreach (var member in run.Members.Values)
|
||||
{
|
||||
writer.Write(member.Serial);
|
||||
writer.Write(member.Name ?? "");
|
||||
writer.Write(member.Seconds);
|
||||
writer.Write(member.Kills);
|
||||
writer.Write(member.FirstMs);
|
||||
writer.Write(member.LastMs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void OnWorldLoad()
|
||||
{
|
||||
Persistence.Deserialize(
|
||||
SavePath,
|
||||
reader =>
|
||||
{
|
||||
var version = reader.ReadInt();
|
||||
|
||||
if (version < 1)
|
||||
return;
|
||||
|
||||
var runs = reader.ReadInt();
|
||||
|
||||
for (int i = 0; i < runs; i++)
|
||||
{
|
||||
var run = new Run
|
||||
{
|
||||
RunId = reader.ReadString(),
|
||||
MapName = reader.ReadString(),
|
||||
MapIndex = reader.ReadInt(),
|
||||
X = reader.ReadInt(),
|
||||
Y = reader.ReadInt(),
|
||||
Radius = reader.ReadInt(),
|
||||
OpenedMs = reader.ReadLong(),
|
||||
UntilMs = reader.ReadLong(),
|
||||
ClosedMs = reader.ReadLong(),
|
||||
Closed = reader.ReadBool(),
|
||||
KillWeight = reader.ReadDouble(),
|
||||
Refused = reader.ReadLong(),
|
||||
};
|
||||
|
||||
var members = reader.ReadInt();
|
||||
|
||||
for (int j = 0; j < members; j++)
|
||||
{
|
||||
var member = new Member
|
||||
{
|
||||
Serial = reader.ReadInt(),
|
||||
Name = reader.ReadString(),
|
||||
Seconds = reader.ReadLong(),
|
||||
Kills = reader.ReadInt(),
|
||||
FirstMs = reader.ReadLong(),
|
||||
LastMs = reader.ReadLong(),
|
||||
};
|
||||
|
||||
run.Members[member.Serial] = member;
|
||||
}
|
||||
|
||||
if (!String.IsNullOrEmpty(run.RunId))
|
||||
_runs[run.RunId] = run;
|
||||
}
|
||||
|
||||
if (_runs.Count > 0)
|
||||
Console.WriteLine("[Bridge] participation: {0} run(s) restored from the world save", _runs.Count);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static Map MapByName(string name)
|
||||
{
|
||||
if (String.IsNullOrEmpty(name))
|
||||
return null;
|
||||
|
||||
for (int i = 0; i < Map.Maps.Length; i++)
|
||||
{
|
||||
var map = Map.Maps[i];
|
||||
|
||||
if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return map;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Ready(string reqId, string action)
|
||||
{
|
||||
if (!BridgeConfig.EventsEnabled)
|
||||
{
|
||||
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Ok(string reqId, string action, Run run)
|
||||
{
|
||||
var sb = BridgeJson.Begin("participation.ok");
|
||||
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Str("action", action)
|
||||
.Str("runId", run.RunId)
|
||||
.Str("map", run.MapName)
|
||||
.Num("x", run.X)
|
||||
.Num("y", run.Y)
|
||||
.Num("radius", run.Radius)
|
||||
.Bool("closed", run.Closed)
|
||||
.Bool("known", true)
|
||||
.Num("members", run.Members.Count)
|
||||
.Num("refused", run.Refused)
|
||||
.Num("untilMs", run.UntilMs);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Err(string reqId, string action, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("participation.error");
|
||||
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Str("action", action).Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
}
|
||||
}
|
||||
404
overlay/Scripts/Custom/Bridge/BridgePoints.cs
Normal file
404
overlay/Scripts/Custom/Bridge/BridgePoints.cs
Normal file
@@ -0,0 +1,404 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
using Server.Engines.Points;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7). ServUO carries ~25 separate point
|
||||
/// currencies (Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city
|
||||
/// loyalties, Blackthorn, the Doom/Khaldun/Kotl treasure systems, …), every one of them a
|
||||
/// standing a player accumulates over months — and none of them has ever been visible
|
||||
/// anywhere but an in-game gump. This is the diff sweep that publishes them as boards.
|
||||
///
|
||||
/// Shaped like <see cref="BridgeHousing"/>: ServerStarted arms a timer, a sidecar connect
|
||||
/// clears the diff state so a fresh sidecar gets every board, and each pass emits only the
|
||||
/// systems whose top N actually moved. One frame per system (~600 B) rather than one 12 KB
|
||||
/// frame, matching champ.update / guild.update.
|
||||
///
|
||||
/// **There is no `points.remove`.** The set of systems is fixed at Configure() time by
|
||||
/// PointsSystem.Configure — a system cannot disappear at runtime — which is the same
|
||||
/// argument city.update already makes for cities.
|
||||
///
|
||||
/// ---- The perf trap, and why the selection looks like this ----
|
||||
///
|
||||
/// `PlayerTable` is a plain List<PointsEntry>, and QueensLoyalty has AutoAdd = true, so it
|
||||
/// holds an entry for every PlayerMobile that has ever logged in — zero-point rows included.
|
||||
/// The obvious `.OrderByDescending(e => e.Points).Take(N)` is a full sort PER SYSTEM: at
|
||||
/// 20,000 historical characters that is ~25 sorts and ~7.5 M comparisons on the Core thread,
|
||||
/// tens of milliseconds, which BRIDGE_PLUGIN_PLAN.md §1 measured as the second thing in the
|
||||
/// whole bridge capable of blowing a frame budget (bulk profile generation being the first).
|
||||
///
|
||||
/// So: a single pass per system into a fixed N-element array kept sorted by insertion.
|
||||
/// O(n·N) with tiny constants, one allocation for the whole sweep, and the common case is a
|
||||
/// single comparison against the running Nth place before the row is rejected. ~500 k cheap
|
||||
/// iterations per pass at the default 300 s interval.
|
||||
/// </summary>
|
||||
public static class BridgePoints
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// PointsType name -> last-emitted signature.
|
||||
private static readonly Dictionary<string, string> _last =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
private static long _sweeps, _emitted;
|
||||
|
||||
// Reused across systems and across sweeps: the selection is single-threaded (Core thread)
|
||||
// and fully overwritten each time, so there is nothing to allocate per pass.
|
||||
private static PointsEntry[] _top = new PointsEntry[0];
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
// A new sidecar knows nothing; drop the diff state so the next pass re-emits every board.
|
||||
_last.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
Stop();
|
||||
|
||||
_timer = Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(BridgeConfig.PointsSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.PointsSweepSeconds),
|
||||
PointsSweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("points(enabled={0} sweeps={1} emitted={2} tracked={3} topN={4})",
|
||||
BridgeConfig.PointsLeaderboardEnabled, _sweeps, _emitted, _last.Count,
|
||||
BridgeConfig.PointsTopN);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
PointsSweep();
|
||||
}
|
||||
|
||||
private static void PointsSweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!BridgeConfig.PointsLeaderboardEnabled)
|
||||
return;
|
||||
|
||||
_sweeps++;
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
// Systems is a mutable static populated by ~25 separate subsystem constructors in
|
||||
// PointsSystem.Configure(). It is null before that runs and could in principle hold
|
||||
// a null element, so neither is assumed.
|
||||
var systems = PointsSystem.Systems;
|
||||
|
||||
if (systems == null)
|
||||
return;
|
||||
|
||||
var selected = SelectedSystems();
|
||||
|
||||
var n = BridgeConfig.PointsTopN;
|
||||
if (_top.Length != n)
|
||||
_top = new PointsEntry[n];
|
||||
|
||||
for (int i = 0; i < systems.Count; i++)
|
||||
{
|
||||
var sys = systems[i];
|
||||
|
||||
if (sys == null)
|
||||
continue;
|
||||
|
||||
// One bad system must not cost the rest of the sweep: Name/MaxPoints are
|
||||
// abstract members implemented by 25 unrelated subsystems, any of which could
|
||||
// throw on a shard running modified scripts.
|
||||
try
|
||||
{
|
||||
SweepSystem(sys, selected);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] points sweep threw for {0}: {1}",
|
||||
sys.Loyalty, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] points sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SweepSystem(PointsSystem sys, HashSet<string> selected)
|
||||
{
|
||||
var key = sys.Loyalty.ToString();
|
||||
|
||||
if (!IsPublished(sys, key, selected))
|
||||
return;
|
||||
|
||||
int ranked;
|
||||
var count = SelectTop(sys, out ranked);
|
||||
|
||||
var sig = Signature(count, ranked);
|
||||
|
||||
string prior;
|
||||
if (_last.TryGetValue(key, out prior) && prior == sig)
|
||||
return; // top N and participant count both unchanged since last emit
|
||||
|
||||
_last[key] = sig;
|
||||
BridgeLink.Emit(WriteBoard(sys, key, count, ranked));
|
||||
_emitted++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which systems are published. The default is the shard's OWN answer to "is this
|
||||
/// player-facing?" — ShowOnLoyaltyGump, the flag that decides whether a system appears
|
||||
/// on the in-game loyalty gump — rather than a list invented here that would drift from
|
||||
/// the server every time a subsystem is added. `Bridge.cfg PointsSystems=` overrides it
|
||||
/// with an explicit comma-separated list of PointsType names.
|
||||
/// </summary>
|
||||
private static bool IsPublished(PointsSystem sys, string key, HashSet<string> selected)
|
||||
{
|
||||
if (selected != null)
|
||||
return selected.Contains(key);
|
||||
|
||||
return sys.ShowOnLoyaltyGump;
|
||||
}
|
||||
|
||||
// Parsed form of BridgeConfig.PointsSystems, rebuilt when the raw string changes so
|
||||
// `[bridge reload` picks up an edit without a restart. null == "no override, use
|
||||
// ShowOnLoyaltyGump".
|
||||
private static string _selectedRaw;
|
||||
private static HashSet<string> _selected;
|
||||
|
||||
private static HashSet<string> SelectedSystems()
|
||||
{
|
||||
var raw = BridgeConfig.PointsSystems ?? "";
|
||||
|
||||
if (raw == _selectedRaw)
|
||||
return _selected;
|
||||
|
||||
_selectedRaw = raw;
|
||||
_selected = null;
|
||||
|
||||
if (raw.Trim().Length == 0)
|
||||
return null;
|
||||
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var part in raw.Split(','))
|
||||
{
|
||||
var name = part.Trim();
|
||||
|
||||
if (name.Length == 0)
|
||||
continue;
|
||||
|
||||
// Resolve through the enum so a typo is reported loudly rather than silently
|
||||
// publishing one board fewer than the operator asked for.
|
||||
PointsType parsed;
|
||||
if (Enum.TryParse(name, true, out parsed) && Enum.IsDefined(typeof(PointsType), parsed))
|
||||
set.Add(parsed.ToString());
|
||||
else
|
||||
Console.WriteLine("[Bridge] unknown PointsSystems entry '{0}', ignoring", name);
|
||||
}
|
||||
|
||||
_selected = set;
|
||||
return _selected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single pass over one system's PlayerTable, keeping the best <c>_top.Length</c> entries
|
||||
/// in descending order. Returns how many slots were filled; <paramref name="ranked"/>
|
||||
/// receives the number of players actually holding points.
|
||||
///
|
||||
/// Ties do not displace (the shift test is strict, and the reject test is inclusive), so
|
||||
/// an unchanged table produces an unchanged board — which is what makes the diff
|
||||
/// signature meaningful rather than a source of spurious re-emits.
|
||||
/// </summary>
|
||||
private static int SelectTop(PointsSystem sys, out int ranked)
|
||||
{
|
||||
ranked = 0;
|
||||
|
||||
var table = sys.PlayerTable;
|
||||
var top = _top;
|
||||
|
||||
if (table == null || top.Length == 0)
|
||||
return 0;
|
||||
|
||||
var count = 0;
|
||||
|
||||
for (int i = 0; i < table.Count; i++)
|
||||
{
|
||||
var entry = table[i];
|
||||
|
||||
if (entry == null)
|
||||
continue;
|
||||
|
||||
var player = entry.Player;
|
||||
|
||||
// A deleted character keeps its row until the next save/load cycle, and AutoAdd
|
||||
// systems are mostly zero-point rows. Neither belongs on a leaderboard.
|
||||
if (player == null || player.Deleted || entry.Points <= 0)
|
||||
continue;
|
||||
|
||||
ranked++;
|
||||
|
||||
var points = entry.Points;
|
||||
|
||||
// The common case for a big table: worse than the running Nth place, one compare.
|
||||
if (count == top.Length && points <= top[count - 1].Points)
|
||||
continue;
|
||||
|
||||
var pos = count < top.Length ? count : top.Length - 1;
|
||||
|
||||
while (pos > 0 && top[pos - 1].Points < points)
|
||||
{
|
||||
top[pos] = top[pos - 1];
|
||||
pos--;
|
||||
}
|
||||
|
||||
top[pos] = entry;
|
||||
|
||||
if (count < top.Length)
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A system's point ceiling as a whole number, or **0 meaning "uncapped"**.
|
||||
///
|
||||
/// `MaxPoints` is a double, and ServUO's idiom for "no cap" is `double.MaxValue`
|
||||
/// (DespiseCrystals, ShameCrystals and VoidPool all do this). A plain `(long)` cast of
|
||||
/// that is an UNCHECKED conversion — it does not throw, it produces `long.MinValue` —
|
||||
/// which is exactly what the first sweep against a real shard published:
|
||||
/// `"maxPoints": -9223372036854775808`. Anything not representable as a positive long
|
||||
/// therefore becomes 0, which the website already renders as "no maximum".
|
||||
/// </summary>
|
||||
internal static long Cap(double value)
|
||||
{
|
||||
// NaN first: every comparison against NaN is false, so it would otherwise fall through
|
||||
// to the same unchecked cast.
|
||||
if (Double.IsNaN(value) || value <= 0 || value >= 9.2233720368547758E18)
|
||||
return 0;
|
||||
|
||||
return (long)value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A score as a whole number. Same unchecked-cast hazard as <see cref="Cap"/>, but the
|
||||
/// saturating direction is the opposite: an implausibly large score is still a large
|
||||
/// score, so it clamps to long.MaxValue rather than collapsing to 0.
|
||||
/// </summary>
|
||||
internal static long Score(double value)
|
||||
{
|
||||
if (Double.IsNaN(value) || value <= 0)
|
||||
return 0;
|
||||
|
||||
if (value >= 9.2233720368547758E18)
|
||||
return Int64.MaxValue;
|
||||
|
||||
return (long)value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The diff key: every published serial and its whole-point score, plus the participant
|
||||
/// count. Points are compared exactly as they are emitted, so a fractional award that
|
||||
/// does not move the displayed number does not cost a frame either.
|
||||
/// </summary>
|
||||
private static string Signature(int count, int ranked)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
sb.Append(ranked).Append('|');
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entry = _top[i];
|
||||
sb.Append(entry.Player.Serial.Value.ToString("X"))
|
||||
.Append(':')
|
||||
.Append(Score(entry.Points))
|
||||
.Append(';');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One board frame.
|
||||
///
|
||||
/// `nameString` AND `nameNumber` are both emitted because Name is a TextDefinition, which
|
||||
/// may carry either a literal or a cliloc id — the same contract titles.reward already
|
||||
/// documents at BridgeProfile.cs:107-110. Resolving clilocs is the website's job.
|
||||
///
|
||||
/// **Entries are written inline as {serial, name} — never through BridgeJson.Actor.**
|
||||
/// That is deliberate even though the website can now reveal fields by audience rung:
|
||||
/// Actor would add `acct` and `webId`, and neither is needed here, because the site
|
||||
/// resolves serial → user from its own shard_account_links mirror for staff views. A
|
||||
/// board is the widest-audience surface the bridge has; the account name of every ranked
|
||||
/// player has no business crossing the wire to reach it.
|
||||
/// </summary>
|
||||
private static string WriteBoard(PointsSystem sys, string key, int count, int ranked)
|
||||
{
|
||||
var name = sys.Name;
|
||||
|
||||
var sb = BridgeJson.Begin("points.board")
|
||||
.Str("system", key)
|
||||
.Str("nameString", name == null ? null : name.String)
|
||||
.Num("nameNumber", name == null ? 0 : name.Number)
|
||||
.Num("maxPoints", Cap(sys.MaxPoints))
|
||||
.Bool("showOnGump", sys.ShowOnLoyaltyGump)
|
||||
// Players actually HOLDING points, not PlayerTable.Count: an AutoAdd system has a
|
||||
// zero-point row for every character that ever logged in, so the raw count would
|
||||
// report the shard's whole character census as this system's participants.
|
||||
.Num("players", ranked);
|
||||
|
||||
sb.Append(",\"top\":[");
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entry = _top[i];
|
||||
|
||||
if (i > 0)
|
||||
sb.Append(',');
|
||||
|
||||
sb.Append("{\"rank\":").Append(i + 1);
|
||||
sb.Append(",\"serial\":\"0x").Append(entry.Player.Serial.Value.ToString("X")).Append('"');
|
||||
sb.Append(",\"name\":");
|
||||
BridgeJson.Escape(sb, entry.Player.Name ?? "");
|
||||
// Whole points: every one of these systems awards and displays integers in game,
|
||||
// and a board that renders 29500.00000000001 would be a bug report.
|
||||
sb.Append(",\"points\":").Append(Score(entry.Points));
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
}
|
||||
}
|
||||
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The presence stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts:
|
||||
///
|
||||
/// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted
|
||||
/// on a sweep but only when it changes, so the site has a live "N online"
|
||||
/// plus a change history without a firehose of identical frames.
|
||||
/// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap
|
||||
/// per-player movement signal PLAN.md §5.6 recommends over Movement.
|
||||
///
|
||||
/// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the
|
||||
/// same population the vitals sweep already walks; counting them by map and region is a handful
|
||||
/// of field reads. region.enter is filtered to players.
|
||||
/// </summary>
|
||||
public static class BridgePresence
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// Signature of the last-emitted snapshot, so an unchanged population emits nothing.
|
||||
private static string _lastSig;
|
||||
|
||||
private static long _sweeps, _emitted, _regionEnters;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.OnEnterRegion += OnEnterRegion;
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
// Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the
|
||||
// current population within one sweep.
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
_lastSig = null;
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
Stop();
|
||||
|
||||
_timer = Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
|
||||
PresenceSweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})",
|
||||
_sweeps, _emitted, _regionEnters);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
PresenceSweep();
|
||||
}
|
||||
|
||||
private static void PresenceSweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
int total = 0;
|
||||
var byFacet = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||
var byRegion = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var pm = m as PlayerMobile;
|
||||
|
||||
if (pm == null || pm.NetState == null || pm.Deleted)
|
||||
continue;
|
||||
|
||||
total++;
|
||||
|
||||
var facet = pm.Map == null ? "Internal" : pm.Map.Name;
|
||||
Bump(byFacet, facet);
|
||||
|
||||
var region = pm.Region;
|
||||
var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name;
|
||||
Bump(byRegion, regionName);
|
||||
}
|
||||
|
||||
var sig = Signature(total, byFacet, byRegion);
|
||||
if (sig == _lastSig)
|
||||
return; // population unchanged since last emit
|
||||
|
||||
_lastSig = sig;
|
||||
BridgeLink.Emit(WriteOnline(total, byFacet, byRegion));
|
||||
_emitted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Bump(IDictionary<string, int> map, string key)
|
||||
{
|
||||
int n;
|
||||
map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
|
||||
}
|
||||
|
||||
private static string Signature(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append(total);
|
||||
foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
|
||||
sb.Append('#');
|
||||
foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string WriteOnline(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
||||
{
|
||||
var sb = BridgeJson.Begin("presence.online").Num("count", total);
|
||||
|
||||
WriteCounts(sb, "byFacet", byFacet);
|
||||
WriteCounts(sb, "byRegion", byRegion);
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>Writes a nested object of {name: count} pairs.</summary>
|
||||
private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary<string, int> counts)
|
||||
{
|
||||
sb.Append(",\"").Append(field).Append("\":{");
|
||||
|
||||
bool first = true;
|
||||
foreach (var kv in counts)
|
||||
{
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
first = false;
|
||||
|
||||
BridgeJson.Escape(sb, kv.Key);
|
||||
sb.Append(':').Append(kv.Value);
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
// ---- real-time region transitions ----
|
||||
|
||||
private static void OnEnterRegion(OnEnterRegionEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (e == null || e.From == null || !e.From.Player)
|
||||
return;
|
||||
|
||||
var from = e.OldRegion;
|
||||
var to = e.NewRegion;
|
||||
|
||||
// Only meaningful when the named region actually changed.
|
||||
var fromName = from == null ? null : from.Name;
|
||||
var toName = to == null ? null : to.Name;
|
||||
if (String.Equals(fromName, toName, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var sb = BridgeJson.Begin("region.enter")
|
||||
.Str("from", fromName)
|
||||
.Str("to", toName)
|
||||
.Str("map", e.From.Map == null ? null : e.From.Map.Name);
|
||||
|
||||
sb.Actor("who", e.From);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
_regionEnters++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Text;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Engines.Points;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
|
||||
@@ -15,7 +16,7 @@ namespace Server.Custom.Bridge
|
||||
///
|
||||
/// 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.
|
||||
/// a sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
|
||||
/// </summary>
|
||||
public static class BridgeProfile
|
||||
{
|
||||
@@ -83,7 +84,7 @@ namespace Server.Custom.Bridge
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
// worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4)
|
||||
// worn equipment only — not the backpack/bank (see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §IV.4)
|
||||
sb.Append(",\"equipment\":[");
|
||||
first = true;
|
||||
foreach (var item in m.Items)
|
||||
@@ -98,9 +99,197 @@ namespace Server.Custom.Bridge
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
WriteTitles(sb, m);
|
||||
WritePoints(sb, m);
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The titles a character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.3). `selected` is the index into
|
||||
/// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed
|
||||
/// display titles (may be absent). `reward` is the raw reward-title list — an entry may be
|
||||
/// a cliloc number (as a string) or a literal string; resolve clilocs website-side.
|
||||
/// </summary>
|
||||
private static void WriteTitles(StringBuilder sb, PlayerMobile m)
|
||||
{
|
||||
sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle);
|
||||
|
||||
var fameKarma = m.FameKarmaTitle;
|
||||
if (!String.IsNullOrEmpty(fameKarma))
|
||||
{
|
||||
sb.Append(",\"fameKarma\":");
|
||||
BridgeJson.Escape(sb, fameKarma);
|
||||
}
|
||||
|
||||
var skill = m.PaperdollSkillTitle;
|
||||
if (!String.IsNullOrEmpty(skill))
|
||||
{
|
||||
sb.Append(",\"skill\":");
|
||||
BridgeJson.Escape(sb, skill);
|
||||
}
|
||||
|
||||
sb.Append(",\"reward\":[");
|
||||
var rewards = m.RewardTitles;
|
||||
if (rewards != null)
|
||||
{
|
||||
bool first = true;
|
||||
for (int i = 0; i < rewards.Count; i++)
|
||||
{
|
||||
var r = rewards[i];
|
||||
if (r == null)
|
||||
continue;
|
||||
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
|
||||
BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
sb.Append("]}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The point/loyalty standings this character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7.3). Read-model
|
||||
/// enrichment on an existing kind, exactly like <see cref="WriteTitles"/> — there is no
|
||||
/// request kind for "one character's points", because the profile is already the place
|
||||
/// the website asks for everything about one character.
|
||||
///
|
||||
/// Systems with no entry, or an entry at zero, are omitted: ten of the ~25 systems have
|
||||
/// AutoAdd = true and therefore hold a zero-point row for every character that has ever
|
||||
/// logged in, so emitting them all would be ~25 lines of noise on every sheet.
|
||||
///
|
||||
/// **Never call PointsSystem.GetEntry / GetPoints here.** Both look benign and both
|
||||
/// MUTATE THE WORLD: `GetEntry(from, create: false)` still calls AddEntry when the system
|
||||
/// has AutoAdd (PointsSystem.cs:207), which appends a row to PlayerTable and fires
|
||||
/// OnPlayerAdded. A read model that used them would silently grow the points save file by
|
||||
/// up to ten rows every time anyone viewed a character sheet. Hence the manual scan.
|
||||
///
|
||||
/// Cost: one early-exiting pass over each published system's PlayerTable. The AutoAdd
|
||||
/// tables are census-sized, so this is the dominant term in the profile — roughly 10 × n
|
||||
/// comparisons, against the ~0.069 ms/2.4 KB the rest of the profile measures at. That is
|
||||
/// acceptable because profiles are built on demand at human rates and never in a sweep;
|
||||
/// PointsProfileEnabled turns it off for a shard where it isn't.
|
||||
///
|
||||
/// **Deliberately no `rank`.** Rank cannot early-exit — it must count every row that
|
||||
/// beats the player, in every system, every time — and the website can derive it from
|
||||
/// the points.board frame for anyone who is actually on a board. See PointsProfileRank.
|
||||
/// </summary>
|
||||
private static void WritePoints(StringBuilder sb, PlayerMobile m)
|
||||
{
|
||||
if (!BridgeConfig.PointsProfileEnabled)
|
||||
return;
|
||||
|
||||
sb.Append(",\"points\":[");
|
||||
|
||||
try
|
||||
{
|
||||
var systems = PointsSystem.Systems;
|
||||
|
||||
if (systems != null)
|
||||
{
|
||||
bool first = true;
|
||||
|
||||
for (int i = 0; i < systems.Count; i++)
|
||||
{
|
||||
var sys = systems[i];
|
||||
|
||||
if (sys == null || !sys.ShowOnLoyaltyGump)
|
||||
continue;
|
||||
|
||||
var points = LookupPoints(sys, m);
|
||||
|
||||
if (points <= 0)
|
||||
continue;
|
||||
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
|
||||
var name = sys.Name;
|
||||
|
||||
sb.Append("{\"system\":\"").Append(sys.Loyalty).Append('"');
|
||||
sb.Append(",\"nameString\":");
|
||||
if (name == null || name.String == null)
|
||||
sb.Append("null");
|
||||
else
|
||||
BridgeJson.Escape(sb, name.String);
|
||||
sb.Append(",\"nameNumber\":").Append(name == null ? 0 : name.Number);
|
||||
sb.Append(",\"points\":").Append(BridgePoints.Score(points));
|
||||
sb.Append(",\"maxPoints\":").Append(BridgePoints.Cap(sys.MaxPoints));
|
||||
|
||||
// Off by default. The field is absent rather than null when disabled, so a
|
||||
// consumer can tell "this shard does not compute rank" from "unranked".
|
||||
if (BridgeConfig.PointsProfileRank)
|
||||
sb.Append(",\"rank\":").Append(RankOf(sys, points));
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A profile is worth more than its points block; never fail the sheet over one.
|
||||
Console.WriteLine("[Bridge] profile points threw: {0}", ex.Message);
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This character's score in one system, or 0 if it has no entry. A hand-rolled scan
|
||||
/// rather than GetEntry/GetPoints for the mutation reason above; it stops at the match,
|
||||
/// which the rank computation could not.
|
||||
/// </summary>
|
||||
private static double LookupPoints(PointsSystem sys, PlayerMobile m)
|
||||
{
|
||||
var table = sys.PlayerTable;
|
||||
|
||||
if (table == null)
|
||||
return 0;
|
||||
|
||||
for (int i = 0; i < table.Count; i++)
|
||||
{
|
||||
var entry = table[i];
|
||||
|
||||
if (entry != null && entry.Player == m)
|
||||
return entry.Points;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1-based standing in one system: how many live characters hold strictly more points,
|
||||
/// plus one. Ties share a rank, which is what a player expects to see.
|
||||
///
|
||||
/// Only reachable with PointsProfileRank=true, and off by default for the reason stated
|
||||
/// in <see cref="WritePoints"/>: unlike the points lookup, this visits every row of the
|
||||
/// table every time, so it turns a bounded early-exiting scan into a guaranteed full one
|
||||
/// per published system per profile.
|
||||
/// </summary>
|
||||
private static int RankOf(PointsSystem sys, double points)
|
||||
{
|
||||
var table = sys.PlayerTable;
|
||||
|
||||
if (table == null)
|
||||
return 1;
|
||||
|
||||
var better = 0;
|
||||
|
||||
for (int i = 0; i < table.Count; i++)
|
||||
{
|
||||
var entry = table[i];
|
||||
|
||||
if (entry == null || entry.Player == null || entry.Player.Deleted)
|
||||
continue;
|
||||
|
||||
if (entry.Points > points)
|
||||
better++;
|
||||
}
|
||||
|
||||
return better + 1;
|
||||
}
|
||||
|
||||
private static bool IsGearLayer(Layer layer)
|
||||
{
|
||||
switch (layer)
|
||||
|
||||
355
overlay/Scripts/Custom/Bridge/BridgeRuleset.cs
Normal file
355
overlay/Scripts/Custom/Bridge/BridgeRuleset.cs
Normal file
@@ -0,0 +1,355 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
using Server.Engines.CityLoyalty;
|
||||
using Server.Engines.VvV;
|
||||
using Server.Multis;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5). One `world.ruleset` frame describing how
|
||||
/// this shard is actually configured: expansion, which systems are on, skill/stat caps, house
|
||||
/// and account limits, champion scroll rules, and the save/restart schedule. It is what turns
|
||||
/// the website's "Rules" page from hand-maintained prose into something that cannot drift from
|
||||
/// the server.
|
||||
///
|
||||
/// Modelled on <see cref="BridgeBoot.EmitHello"/>, NOT on the diff sweeps: the ruleset changes
|
||||
/// only when an operator edits Config/*.cfg and restarts (or runs `[bridge reload`), so there is
|
||||
/// nothing to poll. It subscribes Connected_Core so a sidecar that comes up second still learns
|
||||
/// the ruleset, exactly as server.hello does.
|
||||
///
|
||||
/// **The one hard rule: this is an explicit allowlist of Config.Get calls.** Never enumerate
|
||||
/// Config.Entries (Server/Config.cs) — that would sweep in every key on the server, secrets
|
||||
/// included. Files deliberately never read here, in addition to anything not named below:
|
||||
///
|
||||
/// Server.cfg — Address / Listen / Port. Only Bridge.PublicConnectAddress is published,
|
||||
/// and only because an operator typed it there for exactly this purpose.
|
||||
/// Staff.cfg — staff account names.
|
||||
/// Email.cfg — SMTP credentials.
|
||||
/// DataPath.cfg — filesystem layout.
|
||||
/// Bridge.cfg — the sidecar host/port and our own caps.
|
||||
/// Compiler.cfg — build flags.
|
||||
/// Reports.cfg — report upload credentials.
|
||||
/// Client.cfg — client-version enforcement (not player-facing rules).
|
||||
///
|
||||
/// `rev` is an FNV-1a hash of the emitted body, so a reconnect that carries an unchanged ruleset
|
||||
/// is a no-op site-side. String.GetHashCode() is deliberately NOT used: it is randomized per
|
||||
/// process on modern .NET, so it would change on every shard restart and defeat the whole point.
|
||||
/// </summary>
|
||||
public static class BridgeRuleset
|
||||
{
|
||||
private static long _emitted;
|
||||
private static string _rev = "";
|
||||
private static int _bytes;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeLink.Connected_Core += Emit;
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("ruleset(enabled={0} emitted={1} rev={2} bytes={3})",
|
||||
BridgeConfig.RulesetEnabled, _emitted, _rev.Length == 0 ? "-" : _rev, _bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core thread. Builds and queues one `world.ruleset` frame. Called on every sidecar
|
||||
/// connect and by `[bridge reload` (an operator who just edited a .cfg wants to see the
|
||||
/// change on the site without restarting the shard).
|
||||
/// </summary>
|
||||
public static void Emit()
|
||||
{
|
||||
if (!BridgeConfig.RulesetEnabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var body = BuildBody();
|
||||
|
||||
_rev = Fnv1a(body);
|
||||
_bytes = body.Length;
|
||||
_emitted++;
|
||||
|
||||
// rev goes first so a reader can short-circuit on an unchanged frame before parsing
|
||||
// the rest of it.
|
||||
BridgeLink.Emit(BridgeJson.Begin("world.ruleset")
|
||||
.Str("rev", _rev)
|
||||
.Append(body)
|
||||
.End());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] ruleset emit threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The allowlist. Every block is optional and omitted when its system is off, so a shard
|
||||
/// that does not run (say) VvV publishes no `vvv` block rather than a block of zeroes.
|
||||
/// </summary>
|
||||
private static string BuildBody()
|
||||
{
|
||||
var sb = new StringBuilder(2048);
|
||||
|
||||
sb.Str("shard", Server.Misc.ServerList.ServerName);
|
||||
sb.Str("expansion", Core.Expansion.ToString());
|
||||
|
||||
// The ONLY thing published from a connection-address setting, and only because the
|
||||
// operator put it in Bridge.cfg specifically to be shown. Server.cfg is never read.
|
||||
var connect = BridgeConfig.PublicConnectAddress;
|
||||
if (!String.IsNullOrEmpty(connect))
|
||||
sb.Str("connect", connect);
|
||||
|
||||
WriteSystems(sb);
|
||||
WriteCaps(sb);
|
||||
|
||||
sb.Append(",\"housing\":{\"accountHouseLimit\":")
|
||||
.Append(BaseHouse.AccountHouseLimit).Append('}');
|
||||
|
||||
WriteAccounts(sb);
|
||||
WriteVetRewards(sb);
|
||||
WriteLoot(sb);
|
||||
WriteVendors(sb);
|
||||
WriteChampions(sb);
|
||||
WriteTreasureMaps(sb);
|
||||
WriteVvV(sb);
|
||||
WriteStore(sb);
|
||||
|
||||
if (BridgeConfig.RulesetIncludeSchedule)
|
||||
WriteSchedule(sb);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which optional systems this shard runs. Read from each system's own static rather than
|
||||
/// re-parsing its .cfg, so a system that derives its state (Factions is on exactly when VvV
|
||||
/// is off — Services/Factions/Core/Faction.cs) is reported the way the server actually sees
|
||||
/// it. This block subsumes the `world.systems` capability frame PROTOCOL_2.md §10.4
|
||||
/// sketched but never implemented.
|
||||
/// </summary>
|
||||
private static void WriteSystems(StringBuilder sb)
|
||||
{
|
||||
sb.Append(",\"systems\":{");
|
||||
sb.Append("\"cityLoyalty\":").Append(Json(CityLoyaltySystem.Enabled));
|
||||
sb.Append(",\"vvv\":").Append(Json(ViceVsVirtueSystem.Enabled));
|
||||
sb.Append(",\"factions\":").Append(Json(Server.Factions.Settings.Enabled));
|
||||
sb.Append(",\"siege\":").Append(Json(Siege.SiegeShard));
|
||||
sb.Append(",\"chat\":").Append(Json(Config.Get("Chat.Enabled", true)));
|
||||
sb.Append(",\"store\":").Append(Json(Config.Get("Store.Enabled", true)));
|
||||
sb.Append(",\"dailyRares\":").Append(Json(Config.Get("DailyRares.Enabled", true)));
|
||||
sb.Append(",\"honesty\":").Append(Json(Config.Get("Honesty.Enabled", true)));
|
||||
sb.Append(",\"shadowguard\":").Append(Json(Core.TOL));
|
||||
sb.Append(",\"treasureMaps\":").Append(Json(Config.Get("TreasureMaps.Enabled", true)));
|
||||
sb.Append(",\"vetRewards\":").Append(Json(Config.Get("VetRewards.Enabled", true)));
|
||||
sb.Append(",\"testCenter\":").Append(Json(Config.Get("TestCenter.Enabled", false)));
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skill and stat caps — the single most-asked "what are the rules here?" question, and the
|
||||
/// one most often wrong on a hand-written page. SkillCap is in tenths (1000 = 100.0).
|
||||
/// </summary>
|
||||
private static void WriteCaps(StringBuilder sb)
|
||||
{
|
||||
sb.Append(",\"caps\":{");
|
||||
sb.Append("\"skill\":").Append(Config.Get("PlayerCaps.SkillCap", 1000));
|
||||
sb.Append(",\"totalSkill\":").Append(Config.Get("PlayerCaps.TotalSkillCap", 7000));
|
||||
sb.Append(",\"stat\":").Append(Config.Get("PlayerCaps.TotalStatCap", 225));
|
||||
sb.Append(",\"str\":").Append(Config.Get("PlayerCaps.StrCap", 125));
|
||||
sb.Append(",\"dex\":").Append(Config.Get("PlayerCaps.DexCap", 125));
|
||||
sb.Append(",\"int\":").Append(Config.Get("PlayerCaps.IntCap", 125));
|
||||
sb.Append(",\"strMax\":").Append(Config.Get("PlayerCaps.StrMaxCap", 150));
|
||||
sb.Append(",\"dexMax\":").Append(Config.Get("PlayerCaps.DexMaxCap", 150));
|
||||
sb.Append(",\"intMax\":").Append(Config.Get("PlayerCaps.IntMaxCap", 150));
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Account limits. `autoCreate` is the in-game first-login auto-create switch, which pairs
|
||||
/// with the bridge's own SignupMode (BridgeConfig.WarnOnSignupMismatch) — publishing it
|
||||
/// lets the site's signup page tell a visitor the truth about how to get an account.
|
||||
/// Character slots come from Siege.cfg, which is where ServUO keeps them regardless of
|
||||
/// whether the shard is actually Siege.
|
||||
/// </summary>
|
||||
private static void WriteAccounts(StringBuilder sb)
|
||||
{
|
||||
sb.Append(",\"accounts\":{");
|
||||
sb.Append("\"perIp\":").Append(Config.Get("Accounts.AccountsPerIp", 1));
|
||||
sb.Append(",\"charSlots\":").Append(Siege.CharacterSlots);
|
||||
sb.Append(",\"autoCreate\":").Append(Json(Config.Get("Accounts.AutoCreateAccounts", true)));
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
private static void WriteVetRewards(StringBuilder sb)
|
||||
{
|
||||
var enabled = Config.Get("VetRewards.Enabled", true);
|
||||
|
||||
sb.Append(",\"vetRewards\":{\"enabled\":").Append(Json(enabled));
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
var interval = Config.Get("VetRewards.RewardInterval", TimeSpan.FromDays(30.0));
|
||||
sb.Append(",\"rewardIntervalDays\":").Append((int)interval.TotalDays);
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>The Felucca risk-vs-reward numbers — the reason players choose a facet.</summary>
|
||||
private static void WriteLoot(StringBuilder sb)
|
||||
{
|
||||
sb.Append(",\"loot\":{");
|
||||
sb.Append("\"feluccaLuckBonus\":").Append(Config.Get("Loot.FeluccaLuckBonus", 0));
|
||||
sb.Append(",\"feluccaBudgetBonus\":").Append(Config.Get("Loot.FeluccaBudgetBonus", 0));
|
||||
sb.Append(",\"feluccaMaxProps\":").Append(Config.Get("Loot.MaxProps", 5));
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
private static void WriteVendors(StringBuilder sb)
|
||||
{
|
||||
sb.Append(",\"vendors\":{");
|
||||
sb.Append("\"restockDelayMinutes\":").Append(Config.Get("Vendors.RestockDelay", 60));
|
||||
sb.Append(",\"maxSell\":").Append(Config.Get("Vendors.MaxSell", 500));
|
||||
sb.Append(",\"economyStockAmount\":").Append(Config.Get("Vendors.EconomyStockAmount", 500));
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Champion spawn rewards. `rankThresholds` is the red-skull count at which each rank is
|
||||
/// reached, which is what a player actually wants to know before committing to a spawn.
|
||||
/// </summary>
|
||||
private static void WriteChampions(StringBuilder sb)
|
||||
{
|
||||
if (!Config.Get("Champions.Enabled", true))
|
||||
return;
|
||||
|
||||
sb.Append(",\"champions\":{");
|
||||
sb.Append("\"powerScrolls\":").Append(Config.Get("Champions.PowerScrolls", 6));
|
||||
sb.Append(",\"statScrolls\":").Append(Config.Get("Champions.StatScrolls", 16));
|
||||
sb.Append(",\"scrollChance\":").Append(Json(Config.Get("Champions.ScrollChance", 0.1)));
|
||||
sb.Append(",\"transcendenceChance\":")
|
||||
.Append(Json(Config.Get("Champions.TranscendenceChance", 50.0)));
|
||||
sb.Append(",\"rankThresholds\":[")
|
||||
.Append(Config.Get("Champions.Rank2RedSkulls", 5)).Append(',')
|
||||
.Append(Config.Get("Champions.Rank3RedSkulls", 10)).Append(',')
|
||||
.Append(Config.Get("Champions.Rank4RedSkulls", 13))
|
||||
.Append(']');
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
private static void WriteTreasureMaps(StringBuilder sb)
|
||||
{
|
||||
var enabled = Config.Get("TreasureMaps.Enabled", true);
|
||||
|
||||
sb.Append(",\"treasureMaps\":{\"enabled\":").Append(Json(enabled));
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
sb.Append(",\"lootChance\":").Append(Json(Config.Get("TreasureMaps.LootChance", 0.01)));
|
||||
sb.Append(",\"resetDays\":").Append(Json(Config.Get("TreasureMaps.ResetTime", 30.0)));
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
private static void WriteVvV(StringBuilder sb)
|
||||
{
|
||||
if (!ViceVsVirtueSystem.Enabled)
|
||||
return;
|
||||
|
||||
sb.Append(",\"vvv\":{");
|
||||
sb.Append("\"enabled\":true");
|
||||
sb.Append(",\"startSilver\":").Append(ViceVsVirtueSystem.StartSilver);
|
||||
sb.Append(",\"enhancedRules\":").Append(Json(ViceVsVirtueSystem.EnhancedRules));
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Ultima Store. Only `enabled` and the currency's display name — never the store's
|
||||
/// price table or any payment configuration, neither of which lives in Config anyway.
|
||||
/// </summary>
|
||||
private static void WriteStore(StringBuilder sb)
|
||||
{
|
||||
var enabled = Config.Get("Store.Enabled", true);
|
||||
|
||||
sb.Append(",\"store\":{\"enabled\":").Append(Json(enabled));
|
||||
|
||||
if (enabled)
|
||||
sb.Str("currencyName", Config.Get("Store.CurrencyName", "Sovereigns"));
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save and restart schedule — "when does the shard hiccup?", the other question a live
|
||||
/// status page is asked. Off behind RulesetIncludeSchedule for an operator who would rather
|
||||
/// not advertise a predictable restart window.
|
||||
/// </summary>
|
||||
private static void WriteSchedule(StringBuilder sb)
|
||||
{
|
||||
sb.Append(",\"schedule\":{");
|
||||
|
||||
var saves = Config.Get("AutoSave.Enabled", true);
|
||||
sb.Append("\"autoSaveEnabled\":").Append(Json(saves));
|
||||
|
||||
if (saves)
|
||||
{
|
||||
var freq = Config.Get("AutoSave.Frequency", TimeSpan.FromMinutes(5.0));
|
||||
sb.Append(",\"autoSaveFrequencyMinutes\":").Append((int)freq.TotalMinutes);
|
||||
}
|
||||
|
||||
var restart = Config.Get("AutoRestart.Enabled", false);
|
||||
sb.Append(",\"autoRestartEnabled\":").Append(Json(restart));
|
||||
|
||||
if (restart)
|
||||
{
|
||||
sb.Append(",\"autoRestartHour\":").Append(Config.Get("AutoRestart.Hour", 12));
|
||||
sb.Append(",\"autoRestartMinute\":").Append(Config.Get("AutoRestart.Minute", 0));
|
||||
sb.Append(",\"autoRestartFrequencyHours\":").Append(Config.Get("AutoRestart.Frequency", 24));
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static string Json(bool value)
|
||||
{
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
private static string Json(double value)
|
||||
{
|
||||
return value.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FNV-1a over the UTF-16 code units of the body, as 8 lowercase hex digits. Any stable
|
||||
/// hash would do; what matters is that it is stable ACROSS PROCESSES, which
|
||||
/// String.GetHashCode() is not (it is seeded randomly per process), so using that would
|
||||
/// produce a different rev after every restart and make the whole diff pointless.
|
||||
/// </summary>
|
||||
private static string Fnv1a(string s)
|
||||
{
|
||||
const uint offset = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
|
||||
uint hash = offset;
|
||||
|
||||
for (int i = 0; i < s.Length; i++)
|
||||
{
|
||||
char c = s[i];
|
||||
hash = (hash ^ (byte)(c & 0xFF)) * prime;
|
||||
hash = (hash ^ (byte)(c >> 8)) * prime;
|
||||
}
|
||||
|
||||
return hash.ToString("x8");
|
||||
}
|
||||
}
|
||||
}
|
||||
367
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
367
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
@@ -0,0 +1,367 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using Server.Guilds;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The guild stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
|
||||
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
|
||||
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
|
||||
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
|
||||
/// tick, fold each guild to a small signature, and emit `guild.update` only when it changes.
|
||||
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
|
||||
///
|
||||
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
|
||||
/// so joined" feed does not wait for the next sweep.
|
||||
///
|
||||
/// Protocol 4 adds the membership half that §10.1 deferred. The sweep holds each guild's
|
||||
/// member serial **set** rather than a sum of it, so a change is detected by set comparison
|
||||
/// (no hash collisions, unlike the old sum where two offsetting changes could cancel) and the
|
||||
/// departures are recoverable by difference — which is what makes a per-member `guild.leave`
|
||||
/// possible without a core tap. A changed set also re-emits `guild.roster`, the full member
|
||||
/// list, so the board self-corrects and nothing downstream has to replay deltas to stay right.
|
||||
///
|
||||
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
|
||||
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
|
||||
/// guild, would look like every guild being created at once.
|
||||
/// </summary>
|
||||
public static class BridgeSocial
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// guild id -> last-emitted signature. An id absent here has never been emitted (or the cache
|
||||
// was cleared on reconnect), so its next sweep counts as a change.
|
||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
||||
|
||||
// guild id -> last-emitted member serial set (Protocol 4). Held rather than summed so a
|
||||
// departure can be recovered as a set difference; see the class remarks.
|
||||
private static readonly Dictionary<int, HashSet<int>> _members =
|
||||
new Dictionary<int, HashSet<int>>();
|
||||
|
||||
private static long _sweeps, _emitted, _removed, _joins, _rosters, _leaves;
|
||||
|
||||
// Set while a post-reconnect baseline is still draining, so the sweep re-arms promptly
|
||||
// instead of leaving the site a sweep interval behind. See GuildSweep.
|
||||
private static bool _draining;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
EventSink.JoinGuild += OnJoinGuild;
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
_last.Clear();
|
||||
_members.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
Stop();
|
||||
|
||||
_timer = Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
|
||||
GuildSweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null) { _timer.Stop(); _timer = null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format(
|
||||
"guilds(sweeps={0} emitted={1} removed={2} joins={3} rosters={4} leaves={5} tracked={6} draining={7})",
|
||||
_sweeps, _emitted, _removed, _joins, _rosters, _leaves, _last.Count, _draining);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
GuildSweep();
|
||||
}
|
||||
|
||||
private static void GuildSweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (!BridgeLink.Connected)
|
||||
return; // nothing is listening; do not fill the queue with perishable snapshots
|
||||
|
||||
var seen = new HashSet<int>();
|
||||
|
||||
// Guilds whose roster this sweep is still allowed to emit. Every guild looks changed
|
||||
// right after a reconnect, and a roster is this plugin's only fat frame, so the
|
||||
// baseline is spread over several passes rather than built in one Core-thread tick.
|
||||
var rosterBudget = BridgeConfig.GuildRosterGuildsPerTick;
|
||||
var deferred = false;
|
||||
|
||||
foreach (var bg in BaseGuild.List.Values)
|
||||
{
|
||||
var g = bg as Guild;
|
||||
|
||||
// Skip disbanded guilds (leader gone): they linger in the list until cleaned up,
|
||||
// and treating them as absent lets the "gone" pass below emit guild.remove.
|
||||
if (g == null || g.Disbanded)
|
||||
continue;
|
||||
|
||||
seen.Add(g.Id);
|
||||
|
||||
var current = MemberSerials(g);
|
||||
|
||||
HashSet<int> priorMembers;
|
||||
var known = _members.TryGetValue(g.Id, out priorMembers);
|
||||
var membersChanged = !known || !priorMembers.SetEquals(current);
|
||||
|
||||
var sig = Signature(g);
|
||||
|
||||
string prior;
|
||||
var sigChanged = !_last.TryGetValue(g.Id, out prior) || prior != sig;
|
||||
|
||||
if (!sigChanged && !membersChanged)
|
||||
continue; // unchanged since last emit
|
||||
|
||||
if (sigChanged)
|
||||
{
|
||||
_last[g.Id] = sig;
|
||||
BridgeLink.Emit(WriteGuild(g));
|
||||
_emitted++;
|
||||
}
|
||||
|
||||
if (!membersChanged)
|
||||
continue;
|
||||
|
||||
// Over budget: leave _members untouched so this guild is still "changed" next
|
||||
// pass and gets its roster then. The guild.update above has already gone, so the
|
||||
// board's counts are current either way.
|
||||
if (rosterBudget <= 0)
|
||||
{
|
||||
deferred = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterBudget--;
|
||||
|
||||
// Departures, per member, before the roster that supersedes them: a consumer
|
||||
// building a "so-and-so left" feed needs the individual events, while a consumer
|
||||
// holding the membership table only needs the roster. On the very first sweep for
|
||||
// a guild there is no prior set, so nothing is reported as having left — an
|
||||
// unknown roster becoming known is not 155 people leaving.
|
||||
if (known)
|
||||
{
|
||||
foreach (var serial in priorMembers)
|
||||
{
|
||||
if (current.Contains(serial))
|
||||
continue;
|
||||
|
||||
BridgeLink.Emit(BridgeJson.Begin("guild.leave")
|
||||
.Num("id", g.Id)
|
||||
.Str("name", g.Name)
|
||||
.Ser("who", (Serial)serial)
|
||||
.End());
|
||||
_leaves++;
|
||||
}
|
||||
}
|
||||
|
||||
EmitRoster(g);
|
||||
_members[g.Id] = current;
|
||||
}
|
||||
|
||||
// Anything tracked last sweep but not seen now has disbanded or been removed.
|
||||
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
foreach (var id in gone)
|
||||
{
|
||||
_last.Remove(id);
|
||||
_members.Remove(id);
|
||||
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
|
||||
_removed++;
|
||||
}
|
||||
|
||||
// Re-arm promptly while a baseline is still draining. Without this the remaining
|
||||
// guilds would each wait a full GuildSweepSeconds, so a 200-guild shard would take
|
||||
// hours to publish its rosters after a reconnect instead of seconds. The sweep is
|
||||
// idempotent, so an extra pass that finds nothing changed costs a few field reads.
|
||||
_draining = deferred;
|
||||
|
||||
if (deferred)
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(2.0), GuildSweep);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The guild's live member serials. Held per guild between sweeps so a membership change
|
||||
/// yields both the fact that it changed and *who* left (Protocol 4).
|
||||
/// </summary>
|
||||
private static HashSet<int> MemberSerials(Guild g)
|
||||
{
|
||||
var set = new HashSet<int>();
|
||||
var members = g.Members;
|
||||
|
||||
if (members != null)
|
||||
{
|
||||
for (int i = 0; i < members.Count; i++)
|
||||
{
|
||||
var m = members[i];
|
||||
if (m == null)
|
||||
continue;
|
||||
set.Add(m.Serial.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
// The volatile fields that define a meaningful change to the *board row*: name, abbreviation,
|
||||
// leader, member count and alliance. Membership is no longer folded in here as a serial sum —
|
||||
// the sweep compares the real member set instead, which cannot collide the way a sum can when
|
||||
// one member joins and another leaves between two passes.
|
||||
private static string Signature(Guild g)
|
||||
{
|
||||
int count = 0;
|
||||
|
||||
var members = g.Members;
|
||||
if (members != null)
|
||||
{
|
||||
for (int i = 0; i < members.Count; i++)
|
||||
{
|
||||
if (members[i] != null)
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value;
|
||||
|
||||
return String.Concat(
|
||||
g.Name ?? "", "|",
|
||||
g.Abbreviation ?? "", "|",
|
||||
leaderSerial.ToString(), "|",
|
||||
count.ToString(), "|",
|
||||
g.Alliance == null ? "" : (g.AllianceName ?? ""));
|
||||
}
|
||||
|
||||
private static string WriteGuild(Guild g)
|
||||
{
|
||||
int online = 0, count = 0;
|
||||
var members = g.Members;
|
||||
if (members != null)
|
||||
{
|
||||
for (int i = 0; i < members.Count; i++)
|
||||
{
|
||||
var m = members[i];
|
||||
if (m == null)
|
||||
continue;
|
||||
count++;
|
||||
if (m.NetState != null)
|
||||
online++;
|
||||
}
|
||||
}
|
||||
|
||||
var sb = BridgeJson.Begin("guild.update")
|
||||
.Num("id", g.Id)
|
||||
.Str("name", g.Name)
|
||||
.Str("abbr", g.Abbreviation)
|
||||
.Num("members", count)
|
||||
.Num("online", online)
|
||||
.Str("alliance", g.Alliance == null ? null : g.AllianceName);
|
||||
|
||||
sb.Actor("leader", g.Leader);
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the guild's full member list as one or more `guild.roster` frames (Protocol 4).
|
||||
///
|
||||
/// A roster is the only fat frame this plugin produces — roughly 69 bytes per member — and
|
||||
/// the sidecar reads a line with no length bound, so the member count per line is capped
|
||||
/// (Bridge.GuildRosterMembersPerLine). A guild over the cap is split, and each frame
|
||||
/// carries `seq` plus `more` so a consumer can tell a complete roster from a partial one:
|
||||
/// `seq` 0 begins a roster and replaces whatever was held, and `more` false ends it. A
|
||||
/// guild inside the cap — every realistic one — emits exactly one frame with `seq` 0 and
|
||||
/// `more` false, which is the same shape as if chunking did not exist.
|
||||
/// </summary>
|
||||
private static void EmitRoster(Guild g)
|
||||
{
|
||||
var members = g.Members;
|
||||
var total = members == null ? 0 : members.Count;
|
||||
var perLine = BridgeConfig.GuildRosterMembersPerLine;
|
||||
|
||||
var seq = 0;
|
||||
var start = 0;
|
||||
|
||||
// do/while, not while: a guild with no members must still emit one empty roster frame,
|
||||
// or a consumer could never learn that a roster it holds has emptied.
|
||||
do
|
||||
{
|
||||
var more = start + perLine < total;
|
||||
|
||||
var sb = BridgeJson.Begin("guild.roster")
|
||||
.Num("id", g.Id)
|
||||
.Str("name", g.Name)
|
||||
.Str("abbr", g.Abbreviation)
|
||||
.Num("total", total)
|
||||
.Num("seq", seq)
|
||||
.Bool("more", more);
|
||||
|
||||
// `withGuildRank` — the roster is the one place a member's rank in THIS guild is
|
||||
// meaningful, and the only frame that carries it. Leadership is rank 4
|
||||
// (RankDefinition.Ranks), and a guild can have several members at it, which is why
|
||||
// the board's single `leader` field was never enough to answer "who leads this".
|
||||
sb.Actors("members", members, start, perLine, withGuildRank: true);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
_rosters++;
|
||||
|
||||
start += perLine;
|
||||
seq++;
|
||||
}
|
||||
while (start < total);
|
||||
}
|
||||
|
||||
// ---- real-time join ----
|
||||
|
||||
private static void OnJoinGuild(JoinGuildEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (e == null || e.Mobile == null)
|
||||
return;
|
||||
|
||||
var g = e.Guild as Guild;
|
||||
|
||||
var sb = BridgeJson.Begin("guild.join");
|
||||
if (g != null)
|
||||
sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation);
|
||||
sb.Actor("who", e.Mobile);
|
||||
BridgeLink.Emit(sb.End());
|
||||
_joins++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ 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
|
||||
/// measured cost (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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
|
||||
@@ -215,11 +215,14 @@ namespace Server.Custom.Bridge
|
||||
if (owner != null)
|
||||
{
|
||||
sb.Ser("ownerSerial", owner.Serial);
|
||||
sb.Str("ownerName", owner.Name);
|
||||
var acct = owner.Account as Account;
|
||||
if (acct != null)
|
||||
sb.Str("ownerAcct", acct.Username);
|
||||
}
|
||||
|
||||
AppendDecaySchedule(sb, house, to);
|
||||
|
||||
// Where a player would physically stand to see it.
|
||||
var ban = house.BanLocation;
|
||||
sb.Append(",\"ban\":{\"x\":").Append(ban.X)
|
||||
@@ -232,6 +235,67 @@ namespace Server.Custom.Bridge
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protocol 5. The three scheduling fields, and the reason they are not all always present.
|
||||
///
|
||||
/// ServUO has two decay implementations and they differ in how KNOWABLE the future is:
|
||||
///
|
||||
/// * Dynamic decay (DynamicDecay.Enabled, i.e. Core.ML) draws each stage's duration at
|
||||
/// RANDOM when the stage is entered (BaseHouse.SetDynamicDecay ->
|
||||
/// DynamicDecay.GetRandomDuration). So NextDecayStage is exact for the NEXT transition
|
||||
/// and nothing beyond it is known at all. Collapse becomes exact only once the house is
|
||||
/// already at IDOC, because then the next transition IS the collapse.
|
||||
/// * Static decay (GetOldDecayLevel) is a pure function of LastRefreshed and DecayPeriod,
|
||||
/// so collapse is exact at EVERY stage -- there is no randomness to wait out.
|
||||
///
|
||||
/// Emitting estimatedCollapse from a dynamic-decay house at, say, Fairly would therefore be
|
||||
/// publishing a guess as a fact, which on the website's side becomes a dated promise in a
|
||||
/// player's mail. It is omitted rather than approximated: the website's `required: false`
|
||||
/// declaration already permits its absence, and an absent field is honest where a wrong
|
||||
/// date is not.
|
||||
/// </summary>
|
||||
private static void AppendDecaySchedule(StringBuilder sb, BaseHouse house, DecayLevel to)
|
||||
{
|
||||
// ONE nested object rather than four sibling keys, for the same reason vendor.listing
|
||||
// nests `location`: the website's visibility projection matches literal JSON keys, so a
|
||||
// nested group is one admin rule that can hide the whole schedule, where four flat keys
|
||||
// would be four rules that drift apart.
|
||||
sb.Append(",\"schedule\":{");
|
||||
|
||||
// The stage clock. Only dynamic decay keeps one; static decay leaves it at MinValue.
|
||||
bool dynamic = DynamicDecay.Enabled;
|
||||
var next = house.NextDecayStage;
|
||||
|
||||
sb.Append("\"dynamicDecay\":").Append(dynamic ? "true" : "false");
|
||||
|
||||
if (dynamic && next > DateTime.MinValue)
|
||||
sb.Str("nextStage", next.ToUniversalTime().ToString("o"));
|
||||
|
||||
// Total seconds from a full refresh to collapse. Constant per house type, but it is what
|
||||
// lets a reader turn lastRefreshed into a percentage without knowing ServUO's tables.
|
||||
var period = house.DecayPeriod;
|
||||
if (period > TimeSpan.Zero)
|
||||
sb.Num("decayPeriodSec", (long)period.TotalSeconds);
|
||||
|
||||
DateTime collapse;
|
||||
bool knowable = true;
|
||||
|
||||
if (!dynamic)
|
||||
collapse = house.LastRefreshed.ToUniversalTime() + period;
|
||||
else if (to == DecayLevel.IDOC && next > DateTime.MinValue)
|
||||
collapse = next.ToUniversalTime();
|
||||
else
|
||||
{
|
||||
collapse = DateTime.MinValue;
|
||||
knowable = false;
|
||||
}
|
||||
|
||||
if (knowable)
|
||||
sb.Str("estimatedCollapse", collapse.ToString("o"));
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
// ---- economy supply ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
1144
overlay/Scripts/Custom/Bridge/BridgeWorld.cs
Normal file
1144
overlay/Scripts/Custom/Bridge/BridgeWorld.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ namespace Server.Custom.Bridge
|
||||
/// <summary>
|
||||
/// Forwards IN-GAME uses of the write-plane verbs to the website as admin.audit
|
||||
/// (origin=in-game), so the site's moderation log is complete regardless of whether an action
|
||||
/// came from the website or a staff member in the game client. See docs/ADMIN_CONTROLS.md §5.5.
|
||||
/// came from the website or a staff member in the game client. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
|
||||
///
|
||||
/// Two sources, mirroring how the shard records each:
|
||||
/// - ban / kick: resolved with their target inside the stock generic command, which logs a
|
||||
|
||||
@@ -9,9 +9,17 @@ git apply --check patches/<name>.patch # dry run
|
||||
git apply patches/<name>.patch
|
||||
```
|
||||
|
||||
## `tier.json` — adding or changing a patch
|
||||
|
||||
A `.patch` file does not say enough on its own. The Runic Gateway installer's patch tier also has to know which patches form **one all-or-nothing unit**, which companion `.cs` may only be copied once that unit has landed, whether the change needs a **core** solution rebuild or just the dynamic script build, and what the operator loses by declining. None of that is derivable from a diff, so it is declared in [`tier.json`](tier.json).
|
||||
|
||||
**Adding a patch means adding it there in the same PR.** The release workflow checks the table in both directions — every `.patch` described by exactly one feature, every named patch and companion present, every `target` equal to the file the diff actually edits — so a patch without an entry fails the release rather than shipping a tier that silently never offers it.
|
||||
|
||||
`tier.json` is folded into the tarball's `manifest.json` as `patch_tier` and removed from the staged `patches/` directory, so the artifact carries exactly one copy of the table and it is the one the installer reads. Installers older than this key ignore it; an installer newer than the overlay it is deploying falls back to a built-in copy. See `docs/installer/PLAN.md` §2.2 and §7.0.
|
||||
|
||||
## 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.
|
||||
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 [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §6.
|
||||
|
||||
This is the one non-drop-in piece. Apply all three together:
|
||||
|
||||
@@ -56,4 +64,4 @@ Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Sc
|
||||
|
||||
## 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.
|
||||
The deletions and edits described in [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
|
||||
|
||||
69
patches/tier.json
Normal file
69
patches/tier.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"_comment": [
|
||||
"The patch tier, described for the Runic Gateway installer.",
|
||||
"",
|
||||
"A .patch file on its own does not say enough to run the tier safely. The installer",
|
||||
"additionally has to know which patches form ONE all-or-nothing unit (the two",
|
||||
"vendor-sale patches are useless apart), which companion .cs may only be copied once",
|
||||
"that unit has landed, whether the change needs a CORE solution rebuild or just the",
|
||||
"dynamic script build, and what capability the operator loses by declining. None of",
|
||||
"that is derivable from the diffs, so it is declared here.",
|
||||
"",
|
||||
"This file is the maintainer-facing source of truth. release.yml folds it into",
|
||||
"manifest.json as `patch_tier` and removes it from the staged patches/ directory, so",
|
||||
"the tarball carries exactly one copy and it is the one the installer reads",
|
||||
"(docs/installer/PLAN.md §7.0). CI also asserts that every .patch here is named by",
|
||||
"exactly one feature and every named patch and companion exists — adding a patch",
|
||||
"without describing it fails the release rather than shipping a tier that silently",
|
||||
"ignores it.",
|
||||
"",
|
||||
"Older installers ignore `patch_tier` entirely, and an installer newer than the",
|
||||
"overlay it is deploying falls back to its own built-in copy of this table."
|
||||
],
|
||||
|
||||
"features": [
|
||||
{
|
||||
"name": "vendor-sale",
|
||||
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
|
||||
"lost": "no vendor.sale events",
|
||||
"rebuild": "core",
|
||||
"patches": [
|
||||
{
|
||||
"name": "playervendor-sale-eventsink",
|
||||
"file": "playervendor-sale-eventsink.patch",
|
||||
"target": "Server/EventSink.cs"
|
||||
},
|
||||
{
|
||||
"name": "playervendor-sale-gump",
|
||||
"file": "playervendor-sale-gump.patch",
|
||||
"target": "Scripts/Gumps/PlayerVendorGumps.cs"
|
||||
}
|
||||
],
|
||||
"companions": [
|
||||
{
|
||||
"file": "BridgeVendorSale.cs",
|
||||
"install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "moderation-audit",
|
||||
"summary": "in-game moderation actions ([ban, [kick, [bcast) forwarded to the website as admin.audit",
|
||||
"lost": "no in-game moderation audit forwarding",
|
||||
"rebuild": "scripts",
|
||||
"patches": [
|
||||
{
|
||||
"name": "commandlogging-event",
|
||||
"file": "commandlogging-event.patch",
|
||||
"target": "Scripts/Commands/Logging.cs"
|
||||
}
|
||||
],
|
||||
"companions": [
|
||||
{
|
||||
"file": "BridgeModerationAudit.cs",
|
||||
"install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
687
tools/scaffolding/BridgeDemoDress.cs
Normal file
687
tools/scaffolding/BridgeDemoDress.cs
Normal file
@@ -0,0 +1,687 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Commands;
|
||||
using Server.Guilds;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Gives a BridgeSeeder world presentable names, so a shard standing behind a public
|
||||
/// screenshot does not read as test data.
|
||||
///
|
||||
/// Test scaffolding. Not part of the bridge. Never deployed — see tools/README.md.
|
||||
///
|
||||
/// WHY THIS EXISTS
|
||||
/// ---------------
|
||||
/// BridgeSeeder builds a world at realistic SCALE, which is what the bridge needed:
|
||||
/// 50 accounts, 150 characters, 30 houses, 30 vendors, 1,200 listings. It never needed
|
||||
/// the world to look like anything, so a vendor is "seed vendor" trading as
|
||||
/// "Seed Shop 810" and a character is "Seed004A". Every one of those names travels the
|
||||
/// whole bridge — plugin, sidecar, website — and lands on the marketplace, the guild
|
||||
/// roster and the housing pages, which are exactly the pages a screenshot wants.
|
||||
///
|
||||
/// This pass renames what is already there rather than seeding anything new. That
|
||||
/// matters: the data keeps its provenance. The prices, the listing counts, the decay
|
||||
/// stages, the fame and the skill sheets are all still whatever BridgeSeeder produced
|
||||
/// and whatever the shard has done to them since — only the strings a human reads are
|
||||
/// replaced. Nothing here invents shard state that the game did not produce.
|
||||
///
|
||||
/// IDEMPOTENT, AND DETERMINISTIC
|
||||
/// -----------------------------
|
||||
/// Names come from fixed tables indexed by the object's own serial, so the same vendor
|
||||
/// draws the same shop name on every run against the same save — screenshots retaken
|
||||
/// later still match. A second run is therefore a no-op, and a world half-dressed by an
|
||||
/// interrupted run finishes cleanly.
|
||||
///
|
||||
/// Shop and house names are also re-dressed when they are names THIS pass produced, so
|
||||
/// a change to the tables or to the hash can be applied to a world that has already been
|
||||
/// through here once. Character names are not: a person's name is an ordinary string
|
||||
/// with no closed set to recognise it by, so once dressed it is left alone.
|
||||
///
|
||||
/// WHAT IT ALSO DOES, AND WHY EACH IS HERE
|
||||
/// ---------------------------------------
|
||||
/// - Walks a few houses into IDOC, in two passes with a wait between them, because the
|
||||
/// website only records a collapse it watched happen. Decay is a live process: by the
|
||||
/// time anybody looks the stages have moved on and "Houses in danger" is empty. Empty
|
||||
/// is a true state and a poor screenshot, so this stages a handful — see PrimeIdoc.
|
||||
/// - Sets a known password on one seeded account. Logging a character in is the only
|
||||
/// way to make the online roster non-empty, and it needs a client, and a client needs
|
||||
/// a password. The seeder gives every account a random GUID nobody kept.
|
||||
/// - BUILDS GUILDS, which is the one thing here that creates rather than renames. The
|
||||
/// seeder never made any, so a shard behind these screenshots has an empty guild
|
||||
/// board and — because the website's Teams are reconciled from that board — no teams
|
||||
/// either. There is nothing to rename: a guild has to exist before it can be called
|
||||
/// something. Members are drawn from characters the seeder already made, so the only
|
||||
/// invention is the association itself.
|
||||
///
|
||||
/// A NOTE ON THE GUILD BOARD, FOUND WHILE BUILDING THIS
|
||||
/// ---------------------------------------------------
|
||||
/// `BridgeSocial.Signature()` folds name, abbreviation, leader serial, member count and
|
||||
/// alliance — not member NAMES — and the roster is only re-emitted when the member SET
|
||||
/// changes. So renaming a guild member never reaches the site: the board keeps the name
|
||||
/// the member had when the roster was last emitted. Dressing a world that was already
|
||||
/// published therefore leaves stale rosters behind, and creating the guilds after the
|
||||
/// rename (as this does) is what avoids it. Raised as a product observation, not fixed
|
||||
/// here — a rename is rare in a real shard, and the fix belongs in the plugin.
|
||||
///
|
||||
/// Flag: `DemoDressOnStart=True` in Config/Bridge.cfg. In game: `[demodress`.
|
||||
/// </summary>
|
||||
public static class BridgeDemoDress
|
||||
{
|
||||
private const string Prefix = "seed_";
|
||||
|
||||
/// <summary>The account whose password is set, so a character can be logged in.</summary>
|
||||
private const string LoginAccount = "seed_000";
|
||||
|
||||
/// <summary>
|
||||
/// Read from Config/Bridge.cfg (`DemoDressPassword`) so a password never lands in
|
||||
/// source control. Absent means the account is left alone.
|
||||
/// </summary>
|
||||
private static string LoginPassword
|
||||
{
|
||||
get { return Config.Get("Bridge.DemoDressPassword", default(string)); }
|
||||
}
|
||||
|
||||
/// <summary>How many condemned houses to put back into the last two decay stages.</summary>
|
||||
private const int IdocHouses = 4;
|
||||
|
||||
/// <summary>
|
||||
/// How long after boot the second IDOC pass runs. See <see cref="PrimeIdoc"/> —
|
||||
/// the delay is the whole point, not a politeness.
|
||||
/// </summary>
|
||||
private static int IdocDelaySeconds
|
||||
{
|
||||
get { return Config.Get("Bridge.DemoDressIdocDelaySeconds", 150); }
|
||||
}
|
||||
|
||||
// ── Name tables ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Ordinary fantasy given names and English trade-sign nouns. Deliberately dull: the
|
||||
// point is that a reader's eye passes over them, which is what a real roster does.
|
||||
|
||||
private static readonly string[] Given =
|
||||
{
|
||||
"Alaric", "Bess", "Corwin", "Dagna", "Edric", "Fenna", "Garrick", "Halle",
|
||||
"Ivo", "Jessa", "Kellen", "Lira", "Marek", "Nessa", "Orrin", "Perrin",
|
||||
"Quill", "Rowan", "Sera", "Tamsin", "Ulric", "Vera", "Wendel", "Xanthe",
|
||||
"Yorick", "Zara", "Bram", "Caitrin", "Doran", "Elspeth"
|
||||
};
|
||||
|
||||
private static readonly string[] Family =
|
||||
{
|
||||
"Ashdown", "Bellweather", "Crowe", "Dunmore", "Eastgate", "Fairbourne",
|
||||
"Grimsby", "Hollowell", "Ironwood", "Larkspur", "Mosswick", "Thornbury"
|
||||
};
|
||||
|
||||
private static readonly string[] ShopFirst =
|
||||
{
|
||||
"The Copper", "The Silver", "The Gilded", "The Iron", "The Rusted", "The Amber",
|
||||
"The Quiet", "The Crooked", "The Old", "The Wandering", "The Salted", "The Ember"
|
||||
};
|
||||
|
||||
private static readonly string[] ShopSecond =
|
||||
{
|
||||
"Anvil", "Kettle", "Lantern", "Compass", "Bellows", "Flask", "Ledger",
|
||||
"Wagon", "Tankard", "Whetstone", "Sextant", "Coffer"
|
||||
};
|
||||
|
||||
private static readonly string[] HouseNames =
|
||||
{
|
||||
"Ashwood Cottage", "Bramblegate", "Candlewick House", "Dovecote",
|
||||
"Eastmarch", "Fernhollow", "Greywater", "Hearthstone",
|
||||
"Ivyfall", "Kestrel Lodge", "Longmeadow", "Millrace",
|
||||
"Northrest", "Oakenshaw", "Pinefall", "Quarrystone",
|
||||
"Riverwatch", "Stonebrook", "Thistledown", "Umberley",
|
||||
"Vinesend", "Westbarrow", "Yewcross", "Almsgate",
|
||||
"Brightmoor", "Coldspring", "Duskvale", "Elmshade",
|
||||
"Foxhollow", "Gravensward"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// One guild to build, and how many of the seeded characters to put in it.
|
||||
///
|
||||
/// Four rather than one, and four of different sizes, because every screen that
|
||||
/// shows guilds shows a LIST: a board with one row proves nothing about sorting,
|
||||
/// member counts or the online column. The sizes are the shape a small shard
|
||||
/// actually has — one large guild, one middling, two small.
|
||||
/// </summary>
|
||||
private struct GuildPlan
|
||||
{
|
||||
public readonly string Name;
|
||||
public readonly string Abbr;
|
||||
public readonly int Size;
|
||||
|
||||
public GuildPlan(string name, string abbr, int size)
|
||||
{
|
||||
Name = name;
|
||||
Abbr = abbr;
|
||||
Size = size;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly GuildPlan[] GuildsToBuild =
|
||||
{
|
||||
new GuildPlan("The Ashen Compact", "ASH", 14),
|
||||
new GuildPlan("Hollowell Rangers", "HOL", 9),
|
||||
new GuildPlan("The Quiet Ledger", "QLG", 6),
|
||||
new GuildPlan("Wardens of Northrest", "WRD", 4)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The first two guilds are allied, because `/uo/guilds` promises "rosters,
|
||||
/// alliances and who's online" and an alliance column that is empty on every row
|
||||
/// reads as a feature that does not work.
|
||||
/// </summary>
|
||||
private const string AllianceName = "The Northern Compact";
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("demodress", AccessLevel.Administrator, Dress_OnCommand);
|
||||
|
||||
if (Config.Get("Bridge.DemoDressOnStart", false))
|
||||
EventSink.ServerStarted += () => Run(null, save: true);
|
||||
}
|
||||
|
||||
[Usage("demodress")]
|
||||
[Description("Renames BridgeSeeder's synthetic world so it is presentable in screenshots.")]
|
||||
private static void Dress_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
Run(e.Mobile, save: false);
|
||||
}
|
||||
|
||||
private static void Report(Mobile to, string text)
|
||||
{
|
||||
Console.WriteLine("[BridgeDemoDress] " + text);
|
||||
|
||||
if (to != null)
|
||||
to.SendMessage(text);
|
||||
}
|
||||
|
||||
private static void Run(Mobile to, bool save)
|
||||
{
|
||||
try
|
||||
{
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
int chars = DressCharacters();
|
||||
int vendors = DressVendors();
|
||||
int houses = DressHouses();
|
||||
|
||||
// After the rename, never before: the roster the bridge publishes is the one
|
||||
// that exists when the guild's member set first changes, and that is here.
|
||||
int guilds = BuildGuilds(to);
|
||||
|
||||
bool password = SetLoginPassword(to);
|
||||
|
||||
Report(to, String.Format(
|
||||
"Dressed {0} characters, {1} vendors, {2} house signs; " +
|
||||
"built {3} guilds; login password {4}. ({5:F1}s)",
|
||||
chars, vendors, houses, guilds, password ? "set" : "skipped",
|
||||
(DateTime.UtcNow - start).TotalSeconds));
|
||||
|
||||
// IDOC is two steps, and at boot the second one is LATE. See PrimeIdoc.
|
||||
Report(to, "Primed " + PrimeIdoc() + " houses for decay.");
|
||||
|
||||
if (save)
|
||||
Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(IdocDelaySeconds),
|
||||
() => Report(to, "Staged " + StageIdoc() + " houses into IDOC."));
|
||||
else
|
||||
Report(to, "Staged " + StageIdoc() + " houses into IDOC.");
|
||||
|
||||
if (save)
|
||||
{
|
||||
Report(to, "Saving world...");
|
||||
World.Save();
|
||||
Report(to, "Save complete.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Report(to, "FAILED: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stable index for a world object, salted so that two names drawn for the SAME
|
||||
/// object land in unrelated places in their tables.
|
||||
///
|
||||
/// Serial is the only identifier that survives a save and is identical on every
|
||||
/// load, which is what makes the naming reproducible. But serials are dense and
|
||||
/// sequential, so a weak mix hands neighbouring objects neighbouring names. The
|
||||
/// first attempt derived the second word from `serial / 5`, which is constant
|
||||
/// across five consecutive serials — twenty-seven vendors came out as four
|
||||
/// Flasks, four Lanterns and three Anvils in a row. Salting and re-mixing per
|
||||
/// draw is what fixes that: each word is an independent hash of the pair.
|
||||
/// </summary>
|
||||
private static int Pick(int serial, int salt, int modulus)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
uint h = (uint)serial ^ ((uint)salt * 0x9E3779B1u);
|
||||
h ^= h >> 15;
|
||||
h *= 2246822519u;
|
||||
h ^= h >> 13;
|
||||
h *= 3266489917u;
|
||||
h ^= h >> 16;
|
||||
return (int)(h % (uint)modulus);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PersonName(int serial)
|
||||
{
|
||||
return Given[Pick(serial, 1, Given.Length)] + " " + Family[Pick(serial, 2, Family.Length)];
|
||||
}
|
||||
|
||||
private static string ShopSign(int serial)
|
||||
{
|
||||
return ShopFirst[Pick(serial, 3, ShopFirst.Length)] + " " +
|
||||
ShopSecond[Pick(serial, 4, ShopSecond.Length)];
|
||||
}
|
||||
|
||||
private static bool LooksSeeded(string name, string marker)
|
||||
{
|
||||
return name != null && name.StartsWith(marker, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a name is one this pass could have produced.
|
||||
///
|
||||
/// Dressing has to be re-runnable in both directions: a first pass renames what the
|
||||
/// seeder left, and a later pass — after the tables or the hash change — has to be
|
||||
/// able to rename its own earlier output. A name is recognised by MEMBERSHIP of the
|
||||
/// closed tables rather than by a marker on the object, because the object is a
|
||||
/// PlayerVendor whose name is a plain string with nowhere to hide a flag, and a
|
||||
/// name that is not in the tables was set by a person and is left alone.
|
||||
/// </summary>
|
||||
private static bool IsOurs(string name, string[] first, string[] second)
|
||||
{
|
||||
if (String.IsNullOrEmpty(name))
|
||||
return false;
|
||||
|
||||
foreach (var a in first)
|
||||
{
|
||||
if (!name.StartsWith(a + " ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var rest = name.Substring(a.Length + 1);
|
||||
|
||||
foreach (var b in second)
|
||||
{
|
||||
if (rest == b)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsOurHouseName(string name)
|
||||
{
|
||||
foreach (var h in HouseNames)
|
||||
{
|
||||
if (h == name)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int DressCharacters()
|
||||
{
|
||||
int n = 0;
|
||||
|
||||
foreach (Account acct in Accounts.GetAccounts())
|
||||
{
|
||||
if (!acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
for (int i = 0; i < acct.Length; i++)
|
||||
{
|
||||
var m = acct[i];
|
||||
|
||||
if (m == null || !LooksSeeded(m.Name, "Seed"))
|
||||
continue;
|
||||
|
||||
// Offset by the slot so an account's three characters are three people
|
||||
// rather than three spellings of one.
|
||||
m.Name = PersonName(m.Serial.Value + i * 101);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
private static int DressVendors()
|
||||
{
|
||||
int n = 0;
|
||||
|
||||
if (PlayerVendor.PlayerVendors == null)
|
||||
return 0;
|
||||
|
||||
// PlayerVendors is a live collection; the rename does not add or remove members,
|
||||
// but copy anyway so an unrelated vendor placement mid-pass cannot invalidate it.
|
||||
var vendors = new List<PlayerVendor>(PlayerVendor.PlayerVendors);
|
||||
|
||||
foreach (var vendor in vendors)
|
||||
{
|
||||
bool touched = false;
|
||||
|
||||
// "Bridge Test Shop" is not the seeder's — it is left over from a hand-run
|
||||
// smoke test — and it reaches the marketplace exactly like the rest.
|
||||
if (LooksSeeded(vendor.ShopName, "Seed Shop") ||
|
||||
LooksSeeded(vendor.ShopName, "Bridge Test") ||
|
||||
IsOurs(vendor.ShopName, ShopFirst, ShopSecond))
|
||||
{
|
||||
var sign = ShopSign(vendor.Serial.Value);
|
||||
|
||||
if (sign != vendor.ShopName)
|
||||
{
|
||||
vendor.ShopName = sign;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (LooksSeeded(vendor.Name, "seed vendor"))
|
||||
{
|
||||
vendor.Name = PersonName(vendor.Serial.Value + 7919);
|
||||
touched = true;
|
||||
}
|
||||
|
||||
if (touched)
|
||||
n++;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
private static int DressHouses()
|
||||
{
|
||||
int n = 0;
|
||||
|
||||
foreach (var house in BaseHouse.AllHouses)
|
||||
{
|
||||
if (house.Sign == null)
|
||||
continue;
|
||||
|
||||
if (!LooksSeeded(house.Sign.Name, "Seed House") && !IsOurHouseName(house.Sign.Name))
|
||||
continue;
|
||||
|
||||
var name = HouseNames[Pick(house.Serial.Value, 5, HouseNames.Length)];
|
||||
|
||||
if (name == house.Sign.Name)
|
||||
continue;
|
||||
|
||||
house.Sign.Name = name;
|
||||
n++;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The houses this run picked to walk into IDOC, held between the two passes so the
|
||||
/// second one moves the same houses the first one primed.
|
||||
/// </summary>
|
||||
private static readonly List<BaseHouse> _idocPicks = new List<BaseHouse>();
|
||||
|
||||
/// <summary>
|
||||
/// Picks the houses that will collapse and puts them at a MIDDLE decay stage.
|
||||
///
|
||||
/// Only houses that CAN decay are touched — an active owner's AutoRefresh house is
|
||||
/// left alone, because forcing one into IDOC would be inventing a state the game
|
||||
/// would never produce and the next refresh would undo it anyway.
|
||||
///
|
||||
/// WHY THE STAGING IS TWO PASSES, WITH A WAIT BETWEEN THEM
|
||||
/// ------------------------------------------------------
|
||||
/// The website's "Houses in danger" page reads a column the ingest only writes when
|
||||
/// the plugin reports a house CHANGING decay stage (`house.decay`). The richer
|
||||
/// `house.update` registry frame carries the stage as well, but the ingest
|
||||
/// deliberately leaves that column to the transition feed so the two cannot clobber
|
||||
/// each other. A house that is ALREADY in IDOC when the site connects therefore
|
||||
/// never appears: the plugin's baseline records IDOC as the starting state and no
|
||||
/// transition is ever emitted. The first run of this pass hit exactly that — the
|
||||
/// shard plainly had two collapsing houses and the page said none.
|
||||
///
|
||||
/// So: prime now, collapse later. The sweep takes its baseline at the middle stage
|
||||
/// and then sees a real move to IDOC, which is the event the page is built to show.
|
||||
/// The underlying asymmetry is a product observation, raised rather than patched
|
||||
/// from here.
|
||||
/// </summary>
|
||||
private static int PrimeIdoc()
|
||||
{
|
||||
_idocPicks.Clear();
|
||||
|
||||
foreach (var house in BaseHouse.AllHouses)
|
||||
{
|
||||
if (_idocPicks.Count >= IdocHouses)
|
||||
break;
|
||||
|
||||
if (house == null || house.Deleted || !house.CanDecay)
|
||||
continue;
|
||||
|
||||
_idocPicks.Add(house);
|
||||
}
|
||||
|
||||
// Most of the world cannot decay at all: a house whose owner's account is active
|
||||
// is AutoRefresh, and AutoRefresh reports Ageless forever. The seeder condemned
|
||||
// its houses by backdating the owner's last login, which is the same lever a real
|
||||
// shard pulls when somebody stops playing — so where there are not enough
|
||||
// candidates, condemn a few more the same way rather than forcing a stage that
|
||||
// the next refresh would undo.
|
||||
if (_idocPicks.Count < IdocHouses)
|
||||
{
|
||||
foreach (var house in BaseHouse.AllHouses)
|
||||
{
|
||||
if (_idocPicks.Count >= IdocHouses)
|
||||
break;
|
||||
|
||||
if (house == null || house.Deleted || house.CanDecay || house.Owner == null)
|
||||
continue;
|
||||
|
||||
var acct = house.Owner.Account as Account;
|
||||
|
||||
// Never the account somebody is about to log in with: an inactive account
|
||||
// is exactly what this is making, and logging in would undo it anyway.
|
||||
if (acct == null || acct.Username == LoginAccount)
|
||||
continue;
|
||||
|
||||
acct.LastLogin = DateTime.UtcNow - TimeSpan.FromDays(365);
|
||||
|
||||
if (house.CanDecay)
|
||||
_idocPicks.Add(house);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var house in _idocPicks)
|
||||
{
|
||||
house.SetDynamicDecay(DecayLevel.Fairly);
|
||||
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
|
||||
}
|
||||
|
||||
return _idocPicks.Count;
|
||||
}
|
||||
|
||||
/// <summary>Collapses the primed houses. See <see cref="PrimeIdoc"/> for the two-step.</summary>
|
||||
private static int StageIdoc()
|
||||
{
|
||||
int n = 0;
|
||||
|
||||
foreach (var house in _idocPicks)
|
||||
{
|
||||
if (house == null || house.Deleted)
|
||||
continue;
|
||||
|
||||
// Alternating, so the page shows a stage column doing something rather than
|
||||
// four identical rows.
|
||||
house.SetDynamicDecay(n % 2 == 0 ? DecayLevel.IDOC : DecayLevel.Greatly);
|
||||
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
|
||||
n++;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the guilds in <see cref="GuildsToBuild"/> out of seeded characters that
|
||||
/// are not in a guild already, and allies the first two.
|
||||
///
|
||||
/// Idempotent by NAME: a guild that already exists is left exactly as it is, so a
|
||||
/// second run adds nobody and a guild somebody has since edited in game is not
|
||||
/// stamped back to the table. A character already in a guild is never moved, which
|
||||
/// is what keeps a re-run from shuffling the world between screenshots.
|
||||
///
|
||||
/// Ranks are set rather than left at the default, because the roster the site draws
|
||||
/// shows a rank per member and a page where every row says the same word tells a
|
||||
/// reader nothing about what ranks are for. Real guilds are mostly members with a
|
||||
/// couple of officers, so that is what this makes.
|
||||
/// </summary>
|
||||
private static int BuildGuilds(Mobile to)
|
||||
{
|
||||
var pool = UnguildedSeedCharacters();
|
||||
var cursor = 0;
|
||||
var made = 0;
|
||||
|
||||
var built = new List<Guild>();
|
||||
|
||||
foreach (var plan in GuildsToBuild)
|
||||
{
|
||||
var existing = FindGuild(plan.Name);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
built.Add(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cursor >= pool.Count)
|
||||
{
|
||||
Report(to, "Ran out of unguilded characters — " + plan.Name + " not built.");
|
||||
break;
|
||||
}
|
||||
|
||||
var leader = pool[cursor++];
|
||||
var guild = new Guild(leader, plan.Name, plan.Abbr);
|
||||
|
||||
for (int i = 1; i < plan.Size && cursor < pool.Count; i++)
|
||||
{
|
||||
var member = pool[cursor++];
|
||||
guild.AddMember(member);
|
||||
|
||||
var pm = member as PlayerMobile;
|
||||
|
||||
if (pm == null)
|
||||
continue;
|
||||
|
||||
// Two officers per guild, then members. RankDefinition.Ranks is
|
||||
// { Ronin, Member, Emissary, Warlord, Leader } — Ronin is the default a
|
||||
// fresh member gets, and a board of Ronins looks like nobody has ever
|
||||
// touched the guild.
|
||||
pm.GuildRank =
|
||||
i == 1 ? RankDefinition.Ranks[3] :
|
||||
i == 2 ? RankDefinition.Ranks[2] :
|
||||
RankDefinition.Member;
|
||||
}
|
||||
|
||||
built.Add(guild);
|
||||
made++;
|
||||
}
|
||||
|
||||
if (built.Count >= 2 && built[0].Alliance == null && built[1].Alliance == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var alliance = new AllianceInfo(built[0], AllianceName, built[1]);
|
||||
alliance.TurnToMember(built[1]);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Report(to, "Alliance not formed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return made;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every seeded character with no guild, in a stable order: account name, then
|
||||
/// character slot. Stable ordering is what makes the same person lead the same
|
||||
/// guild on every run against the same save.
|
||||
/// </summary>
|
||||
private static List<Mobile> UnguildedSeedCharacters()
|
||||
{
|
||||
var accounts = new List<Account>();
|
||||
|
||||
foreach (Account acct in Accounts.GetAccounts())
|
||||
{
|
||||
if (acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
accounts.Add(acct);
|
||||
}
|
||||
|
||||
accounts.Sort((a, b) => String.CompareOrdinal(a.Username, b.Username));
|
||||
|
||||
var chars = new List<Mobile>();
|
||||
|
||||
foreach (var acct in accounts)
|
||||
{
|
||||
for (int i = 0; i < acct.Length; i++)
|
||||
{
|
||||
var m = acct[i];
|
||||
|
||||
if (m == null || m.Deleted || m.Guild != null)
|
||||
continue;
|
||||
|
||||
chars.Add(m);
|
||||
}
|
||||
}
|
||||
|
||||
return chars;
|
||||
}
|
||||
|
||||
private static Guild FindGuild(string name)
|
||||
{
|
||||
foreach (var bg in BaseGuild.List.Values)
|
||||
{
|
||||
var g = bg as Guild;
|
||||
|
||||
if (g != null && !g.Disbanded && g.Name == name)
|
||||
return g;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a known password on one seeded account so a character can be logged in with
|
||||
/// a real client. The seeder assigns a random GUID, which nobody kept.
|
||||
/// </summary>
|
||||
private static bool SetLoginPassword(Mobile to)
|
||||
{
|
||||
var password = LoginPassword;
|
||||
|
||||
if (String.IsNullOrEmpty(password))
|
||||
return false;
|
||||
|
||||
var acct = Accounts.GetAccount(LoginAccount) as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
Report(to, "No account " + LoginAccount + " — password not set.");
|
||||
return false;
|
||||
}
|
||||
|
||||
acct.SetPassword(password);
|
||||
|
||||
// The seeder backdates some accounts past InactiveDuration to condemn their
|
||||
// houses. This one has to be able to log in, so bring it back to the present.
|
||||
acct.LastLogin = DateTime.UtcNow;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
159
tools/scaffolding/BridgeParticipationProbe.cs
Normal file
159
tools/scaffolding/BridgeParticipationProbe.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
using Server.Commands;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Produces real kill credit inside a participation area, without a game client.
|
||||
///
|
||||
/// ── What this can drive, and what it cannot ───────────────────────────────────────────
|
||||
///
|
||||
/// The participation ledger counts two things: presence, and kill credit. Only one of them
|
||||
/// is reachable from a headless rig, and the split is worth stating rather than discovering.
|
||||
///
|
||||
/// **Presence needs a connected client.** The sweep credits online players — `NetState !=
|
||||
/// null` — which is the correct test and not one a probe should loosen: a character parked
|
||||
/// in Britain and logged out for eight hours did not attend anything, and a ledger that said
|
||||
/// otherwise would put people at the top of a leaderboard for being AFK. There is no way to
|
||||
/// produce a NetState here short of writing a client, so presence accrual is exercised by a
|
||||
/// real login and not by this file.
|
||||
///
|
||||
/// **Kill credit needs none.** `EventSink.CreatureDeath` fires for a creature killed by any
|
||||
/// means, `Mobile.DamageEntries` is populated by real damage, and the area test is a
|
||||
/// coordinate comparison. So the whole of the credit path — the damager filter, the
|
||||
/// per-damager fold, the area test applied to the DAMAGER rather than only the corpse, the
|
||||
/// member cap — runs exactly as it would in a fight.
|
||||
///
|
||||
/// What it does, in order: moves two real player mobiles to the venue, spawns a creature
|
||||
/// there, damages it unequally from both, and kills it.
|
||||
///
|
||||
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||
/// In game: `[partprobe <map> <x> <y>`. From a headless rig, through
|
||||
/// `BridgeRigDriver`'s `partprobe` verb — the two ship together for that reason.
|
||||
/// **Moves players and spawns and kills a creature. Rig only.**
|
||||
/// </summary>
|
||||
public static class BridgeParticipationProbe
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("partprobe", AccessLevel.Administrator, Probe_OnCommand);
|
||||
}
|
||||
|
||||
[Usage("partprobe <map> <x> <y>")]
|
||||
[Description("Moves two players to a point, spawns a creature there and kills it.")]
|
||||
private static void Probe_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
if (e.Length < 3)
|
||||
{
|
||||
Say(e.Mobile, "partprobe <map> <x> <y>");
|
||||
return;
|
||||
}
|
||||
|
||||
Run(e.Mobile, e.GetString(0), e.GetInt32(1), e.GetInt32(2));
|
||||
}
|
||||
|
||||
public static void Run(Mobile from, string mapName, int x, int y)
|
||||
{
|
||||
var map = MapByName(mapName);
|
||||
|
||||
if (map == null)
|
||||
{
|
||||
Say(from, "partprobe: unknown map " + mapName);
|
||||
return;
|
||||
}
|
||||
|
||||
var players = FindPlayers(2);
|
||||
|
||||
if (players.Count < 2)
|
||||
{
|
||||
Say(from, "partprobe: need two player mobiles in the world; found " + players.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
var z = map.GetAverageZ(x, y);
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
// Spread them a tile apart so neither lands inside the other, and so the area test
|
||||
// is answering about two distinct points rather than one.
|
||||
players[i].MoveToWorld(new Point3D(x + i, y, z), map);
|
||||
Say(from, String.Format(CultureInfo.InvariantCulture,
|
||||
"partprobe: {0} moved to {1} ({2}, {3})", players[i].Name, map.Name, x + i, y));
|
||||
}
|
||||
|
||||
var victim = new Mongbat();
|
||||
victim.MoveToWorld(new Point3D(x, y + 1, z), map);
|
||||
|
||||
// Real damage through the real path, unequal so the fold is doing something: the
|
||||
// ledger credits one kill per damager regardless of how much they did, and a table
|
||||
// where both did the same amount could not show that.
|
||||
//
|
||||
// **Both amounts are small on purpose, and the first run of this probe is why.** A
|
||||
// Mongbat has around thirty hit points, and an opening blow of 40 killed it where it
|
||||
// stood -- so the SECOND damager never landed a hit, `DamageEntries` held one name,
|
||||
// and the ledger correctly credited one player. The frame looked like a plugin bug
|
||||
// crediting only the killer and was a rig artefact. A probe that means to produce two
|
||||
// damagers has to leave the creature alive to receive the second one.
|
||||
var hit = Math.Max(1, victim.HitsMax / 10);
|
||||
victim.Damage(hit * 2, players[0]);
|
||||
victim.Damage(hit, players[1]);
|
||||
|
||||
Say(from, String.Format(CultureInfo.InvariantCulture,
|
||||
"partprobe: {0} spawned at ({1}, {2}) and damaged by {3} and {4}",
|
||||
victim.Name, x, y + 1, players[0].Name, players[1].Name));
|
||||
|
||||
// Killed on the next tick rather than inline, so the damage above has actually been
|
||||
// registered against the creature before CreatureDeath reads the entries.
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(1.0), () =>
|
||||
{
|
||||
victim.Kill();
|
||||
Say(from, "partprobe: killed; the credit should now be on the ledger");
|
||||
});
|
||||
}
|
||||
|
||||
private static List<PlayerMobile> FindPlayers(int count)
|
||||
{
|
||||
var found = new List<PlayerMobile>();
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var pm = m as PlayerMobile;
|
||||
|
||||
if (pm == null || pm.Deleted || pm.AccessLevel > AccessLevel.Player)
|
||||
continue;
|
||||
|
||||
found.Add(pm);
|
||||
|
||||
if (found.Count >= count)
|
||||
break;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private static Map MapByName(string name)
|
||||
{
|
||||
for (int i = 0; i < Map.Maps.Length; i++)
|
||||
{
|
||||
var map = Map.Maps[i];
|
||||
|
||||
if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return map;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Say(Mobile to, string text)
|
||||
{
|
||||
if (to != null)
|
||||
to.SendMessage(text);
|
||||
else
|
||||
Console.WriteLine("[PartProbe] " + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
275
tools/scaffolding/BridgeProtocol5Probe.cs
Normal file
275
tools/scaffolding/BridgeProtocol5Probe.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Commands;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
using Server.Network;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises all three Protocol 5 enrichments on a live shard, without a game client.
|
||||
///
|
||||
/// Each of the three needs something a unit test cannot produce, and each needs it for a
|
||||
/// different reason:
|
||||
///
|
||||
/// * house.decay's `schedule` is only interesting ACROSS a transition, and the interesting
|
||||
/// pair is Greatly -> IDOC: the first must carry no estimatedCollapse (under dynamic
|
||||
/// decay the remaining stages have not been drawn yet) and the second must carry one.
|
||||
/// A fixture can assert the mapping; only a real BaseHouse walking a real
|
||||
/// SetDynamicDecay proves the emitter reads ServUO the way the comment claims.
|
||||
/// * vendor.listing's `fees` are computed from PlayerVendor state that differs between
|
||||
/// ServUO's two vendor systems. This reports what the shard actually holds so the
|
||||
/// emitted frame can be checked against it rather than against an assumption.
|
||||
/// * account.login.result is the one that could not be built at all before v5, because
|
||||
/// EventSink.AccountLogin fires BEFORE the verdict exists. Invoking the real sink with a
|
||||
/// real password (right and wrong) runs the shard's own AccountHandler, which is what
|
||||
/// sets Accepted/RejectReason -- so this proves the deferred read sees the FINAL verdict
|
||||
/// and not the constructor's default of true.
|
||||
///
|
||||
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||
/// In game / at the console: `[p5probe`.
|
||||
/// </summary>
|
||||
public static class BridgeProtocol5Probe
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("p5probe", AccessLevel.Administrator, Probe_OnCommand);
|
||||
|
||||
if (Config.Get("Bridge.Protocol5ProbeOnStart", false))
|
||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(8.0), () => Run(null));
|
||||
}
|
||||
|
||||
[Usage("p5probe")]
|
||||
[Description("Drives the three Protocol 5 enrichments so their frames can be observed.")]
|
||||
private static void Probe_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
Run(e.Mobile);
|
||||
}
|
||||
|
||||
private static void Report(Mobile to, string line)
|
||||
{
|
||||
Console.WriteLine("[P5Probe] " + line);
|
||||
|
||||
if (to != null)
|
||||
to.SendMessage(line);
|
||||
}
|
||||
|
||||
private static void Run(Mobile to)
|
||||
{
|
||||
try
|
||||
{
|
||||
ReportVendorFees(to);
|
||||
DriveLogins(to);
|
||||
WalkHouseToIdoc(to);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Report(to, "threw: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (a) house.decay schedule ----
|
||||
|
||||
/// <summary>
|
||||
/// Walks one house Greatly, then (after a pause long enough for a decay sweep to run)
|
||||
/// IDOC. Two frames, and the PAIR is the assertion: no estimatedCollapse on the first,
|
||||
/// one on the second.
|
||||
/// </summary>
|
||||
private static void WalkHouseToIdoc(Mobile to)
|
||||
{
|
||||
BaseHouse target = null;
|
||||
var byType = new Dictionary<string, int>();
|
||||
|
||||
foreach (var h in BaseHouse.AllHouses)
|
||||
{
|
||||
if (h == null || h.Deleted || h.Owner == null)
|
||||
continue;
|
||||
|
||||
var type = h.DecayType.ToString();
|
||||
byType[type] = (byType.ContainsKey(type) ? byType[type] : 0) + 1;
|
||||
|
||||
// CanDecay is the filter that matters, and getting it wrong is silent. A house
|
||||
// whose DecayType is AutoRefresh or Ageless -- and the owner's NEWEST house is
|
||||
// always AutoRefresh -- has a DecayLevel getter that calls ResetDynamicDecay() and
|
||||
// reports Ageless, so a forced SetDynamicDecay is wiped on the very next read. The
|
||||
// sweep then sees no change and emits nothing at all, which looks exactly like a
|
||||
// broken emitter.
|
||||
if (!h.CanDecay)
|
||||
continue;
|
||||
|
||||
// The current level does NOT disqualify a house. On this rig every decaying house
|
||||
// is already at IDOC (a seeded world has only a couple of Condemned houses and they
|
||||
// have long since bottomed out), so the walk starts by putting one BACK to Fairly.
|
||||
// BridgeDemoDress.PrimeIdoc does the same thing for the same reason.
|
||||
target = h;
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var kv in byType)
|
||||
Report(to, "houses by DecayType: " + kv.Key + "=" + kv.Value);
|
||||
|
||||
if (target == null)
|
||||
{
|
||||
Report(to, "no walkable house found (none with CanDecay below IDOC)");
|
||||
return;
|
||||
}
|
||||
|
||||
Report(to, string.Format(
|
||||
"walking house 0x{0:X} owner={1} decayType={2} from {3}; dynamicDecay={4}",
|
||||
target.Serial.Value,
|
||||
target.Owner == null ? "?" : target.Owner.Name,
|
||||
target.DecayType,
|
||||
target.DecayLevel,
|
||||
DynamicDecay.Enabled));
|
||||
|
||||
// Each step needs its own sweep to land, or the sweep sees one net change and emits a
|
||||
// single frame -- which would collapse the whole point, since the assertion is the
|
||||
// DIFFERENCE between the Greatly frame and the IDOC one.
|
||||
var step = TimeSpan.FromSeconds(Math.Max(4, BridgeConfigSeconds()) * 2 + 4);
|
||||
|
||||
Step(to, target, DecayLevel.Fairly, TimeSpan.Zero, "reset (no estimatedCollapse expected)");
|
||||
Step(to, target, DecayLevel.Greatly, step, "expect schedule WITHOUT estimatedCollapse");
|
||||
Step(to, target, DecayLevel.IDOC, TimeSpan.FromTicks(step.Ticks * 2), "expect schedule WITH estimatedCollapse");
|
||||
}
|
||||
|
||||
private static void Step(Mobile to, BaseHouse house, DecayLevel level, TimeSpan after, string note)
|
||||
{
|
||||
Action go = () =>
|
||||
{
|
||||
if (house.Deleted)
|
||||
return;
|
||||
|
||||
Report(to, string.Format("house 0x{0:X} -> {1} ({2})", house.Serial.Value, level, note));
|
||||
house.SetDynamicDecay(level);
|
||||
};
|
||||
|
||||
if (after <= TimeSpan.Zero)
|
||||
go();
|
||||
else
|
||||
Timer.DelayCall(after, () => go());
|
||||
}
|
||||
|
||||
/// <summary>The decay sweep interval, read the same way the bridge reads it.</summary>
|
||||
private static int BridgeConfigSeconds()
|
||||
{
|
||||
return Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||
}
|
||||
|
||||
// ---- (b) vendor.listing fees ----
|
||||
|
||||
/// <summary>
|
||||
/// Prints the fee state of the first few player vendors straight off the PlayerVendor
|
||||
/// objects, so the emitted `fees` block can be compared against the shard's own numbers
|
||||
/// rather than against what the emitter believes them to be.
|
||||
/// </summary>
|
||||
private static void ReportVendorFees(Mobile to)
|
||||
{
|
||||
bool newSystem = BaseHouse.NewVendorSystem;
|
||||
int shown = 0;
|
||||
|
||||
Report(to, "NewVendorSystem=" + newSystem);
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var v = m as PlayerVendor;
|
||||
|
||||
if (v == null || v.Deleted)
|
||||
continue;
|
||||
|
||||
int charge = newSystem ? v.ChargePerRealWorldDay : v.ChargePerDay;
|
||||
int funds = newSystem ? v.HoldGold : v.BankAccount + v.HoldGold;
|
||||
var acct = v.Owner == null ? null : v.Owner.Account as Account;
|
||||
|
||||
Report(to, string.Format(
|
||||
"vendor 0x{0:X} owner={1} acct={2} commission={3} charge={4} funds={5} periods={6} nextPay={7:o}",
|
||||
v.Serial.Value,
|
||||
v.Owner == null ? "?" : v.Owner.Name,
|
||||
acct == null ? "<none>" : acct.Username,
|
||||
v.IsCommission,
|
||||
charge,
|
||||
funds,
|
||||
charge > 0 ? (funds / charge).ToString() : "n/a",
|
||||
v.NextPayTime.ToUniversalTime()));
|
||||
|
||||
if (++shown >= 3)
|
||||
break;
|
||||
}
|
||||
|
||||
if (shown == 0)
|
||||
Report(to, "no player vendors in the world");
|
||||
}
|
||||
|
||||
// ---- (c) account.login.result ----
|
||||
|
||||
/// <summary>
|
||||
/// Fires the real EventSink.AccountLogin twice against a real account: once with a
|
||||
/// deliberately wrong password and once with the right one.
|
||||
///
|
||||
/// The shard's own AccountHandler is what decides, and it decides AFTER our handler has
|
||||
/// returned. So a correct implementation emits `accepted:false reason:BadPass` for the
|
||||
/// first and `accepted:true` for the second. An implementation that read the verdict
|
||||
/// inside the handler would emit `accepted:true` for BOTH -- which is precisely the bug
|
||||
/// this kind exists to make impossible, and precisely what this probe would show.
|
||||
///
|
||||
/// The password is read from config, never compiled in. `Bridge.Protocol5ProbeAccount`
|
||||
/// and `Bridge.Protocol5ProbePassword`; with no password configured only the failing
|
||||
/// half runs, which is still the half that matters.
|
||||
/// </summary>
|
||||
private static void DriveLogins(Mobile to)
|
||||
{
|
||||
var username = Config.Get("Bridge.Protocol5ProbeAccount", (string)null);
|
||||
|
||||
if (String.IsNullOrEmpty(username))
|
||||
{
|
||||
Report(to, "no Bridge.Protocol5ProbeAccount configured; skipping the login probe");
|
||||
return;
|
||||
}
|
||||
|
||||
var password = Config.Get("Bridge.Protocol5ProbePassword", (string)null);
|
||||
|
||||
// Accounts store a hash, so the rig cannot READ a password to log in with -- it has to
|
||||
// set one. Same posture as BridgeDemoDress, which does this for the same reason: the
|
||||
// value comes from config and is never compiled in or logged.
|
||||
if (!String.IsNullOrEmpty(password))
|
||||
{
|
||||
var acct = Accounts.GetAccount(username) as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
Report(to, "account '" + username + "' does not exist; skipping the login probe");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.SetPassword(password);
|
||||
Report(to, "set a known password on '" + username + "' for the accepted half");
|
||||
}
|
||||
|
||||
Report(to, "login probe: '" + username + "' with a WRONG password (expect accepted:false)");
|
||||
Fire(username, "definitely-not-the-password-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
if (String.IsNullOrEmpty(password))
|
||||
{
|
||||
Report(to, "no Bridge.Protocol5ProbePassword configured; skipping the accepted half");
|
||||
return;
|
||||
}
|
||||
|
||||
// Spaced out so the two results are unambiguous in the sidecar's history.
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(3.0), () =>
|
||||
{
|
||||
Report(to, "login probe: '" + username + "' with the RIGHT password (expect accepted:true)");
|
||||
Fire(username, password);
|
||||
});
|
||||
}
|
||||
|
||||
private static void Fire(string username, string password)
|
||||
{
|
||||
// A null NetState is deliberate and is itself part of the test: the real emitter reads
|
||||
// the address defensively because AccountLogin_ReplyRej disposes the state before the
|
||||
// deferred read runs, so it must already survive not having one.
|
||||
EventSink.InvokeAccountLogin(new AccountLoginEventArgs(null, username, password));
|
||||
}
|
||||
}
|
||||
}
|
||||
201
tools/scaffolding/BridgeProtocol6Probe.cs
Normal file
201
tools/scaffolding/BridgeProtocol6Probe.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using Server.Commands;
|
||||
using Server.Custom.Bridge;
|
||||
using Server.Engines.CannedEvil;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises the two halves of Protocol 6 on a live shard, without a game client.
|
||||
///
|
||||
/// **Idempotency needs no probe.** It is driven from the OTHER end — two identical POSTs to
|
||||
/// the sidecar, the second of which must come back `replayed: true` under its own reqId — so
|
||||
/// a curl and the shard's own audit trail are the whole test. Nothing here would make that
|
||||
/// more convincing.
|
||||
///
|
||||
/// `champ.boss.killed` is the opposite case. It cannot be produced from outside the game at
|
||||
/// all: a champion boss appears only when a spawn is driven to its final level, and the
|
||||
/// damage table the frame carries is assembled by real combat against a real creature. A
|
||||
/// fixture can assert the shape of the JSON; only this proves that
|
||||
/// `EventSink.CreatureDeath` fires for a `BaseChampion`, that `DamageEntries` still holds
|
||||
/// anything by the time it does, and that the sweep's spawn attribution is there to name the
|
||||
/// altar.
|
||||
///
|
||||
/// What it does, in order:
|
||||
///
|
||||
/// 1. Places a real `ChampionSpawn`, activates it and calls `SpawnChampion()` — the
|
||||
/// shard's own code path, not a hand-constructed creature.
|
||||
/// 2. Waits for the champ sweep to see it, so the boss is attributed to its altar exactly
|
||||
/// the way a real one would be. **This wait is the assertion**: run without it and the
|
||||
/// kill still emits, but with no `serial`, `type` or `level` — which is the phase's own
|
||||
/// documented fallback rather than the case being tested.
|
||||
/// 3. Damages it from two real player mobiles found in the world, so the damage table has
|
||||
/// two ranked entries rather than none.
|
||||
/// 4. Kills it and cleans up the altar.
|
||||
///
|
||||
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||
/// In game / at the console: `[p6probe`. Flag: `Protocol6ProbeOnStart`.
|
||||
/// **Spawns and kills a champion boss.** Use on a rig, never on a live shard.
|
||||
/// </summary>
|
||||
public static class BridgeProtocol6Probe
|
||||
{
|
||||
private static ChampionSpawn _spawn;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("p6probe", AccessLevel.Administrator, Probe_OnCommand);
|
||||
|
||||
if (Config.Get("Bridge.Protocol6ProbeOnStart", false))
|
||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(10.0), () => Run(null));
|
||||
}
|
||||
|
||||
[Usage("p6probe")]
|
||||
[Description("Spawns a champion boss, damages it from two players and kills it.")]
|
||||
private static void Probe_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
Run(e == null ? null : e.Mobile);
|
||||
}
|
||||
|
||||
private static void Say(Mobile to, string text)
|
||||
{
|
||||
Console.WriteLine("[p6probe] {0}", text);
|
||||
|
||||
if (to != null)
|
||||
to.SendMessage(text);
|
||||
}
|
||||
|
||||
private static void Run(Mobile from)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Inside a NAMED region, deliberately. A champion altar really lives in a dungeon
|
||||
// and the first version of this probe put one there — but the dungeon floor at
|
||||
// Destard belongs to the map's default region, whose Name is empty, so the emitted
|
||||
// frame carried no `region` at all and the one field a phase condition is most
|
||||
// likely to match on ("the boss in Yew") went unproven. Britain has a named region,
|
||||
// so this exercises the field rather than the guard that omits it.
|
||||
var where = new Point3D(1496, 1628, 10);
|
||||
var map = Map.Felucca;
|
||||
|
||||
Cleanup();
|
||||
|
||||
_spawn = new ChampionSpawn();
|
||||
_spawn.MoveToWorld(where, map);
|
||||
_spawn.Type = ChampionSpawnType.Abyss;
|
||||
_spawn.AutoRestart = false;
|
||||
_spawn.Active = true;
|
||||
|
||||
Say(from, "altar placed; spawning its champion");
|
||||
|
||||
_spawn.SpawnChampion();
|
||||
|
||||
var boss = _spawn.Champion;
|
||||
|
||||
if (boss == null)
|
||||
{
|
||||
Say(from, "FAILED: the spawn produced no champion");
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
Say(from, String.Format("champion up: {0} ({1}) serial {2} region {3}",
|
||||
boss.Name, boss.GetType().Name, boss.Serial,
|
||||
boss.Region == null ? "(none)" : ("\"" + boss.Region.Name + "\"")));
|
||||
|
||||
// Give the sweep time to attribute the boss to its altar. Two intervals, because a
|
||||
// single one races the timer that is already part-way through its period.
|
||||
var wait = TimeSpan.FromSeconds(Math.Max(2.0, BridgeConfig.ChampSweepSeconds * 2.0));
|
||||
|
||||
Say(from, String.Format("waiting {0:0}s for the champ sweep to see it", wait.TotalSeconds));
|
||||
|
||||
Timer.DelayCall(wait, () => Finish(from, boss));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Say(from, "threw: " + ex);
|
||||
Cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Finish(Mobile from, Mobile boss)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (boss == null || boss.Deleted)
|
||||
{
|
||||
Say(from, "FAILED: the champion vanished before it could be killed");
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Two real players, so the damage table has two ranked entries and the ranking is
|
||||
// testable rather than trivially one row. Registered through Mobile.RegisterDamage,
|
||||
// which is the same call combat makes.
|
||||
var players = World.Mobiles.Values
|
||||
.OfType<PlayerMobile>()
|
||||
.Where(p => !p.Deleted && p.Account != null)
|
||||
.Take(2)
|
||||
.ToList();
|
||||
|
||||
if (players.Count < 2)
|
||||
{
|
||||
Say(from, "note: fewer than two player mobiles in the world; the table will be short");
|
||||
}
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
// Deliberately unequal and deliberately in ascending order, so a frame that
|
||||
// reported them in arrival order rather than by damage would be visibly wrong.
|
||||
int amount = 120 * (i + 1);
|
||||
boss.RegisterDamage(amount, players[i]);
|
||||
Say(from, String.Format("registered {0} damage from {1}", amount, players[i].Name));
|
||||
}
|
||||
|
||||
var killer = players.Count > 0 ? players[players.Count - 1] : null;
|
||||
|
||||
Say(from, "killing the champion");
|
||||
|
||||
boss.Damage(boss.HitsMax * 10, killer);
|
||||
|
||||
if (!boss.Deleted && boss.Alive)
|
||||
{
|
||||
Say(from, "note: it survived the blow; killing it outright");
|
||||
boss.Kill();
|
||||
}
|
||||
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(2.0), () =>
|
||||
{
|
||||
Cleanup();
|
||||
Say(from, "done — check the sidecar feed for champ.boss.killed");
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Say(from, "threw: " + ex);
|
||||
Cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Cleanup()
|
||||
{
|
||||
if (_spawn == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_spawn.Active = false;
|
||||
_spawn.Delete();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The altar is scaffolding; failing to tidy it is not worth an exception.
|
||||
}
|
||||
|
||||
_spawn = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
794
tools/scaffolding/BridgeRigDriver.cs
Normal file
794
tools/scaffolding/BridgeRigDriver.cs
Normal file
@@ -0,0 +1,794 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Commands;
|
||||
using Server.Engines.CityLoyalty;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the shard from OUTSIDE the game, one verb per line in a file the driver polls.
|
||||
///
|
||||
/// Every other probe here runs a fixed script at boot or from `[command`, and both are the
|
||||
/// wrong shape for an acceptance walk: a walk asserts what happened BETWEEN two steps
|
||||
/// ("one mail, then nothing for a day"), so the steps have to be separated by the observer
|
||||
/// rather than by a hard-coded delay -- and ServUO's console reads a fixed verb set
|
||||
/// (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard
|
||||
/// at all. A file is the one channel a headless shard already has.
|
||||
///
|
||||
/// Write one or more lines to `Config/rigcmd.txt`; the driver runs them on the Core thread
|
||||
/// within a second, prints `[RigDriver]` lines, and TRUNCATES the file so the next write is
|
||||
/// the next command. Output is console-only: nothing here emits, and everything observed
|
||||
/// travels the real bridge.
|
||||
///
|
||||
/// Verbs:
|
||||
/// decaylist houses that CAN decay, with owner account and stage
|
||||
/// decay <serial|any> <stage> force a decay stage (LikeNew|Slightly|Somewhat|
|
||||
/// Fairly|Greatly|IDOC|Collapsed)
|
||||
/// vendorlist player vendors, with owner account and next pay time
|
||||
/// vendorfunds <serial> <gold> set a vendor's held gold (drives periodsRemaining)
|
||||
/// citylist cities, governors and election phases
|
||||
/// governor <city> <mobile|none> seat a governor (a mobile serial, or a player's name)
|
||||
/// election <city> force a new election into its nomination window
|
||||
/// activate <account> clear an account's inactivity, so its houses stop
|
||||
/// being Condemned and CAN be refreshed
|
||||
/// password <account> <pw> set a game account's password (for a login probe)
|
||||
/// worldgone <serial> delete an object BEHIND the ownership registry's
|
||||
/// back, playing the player who killed it
|
||||
/// spawnerlist [n] name a few XmlSpawners, serial AND UniqueId --
|
||||
/// the two ways a property lease names its target
|
||||
/// propset <target> <prop> <v> set a property BEHIND the lease plane's back,
|
||||
/// which is the only way to reach `drifted` here
|
||||
/// propread <target> <prop> read one back, to assert a restore landed
|
||||
/// seasonlist every seasonal event and its status
|
||||
/// save a world save
|
||||
/// shutdown a CLEAN shutdown, so the bridge emits server.shutdown
|
||||
///
|
||||
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||
/// </summary>
|
||||
public static class BridgeRigDriver
|
||||
{
|
||||
private static string _path;
|
||||
private static DateTime _lastWrite = DateTime.MinValue;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!Config.Get("Bridge.RigDriverEnabled", false))
|
||||
return;
|
||||
|
||||
_path = Path.Combine(Core.BaseDirectory, "Config", "rigcmd.txt");
|
||||
|
||||
CommandSystem.Register("rigdriver", AccessLevel.Administrator, e => Poll());
|
||||
|
||||
Console.WriteLine("[RigDriver] watching {0}", _path);
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(1.0), Poll);
|
||||
}
|
||||
|
||||
// ---- the poll ----
|
||||
|
||||
private static void Poll()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
return;
|
||||
|
||||
// Written-and-not-finished is a real case: the observer writes with a shell
|
||||
// redirect while this timer fires. An empty file is nothing to do, and the
|
||||
// timestamp guard keeps a slow write from being run twice.
|
||||
var stamp = File.GetLastWriteTimeUtc(_path);
|
||||
if (stamp <= _lastWrite)
|
||||
return;
|
||||
|
||||
var lines = File.ReadAllLines(_path);
|
||||
if (lines.Length == 0)
|
||||
return;
|
||||
|
||||
_lastWrite = stamp;
|
||||
File.WriteAllText(_path, String.Empty);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = (line ?? String.Empty).Trim();
|
||||
if (trimmed.Length == 0 || trimmed.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
Run(trimmed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Say("\"" + trimmed + "\" threw: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
Say("done");
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The writer still holds it. Next tick.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Say("poll threw: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Say(string line)
|
||||
{
|
||||
Console.WriteLine("[RigDriver] " + line);
|
||||
}
|
||||
|
||||
private static void Run(string line)
|
||||
{
|
||||
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var verb = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (verb)
|
||||
{
|
||||
case "decaylist": DecayList(); break;
|
||||
case "decay": Decay(Arg(parts, 1), Arg(parts, 2)); break;
|
||||
case "vendorlist": VendorList(); break;
|
||||
case "vendorfunds": VendorFunds(Arg(parts, 1), Arg(parts, 2)); break;
|
||||
case "citylist": CityList(); break;
|
||||
case "governor": Governor(Arg(parts, 1), Arg(parts, 2)); break;
|
||||
case "election": Election(Arg(parts, 1)); break;
|
||||
case "activate": Activate(Arg(parts, 1)); break;
|
||||
case "password": Password(Arg(parts, 1), Arg(parts, 2)); break;
|
||||
// Phase 11b. Plays the interfering GM a config lease's compare-and-set exists to
|
||||
// catch, and reads a key back the way the game reads it. Both halves are here
|
||||
// rather than only in `[leaseprobe` because a headless rig has no client to type
|
||||
// a command at, and ServUO's own console takes a fixed verb set.
|
||||
case "configset": ConfigSet(Arg(parts, 1), Arg(parts, 2)); break;
|
||||
case "configread": ConfigRead(Arg(parts, 1)); break;
|
||||
// Kill credit inside a participation area. Lives in BridgeParticipationProbe
|
||||
// because it moves mobiles and spawns a creature; reachable from here because a
|
||||
// headless rig has no client to type `[partprobe` at. The two files ship together.
|
||||
case "partprobe":
|
||||
BridgeParticipationProbe.Run(null, Arg(parts, 1), Int(Arg(parts, 2)), Int(Arg(parts, 3)));
|
||||
break;
|
||||
// Phase 12a. `world.despawn` answering `gone` rather than `removed` is the
|
||||
// path a player takes every time they kill an event creature, and it is the one
|
||||
// outcome the rig cannot reach by asking the bridge: every bridge verb that
|
||||
// removes an object also drops its registry row, so the two never disagree.
|
||||
// This deletes the object and leaves the row, which is exactly what a sword does.
|
||||
case "worldgone": WorldGone(Arg(parts, 1)); break;
|
||||
case "spawnerlist": SpawnerList(Arg(parts, 1)); break;
|
||||
case "propset": PropSet(Arg(parts, 1), Arg(parts, 2), Arg(parts, 3)); break;
|
||||
case "propread": PropRead(Arg(parts, 1), Arg(parts, 2)); break;
|
||||
case "seasonlist": SeasonList(); break;
|
||||
case "save": Say("saving"); Misc.AutoSave.Save(); break;
|
||||
// A clean shutdown, which is the only kind that EMITS. `Stop-Process` drops the
|
||||
// socket and the shard says nothing, so a killed shard is indistinguishable from
|
||||
// a wedged one -- and `uo.server.down` never fires. Core.Kill runs
|
||||
// EventSink.Shutdown, which is what BridgeBoot listens on.
|
||||
case "shutdown": Say("shutting down"); Timer.DelayCall(TimeSpan.Zero, () => Core.Kill(false)); break;
|
||||
default: Say("unknown verb \"" + verb + "\""); break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an object by serial, without telling anything.
|
||||
///
|
||||
/// Accepts the `0x…` form the bridge writes serials in, so a serial can be pasted
|
||||
/// straight out of a `world.owned` reply.
|
||||
/// </summary>
|
||||
private static void WorldGone(string raw)
|
||||
{
|
||||
if (String.IsNullOrEmpty(raw))
|
||||
{
|
||||
Say("worldgone <serial>");
|
||||
return;
|
||||
}
|
||||
|
||||
var text = raw.Trim();
|
||||
uint parsed;
|
||||
var ok = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed)
|
||||
: UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
Say("worldgone: \"" + raw + "\" is not a serial");
|
||||
return;
|
||||
}
|
||||
|
||||
var entity = World.FindEntity((Serial)unchecked((int)parsed));
|
||||
|
||||
if (entity == null || entity.Deleted)
|
||||
{
|
||||
Say("worldgone: nothing at " + text);
|
||||
return;
|
||||
}
|
||||
|
||||
entity.Delete();
|
||||
Say("worldgone: deleted " + text + " and told nobody");
|
||||
}
|
||||
/// <summary>
|
||||
/// Names a few spawners, with both ways of addressing one.
|
||||
///
|
||||
/// A property lease is targeted by a serial or by an `XmlSpawner.UniqueId`, and the rig
|
||||
/// has no other way to learn either — the website's dropdown comes from the atlas, and
|
||||
/// the rig does not have one.
|
||||
/// </summary>
|
||||
private static void SpawnerList(string raw)
|
||||
{
|
||||
var want = 5;
|
||||
|
||||
if (!String.IsNullOrEmpty(raw))
|
||||
Int32.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out want);
|
||||
|
||||
if (want < 1)
|
||||
want = 1;
|
||||
|
||||
var shown = 0;
|
||||
|
||||
foreach (var item in World.Items.Values)
|
||||
{
|
||||
if (shown >= want)
|
||||
break;
|
||||
|
||||
var xml = item as Mobiles.XmlSpawner;
|
||||
|
||||
if (xml == null || xml.Deleted)
|
||||
continue;
|
||||
|
||||
Say(String.Format(CultureInfo.InvariantCulture,
|
||||
"spawner 0x{0:X} uid={1} maxCount={2} running={3} name={4}",
|
||||
item.Serial.Value, xml.UniqueId, xml.MaxCount, xml.Running, xml.Name ?? "-"));
|
||||
|
||||
shown++;
|
||||
}
|
||||
|
||||
if (shown == 0)
|
||||
Say("spawnerlist: this world has no XmlSpawners");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a property on an object BEHIND the lease plane's back.
|
||||
///
|
||||
/// 11b's `configset` exists because `Config.Set` has one caller in the whole tree, so
|
||||
/// nothing on a stock shard could drift a config lease. A spawner is the opposite — a GM
|
||||
/// drifts one with `[props` in about four seconds — but the rig has no client, so it
|
||||
/// needs the same door. This is the only way to reach `drifted` on a property lease
|
||||
/// without one, and it is exactly what a staff member's `[set` does.
|
||||
/// </summary>
|
||||
private static void PropSet(string target, string property, string value)
|
||||
{
|
||||
if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property) || value == null)
|
||||
{
|
||||
Say("propset <serial|uniqueId> <property> <value>");
|
||||
return;
|
||||
}
|
||||
|
||||
Item item = null;
|
||||
uint parsed;
|
||||
var text = target.Trim();
|
||||
var isSerial = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed)
|
||||
: UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||
|
||||
if (isSerial)
|
||||
{
|
||||
item = World.FindItem((Serial)unchecked((int)parsed));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var candidate in World.Items.Values)
|
||||
{
|
||||
var xml = candidate as Mobiles.XmlSpawner;
|
||||
|
||||
if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
item = xml;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (item == null || item.Deleted)
|
||||
{
|
||||
Say("propset: nothing at " + text);
|
||||
return;
|
||||
}
|
||||
|
||||
var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
if (info == null || !info.CanWrite)
|
||||
{
|
||||
Say("propset: " + item.GetType().Name + " has no writable " + property);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
object typed;
|
||||
|
||||
if (info.PropertyType == typeof(TimeSpan))
|
||||
typed = TimeSpan.FromSeconds(Double.Parse(value, CultureInfo.InvariantCulture));
|
||||
else if (info.PropertyType == typeof(bool))
|
||||
typed = String.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1";
|
||||
else
|
||||
typed = Convert.ChangeType(value, info.PropertyType, CultureInfo.InvariantCulture);
|
||||
|
||||
info.SetValue(item, typed, null);
|
||||
Say("propset: " + property + " on " + text + " is now " + value + ", and nobody was told");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Say("propset: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads a property back, so the rig can assert a restore actually landed.</summary>
|
||||
private static void PropRead(string target, string property)
|
||||
{
|
||||
if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property))
|
||||
{
|
||||
Say("propread <serial|uniqueId> <property>");
|
||||
return;
|
||||
}
|
||||
|
||||
Item item = null;
|
||||
uint parsed;
|
||||
var text = target.Trim();
|
||||
var isSerial = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed)
|
||||
: UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||
|
||||
if (isSerial)
|
||||
{
|
||||
item = World.FindItem((Serial)unchecked((int)parsed));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var candidate in World.Items.Values)
|
||||
{
|
||||
var xml = candidate as Mobiles.XmlSpawner;
|
||||
|
||||
if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
item = xml;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (item == null || item.Deleted)
|
||||
{
|
||||
Say("propread: nothing at " + text);
|
||||
return;
|
||||
}
|
||||
|
||||
var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
Say("propread: " + item.GetType().Name + " has no " + property);
|
||||
return;
|
||||
}
|
||||
|
||||
var raw = info.GetValue(item, null);
|
||||
Say("propread: " + property + " = " + Convert.ToString(raw, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>Says what the seasonal system holds, which is the seasonal lease's target list.</summary>
|
||||
private static void SeasonList()
|
||||
{
|
||||
foreach (Engines.SeasonalEvents.EventType type in Enum.GetValues(typeof(Engines.SeasonalEvents.EventType)))
|
||||
{
|
||||
var entry = Engines.SeasonalEvents.SeasonalEventSystem.GetEntry(type);
|
||||
|
||||
Say(entry == null
|
||||
? "season " + type + " = (no entry)"
|
||||
: "season " + type + " = " + entry.Status);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static string Arg(string[] parts, int i)
|
||||
{
|
||||
return i < parts.Length ? parts[i] : null;
|
||||
}
|
||||
|
||||
private static int Int(string raw)
|
||||
{
|
||||
int n;
|
||||
return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out n) ? n : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a live config key, so a lease's `drifted` verdict can be produced at all.
|
||||
///
|
||||
/// **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4**
|
||||
/// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). No in-game command, gump or
|
||||
/// console verb writes a config key, so on a stock shard a GM cannot drift a
|
||||
/// configuration lease even deliberately -- and the one safety property a lease has
|
||||
/// that nothing else does would go untested. Written through the same typed setter a
|
||||
/// float lease uses, so what it produces is indistinguishable to the compare-and-set
|
||||
/// from a real interfering write.
|
||||
///
|
||||
/// Deliberately no `Config.Save()`, matching BridgeLeases: nothing about a rig should
|
||||
/// leave a modified .cfg behind for the next boot to inherit.
|
||||
/// </summary>
|
||||
private static void ConfigSet(string key, string raw)
|
||||
{
|
||||
if (key == null || raw == null)
|
||||
{
|
||||
Say("configset <key> <value>");
|
||||
return;
|
||||
}
|
||||
|
||||
double n;
|
||||
|
||||
if (Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
|
||||
Config.Set(key, n);
|
||||
else
|
||||
Config.Set(key, raw);
|
||||
|
||||
Say("configset " + key + " = " + raw + " (in memory only)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a key back through `Config.Get`, at a moment long after every type
|
||||
/// initialiser has run.
|
||||
///
|
||||
/// This is the check that tells a key which TOOK from one that only appeared to: a
|
||||
/// lease on one of ServUO's ~150 cached call sites applies cleanly and does nothing,
|
||||
/// which is the worst failure this feature has.
|
||||
/// </summary>
|
||||
private static void ConfigRead(string key)
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
Say("configread <key>");
|
||||
return;
|
||||
}
|
||||
|
||||
Say("configread " + key + " = " + Config.Get(key, Double.NaN).ToString("R", CultureInfo.InvariantCulture)
|
||||
+ " (double), \"" + Config.Get(key, "<unset>") + "\" (string)");
|
||||
}
|
||||
|
||||
// ---- houses ----
|
||||
|
||||
/// <summary>
|
||||
/// `CanDecay` is the filter, and getting it wrong is silent: an AutoRefresh house --
|
||||
/// and the owner's newest house is always AutoRefresh -- has a DecayLevel getter that
|
||||
/// calls ResetDynamicDecay(), so a forced stage is wiped before the sweep reads it and
|
||||
/// NOTHING is emitted. That looks exactly like a broken emitter.
|
||||
/// </summary>
|
||||
private static IEnumerable<BaseHouse> Decayable()
|
||||
{
|
||||
return BaseHouse.AllHouses
|
||||
.Where(h => h != null && !h.Deleted && h.Owner != null && h.CanDecay);
|
||||
}
|
||||
|
||||
private static void DecayList()
|
||||
{
|
||||
foreach (var h in Decayable())
|
||||
{
|
||||
var acct = h.Owner.Account == null ? "-" : h.Owner.Account.Username;
|
||||
Say(String.Format(
|
||||
"house 0x{0:X} owner={1} acct={2} name=\"{3}\" region={4} type={5} level={6}",
|
||||
h.Serial.Value, h.Owner.Name, acct, HouseName(h), RegionName(h),
|
||||
h.DecayType, h.DecayLevel));
|
||||
}
|
||||
|
||||
Say("decayable=" + Decayable().Count());
|
||||
}
|
||||
|
||||
private static string HouseName(BaseHouse h)
|
||||
{
|
||||
return h.Sign != null && h.Sign.Name != null ? h.Sign.Name : String.Empty;
|
||||
}
|
||||
|
||||
private static string RegionName(BaseHouse h)
|
||||
{
|
||||
var r = Region.Find(h.Location, h.Map);
|
||||
return r == null ? "-" : r.Name ?? "-";
|
||||
}
|
||||
|
||||
private static void Decay(string which, string stage)
|
||||
{
|
||||
DecayLevel level;
|
||||
if (!TryParseStage(stage, out level))
|
||||
{
|
||||
Say("unknown stage \"" + stage + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
BaseHouse house = null;
|
||||
|
||||
if (String.IsNullOrEmpty(which) || which == "any")
|
||||
house = Decayable().FirstOrDefault();
|
||||
else
|
||||
{
|
||||
var serial = ParseSerial(which);
|
||||
house = Decayable().FirstOrDefault(h => h.Serial.Value == serial);
|
||||
}
|
||||
|
||||
if (house == null)
|
||||
{
|
||||
Say("no decayable house matched \"" + which + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
var from = house.DecayLevel;
|
||||
|
||||
// A refresh is what a player does at the sign, and it is NOT SetDynamicDecay: the
|
||||
// level is derived from LastRefreshed, so a "LikeNew" that only rewrote the dynamic
|
||||
// stage would be undone by the next read.
|
||||
if (level == DecayLevel.LikeNew)
|
||||
house.RefreshDecay();
|
||||
else
|
||||
house.SetDynamicDecay(level);
|
||||
|
||||
Say(String.Format(
|
||||
"house 0x{0:X} {1} -> {2} (now {3})",
|
||||
house.Serial.Value, from, level, house.DecayLevel));
|
||||
}
|
||||
|
||||
private static bool TryParseStage(string s, out DecayLevel level)
|
||||
{
|
||||
level = DecayLevel.Ageless;
|
||||
if (String.IsNullOrEmpty(s))
|
||||
return false;
|
||||
|
||||
foreach (DecayLevel candidate in Enum.GetValues(typeof(DecayLevel)))
|
||||
{
|
||||
if (String.Equals(candidate.ToString(), s, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
level = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int ParseSerial(string s)
|
||||
{
|
||||
var text = s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? s.Substring(2) : s;
|
||||
int parsed;
|
||||
|
||||
if (Int32.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed))
|
||||
return parsed;
|
||||
|
||||
return Int32.TryParse(s, out parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
// ---- vendors ----
|
||||
|
||||
private static IEnumerable<PlayerVendor> Vendors()
|
||||
{
|
||||
return World.Mobiles.Values.OfType<PlayerVendor>().Where(v => !v.Deleted);
|
||||
}
|
||||
|
||||
private static void VendorList()
|
||||
{
|
||||
Say("NewVendorSystem=" + BaseHouse.NewVendorSystem);
|
||||
|
||||
foreach (var v in Vendors())
|
||||
{
|
||||
var owner = v.Owner;
|
||||
var acct = owner == null || owner.Account == null ? "-" : owner.Account.Username;
|
||||
Say(String.Format(
|
||||
"vendor 0x{0:X} shop=\"{1}\" owner={2} acct={3} hold={4} charge={5} nextPay={6}",
|
||||
v.Serial.Value, v.ShopName, owner == null ? "-" : owner.Name, acct,
|
||||
v.HoldGold, v.ChargePerDay, v.NextPayTime.ToUniversalTime().ToString("o")));
|
||||
}
|
||||
|
||||
Say("vendors=" + Vendors().Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a vendor's held gold, which is the only knob that walks it toward dismissal
|
||||
/// without waiting a pay period -- `NextPayTime` has a private setter, and a period is
|
||||
/// a real day on the new vendor system and a UO day (~2 real hours) on the old one.
|
||||
/// The emitter computes `periodsRemaining` as funds / chargePerPeriod, so this moves
|
||||
/// exactly the field the threshold tracker watches.
|
||||
/// </summary>
|
||||
private static void VendorFunds(string which, string gold)
|
||||
{
|
||||
var serial = ParseSerial(which ?? String.Empty);
|
||||
var vendor = Vendors().FirstOrDefault(v => v.Serial.Value == serial);
|
||||
|
||||
if (vendor == null)
|
||||
{
|
||||
Say("no vendor matched \"" + which + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
int funds;
|
||||
if (!Int32.TryParse(gold, out funds))
|
||||
{
|
||||
Say("bad gold \"" + gold + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
// Both, because the old vendor system spends BankAccount + HoldGold and the new one
|
||||
// spends HoldGold alone -- setting one would leave the other paying the charge.
|
||||
vendor.HoldGold = funds;
|
||||
vendor.BankAccount = 0;
|
||||
|
||||
var charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
|
||||
Say(String.Format(
|
||||
"vendor 0x{0:X} hold={1} bank=0 charge={2} periodsRemaining={3}",
|
||||
vendor.Serial.Value, vendor.HoldGold, charge, charge > 0 ? funds / charge : -1));
|
||||
}
|
||||
|
||||
// ---- accounts ----
|
||||
|
||||
/// <summary>
|
||||
/// Mark an account as having just logged in.
|
||||
///
|
||||
/// This is the ONLY way to walk a decaying house back out of danger on a seeded
|
||||
/// world, and the reason is ServUO's, not the rig's: every house that CAN decay here
|
||||
/// is `DecayType.Condemned` (the seeder backdates accounts past
|
||||
/// `Account.InactiveDuration` precisely to make them decay), and
|
||||
/// `BaseHouse.RefreshDecay()` returns false immediately for a Condemned house. A
|
||||
/// condemned house is not refreshable by anyone; it is rescued by its OWNER LOGGING
|
||||
/// IN, which is what this reproduces.
|
||||
///
|
||||
/// What the shard then reports depends on how many houses the owner has:
|
||||
/// `AutoRefresh` (their newest) stops decaying and reads **Ageless**, while an older
|
||||
/// `ManualRefresh` one is back on the clock and reads **LikeNew**. Both are "out of
|
||||
/// danger", and a mapper that reads only one of them misses most rescues.
|
||||
/// </summary>
|
||||
private static void Activate(string username)
|
||||
{
|
||||
var acct = Accounts.GetAccount(username) as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
Say("no account \"" + username + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.LastLogin = DateTime.UtcNow;
|
||||
Say(String.Format("account {0} lastLogin=now inactive={1}", acct.Username, acct.Inactive));
|
||||
|
||||
foreach (var h in BaseHouse.AllHouses)
|
||||
{
|
||||
if (h == null || h.Deleted || h.Owner == null || h.Owner.Account != acct)
|
||||
continue;
|
||||
|
||||
Say(String.Format(
|
||||
" house 0x{0:X} type={1} level={2}", h.Serial.Value, h.DecayType, h.DecayLevel));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a game account's password, so a login can be driven over a real socket.
|
||||
///
|
||||
/// The socket is not optional for the ACCEPTED half: ServUO's own AccountHandler calls
|
||||
/// `acct.HasAccess(e.State)` before it ever checks the password, and a null NetState
|
||||
/// fails that -- so an in-process probe reports "access denied" for a correct password
|
||||
/// and can never produce `accepted:true`.
|
||||
/// </summary>
|
||||
private static void Password(string username, string pw)
|
||||
{
|
||||
var acct = Accounts.GetAccount(username) as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
Say("no account \"" + username + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.IsNullOrEmpty(pw))
|
||||
{
|
||||
Say("refusing to set an empty password");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.SetPassword(pw);
|
||||
Say("account " + acct.Username + " password set");
|
||||
}
|
||||
|
||||
// ---- cities ----
|
||||
|
||||
private static void CityList()
|
||||
{
|
||||
Say("CityLoyaltySystem.Enabled=" + CityLoyaltySystem.Enabled);
|
||||
|
||||
foreach (var city in CityLoyaltySystem.Cities)
|
||||
{
|
||||
if (city == null)
|
||||
continue;
|
||||
|
||||
var e = city.Election;
|
||||
Say(String.Format(
|
||||
"city={0} governor={1} elect={2} election={3} candidates={4} autoPick={5}",
|
||||
city.City,
|
||||
city.Governor == null ? "-" : city.Governor.Name + "/0x" + city.Governor.Serial.Value.ToString("X"),
|
||||
city.GovernorElect == null ? "-" : city.GovernorElect.Name,
|
||||
e == null ? "-" : (e.CanNominate() ? "nominate" : e.CanVote() ? "vote" : e.Ongoing ? "pending" : "none"),
|
||||
e == null || e.Candidates == null ? 0 : e.Candidates.Count,
|
||||
e == null ? "-" : e.AutoPickGovernor.ToUniversalTime().ToString("o")));
|
||||
}
|
||||
}
|
||||
|
||||
private static CityLoyaltySystem FindCity(string name)
|
||||
{
|
||||
return CityLoyaltySystem.Cities.FirstOrDefault(
|
||||
c => c != null && String.Equals(c.City.ToString(), name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void Governor(string cityName, string who)
|
||||
{
|
||||
var city = FindCity(cityName);
|
||||
if (city == null)
|
||||
{
|
||||
Say("no city \"" + cityName + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String.Equals(who, "none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
city.Governor = null;
|
||||
Say("city=" + city.City + " governor cleared");
|
||||
return;
|
||||
}
|
||||
|
||||
var mob = FindMobile(who);
|
||||
if (mob == null)
|
||||
{
|
||||
Say("no player matched \"" + who + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
city.Governor = mob;
|
||||
var acct = mob.Account == null ? "-" : mob.Account.Username;
|
||||
Say(String.Format(
|
||||
"city={0} governor={1} 0x{2:X} acct={3}",
|
||||
city.City, mob.Name, mob.Serial.Value, acct));
|
||||
}
|
||||
|
||||
private static Mobile FindMobile(string who)
|
||||
{
|
||||
var serial = ParseSerial(who);
|
||||
|
||||
if (serial != 0)
|
||||
{
|
||||
var bySerial = World.FindMobile(serial);
|
||||
if (bySerial != null)
|
||||
return bySerial;
|
||||
}
|
||||
|
||||
return World.Mobiles.Values.OfType<PlayerMobile>()
|
||||
.FirstOrDefault(m => !m.Deleted && String.Equals(m.Name, who, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void Election(string cityName)
|
||||
{
|
||||
var city = FindCity(cityName);
|
||||
if (city == null)
|
||||
{
|
||||
Say("no city \"" + cityName + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
if (city.Election == null)
|
||||
{
|
||||
Say("city=" + city.City + " has no election object");
|
||||
return;
|
||||
}
|
||||
|
||||
city.Election.StartNewElection();
|
||||
Say(String.Format(
|
||||
"city={0} election restarted; autoPick={1} nominate={2}",
|
||||
city.City,
|
||||
city.Election.AutoPickGovernor.ToUniversalTime().ToString("o"),
|
||||
city.Election.CanNominate()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**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.
|
||||
These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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 |
|
||||
|------|--------------------------|------|
|
||||
@@ -13,6 +13,11 @@ These two scripts produced the measured budget in `docs/PLAN.md` §1. They are k
|
||||
| `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`. |
|
||||
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
|
||||
| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `configset`, `configread`, `partprobe`, `save`, `shutdown`. Flag: `RigDriverEnabled`. `configset` exists because **`Config.Get` is written by exactly ONE caller in the whole of ServUO 57.4** (`Server/ScriptCompiler.cs`): no in-game command, gump or console verb writes a config key, so on a stock shard a GM cannot drift a configuration lease even deliberately, and a lease's compare-and-set restore would have no way to be proved. `configread` reads a key back through `Config.Get` long after every type initialiser has run, which is how a key that TOOK is told from one that only appeared to. **Sets passwords, writes live config and mutates the world.** |
|
||||
| `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** |
|
||||
| `BridgeProtocol6Probe.cs` | `Scripts/Custom/BridgeProtocol6Probe.cs` | Spawns a real champion boss through the shard's own `SpawnChampion()`, waits two champ sweeps so the boss is attributed to its altar, registers unequal damage from two seeded players and kills it -- so `champ.boss.killed` can be observed with a real damage table. **The wait is the assertion**: without it the kill still emits, but with no `serial`/`type`/`level`, which is the documented fallback rather than the case being tested. The altar is placed inside a NAMED region on purpose (see below). Flag: `Protocol6ProbeOnStart`. In game: `[p6probe`. **Spawns and kills a champion boss; rig only.** Protocol 6's other half, the idempotency key, needs no probe -- it is driven from outside with two identical POSTs to the sidecar. |
|
||||
| `BridgeParticipationProbe.cs` | `Scripts/Custom/BridgeParticipationProbe.cs` | Produces real kill credit inside a participation area with no game client: moves two player mobiles to the venue, spawns a creature there, damages it unequally from both and kills it. **Presence is the half it cannot drive** -- the sweep credits players with a live `NetState`, which is the correct test and not one a probe should loosen, so presence accrual needs a real login. In game: `[partprobe <map> <x> <y>`; from a headless rig, through `BridgeRigDriver`'s `partprobe` verb (the two ship together for that reason). **Moves players and spawns and kills a creature; rig only.** |
|
||||
|
||||
## Deploy overwrites Bridge.cfg
|
||||
|
||||
@@ -34,6 +39,41 @@ Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.c
|
||||
|
||||
In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
|
||||
|
||||
## Dressing a seeded world for screenshots
|
||||
|
||||
`BridgeSeeder` builds a world at realistic **scale**, which is all the bridge ever needed. It does not
|
||||
build one that looks like anything: a vendor is `seed vendor` trading as `Seed Shop 810`, a character
|
||||
is `Seed004A`, a house sign says `Seed House 12`. Those strings travel the whole bridge and land on
|
||||
the marketplace, the guild roster and the housing pages of the website — fine for a protocol test,
|
||||
wrong for a screenshot.
|
||||
|
||||
`BridgeDemoDress.cs` renames them in place. It seeds nothing: prices, listing counts, decay stages,
|
||||
fame and skills stay exactly as the seeder left them and as the shard has moved them since, so the
|
||||
data keeps its provenance and only the strings a human reads change. Names are drawn from fixed
|
||||
tables by a hash of each object's serial, so a re-run reproduces the same world, and shop and house
|
||||
names are re-dressed when they are names the pass itself produced — so a change to the tables can be
|
||||
applied to a world that has already been through here.
|
||||
|
||||
```ini
|
||||
DemoDressOnStart=True
|
||||
DemoDressPassword=<a password you choose>
|
||||
```
|
||||
|
||||
Boot once, then set `DemoDressOnStart=False`. The password is written to `seed_000` so a real client
|
||||
can log a character in — the only way to make the website's online roster non-empty — and it is read
|
||||
from the config rather than compiled in, so it never lands in source control.
|
||||
|
||||
**It dresses seeded objects only, which means your own characters keep their names.** That is the
|
||||
right behaviour for a test shard and a thing to remember before pointing a camera at one: a dev
|
||||
world usually also holds the accounts, characters, guilds and houses of whoever built it, and those
|
||||
are real identifiers on a page that may end up public.
|
||||
|
||||
**The sidecar's board is cached, so the website lags a rename.** A shop name reaches the site on the
|
||||
next market sweep, and a sweep advances `MarketSweepBatch` vendors per tick — 27 vendors at the
|
||||
defaults is two ticks. Allow a couple of minutes before concluding that a rename failed. This cost a
|
||||
debugging detour once: the shard had the new names all along and the sidecar was still serving the
|
||||
previous ones.
|
||||
|
||||
## 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.
|
||||
@@ -70,3 +110,82 @@ Probe, best-of-20 on the Core thread:
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## The login half needs a socket, not the sink
|
||||
|
||||
`BridgeProtocol5Probe` fires `EventSink.InvokeAccountLogin` directly, which proves the REJECTED
|
||||
half of `account.login.result` and nothing more. ServUO's own `AccountHandler` calls
|
||||
`acct.HasAccess(e.State)` *before* it ever checks the password, and a null `NetState` fails that --
|
||||
so an in-process probe logs `Access denied` for a correct password too, and never produces an
|
||||
`accepted:true`.
|
||||
|
||||
To prove the accepted half, speak the wire. A real socket also gives the frame a real `ip`, which
|
||||
is one of the fields being tested:
|
||||
|
||||
```python
|
||||
# 4-byte seed, then 0x80 = [0x80][30b username][30b password][1b]
|
||||
s = socket.create_connection(('127.0.0.1', 2593))
|
||||
s.sendall(b'\x7f\x00\x00\x01')
|
||||
s.sendall(b'\x80' + pad(user) + pad(password) + b'\x5d')
|
||||
```
|
||||
|
||||
The shard logs `Invalid password for '<acct>'` or `Valid credentials for '<acct>'`, and the sidecar's
|
||||
`/history?kind=account.login.result` should show `accepted:false reason:BadPass` and `accepted:true`
|
||||
respectively. **Both saying `accepted:true` is the bug the kind exists to prevent** -- it means the
|
||||
verdict was read inside the handler, before it existed.
|
||||
|
||||
## Walking a house into IDOC needs a house that can decay
|
||||
|
||||
Only a `Condemned` or `ManualRefresh` house decays. An `AutoRefresh` one -- and the owner's NEWEST
|
||||
house is always `AutoRefresh` -- has a `DecayLevel` getter that calls `ResetDynamicDecay()` and
|
||||
reports `Ageless`, so a forced `SetDynamicDecay` is wiped on the very next read, the sweep sees no
|
||||
change, and **nothing is emitted at all**. That looks exactly like a broken emitter. Filter on
|
||||
`house.CanDecay`, and expect a seeded world to have only one or two houses that qualify -- both
|
||||
probably already at IDOC, so the walk has to put one back down first.
|
||||
|
||||
## A decaying house cannot be refreshed — only its owner coming back rescues it
|
||||
|
||||
`BaseHouse.RefreshDecay()` returns `false` immediately when `DecayType == Condemned`, and on a
|
||||
seeded world **every house that can decay is Condemned** — the seeder backdates 18 accounts past
|
||||
`Account.InactiveDuration` precisely to make them decay. So `SetDynamicDecay(DecayLevel.LikeNew)`
|
||||
is wiped by the next read and `RefreshDecay()` does nothing: the sweep sees no change and emits
|
||||
nothing, which looks exactly like a broken emitter for the second time on the same page.
|
||||
|
||||
The rescue is the OWNER LOGGING IN (`BridgeRigDriver`'s `activate <account>` reproduces it by
|
||||
setting `LastLogin`). What the shard then reports depends on how many houses that owner has:
|
||||
|
||||
| the house | `DecayType` after the login | `DecayLevel` reads |
|
||||
|---|---|---|
|
||||
| their newest | `AutoRefresh` | **`Ageless`** — off the decay clock entirely |
|
||||
| any older one | `ManualRefresh` | **`LikeNew`** — back on the clock, at the top |
|
||||
|
||||
Both are "out of danger", and the newest-house case is the common one. A consumer that watches only
|
||||
for `LikeNew` misses most rescues — which is what the engagement mapper did until this walk.
|
||||
|
||||
## The console takes a fixed verb set, so `[commands` cannot be typed at a headless shard
|
||||
|
||||
`Scripts/Misc/ConsoleCommands.cs` handles `save`, `shutdown`, `restart`, `online`, `kick` and a
|
||||
handful more; it does **not** dispatch arbitrary `[commands`. Every other probe here therefore runs
|
||||
either at boot or from an in-game client, and neither works for a walk driven from a script. That is
|
||||
what `BridgeRigDriver` and its `rigcmd.txt` are for.
|
||||
|
||||
Also: only a CLEAN shutdown emits. `Stop-Process` drops the socket and the shard says nothing, so a
|
||||
killed shard is indistinguishable from a wedged one and `server.shutdown` never reaches the sidecar —
|
||||
use the driver's `shutdown` verb (`Core.Kill`) when the shutdown itself is what is being tested.
|
||||
|
||||
## The innermost region has no name
|
||||
|
||||
`BridgeProtocol6Probe` places its altar in the middle of **Britain** rather than at a dungeon altar,
|
||||
and that is not cosmetic. An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its own
|
||||
spawn area, constructed with a **null name** and with the town region as its parent -- so the most
|
||||
specific region containing a champion boss is the one region on the map guaranteed to be nameless.
|
||||
`Mobile.Region` then hides that by falling back to the map's unnamed default region rather than to
|
||||
null, and the emitted frame simply has no `region`.
|
||||
|
||||
Region registration is also **deferred**, which is what makes this survive a first look: a lookup
|
||||
taken immediately after the altar is placed answers `"Britain"`, and one taken at the kill twenty
|
||||
seconds later does not. The probe prints the spawn-time read for exactly this reason -- it is the
|
||||
value that lies, printed next to a frame that disagrees with it.
|
||||
|
||||
Emitting from a named region is therefore the test. At a dungeon altar the field is legitimately
|
||||
absent and the probe proves nothing about it.
|
||||
|
||||
Reference in New Issue
Block a user