diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..14c8eb9 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,516 @@ +# Automated release for the Rust bridge plugin. +# +# Trigger: every push to `main` (i.e. every merged PR — in practice the +# edge→main cutover), and by hand. +# +# Why this exists: the Runic Gateway installer (`--game rust`) and the +# Pterodactyl egg both deploy the plugin from a release tarball, never from git +# — a game host gets no git and no Gitea credentials. Until module-rust phase 18 +# (docs/modules/rust/PLAN.md §34.2.1, D145) this repository had never released, +# so there was nothing for either to fetch. +# +# Flow — the same two halves as servuo-plugins' release.yml, whose release +# engine is reused here unchanged: +# +# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ +# │ reads: latest v* git tag + conventional-commit subjects │ +# │ produces: next version, changelog, and (at the end) the release │ +# └───────────────────────────────────────────────────────────────────┘ +# ┌── PLUGIN ADAPTER (the only repo-specific part) ───────────────────┐ +# │ consumes: the version │ +# │ produces: runicgateway-rust-plugin-.tar.gz + SHA256SUMS │ +# └───────────────────────────────────────────────────────────────────┘ +# +# ── What differs from servuo-plugins' copy ─────────────────────────────────── +# +# 1. NO BUILD, for the same reason: the plugin ships as C# source and Oxide or +# Carbon compiles it against assemblies that exist only on a Rust server. +# The gates are the static checks PR Checks already runs, re-run on the +# exact commit being released. +# +# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, +# `runicgateway-rust-plugin/`, with `RunicGateway.cs` and `manifest.json` at +# its top. It does NOT mirror `oxide/plugins/`: the same file goes to +# `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each +# decide which (§34.2.1). The fixed, unversioned prefix is so a reader never +# has to parse the version out of a path to find the manifest that states it. +# +# 3. THE VERSION IS STAMPED into the shipped copy's `[Info(…)]` attribute, so +# `oxide.plugins` / `c.plugins` on a server names the release it runs. The +# committed file keeps its placeholder; the manifest records the commit. +# +# The version is never the protocol. The protocol lives in overlay.toml and +# moves only when a message shape does; the version moves on every releasable +# commit. +# +# Version bump (conventional commits since the last v* tag): +# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch +# nothing releasable -> no release is cut +# (first ever run, no tag) -> releases SEED_VERSION below (§34.4: the first +# release takes what the engine derives) +# +# Prerequisites (Settings → Actions → Secrets on RunicGateway/Rust-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 — 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. + +name: Release plugin + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-plugin + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/Rust-Plugins + ARTIFACT: runicgateway-rust-plugin + PLUGIN: overlay/oxide/plugins/RunicGateway.cs + SEED_VERSION: "0.1.0" + 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() { # -> 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 servuo-plugins' first + # run, when 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: the commit that ADDED the + # recovery above was itself a `fix:`, so it bumped to v0.1.1 and the + # run that introduced the recovery stepped straight past the tag it + # was written to rescue. + # + # 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:-}" + + # ── 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 servuo-plugins' first run failed. REGISTRY_USER and + # REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway: + # actions/checkout leaves an `http..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 + + # ── PLUGIN ADAPTER: gates ──────────────────────────────────────────── + # No compiler exists for this plugin outside a Rust server, so the gates + # are the ones PR Checks runs, re-run on the exact commit being released: + # every hook listed in ExpectedHooks, every hook void, and ProtocolVersion + # equal to overlay.toml. A cutover merge commit is a tree no PR check ran + # on as such, so it is worth asking again. + - uses: actions/setup-node@v4 + if: ${{ steps.plan.outputs.release == 'true' }} + with: + node-version: 20 + + - name: Validate the plugin + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + [ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; } + [ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; } + node scripts/checkPlugin.js + # The stamp below rewrites exactly one [Info(...)] line; zero or two + # would ship a plugin whose reported version is a lie. + COUNT="$(grep -cE '^[[:space:]]*\[Info\("Runic Gateway", "RunicGateway", "[^"]*"\)\]' "$PLUGIN" || true)" + [ "$COUNT" = 1 ] || { echo "::error::expected exactly one [Info(\"Runic Gateway\", \"RunicGateway\", ...)] attribute in ${PLUGIN}, found ${COUNT}"; exit 1; } + + # ── PLUGIN ADAPTER: stage, manifest, package ───────────────────────── + # 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 + id: package + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + STAGE="dist/stage/${ARTIFACT}" + mkdir -p "${STAGE}" + + sed -E 's/^([[:space:]]*\[Info\("Runic Gateway", "RunicGateway", ")[^"]*("\)\])/\1'"${VERSION}"'\2/' \ + "$PLUGIN" > "${STAGE}/RunicGateway.cs" + grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \ + || { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; } + + # Declarations from overlay.toml. Read, don't hardcode — the point of + # that file is that the protocol number lives in one place. + toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; } + PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')" + MIN_OXIDE="$(toml_str min_oxide_version)" + MIN_CARBON="$(toml_str min_carbon_version)" + # requires_plugins = ["Kits", "ZoneManager"] is already a JSON array. + # One line, quoted strings only; anything else fails the check below. + REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')" + [ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; } + [ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; } + [ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; } + jq -e 'type == "array" and all(type == "string")' <<<"$REQUIRES" >/dev/null \ + || { echo "::error::requires_plugins in overlay.toml is not a one-line array of strings"; exit 1; } + echo "==> protocol=${PROTOCOL} oxide>=${MIN_OXIDE} carbon>=${MIN_CARBON} requires=${REQUIRES}" + + jq -n \ + --arg component "rust-plugin" \ + --arg version "${VERSION}" \ + --arg commit "${GITHUB_SHA}" \ + --arg repo "${REPO}" \ + --argjson protocol "${PROTOCOL}" \ + --arg min_oxide "${MIN_OXIDE}" \ + --arg min_carbon "${MIN_CARBON}" \ + --argjson requires "${REQUIRES}" \ + --arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \ + '{ + component: $component, + version: $version, + commit: $commit, + repo: $repo, + protocol: $protocol, + min_oxide_version: $min_oxide, + min_carbon_version: $min_carbon, + requires_plugins: $requires, + files: { "RunicGateway.cs": $sha } + }' > "${STAGE}/manifest.json" + echo "----- manifest.json -----" + cat "${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 + + # ── 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 "rust-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 ──────────────────────── + # Neither the installer nor the egg resolves "latest" at run time — both + # deploy the exact plugin named by a published bundle + # (docs/modules/rust/PLAN.md §34.2.2). A plugin 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 Rust-Link's PROTOCOL_VERSION before publishing + # anything (gate 1) — the check this repo cannot perform for itself, and + # the one that matters most here: the game link has no 409, so a + # mismatched plugin would mis-parse rather than be refused. + # + # 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 diff --git a/README.md b/README.md index 88eecf3..bdb5b7f 100644 --- a/README.md +++ b/README.md @@ -52,17 +52,39 @@ Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway. is deliberately **not** derived from the hostname: an operator renames a server for a season, and the site must not lose its history for it. +**When that first config is written**, and only then, a valid `RUSTLINK_SERVER_ID` in the server's +environment becomes its `ServerId` (an invalid one is logged and ignored). That is how the +Pterodactyl egg names a server, since an egg can set a variable but cannot reliably write a +framework's config directory. After that the file is canonical and the website locks the value, so +changing the variable later changes nothing. + +On Carbon the same file goes to `carbon/plugins/` and its config to `carbon/configs/`. + That is the developer's loop. An operator uses the -[installer](https://gitea.whitlocktech.com/RunicGateway/installer), which syncs the released overlay -tarball and installs the sidecar alongside it. +[installer](https://gitea.whitlocktech.com/RunicGateway/installer) (`--game rust`) or the Pterodactyl +egg, both of which deploy a **released** plugin and install the sidecar beside it. + +### Releases + +Every merge to `main` that carries a `feat`, `fix` or `perf` commit cuts a release +(`.gitea/workflows/release.yml`): `runicgateway-rust-plugin-.tar.gz` and `SHA256SUMS`. The +tarball holds one directory, `runicgateway-rust-plugin/`, with `RunicGateway.cs` (its `[Info]` +version stamped to the release) and a `manifest.json` folding in `overlay.toml`: the protocol, the +Oxide and Carbon floors, and `requires_plugins`. The layout names no framework; the installer and +the egg decide where the file goes. A release then asks the installer repo to recompose its Rust +bundle. ### Prerequisites -The bridge itself needs nothing but Oxide. The features that follow it read four third-party plugins -an operator installs from uMod — `Clans`, `Kits`, `PopupNotifications` and `ZoneManager`. They are +The bridge itself needs nothing but Oxide. The features that follow it read three third-party +plugins an operator installs from uMod — `Kits`, `PopupNotifications` and `ZoneManager`. They are listed in `overlay.toml` so the installer's `doctor` can report a missing one by name rather than leaving the site quietly short of a feature. +Clans on the website come from Rust's **own** clan system, not from the uMod `Clans` plugin, which +is optional. The two share no data: a server running the plugin has two unrelated clan systems, and +only the game's own clans become Teams on the site. + ## Diagnosing it ``` diff --git a/overlay.toml b/overlay.toml index d3a3ca7..2ba4501 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,8 +26,13 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8). -protocol = 2 +# Current: 12 — the transport, the read path, identity, the permission mirror, +# configuration from the site, first-party clans, a raid frame that names who +# lives there, the event leases, the world verbs events place and give back, and +# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16), +# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the +# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18). +protocol = 12 # ── Oxide compatibility ────────────────────────────────────────────────────── # @@ -41,9 +46,29 @@ protocol = 2 # simpler than the ServUO one. min_oxide_version = "2.0.7585" +# ── Carbon compatibility ───────────────────────────────────────────────────── +# +# The same file runs unchanged on Carbon (docs/modules/rust/PLAN.md R19); the +# installer and the egg decide whether it lands in `oxide/plugins/` or +# `carbon/plugins/`. This is the build R19 was proven on (PLAN.md §34.4). +# +# Neither floor is measured from a DLL by `doctor`: a plugin that compiled and +# connected on this framework is the fact a floor only approximates, so a +# connected plugin is the pass, and the floors are printed with any failure. +min_carbon_version = "2.0.259" + # The `oxide/plugins/` files this overlay expects to find already installed. They # are not shipped here — they are third-party plugins an operator installs from # uMod — and the installer's `doctor` reports a missing one rather than -# installing it. Listing them is what turns "the site shows no clans" into a +# installing it. Listing them is what turns a missing reward or zone into a # named prerequisite. -requires_plugins = ["Clans", "Kits", "PopupNotifications", "ZoneManager"] +# +# The uMod **Clans** plugin is deliberately NOT listed (docs/modules/rust/PLAN.md +# D47). Teams come from Rust's own clan system, which that plugin never touches, +# so requiring it would split a server's players across two unrelated clan +# systems. Phase 17 dropped the adapter it was meant to get (D134). +# +# **PopupNotifications is not listed either** (D141). It is optional: a popup +# asked of a server without it is refused with a reason, and chat is the default. +# BetterChat is optional in the same way — titles and group styles wait for it. +requires_plugins = ["Kits", "ZoneManager"] diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index dd1fb84..d16d02e 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Net.Sockets; using System.Text; using System.Threading; @@ -56,7 +57,7 @@ namespace Oxide.Plugins /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// - private const int ProtocolVersion = 2; + private const int ProtocolVersion = 12; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -94,12 +95,6 @@ namespace Oxide.Plugins /// private const float BoardCadenceSeconds = 60f; - /// - /// The side of one map grid cell, in world units. Rust's own grid, as every community - /// map and every server console uses it. - /// - private const float GridCellSize = 146.3f; - private static readonly ConcurrentQueue Outbound = new ConcurrentQueue(); private static readonly AutoResetEvent Wake = new AutoResetEvent(false); @@ -231,7 +226,15 @@ namespace Oxide.Plugins "OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned", "OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath", "OnPlayerReported", "OnUserBanned", "OnUserUnbanned", - "CanUserLogin", "OnUserApproved" + "CanUserLogin", "OnUserApproved", + "OnUserPermissionGranted", "OnUserPermissionRevoked", + "OnUserGroupAdded", "OnUserGroupRemoved", + "OnGroupPermissionGranted", "OnGroupPermissionRevoked", + "OnPluginLoaded", "OnPluginUnloaded", + "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", + "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", + "OnKitRedeemed", + "OnEntitySpawned", "OnEntityKill" }; private void Init() @@ -243,6 +246,10 @@ namespace Oxide.Plugins _wipeId = ResolveWipeId(); _running = true; + // Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is + // never looked up, so nothing else would ever remove one (protocol 3). + timer.Every(60f, PurgeExpiredCodes); + _link = new Thread(LinkLoop) { Name = "RunicGateway Link", @@ -282,6 +289,42 @@ namespace Oxide.Plugins _cadence = null; } + if (_clansBoardSoon != null) + { + _clansBoardSoon.Destroy(); + _clansBoardSoon = null; + } + + // A half-drained permission sync is abandoned rather than finished, and the site is + // told nothing — which is right: the report would describe a set that was only partly + // applied, and the next sync re-derives the whole answer from the store anyway. What + // must not happen is the timer outliving the plugin and writing during a reload. + _permJob = null; + StopPermTimer(); + + // A config write waiting on a reload dies here too, and its files stay as written. + // That is the honest outcome: the rollback exists to undo an edit that took a plugin + // down, and this plugin being unloaded is not evidence of either — it is usually THIS + // plugin being reloaded by somebody. The caller times out and re-reads. + StopConfigWatch(); + _configJob = null; + + // Holds are NOT given back on unload. The file keeps them, and the next load re-arms + // them: a plugin reload is not the end of an event (§27.3). + StopLeaseTimer(); + + // Zones are not erased here by us — ZoneManager erases them itself as this plugin + // unloads — and they are NOT forgotten: the next load re-creates them (D92). + StopWorldTimer(); + + // A tally is kept across a reload — it records who was there, and the next load + // carries on counting — so what it has counted since the last minute's save is written + // now rather than lost. + StopTallyTimer(); + + if (_talliesDirty) + SaveTallies(); + _running = false; _stopping.Set(); Wake.Set(); @@ -327,11 +370,90 @@ namespace Oxide.Plugins /// a season and the site must not lose its history for it. /// public string ServerId = "main"; + + /// + /// Whether the website may borrow values on this server for events (protocol 8, + /// D76). Off until an operator turns it on. Letting staff moderate from a + /// screen they are looking at, and letting a schedule change the world at four in + /// the morning, are different consents, and one switch cannot honestly express + /// both. It gates taking a lease only: listing and giving back always work, so + /// turning it off never strands a value somebody already borrowed. + /// + public bool EventsEnabled = false; + + /// + /// The most crates one event step may place (protocol 9, D95). An operator may + /// lower it; is the ceiling it cannot be raised + /// past. A step over the bound is refused, never trimmed to fit. + /// + public int EventsMaxCrates = WorldHardMaxCrates; + + /// The most NPCs one event step may place (D95). Lowerable, like the above. + public int EventsMaxNpcs = WorldHardMaxNpcs; + + /// + /// The most people one reward step may name (protocol 10, §29.5). The website reads + /// it and refuses a step over it; it is never trimmed to fit. + /// + public int EventsMaxRecipients = RecipientsHardMax; + + /// The most tallies that may count at once on this server (§29.5). + public int EventsMaxTallies = TallyHardMax; + + /// The longest line an event or a news post may say in chat, in characters. + public int EventsMaxChat = ChatHardMax; + + /// + /// The most offline sleepers one map answer carries (protocol 11). Over it, the layer + /// answers the first this many and says it was cut short. + /// + public int MapMaxSleepers = MapDefaultMax; + + /// The most tool cupboards and vending machines one map answer carries. + public int MapMaxBases = MapDefaultMax; } + /// + /// Runs only when this plugin has no config file yet — so it is the one moment a server id + /// can arrive from outside the file (D150). A Pterodactyl egg cannot write a framework's + /// config directory (Carbon lets an operator move it), but it can set an environment + /// variable, and `RUSTLINK_SERVER_ID` is the one the sidecar already reads for its + /// cross-check. After this write the file is canonical and the website's lock applies, + /// so changing the variable later changes nothing — which is what keeps a server's + /// history attached to one id. + /// protected override void LoadDefaultConfig() { - Config.WriteObject(new PluginConfig(), true); + var config = new PluginConfig(); + + string fromEnv = Environment.GetEnvironmentVariable("RUSTLINK_SERVER_ID"); + if (!string.IsNullOrEmpty(fromEnv)) + { + if (IsValidServerId(fromEnv)) + config.ServerId = fromEnv; + else + PrintWarning($"RUSTLINK_SERVER_ID '{fromEnv}' is not a valid server id " + + $"(lowercase letters, digits and '-', 1-64, not starting with '-'); " + + $"the new config keeps '{config.ServerId}'"); + } + + Config.WriteObject(config, true); + } + + /// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`, without a Regex. + private static bool IsValidServerId(string id) + { + if (string.IsNullOrEmpty(id) || id.Length > 64 || id[0] == '-') + return false; + + foreach (char c in id) + { + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; + if (!ok) + return false; + } + + return true; } private void LoadConfigValues() @@ -351,12 +473,38 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(_config.ServerId)) _config.ServerId = "main"; + + // A config written before protocol 8 has no `EventsEnabled`, and a key that is not in + // the file is a key the website's config editor cannot show an operator. Written back + // once, with its default, so the switch is visible before anybody needs it. + // The two bounds are clamped to their ceilings rather than refused: a config asking + // for MORE than the plugin will ever place is an operator's typo, and the ceiling is + // what the website mirrors (D95). + _config.EventsMaxCrates = Math.Max(0, Math.Min(_config.EventsMaxCrates, WorldHardMaxCrates)); + _config.EventsMaxNpcs = Math.Max(0, Math.Min(_config.EventsMaxNpcs, WorldHardMaxNpcs)); + _config.EventsMaxRecipients = Math.Max(0, Math.Min(_config.EventsMaxRecipients, RecipientsHardMax)); + _config.EventsMaxTallies = Math.Max(0, Math.Min(_config.EventsMaxTallies, TallyHardMax)); + _config.EventsMaxChat = Math.Max(0, Math.Min(_config.EventsMaxChat, ChatHardMax)); + _config.MapMaxSleepers = Math.Max(0, _config.MapMaxSleepers); + _config.MapMaxBases = Math.Max(0, _config.MapMaxBases); + + if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null || + Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null || + Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null) + Config.WriteObject(_config, true); } private void Loaded() { LoadConfigValues(); Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}"); + + // Here rather than at `OnServerInitialized`: the link thread starts in `Init`, so a + // lease command can arrive before the world has finished loading, and it must find + // the holds already in place rather than have them loaded over it afterwards. + LoadLeases(); + LoadWorld(); + LoadRewards(); } // ---- the outbound queue ---- @@ -743,6 +891,123 @@ namespace Oxide.Plugins EmitFrame(status); break; + case "link.confirm": + // The first command the WEBSITE originates (protocol 3). Everything before it + // was the game talking, or the sidecar asking the game to say something it + // already knew. + HandleLinkConfirm(cmd, reqId); + break; + + case "perm.catalogue": + // Protocol 4. What this server's loaded plugins have registered — the option + // source the website's authoring form offers, so a grant can only be written + // against a name that will actually resolve. + HandlePermCatalogue(reqId); + break; + + case "perm.sync": + // The first command that WRITES to the game (protocol 4, R2). Everything + // before it read, or spent a code the game itself had minted. + HandlePermSync(cmd, reqId); + break; + + case "config.list": + // Protocol 5 (R18). What settings files exist on this host, and which plugins + // are loaded to reload them. A description of the tree, never its contents. + HandleConfigList(reqId); + break; + + case "config.read": + HandleConfigRead(cmd, reqId); + break; + + case "config.write": + // The first command that writes to the game HOST's filesystem, which is the + // most powerful thing the website can do to a server. + HandleConfigWrite(cmd, reqId); + break; + + case "lease.list": + // Protocol 8 (PLAN.md §27). What this server lends, what it holds now, and + // every hold in force — one read serving the website's `read()` and + // `inForce()` alike. + HandleLeaseList(cmd, reqId); + break; + + case "lease.apply": + HandleLeaseApply(cmd, reqId); + break; + + case "lease.release": + HandleLeaseRelease(cmd, reqId); + break; + + case "world.monuments": + // Protocol 9 (PLAN.md §28). The monuments this wipe's map has, for the + // authoring form — and the same vocabulary a step's location is written in. + HandleWorldMonuments(reqId); + break; + + case "world.zone": + HandleWorldZone(cmd, reqId); + break; + + case "world.place": + HandleWorldPlace(cmd, reqId); + break; + + case "world.revert": + HandleWorldRevert(cmd, reqId); + break; + + case "world.owned": + HandleWorldOwned(cmd, reqId); + break; + + case "tally.open": + // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game. + HandleTallyOpen(cmd, reqId); + break; + + case "tally.snapshot": + HandleTallySnapshot(cmd, reqId); + break; + + case "tally.close": + HandleTallyClose(cmd, reqId); + break; + + case "kits.list": + HandleKitsList(reqId); + break; + + case "chat.say": + HandleChatSay(cmd, reqId); + break; + + case "titles.set": + // Protocol 12. The chat titles players have earned, as one whole set that + // replaces the last (PLAN.md §33). + HandleTitlesSet(cmd, reqId); + break; + + case "map.info": + // Protocol 11 (PLAN.md §30). What this map is and where its picture comes from. + HandleMapInfo(reqId); + break; + + case "map.fetch": + HandleMapFetch(cmd, reqId); + break; + + case "map.render": + HandleMapRender(cmd, reqId); + break; + + case "map.live": + HandleMapLive(reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -806,6 +1071,18 @@ namespace Oxide.Plugins { SendHello(); SendPlayersBoard(); + + // Last, and fenced: the clan board reads the game's clan database, which is the one + // board here built from something other than a list in memory. A failure there must + // cost the clans and nothing else — the two boards above are what every page renders. + try + { + SendClansBoard(); + } + catch (Exception ex) + { + Puts($"could not build the clans board: {Flatten(ex.Message)}"); + } } /// Main thread. @@ -819,6 +1096,10 @@ namespace Oxide.Plugins hello["bootId"] = _bootId; hello["plugin"] = Version.ToString(); + // The website compares boot ids to decide when to reconcile the events ledger, and + // must not do it until the world it would be asking about exists (§28.6). + hello["worldReady"] = _worldReady; + EmitFrame(hello); } @@ -893,6 +1174,10 @@ namespace Oxide.Plugins // A server that has never saved has no created time. Absent, not zero. } + // Protocol 12: which of the optional mods this server has, so Admin → Rust can say why + // a title or a popup is not showing before anybody goes looking (§33.2). + status["integrations"] = IntegrationsBody(); + return status; } @@ -993,7 +1278,7 @@ namespace Oxide.Plugins /// /// An entity's position as Rust's own map reference (H7), or null if it has no - /// position to read. A reference, never a coordinate: a grid cell is 146.3 units + /// position to read. A reference, never a coordinate: a grid cell is about 150 m /// across, which is precise enough to say where a fight happened and far too coarse to /// walk to somebody's door with. /// @@ -1005,51 +1290,31 @@ namespace Oxide.Plugins return Grid(entity.transform.position); } + /// + /// The game's own label for a position, from MapHelper — the function the in-game + /// map draws its grid with (D119). + /// + /// + /// Phases 3 to 13 computed this here from a 146.3 m cell, the number community tools + /// quote. It is wrong: the game fits a whole number of cells to the world, so a 3000 map + /// is 20 cells of exactly 150 m, and the constant put every row below the first a row + /// too far south (the ferry terminal read O17 where the game says O16). Nobody could + /// see it without a second opinion, and the map's grid was the second opinion. + /// + /// private static string Grid(UnityEngine.Vector3 position) { - float size = ConVar.Server.worldsize; - - if (size <= 0f) + if (ConVar.Server.worldsize <= 0) return null; - float half = size / 2f; - int columns = (int)Math.Ceiling(size / GridCellSize); - - int x = (int)((position.x + half) / GridCellSize); - - // Rows are numbered from the NORTH edge downwards, which is why this is a subtraction - // and not the same expression as the column. Getting it wrong mirrors every grid on - // the map and is invisible without a second opinion. - int z = (int)((half - position.z) / GridCellSize); - - x = Clamp(x, 0, columns - 1); - z = Clamp(z, 0, columns - 1); - - return Column(x) + z.ToString(System.Globalization.CultureInfo.InvariantCulture); - } - - private static int Clamp(int value, int low, int high) - { - if (high < low) - return low; - - return value < low ? low : (value > high ? high : value); - } - - /// A column index as Rust spells it: 0 is A, 25 is Z, 26 is AA. - private static string Column(int index) - { - string name = string.Empty; - int n = index + 1; - - while (n > 0) + try { - int remainder = (n - 1) % 26; - name = (char)('A' + remainder) + name; - n = (n - 1) / 26; + return MapHelper.PositionToString(position); + } + catch (Exception) + { + return null; } - - return name; } // ---- identity of the world ---- @@ -1135,12 +1400,33 @@ namespace Oxide.Plugins // one — to show `wipeId=none` on a server with a save sitting on disk. _wipeId = ResolveWipeId(); + // Only now is the wipe known, so only now can what an earlier map owned be dropped; + // and this hook also fires on a hotload, which is exactly when ZoneManager has just + // erased every zone the previous load of this plugin owned (D92). + _worldReady = true; + DropWorldFromOtherWipes(); + RecreateZones("load"); + + // The map's sets are seeded here for the same reason: a hotload hears no spawns. + SeedMapSets(); + + // The titles this plugin last held, if they are this wipe's (D144), and then the getter + // handed to BetterChat, if it is here. When it loads later, or reloads and forgets us, + // OnPluginLoaded does this again (§33.1). + LoadTitles(); + RegisterTitles("load"); + // Started here rather than in Init: this hook fires both at the end of server startup // and immediately on a hotload, so it is the one place that runs exactly once per // load with the world available either way. if (_cadence == null) _cadence = timer.Every(BoardCadenceSeconds, Cadence); + // Say the world is ready now rather than at the next tick: the website holds its + // reconcile until it hears this. + if (_connected) + SendHello(); + Dictionary frame = Frame("server.initialized", "event"); frame["initial"] = initial; EmitFrame(frame); @@ -1150,6 +1436,9 @@ namespace Oxide.Plugins { MarkHook("OnServerShutdown"); FlushTallies(); + + if (_talliesDirty) + SaveTallies(); EmitFrame(Frame("server.shutdown", "event")); } @@ -1175,11 +1464,19 @@ namespace Oxide.Plugins EmitFrame(frame); - // Nothing from the old world is still true. The tallies are the only such state this - // plugin holds; everything else lives on the far side of the link. + // Nothing from the old world is still true. The tallies are one such piece of state + // this plugin holds. _tallies.Clear(); _connectedAt.Clear(); + // Nor is anything an event placed: the old map, and everything on it, is gone. + DropWorldFromOtherWipes(); + + // A run's tally is NOT dropped: it records people, not the map, and a zone it counted + // in simply stops gaining anybody. Kit credits are kept too — unless Kits is about to + // clear its use counts, when the ones moved into them go with it (§29.3). + ResetCreditsForWipe(); + Puts($"new save; wipe is now {(_wipeId ?? "unknown")}"); } @@ -1254,6 +1551,12 @@ namespace Oxide.Plugins if (player == null || player.IsNpc) return; + // A tally's player kill (D98): the last hit, never your own. + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + + if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID) + CreditKill(killer, true); + Dictionary frame = Frame("player.death", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; @@ -1336,37 +1639,148 @@ namespace Oxide.Plugins // A real player's death is OnPlayerDeath's, not ours — emitting here too would // double every kill in the feed. Only the NPC kind is ours to count. if (victimPlayer.IsNpc) + { TallyFor(attacker).NpcKills++; + CreditKill(attacker, false); + } return; } + // An animal is an NPC to a run's tally exactly as it is to the leaderboard (§29.5): + // two meanings of one word would be two answers to "how many NPCs did I kill". if (entity is BaseNpc) { TallyFor(attacker).NpcKills++; + CreditKill(attacker, false); return; } - BuildingBlock block = entity as BuildingBlock; + // Protocol 7 (PROTOCOL.md §13): a raid is the loss of anything a base is made of, not + // only a building block. A door is NOT a BuildingBlock — both derive from + // StabilityEntity as siblings — so until protocol 7 the most common raid there is, two + // charges on a door, reached nobody. + string structure = StructureKind(entity); - if (block == null || block.OwnerID == 0UL) + // `OwnerID == 0` is the world's own: a monument's doors and walls. Destroying those is + // not a raid on anybody, and emitting them would fill the staff feed with loot runs. + if (structure == null || entity.OwnerID == 0UL) return; - TallyFor(attacker).Structures++; + // The tally's meaning is unchanged since protocol 2 — building blocks only — so a + // leaderboard column does not quietly change what it counts in the middle of a wipe. + if (structure == "block") + TallyFor(attacker).Structures++; Dictionary frame = Frame("entity.destroyed", "event"); - frame["ownerId"] = block.OwnerID.ToString(); - frame["prefab"] = block.ShortPrefabName; - frame["attackerId"] = attacker.UserIDString; + frame["ownerId"] = entity.OwnerID.ToString(); + frame["prefab"] = entity.ShortPrefabName; + frame["structure"] = structure; + + // From `userID`, not `UserIDString`. The string is filled in when a player connects, + // loads as a sleeper, or is an engine bot — and at no other time, so a player another + // plugin spawned carries none. The website skips the raid alert when the attacker is + // authorised on the cupboard (PLAN.md D59), and a null here would quietly defeat that. + frame["attackerId"] = ((ulong)attacker.userID).ToString(); frame["attackerName"] = attacker.displayName; - string grid = GridOf(block); + string grid = GridOf(entity); if (grid != null) frame["grid"] = grid; + AddAuthorization(frame, entity); + EmitFrame(frame); } + /// + /// The most people one entity.destroyed frame names as authorised. A cupboard + /// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game + /// link; 64 is several full teams, and a frame cut short says so. + /// + private const int MaxAuthorizedOnFrame = 64; + + /// + /// Which of the four things a base is made of this entity is, or null for anything else. + /// Ordered by how often each dies, which is also cheapest-first. + /// + private static string StructureKind(BaseEntity entity) + { + if (entity is BuildingBlock) + return "block"; + + // External gates are Doors too, which is right: a gate is a door in a wall. + if (entity is Door) + return "door"; + + if (entity is SimpleBuildingBlock) + return "wall"; + + if (entity is BuildingPrivlidge) + return "cupboard"; + + return null; + } + + /// + /// Who lives here, by the game's own definition: everyone authorised on the building's + /// tool cupboard. Both fields are ABSENT when there is no cupboard, which is a different + /// answer from an empty list — "nobody is authorised" and "there is nothing to be + /// authorised on" — and the module alerts nobody in either case (PLAN.md D67). + /// + /// + /// authorizedPlayers is the set, and nothing else is. The cupboard also holds + /// recentGroupMembers, which LOOKS like residents and is not: it counts code-lock + /// users towards group upkeep and prunes itself on a timer. + /// + /// + /// + /// The lookup resolves through the entity's building, which is cheap. An external wall + /// has no building and falls back to a sphere query on this thread — acceptable because + /// it runs only after every cheap filter above has passed, at the rate walls fall rather + /// than the rate this hook fires. + /// + /// + private static void AddAuthorization(Dictionary frame, BaseEntity entity) + { + // A cupboard being destroyed still holds its list: OnEntityDeath runs before the kill. + BuildingPrivlidge cupboard = entity as BuildingPrivlidge ?? entity.GetBuildingPrivilege(); + + if (cupboard == null || cupboard.net == null || cupboard.authorizedPlayers == null) + return; + + HashSet online = new HashSet(); + + if (BasePlayer.activePlayerList != null) + { + foreach (BasePlayer player in BasePlayer.activePlayerList) + { + if (player != null) + online.Add(player.userID); + } + } + + List authorized = new List(); + + foreach (ulong steamId in cupboard.authorizedPlayers) + { + if (authorized.Count >= MaxAuthorizedOnFrame) + { + frame["authorizedTruncated"] = true; + break; + } + + authorized.Add(new Dictionary + { + { "steamId", steamId.ToString() }, + { "online", online.Contains(steamId) }, + }); + } + + frame["buildingId"] = cupboard.net.ID.Value.ToString(); + frame["authorized"] = authorized; + } + private void OnPlayerReported( BasePlayer reporter, string targetName, string targetId, string subject, string message, string type) @@ -1449,6 +1863,445 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- clans (protocol 6) ---- + // + // Rust's FIRST-PARTY clan system, which the website turns into core's Teams (R5, + // PROTOCOL.md §12). Not the uMod Clans plugin: that is a separate system that never + // touches `ClanManager` (D47), and this region reads nothing of it except whether it is + // loaded, so the website can warn that its clans are not Teams. + // + // One board and five events, the same split as presence. The events carry the story — + // founded, joined, left, removed, disbanded — and the board is the reconciliation point + // that corrects anything missed within a minute. Leadership travels ONLY on the board, + // because the game raises no hook when somebody is promoted or demoted (§12.3). + // + // Three facts about the game shape everything below, and all three were read from its + // assemblies rather than from a catalogue: + // + // 1. **There is no "list every clan" call.** The backend offers get-by-id and + // get-by-member; the only listing is the clan leaderboard, and the game clamps that + // to 100 clans by score. That clamp is the board's ceiling (D55), and a board at it + // says `truncated` rather than pretending to be the whole answer. + // + // 2. **A clan id is not an identity.** The database file's version number is + // hard-coded, so a game update that bumps it starts a fresh file whose ids restart at + // 1. Every frame therefore carries the clan's creation time beside its id, and the + // pair is what the website keys a Team on (D52). + // + // 3. **The local backend is synchronous behind an async signature.** Every `ValueTask` + // it returns has already completed, which is what makes reading it from a hook + // legal. A task that has NOT completed means some other backend, and it is refused + // rather than waited on — waiting would be the main thread blocking on a database. + + /// + /// The most clans one board can carry, and it is the GAME's number: the clan leaderboard + /// clamps its limit to 10..100. Asking for more returns 100. + /// + private const int MaxClansOnBoard = 100; + + /// + /// How many bytes of clan rows one board may carry. Well inside the sidecar's 1 MiB line + /// cap, which discards an over-long line outright: a board that silently never arrived + /// would look like a server with no clans. 100 clans of 100 members each is about a + /// megabyte, so the cap is reachable, and a board that meets it says so. + /// + private const int ClanBoardBudgetBytes = 768 * 1024; + + /// + /// How long after a membership change the clan board is re-sent. The event reaches the + /// sidecar at once; the roster that follows it waits for the game to refresh its own + /// cached clan, which `OnClanMemberAdded` fires before. + /// + private const float ClanBoardSettleSeconds = 3f; + + /// A pending early re-send of the clan board, so a burst of changes sends one. + private Timer _clansBoardSoon; + + /// + /// Main thread. Every clan on this server, with its roster and roles, as the board the + /// website reconciles Teams against. + /// + /// + /// The board is ALWAYS sent, including when there is nothing to read — with + /// supported: false and a reason. Saying nothing would leave the website holding + /// the last board it saw and no way to know it had stopped being true. + /// + /// + private void SendClansBoard() + { + Dictionary frame = Frame("clans", "snapshot"); + var clans = new List(); + + frame["enabled"] = ConVar.Clan.enabled; + frame["umodClans"] = plugins.Find("Clans") != null; + frame["clans"] = clans; + frame["count"] = 0; + frame["truncated"] = false; + + // Switched off by the operator. That is an authoritative answer — this server has no + // clans — and different from "could not read them", so it is supported and empty. + if (!ConVar.Clan.enabled) + { + frame["supported"] = true; + EmitFrame(frame); + return; + } + + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + + if (backend == null) + { + frame["supported"] = false; + frame["reason"] = "the game's clan backend has not started yet"; + EmitFrame(frame); + return; + } + + frame["backend"] = backend.GetType().Name; + + // A Nexus server keeps its clans on Facepunch's side of the Nexus, behind a backend + // whose calls do not complete on the spot. Refused with a reason rather than guessed + // at (D55): half-reading it would be a board that is wrong in a way nobody can see. + if (!(backend is LocalClanBackend)) + { + frame["supported"] = false; + frame["reason"] = $"this server's clans are held by a {backend.GetType().Name}, which the bridge does not read"; + EmitFrame(frame); + return; + } + + System.Threading.Tasks.ValueTask>> listing = backend.GetLeaderboard(MaxClansOnBoard); + + if (!listing.IsCompleted || !listing.Result.IsSuccess) + { + frame["supported"] = false; + frame["reason"] = listing.IsCompleted + ? $"the game's clan list could not be read ({listing.Result.Result})" + : "the game's clan list did not answer at once"; + EmitFrame(frame); + return; + } + + List entries = listing.Result.Value; + + // At the clamp there may be more clans than the game will list. It cannot be told + // apart from exactly 100, so it is reported as possibly more — the honest direction, + // since the website then only ADDS Teams and never removes one on this answer. + bool truncated = entries.Count >= MaxClansOnBoard; + int bytes = 0; + + foreach (ClanLeaderboardEntry entry in entries) + { + IClan clan = ReadClan(backend, entry.ClanId); + + if (clan == null) + continue; + + Dictionary row = DescribeClan(clan); + int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)); + + if (bytes + size > ClanBoardBudgetBytes) + { + truncated = true; + break; + } + + bytes += size; + clans.Add(row); + } + + frame["supported"] = true; + frame["truncated"] = truncated; + frame["count"] = clans.Count; + + EmitFrame(frame); + } + + /// + /// Main thread. One clan by id, from the game's cache if it is there and from its + /// database if not, or null. Never waits: see fact 3 at the top of this region. + /// + private static IClan ReadClan(IClanBackend backend, long clanId) + { + IClan clan; + + if (backend.TryGet(clanId, out clan) && clan != null) + return clan; + + System.Threading.Tasks.ValueTask> read = backend.Get(clanId); + + if (!read.IsCompleted || !read.Result.IsSuccess) + return null; + + return read.Result.Value; + } + + /// + /// Main thread. A clan as the board carries it. + /// + /// + /// A member's LastSeen is left off on purpose. It says when somebody was last on + /// the server, which is presence, and the website's rule is that nothing names who is on + /// by default (PLAN.md §23). Not sending it is simpler than trusting every reader to + /// withhold it. + /// + /// + private Dictionary DescribeClan(IClan clan) + { + var roles = new Dictionary(); + + if (clan.Roles != null) + { + foreach (ClanRole role in clan.Roles) + roles[role.RoleId] = role; + } + + var members = new List(); + + if (clan.Members != null) + { + foreach (ClanMember member in clan.Members) + { + var row = new Dictionary + { + { "steamId", member.SteamId.ToString() }, + { "joinedMs", member.Joined } + }; + + ClanRole role; + + // A member whose role id matches no role is left without a rank rather than + // given one: "not known" must never read as "leads this clan", and rank 1 is + // exactly what a default of zero-plus-one would produce. + if (roles.TryGetValue(member.RoleId, out role)) + { + row["rank"] = role.Rank; + row["role"] = role.Name; + } + + string name = PlayerName(member.SteamId); + if (!string.IsNullOrEmpty(name)) + row["name"] = name; + + members.Add(row); + } + } + + UnityEngine.Color32 color = clan.Color; + + return new Dictionary + { + { "clanId", clan.ClanId }, + { "createdMs", clan.Created }, + { "name", clan.Name }, + { "color", $"#{color.r:x2}{color.g:x2}{color.b:x2}" }, + { "score", clan.Score }, + { "maxMembers", clan.MaxMemberCount }, + { "members", members } + }; + } + + /// + /// Main thread. A player's name, whether or not they are on, or null. The framework + /// remembers everybody who has ever joined, which is what lets an offline clan member + /// have a name on a roster. + /// + private string PlayerName(ulong steamId) + { + BasePlayer player = BasePlayer.FindByID(steamId) ?? BasePlayer.FindSleeping(steamId); + + if (player != null) + return player.displayName; + + Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId.ToString()); + return known != null ? known.Name : null; + } + + /// + /// Main thread. The envelope every clan event shares: which clan, identified the way + /// the website keys it (D52), and its name so a feed line can say something without a + /// lookup. + /// + private Dictionary ClanFrame(string kind, IClan clan) + { + Dictionary frame = Frame(kind, "event"); + frame["clanId"] = clan.ClanId; + frame["createdMs"] = clan.Created; + frame["clanName"] = clan.Name; + return frame; + } + + /// + /// Main thread. Puts a player onto a clan event as steamId and name — the + /// same two fields every player event carries, so the website's player table learns the + /// name the same way it does from a connect. + /// + private void PutPlayer(Dictionary frame, string idKey, string nameKey, ulong steamId) + { + if (steamId == 0UL) + return; + + frame[idKey] = steamId.ToString(); + + string name = PlayerName(steamId); + if (!string.IsNullOrEmpty(name)) + frame[nameKey] = name; + } + + /// + /// Main thread. Re-sends the clan board shortly, once, however many changes arrive in + /// the meantime. The cadence would get there within a minute anyway; this is what makes + /// a roster follow the event that changed it. + /// + private void ScheduleClansBoard() + { + if (_clansBoardSoon != null) + return; + + _clansBoardSoon = timer.Once(ClanBoardSettleSeconds, () => + { + _clansBoardSoon = null; + + if (!_connected) + return; + + try + { + SendClansBoard(); + } + catch (Exception ex) + { + Puts($"could not build the clans board: {Flatten(ex.Message)}"); + } + }); + } + + /// + /// The founder's own membership raises no OnClanMemberAdded — the game adds them + /// inside the creation — so this event carries them, and the website counts them in. + /// + private void OnClanCreated(LocalClan clan, ulong steamId) + { + MarkHook("OnClanCreated"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.created", clan); + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises + /// a hook with this SAME NAME and arity — OnClanDisbanded(string, List<ulong>) + /// — about its own, unrelated clans. Both frameworks match a call to a method by its + /// argument types, so a loosely typed signature here would file the plugin's clans as + /// the game's. + /// + private void OnClanDisbanded(LocalClan clan, ulong steamId) + { + MarkHook("OnClanDisbanded"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.disbanded", clan); + PutPlayer(frame, "steamId", "name", steamId); + + // Protocol 7: who was in it. The website tells a disbanded clan's members, and by the + // time it reads this frame the next board may already have removed the roster it would + // otherwise ask — the board is re-sent seconds after this, and after an outage it is + // applied before the backlog. The game has deleted the clan from its database by now, + // and walked `Members` to drop each one's membership, but it never empties the list + // itself — so it is still whole here, bounded by the clan's own member limit. + if (clan.Members != null) + { + List members = new List(); + + foreach (ClanMember member in clan.Members) + members.Add(member.SteamId.ToString()); + + frame["members"] = members; + } + + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// The one clan hook that hands over a bare id rather than a clan: the game raises it + /// from inside its database layer, before the cached clan has been refreshed. The id is + /// enough to find the clan and its creation time; if even that fails, the frame goes + /// without createdMs and the website matches it on the id alone. + /// + private void OnClanMemberAdded(long clanId, ulong steamId) + { + MarkHook("OnClanMemberAdded"); + + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + IClan clan = backend != null ? ReadClan(backend, clanId) : null; + + Dictionary frame; + + if (clan != null) + { + frame = ClanFrame("clan.member.added", clan); + } + else + { + frame = Frame("clan.member.added", "event"); + frame["clanId"] = clanId; + } + + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + private void OnClanMemberLeft(LocalClan clan, ulong steamId) + { + MarkHook("OnClanMemberLeft"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.member.left", clan); + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + private void OnClanMemberKicked(LocalClan clan, ulong steamId, ulong bySteamId) + { + MarkHook("OnClanMemberKicked"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.member.kicked", clan); + PutPlayer(frame, "steamId", "name", steamId); + PutPlayer(frame, "bySteamId", "byName", bySteamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// Not an event: a colour is a property of the clan, so it travels on the board. The + /// hook only brings the next board forward. + /// + private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId) + { + MarkHook("OnClanColorChanged"); + ScheduleClansBoard(); + } + // ---- tallies ---- /// @@ -1641,8 +2494,7704 @@ namespace Oxide.Plugins private static long NowMs() { - return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)) - .TotalMilliseconds; + return ToUnixMs(DateTime.UtcNow); + } + + // ---- account linking ---- + + /// + /// The alphabet a link code is drawn from. + /// + /// + /// No O, 0, I or 1. A player reads this code off their screen and types it into + /// a browser, often on a phone, sometimes reading it aloud to somebody else. Those four + /// glyphs are the ones that get transcribed wrong, and a code that fails for a reason + /// the player cannot see is worse than a longer one. + /// + /// + private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private const int CodeLength = 6; + + /// + /// How long a code is good for. Short on purpose: R1 made this an authorization path, + /// not a convenience — the site becomes the author of who may do what in game, so a + /// code that lingers is a privilege waiting to be picked up. + /// + private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5); + + /// How long a player must wait between /link requests. + private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30); + + /// + /// An unconfirmed code, and the Steam identity it will bind. + /// + /// + /// These live in memory and nowhere else, which is the same call the UO bridge + /// made and it is the right one for the same reason: the secret exists only on the + /// server that minted it, for five minutes, and nothing writes it to disk. A plugin + /// reload drops every pending code — and phase 7b's config editor reloads plugins + /// routinely — but the cost of that is a player typing /link again, which is + /// cheaper than a credential in a second process. + /// + /// + private sealed class PendingLink + { + public string SteamId; + public string Name; + public DateTime Expires; + } + + /// code -> pending link. Main thread only, like every world read here. + private readonly Dictionary _codes = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// steam id -> when they last asked, so /link cannot be spammed. + private readonly Dictionary _lastLinkRequest = + new Dictionary(StringComparer.Ordinal); + + private readonly System.Random _codeRandom = new System.Random(); + + /// + /// /link — mint a one-time code for the caller and tell the sidecar about it. + /// + /// + /// Chat, not console, and the reply is private. Both frameworks consume a + /// / command rather than broadcasting it, and SendReply addresses one + /// player — so neither the request nor the code reaches anybody else's chat. That is + /// load-bearing rather than polish: a code read off a stream is a code somebody else + /// can spend. + /// + /// + /// + /// The plugin does not record who is linked. The website is the author of + /// record for that (R2), and a second copy here would be a second thing to keep in + /// step across a wipe. So there is no "you are already linked" check on this path — + /// the site refuses a Steam id it already holds, where it can say which account holds + /// it. + /// + /// + [ChatCommand("link")] + private void CmdLinkChat(BasePlayer player, string command, string[] args) + { + if (player == null) + return; + + string steamId = player.UserIDString; + if (string.IsNullOrEmpty(steamId)) + return; + + DateTime last; + if (_lastLinkRequest.TryGetValue(steamId, out last) && DateTime.UtcNow - last < RequestCooldown) + { + SendReply(player, "Please wait a moment before asking for another link code."); + return; + } + + // One outstanding code per player: drop any earlier one so only the newest works. + // Without this a player who ran /link three times leaves three live codes behind. + DropCodesFor(steamId); + + string code = MintCode(); + _codes[code] = new PendingLink + { + SteamId = steamId, + Name = player.displayName, + Expires = DateTime.UtcNow + CodeTtl + }; + _lastLinkRequest[steamId] = DateTime.UtcNow; + + Dictionary frame = Frame("account.link.requested", "event"); + frame["steamId"] = steamId; + frame["name"] = player.displayName; + frame["ttlSec"] = (long)CodeTtl.TotalSeconds; + + // **The code is NOT in this frame, and that is the point.** The event exists so an + // operator can see that linking is being used and so the site can rate-limit a player + // who is fishing; it is not how the code travels. The code travels through the player, + // which is what makes typing it proof that they are the one who asked. + EmitFrame(frame); + + SendReply(player, "Your link code is " + code + "."); + SendReply( + player, + "Enter it on the website within " + (int)CodeTtl.TotalMinutes + + " minutes to link this Steam account. It works once."); + } + + /// + /// /unlink — ask the website to sever this Steam id's link. + /// + /// + /// It emits rather than deletes, because the plugin holds no link to delete. The + /// site owns the record and applies this like any other event off the feed. + /// + /// + /// + /// It exists because the site refuses to move a Steam id that another account + /// already holds. Without a way out, a player who linked the wrong account while logged + /// in as it would need staff; with this they walk to a computer they are already sitting + /// at. The authority is the Steam id itself — whoever is connected as it is who it is. + /// + /// + [ChatCommand("unlink")] + private void CmdUnlinkChat(BasePlayer player, string command, string[] args) + { + if (player == null) + return; + + string steamId = player.UserIDString; + if (string.IsNullOrEmpty(steamId)) + return; + + DropCodesFor(steamId); + + Dictionary frame = Frame("account.unlinked", "event"); + frame["steamId"] = steamId; + frame["name"] = player.displayName; + frame["origin"] = "in-game"; + EmitFrame(frame); + + SendReply( + player, + "Asked the website to unlink this Steam account. If it was linked, it is not now."); + } + + /// + /// Inbound link.confirm — the website has been given a code and wants to know + /// whose it is. + /// + /// + /// This is the first command the website originates in this bridge; everything + /// before protocol 3 was the game talking. The reply carries the Steam id and nothing + /// the caller did not already prove — there is no session here, no account creation, + /// and no write to the game. + /// + /// + /// + /// A code is consumed on the first lookup whether or not it turns out to be + /// expired, so a code cannot be probed twice. That is why the removal happens before + /// the expiry check rather than after it. + /// + /// + private void HandleLinkConfirm(Dictionary cmd, string reqId) + { + object codeObj; + string code = cmd.TryGetValue("code", out codeObj) && codeObj != null + ? codeObj.ToString().Trim() + : null; + + if (string.IsNullOrEmpty(code)) + { + EmitLinkError(reqId, "malformed"); + return; + } + + PendingLink pending; + if (!_codes.TryGetValue(code, out pending)) + { + EmitLinkError(reqId, "unknown"); + return; + } + + _codes.Remove(code); + + if (DateTime.UtcNow > pending.Expires) + { + EmitLinkError(reqId, "expired"); + return; + } + + Dictionary ok = Frame("link.ok", "reply"); + ok["steamId"] = pending.SteamId; + ok["name"] = pending.Name; + if (reqId != null) + ok["reqId"] = reqId; + + EmitFrame(ok); + } + + /// + /// A refusal, with a reason the website can turn into a sentence. + /// + /// + /// unknown and expired are deliberately separate here and + /// deliberately the same to the player: an operator reading a log wants to know + /// whether codes are being guessed or merely going stale, and a stranger typing codes + /// must not learn which of the two they hit. + /// + /// + private void EmitLinkError(string reqId, string reason) + { + Dictionary frame = Frame("link.error", "reply"); + frame["reason"] = reason; + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + /// Drops every outstanding code belonging to one Steam id. + private void DropCodesFor(string steamId) + { + List stale = null; + + foreach (KeyValuePair entry in _codes) + { + if (entry.Value != null && entry.Value.SteamId == steamId) + { + if (stale == null) + stale = new List(); + + stale.Add(entry.Key); + } + } + + if (stale == null) + return; + + foreach (string key in stale) + _codes.Remove(key); + } + + /// + /// Drops expired codes and stale cooldowns. + /// + /// + /// Run on a timer rather than only on lookup, because an unconfirmed code is never + /// looked up — a player who runs /link and wanders off leaves an entry that + /// nothing else would ever remove. + /// + /// + private void PurgeExpiredCodes() + { + DateTime now = DateTime.UtcNow; + List dead = null; + + foreach (KeyValuePair entry in _codes) + { + if (entry.Value == null || now > entry.Value.Expires) + { + if (dead == null) + dead = new List(); + + dead.Add(entry.Key); + } + } + + if (dead != null) + { + foreach (string key in dead) + _codes.Remove(key); + } + + List cold = null; + + foreach (KeyValuePair entry in _lastLinkRequest) + { + if (now - entry.Value > RequestCooldown) + { + if (cold == null) + cold = new List(); + + cold.Add(entry.Key); + } + } + + if (cold == null) + return; + + foreach (string key in cold) + _lastLinkRequest.Remove(key); + } + + /// + /// A code from , retried until it does not collide. + /// + /// + /// System.Random rather than a cryptographic source, and the reason it is + /// defensible is the shape of the guess rather than the shape of the generator: a code + /// lives five minutes, is consumed on first lookup, and the website rate-limits the + /// confirm endpoint. An attacker who could predict the sequence would still have to + /// reach a code before the player does. + /// + /// + private string MintCode() + { + for (int attempt = 0; attempt < 16; attempt++) + { + var chars = new char[CodeLength]; + + for (int i = 0; i < CodeLength; i++) + chars[i] = CodeAlphabet[_codeRandom.Next(CodeAlphabet.Length)]; + + string candidate = new string(chars); + + if (!_codes.ContainsKey(candidate)) + return candidate; + } + + // Sixteen collisions in a row against a table this small is not bit-space exhaustion, + // it is something being wrong. A code that cannot collide is better than a loop that + // might not end. + return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant(); + } + + // ---- site-owned permissions (protocol 4) ---- + // + // R2: the website is the author of record for groups and grants, and this framework's own + // permission store is an ENFORCEMENT CACHE. Every third-party plugin honours a site grant + // with no adapter, because they all already call `UserHasPermission`. + // + // ── One verb, and the plugin does the diffing (D32) ─────────────────────── + // + // The site sends the whole set it authors for this server; this plugin compares it against + // the live store and writes only what differs. It is the same shape as a board, pointed the + // other way: one producer, re-sent on connect, reconciled by the receiver. + // + // The alternative — shipping this server's permission store to the website and diffing + // there — was rejected for two reasons. The store is the bigger of the two sets and would + // cross the wire constantly, and a website holding a copy of it has a second source of + // truth that is stale the moment it lands. + // + // ── The site must not be able to stall the game, either ─────────────────── + // + // This is the first command whose work is UNBOUNDED: a community with two thousand linked + // players has thousands of grants in one frame, and applying them in the tick the frame + // arrives is a freeze the operator will blame on the game. So a sync is compiled into a + // list of single-store operations and drained a few hundred at a time on a timer, and the + // report goes back when the last one lands. + // + // That is the same rule the outbound queue lives under (§5), applied to the inbound half: + // **nothing the far side sends may cost the main thread an unbounded amount of work.** + // + // ── What it reports, and what it will not do ────────────────────────────── + // + // • A permission this server has not REGISTERED is reported `unresolved` and skipped. + // `GrantUserPermission` silently no-ops for an unregistered name — void, no throw, no + // log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing + // one plugin is a silent partial. Registering the name here instead would fabricate a + // permission the operator never installed (D33). + // • A holder the site does not author is reported `foreign` and never touched (D31). + // Somebody typing `oxide.grant` during an incident is drift, not an error, and the + // website offers an operator two answers to it. + // • A group member who has never connected is reported `pending`. The store has no user + // record to put in a group yet (§12.2 rule 4), and the membership lands on their first + // connection — a direct grant would have reached them immediately, and pretending the + // two are the same thing is how a site promises access it did not give. + + /// + /// How many store operations one timer step performs. + /// + /// + /// Each is a dictionary lookup and, at most, one write to an in-memory structure the + /// framework flushes on its own schedule — microseconds. Two hundred of them is well + /// under a frame at 60fps, and it drains ten thousand operations in about two and a + /// half seconds, which is inside the sidecar's reply timeout. + /// + /// + private const int PermOpsPerStep = 200; + + /// How often the sync drain runs while a job is in flight. + private const float PermStepSeconds = 0.05f; + + /// + /// The ceiling on one sync, in operations. + /// + /// + /// A frame larger than this is refused whole rather than applied for a while and then + /// abandoned. The website has its own limit below this one, so reaching it means + /// something is wrong rather than something is big — and a half-applied permission set + /// is the state nobody can reason about. + /// + /// + private const int PermMaxOps = 20000; + + /// The operations a compiled sync is made of. One store question each. + private enum PermOpKind + { + EnsureGroup, + EnsureGroupPermission, + EnsureMember, + EnsureGrant, + RetireGroupPermission, + RetireMember, + RetireGrant, + RetireGroup, + ScanPermission, + ScanGroup, + + /// Protocol 12: a group's BetterChat style (D138). + EnsureChatStyle, + + /// Protocol 12: a styled group's BetterChat group, gone from the site (D139). + RetireChatGroup + } + + private struct PermOp + { + public PermOpKind Kind; + + /// A Steam id, or a group name — whichever the op is about. + public string Subject; + + /// A permission name, a group name, or null. + public string Object; + + /// Only uses these. + public string Title; + + public int Rank; + } + + /// + /// One sync in flight. There is at most one: a second arriving while this one drains is + /// answered `busy` rather than interleaved, because two writers reconciling the same + /// store against two different desired sets is a race with no correct outcome. + /// + private sealed class PermSyncJob + { + public string ReqId; + public string SetId; + public List Ops = new List(); + public int Next; + + public int Grants; + public int Revokes; + public int GroupsCreated; + public int GroupsRemoved; + public int GroupPermissions; + public int GroupPermissionsRemoved; + public int Members; + public int MembersRemoved; + public int AlreadyCorrect; + public int Absent; + + public readonly List Unresolved = new List(); + public readonly List Foreign = new List(); + public readonly List Pending = new List(); + + /// + /// Grants the store did not hold after being told to (subject:permission). + /// Reported rather than counted, so the site can never again be told a grant + /// landed that did not (D85). + /// + public readonly List NotLanded = new List(); + + /// + /// Group permissions an event lease holds (protocol 8). Neither applied to nor + /// reported foreign; what the site asked for is kept on the hold instead. + /// + public readonly List Leased = new List(); + + public readonly HashSet LeasedSeen = new HashSet(); + + /// Permission names already reported unresolved, so each is reported once. + public readonly HashSet UnresolvedSeen = new HashSet(); + + /// Who the site says should hold each managed permission, for the foreign scan. + public readonly Dictionary> DesiredHolders = + new Dictionary>(); + + /// Who the site says is in each managed group, and what it should carry. + public readonly Dictionary> DesiredMembers = + new Dictionary>(); + + public readonly Dictionary> DesiredGroupPermissions = + new Dictionary>(); + + /// + /// The site's kit credits for this server (protocol 10, D103), or null when the sync + /// carried none — an older website, whose silence must not withdraw anybody's credit. + /// + public List Credits; + + public int CreditsApplied; + public int CreditsWithdrawn; + public bool CreditsWaiting; + public readonly List CreditsUnresolved = new List(); + + /// + /// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it + /// was not, no style op is compiled and the report says loaded: false and + /// nothing else: the permissions sync exactly as they did before. + /// + public bool ChatLoaded; + + public string ChatVersion; + + /// The fields the site wants on each styled group. + public readonly Dictionary> ChatStyles = + new Dictionary>(); + + public int ChatApplied; + public int ChatSaved; + public int ChatSame; + public readonly List ChatDrift = new List(); + public readonly List ChatRemoved = new List(); + public readonly List ChatFailed = new List(); + } + + /// + /// One BetterChat field the site wants on a group (protocol 12): the value, and the + /// value this site last pushed there. + /// + private sealed class ChatFieldWant + { + public string Field; + public string Value; + + /// + /// What the site last pushed, or null for a field it has never pushed. A game value + /// that is neither this nor the one wanted is somebody's hand edit (D138). + /// + public string Expect; + } + + private PermSyncJob _permJob; + private Timer _permTimer; + + /// + /// True while this plugin is writing to the permission store. + /// + /// + /// The framework raises OnUserPermissionGranted and friends for every write, + /// including ours — so without this flag every sync would announce itself as drift and + /// the website would chase its own tail. It is set around each drain step rather than + /// for the whole job, so a write by somebody else BETWEEN steps is still seen. + /// + /// + private bool _applyingPermissions; + + /// The last report, for rg.perms. Diagnostics only. + private string _lastPermSummary = "none yet"; + + /// + /// Main thread. Everything this server knows about permissions and groups — the option + /// source the website's authoring form is built from (D33). + /// + /// + /// It is a live read rather than anything cached, because the answer changes when an + /// operator loads a plugin and the whole point of asking is to offer names that will + /// actually resolve. + /// + /// + private void HandlePermCatalogue(string reqId) + { + Dictionary reply = Frame("perm.catalogue", "reply"); + + var permissions = new List(); + string[] registered = permission.GetPermissions(); + + if (registered != null) + { + foreach (string name in registered) + { + if (!string.IsNullOrEmpty(name)) + permissions.Add(name); + } + } + + var groups = new List(); + string[] groupNames = permission.GetGroups(); + + if (groupNames != null) + { + foreach (string name in groupNames) + { + if (string.IsNullOrEmpty(name)) + continue; + + var row = new Dictionary + { + { "name", name }, + { "title", permission.GetGroupTitle(name) }, + { "rank", permission.GetGroupRank(name) } + }; + + var granted = new List(); + string[] groupPerms = permission.GetGroupPermissions(name, false); + + if (groupPerms != null) + { + foreach (string perm in groupPerms) + { + if (!string.IsNullOrEmpty(perm)) + granted.Add(perm); + } + } + + row["permissions"] = granted; + groups.Add(row); + } + } + + reply["permissions"] = permissions; + reply["groups"] = groups; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Compiles a desired set into operations and starts draining it. + /// + /// + /// Nothing is written here. Compiling is pure bookkeeping over the frame, so a + /// malformed or enormous sync is refused before it has touched the store — there is no + /// state to unwind because none was made. + /// + /// + private void HandlePermSync(Dictionary cmd, string reqId) + { + if (_permJob != null) + { + EmitPermError(reqId, "busy"); + return; + } + + var job = new PermSyncJob + { + ReqId = reqId, + SetId = StringField(cmd, "setId") + }; + + Plugin betterChat = BetterChatPlugin(); + job.ChatLoaded = betterChat != null; + job.ChatVersion = betterChat != null ? betterChat.Version.ToString() : null; + + // ── The desired set ──────────────────────────────────────────────── + // + // Order matters and it is not cosmetic: a group has to exist before anything can be + // put in it, and a permission has to be on the group before a member inherits it. + + foreach (Dictionary group in Rows(cmd, "groups")) + { + string name = Lower(StringField(group, "name")); + if (string.IsNullOrEmpty(name)) + continue; + + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGroup, + Subject = name, + Title = StringField(group, "title") ?? name, + Rank = IntField(group, "rank") + }); + + HashSet wanted = Bucket(job.DesiredGroupPermissions, name); + + foreach (string perm in Strings(group, "permissions")) + { + string lowered = Lower(perm); + if (string.IsNullOrEmpty(lowered)) + continue; + + wanted.Add(lowered); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGroupPermission, + Subject = name, + Object = lowered + }); + } + + foreach (string member in Strings(group, "members")) + { + if (string.IsNullOrEmpty(member)) + continue; + + Bucket(job.DesiredMembers, name).Add(member); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureMember, + Subject = member, + Object = name + }); + } + + // A group with no members still has to be scanned: the interesting case is a + // member the site did not put there. + Bucket(job.DesiredMembers, name); + + // Protocol 12: the group's BetterChat style. After the group's own ops, so the + // Oxide group BetterChat's members are read from exists first. With BetterChat + // absent nothing is compiled, and the report says why (§33.2). + if (job.ChatLoaded) + { + List style = ChatStyle(job, name, group); + + if (style.Count > 0) + { + job.ChatStyles[name] = style; + job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureChatStyle, Subject = name }); + } + } + } + + foreach (Dictionary grant in Rows(cmd, "grants")) + { + string steamId = StringField(grant, "steamId"); + if (string.IsNullOrEmpty(steamId)) + continue; + + foreach (string perm in Strings(grant, "permissions")) + { + string lowered = Lower(perm); + if (string.IsNullOrEmpty(lowered)) + continue; + + Bucket(job.DesiredHolders, lowered).Add(steamId); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGrant, + Subject = steamId, + Object = lowered + }); + } + } + + // ── What the site has withdrawn ──────────────────────────────────── + // + // The site remembers what it put here, so a grant it has since removed arrives as an + // explicit retirement. It cannot be inferred from the desired set: a name that is not + // in it is either something the site withdrew or something a human granted, and those + // two have opposite correct answers (D31). + + foreach (Dictionary row in Rows(cmd, "retire")) + { + string kind = StringField(row, "kind"); + string subject = StringField(row, "subject"); + string target = Lower(StringField(row, "object")); + + if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(subject)) + continue; + + switch (kind) + { + case "grant": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGrant, Subject = subject, Object = target }); + break; + case "member": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireMember, Subject = subject, Object = target }); + break; + case "group-permission": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroupPermission, Subject = Lower(subject), Object = target }); + break; + case "group": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) }); + break; + case "chat-group": + // Only with BetterChat here to ask. Otherwise the report says `loaded: + // false`, and the site keeps the retirement for a sync that can act on it. + if (job.ChatLoaded) + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireChatGroup, Subject = Lower(subject) }); + break; + } + } + + // ── The namespace the site claims ────────────────────────────────── + // + // Foreign holders can only be found for names the site says it manages. A permission it + // does not claim is somebody else's business entirely and is never enumerated — which + // also bounds this scan by the site's own set rather than by the size of the store. + + foreach (string perm in Strings(cmd, "managed")) + { + string lowered = Lower(perm); + if (string.IsNullOrEmpty(lowered)) + continue; + + Bucket(job.DesiredHolders, lowered); + job.Ops.Add(new PermOp { Kind = PermOpKind.ScanPermission, Subject = lowered }); + } + + foreach (string group in job.DesiredMembers.Keys) + job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group }); + + // ── The kit credits (protocol 10) ────────────────────────────────── + // + // Not store operations, so not drained with them: they are settled once, after the + // grants have landed, and counted in the same report. + if (cmd.ContainsKey("credits")) + { + job.Credits = new List(); + + foreach (Dictionary row in Rows(cmd, "credits")) + { + string steamId = StringField(row, "steamId"); + string kit = StringField(row, "kit"); + int count = IntField(row, "count"); + + if (string.IsNullOrEmpty(steamId) || string.IsNullOrEmpty(kit) || count <= 0) + continue; + + job.Credits.Add(new KitCredit { SteamId = steamId, Kit = kit, Earned = count }); + } + } + + if (job.Ops.Count > PermMaxOps) + { + EmitPermError(reqId, "too-large"); + Puts($"permission sync refused: {job.Ops.Count} operations exceeds {PermMaxOps}"); + return; + } + + _permJob = job; + _permTimer = timer.Every(PermStepSeconds, StepPermSync); + + // Run the first slice now rather than waiting out a tick: a small sync — which is every + // ordinary one — then finishes inside the call the website is waiting on. + StepPermSync(); + } + + /// + /// Main thread. Drains up to operations and, on the last + /// one, sends the report. + /// + private void StepPermSync() + { + PermSyncJob job = _permJob; + + if (job == null) + { + StopPermTimer(); + return; + } + + _applyingPermissions = true; + + try + { + int budget = PermOpsPerStep; + + while (budget-- > 0 && job.Next < job.Ops.Count) + { + PermOp op = job.Ops[job.Next++]; + + try + { + Apply(job, op); + } + catch (Exception ex) + { + // One bad operation must not abandon the set. The report still goes back, + // and the next sync retries what this one could not do. + Puts($"permission op {op.Kind} on '{op.Subject}' threw: {ex.Message}"); + } + } + } + finally + { + _applyingPermissions = false; + } + + if (job.Next < job.Ops.Count) + return; + + _permJob = null; + StopPermTimer(); + + if (job.Credits != null) + { + try + { + ReconcileCredits(job); + } + catch (Exception ex) + { + // The grants have landed and must still be reported; a credit that could not be + // settled is settled by the next sync, or by the player's next redemption. + Puts($"could not settle kit credits: {Flatten(ex.Message)}"); + } + } + + SendPermReport(job); + } + + private void StopPermTimer() + { + if (_permTimer == null) + return; + + _permTimer.Destroy(); + _permTimer = null; + } + + /// Main thread. One store question, and at most one store write. + private void Apply(PermSyncJob job, PermOp op) + { + switch (op.Kind) + { + case PermOpKind.EnsureGroup: + if (permission.GroupExists(op.Subject)) + { + job.AlreadyCorrect++; + } + else + { + permission.CreateGroup(op.Subject, op.Title, op.Rank); + job.GroupsCreated++; + } + + break; + + case PermOpKind.EnsureGroupPermission: + if (!Registered(job, op.Object)) + break; + + if (DeferToLease(job, op, true)) + break; + + if (permission.GroupHasPermission(op.Subject, op.Object)) + { + job.AlreadyCorrect++; + break; + } + + // **`null`, not `this`, as the owner (D85).** With an owner, Oxide's grant + // first asks `PermissionExists(perm, owner)` — whether THAT plugin registered + // the name — and returns without a word when it did not. Every permission the + // site grants belongs to some other plugin, so from phase 7 until phase 13 this + // call did nothing on Oxide and was counted as applied (PLAN.md §27.6). The + // read-back is what makes the count true: only a grant the store now holds + // is reported as one. + permission.GrantGroupPermission(op.Subject, op.Object, null); + + if (permission.GroupHasPermission(op.Subject, op.Object)) + job.GroupPermissions++; + else + job.NotLanded.Add(op.Subject + ":" + op.Object); + + break; + + case PermOpKind.EnsureMember: + if (permission.UserHasGroup(op.Subject, op.Object)) + { + job.AlreadyCorrect++; + break; + } + + permission.AddUserGroup(op.Subject, op.Object); + + // Read back, because this is the call that can do nothing at all. A player the + // store has never seen has no user record to put in a group (§12.2 rule 4), and + // the API says so by returning void and changing nothing. + if (permission.UserHasGroup(op.Subject, op.Object)) + job.Members++; + else + job.Pending.Add(op.Subject + ":" + op.Object); + + break; + + case PermOpKind.EnsureGrant: + if (!Registered(job, op.Object)) + break; + + if (HoldsDirectly(op.Subject, op.Object)) + { + job.AlreadyCorrect++; + break; + } + + // The same owner trap as the group grant above (D85), and the same read-back. + permission.GrantUserPermission(op.Subject, op.Object, null); + + if (HoldsDirectly(op.Subject, op.Object)) + job.Grants++; + else + job.NotLanded.Add(op.Subject + ":" + op.Object); + + break; + + case PermOpKind.RetireGrant: + if (HoldsDirectly(op.Subject, op.Object)) + { + permission.RevokeUserPermission(op.Subject, op.Object); + job.Revokes++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.RetireMember: + if (permission.UserHasGroup(op.Subject, op.Object)) + { + permission.RemoveUserGroup(op.Subject, op.Object); + job.MembersRemoved++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.RetireGroupPermission: + if (DeferToLease(job, op, false)) + break; + + if (permission.GroupHasPermission(op.Subject, op.Object)) + { + permission.RevokeGroupPermission(op.Subject, op.Object); + job.GroupPermissionsRemoved++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.RetireGroup: + if (permission.GroupExists(op.Subject)) + { + permission.RemoveGroup(op.Subject); + job.GroupsRemoved++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.ScanPermission: + ScanPermission(job, op.Subject); + break; + + case PermOpKind.ScanGroup: + ScanGroup(job, op.Subject); + break; + + case PermOpKind.EnsureChatStyle: + ApplyChatStyle(job, op.Subject); + break; + + case PermOpKind.RetireChatGroup: + RemoveChatGroup(job, op.Subject); + break; + } + } + + /// + /// Everybody holding a managed permission DIRECTLY, minus everybody the site says + /// should — the rest is a hand edit, and it is reported rather than undone. + /// + private void ScanPermission(PermSyncJob job, string perm) + { + if (!permission.PermissionExists(perm)) + return; + + HashSet desired; + if (!job.DesiredHolders.TryGetValue(perm, out desired)) + desired = new HashSet(); + + string[] holders = permission.GetPermissionUsers(perm); + + if (holders == null) + return; + + foreach (string entry in holders) + { + string id = UserId(entry); + + if (string.IsNullOrEmpty(id) || desired.Contains(id)) + continue; + + job.Foreign.Add(new Dictionary + { + { "kind", "grant" }, + { "subject", id }, + { "object", perm } + }); + } + } + + /// + /// The same question about a managed group, asked twice: who is in it that the site did + /// not put there, and what does it carry that the site did not put on it. + /// + private void ScanGroup(PermSyncJob job, string group) + { + if (!permission.GroupExists(group)) + return; + + HashSet members; + if (!job.DesiredMembers.TryGetValue(group, out members)) + members = new HashSet(); + + string[] present = permission.GetUsersInGroup(group); + + if (present != null) + { + foreach (string entry in present) + { + string id = UserId(entry); + + if (string.IsNullOrEmpty(id) || members.Contains(id)) + continue; + + job.Foreign.Add(new Dictionary + { + { "kind", "member" }, + { "subject", id }, + { "object", group } + }); + } + } + + HashSet wanted; + if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted)) + wanted = new HashSet(); + + string[] carried = permission.GetGroupPermissions(group, false); + + if (carried == null) + return; + + foreach (string perm in carried) + { + string lowered = Lower(perm); + + if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered)) + continue; + + // A pair an event has borrowed is not somebody's console grant (§27.3). + if (LeasedGroupPermission(group, lowered) != null) + { + NoteLeased(job, group, lowered); + continue; + } + + job.Foreign.Add(new Dictionary + { + { "kind", "group-permission" }, + { "subject", group }, + { "object", lowered } + }); + } + } + + /// + /// A sync op about a pair an event lease holds. The store is left to the lease; what + /// the site wants is recorded on the hold and becomes the value the lease gives back + /// (§27.3). Returns whether it deferred. + /// + private bool DeferToLease(PermSyncJob job, PermOp op, bool wants) + { + LeaseHold hold = LeasedGroupPermission(op.Subject, op.Object); + + if (hold == null) + return false; + + if (hold.SiteWants != wants) + { + hold.SiteWants = wants; + SaveLeases(); + } + + NoteLeased(job, op.Subject, op.Object); + return true; + } + + private static void NoteLeased(PermSyncJob job, string group, string perm) + { + string id = Lower(group) + "/" + Lower(perm); + + if (!job.LeasedSeen.Add(id)) + return; + + job.Leased.Add(new Dictionary + { + { "kind", "group-permission" }, + { "subject", Lower(group) }, + { "object", Lower(perm) } + }); + } + + /// + /// Whether this server has a plugin that registered the name, recording the answer once + /// per sync when it has not. + /// + /// + /// This is the pre-check PLAN.md §12.2 rule 1 exists for. Without it the grant below + /// would return void, throw nothing, log nothing and do nothing, and the website would + /// record a privilege it never gave. + /// + /// + private bool Registered(PermSyncJob job, string perm) + { + if (string.IsNullOrEmpty(perm)) + return false; + + if (permission.PermissionExists(perm)) + return true; + + if (job.UnresolvedSeen.Add(perm)) + job.Unresolved.Add(perm); + + return false; + } + + /// + /// Whether a player holds a permission through a grant of their own, as opposed to + /// through a group. + /// + /// + /// UserHasPermission cannot answer this — it is true for a member of a group + /// that carries the permission, so using it would make every direct grant look already + /// applied the moment a group covered it, and a revoke look already done. + /// GetPermissionUsers reads the user's own set, which is the question. + /// + /// + private bool HoldsDirectly(string steamId, string perm) + { + string[] holders = permission.GetPermissionUsers(perm); + + if (holders == null) + return false; + + foreach (string entry in holders) + { + if (UserId(entry) == steamId) + return true; + } + + return false; + } + + /// + /// The id out of a store listing entry. + /// + /// + /// Both GetPermissionUsers and GetUsersInGroup answer with the id and the + /// player's last known name stuck together — 76561198…(Wanderer) — and the + /// spacing differs between the two calls and between the frameworks. So the id is + /// everything before the first bracket, and a name containing one cannot confuse it + /// because only the first is looked for. + /// + /// + private static string UserId(string entry) + { + if (string.IsNullOrEmpty(entry)) + return null; + + int bracket = entry.IndexOf('('); + string id = bracket >= 0 ? entry.Substring(0, bracket) : entry; + + return id.Trim(); + } + + private void SendPermReport(PermSyncJob job) + { + var applied = new Dictionary + { + { "grants", job.Grants }, + { "revokes", job.Revokes }, + { "groupsCreated", job.GroupsCreated }, + { "groupsRemoved", job.GroupsRemoved }, + { "groupPermissions", job.GroupPermissions }, + { "groupPermissionsRemoved", job.GroupPermissionsRemoved }, + { "members", job.Members }, + { "membersRemoved", job.MembersRemoved } + }; + + Dictionary report = Frame("perm.report", "reply"); + report["applied"] = applied; + report["alreadyCorrect"] = job.AlreadyCorrect; + report["absent"] = job.Absent; + report["unresolved"] = job.Unresolved; + report["foreign"] = job.Foreign; + report["leased"] = job.Leased; + report["pending"] = job.Pending; + report["notLanded"] = job.NotLanded; + report["operations"] = job.Ops.Count; + + if (job.Credits != null) + { + // Counted the way `notLanded` is counted for grants: what actually changed in Kits, + // read back, never what was asked for. + report["creditsApplied"] = job.CreditsApplied; + report["creditsWithdrawn"] = job.CreditsWithdrawn; + report["creditsUnresolved"] = job.CreditsUnresolved; + report["creditsWaiting"] = job.CreditsWaiting; + } + + // Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this + // plugin predates styles" — the second has no `chat` at all. + var chat = new Dictionary { { "loaded", job.ChatLoaded } }; + + if (job.ChatLoaded) + { + chat["version"] = job.ChatVersion; + chat["applied"] = job.ChatApplied; + chat["saved"] = job.ChatSaved; + chat["same"] = job.ChatSame; + chat["drift"] = job.ChatDrift; + chat["removed"] = job.ChatRemoved; + chat["failed"] = job.ChatFailed; + } + + report["chat"] = chat; + + if (!string.IsNullOrEmpty(job.SetId)) + report["setId"] = job.SetId; + + if (job.ReqId != null) + report["reqId"] = job.ReqId; + + _lastPermSummary = + $"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " + + $"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " + + $"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " + + $"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " + + $"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " + + $"at={DateTime.UtcNow:HH:mm:ss}Z"; + + EmitFrame(report); + } + + /// A refusal of the whole sync, with a reason the website turns into a sentence. + private void EmitPermError(string reqId, string reason) + { + Dictionary frame = Frame("perm.error", "reply"); + frame["reason"] = reason; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + // ---- drift, as it happens ---- + // + // Both frameworks raise a hook for every permission write, ours included. They are NOT the + // drift record — the sync report is, because only the site's desired set can say whether a + // grant is foreign. What they are is a reason to reconcile SOON: an operator typing + // `oxide.grant` at the console shows up on the website in seconds instead of at the next + // scheduled audit, and the authoritative answer still comes from a sync. + // + // That division is what makes them safe to trust at exactly the weight they deserve. A hook + // that stops firing on a framework upgrade costs latency, not correctness. + + private void OnUserPermissionGranted(string id, string permName) + { + MarkHook("OnUserPermissionGranted"); + EmitPermDrift("granted", id, permName, null); + } + + private void OnUserPermissionRevoked(string id, string permName) + { + MarkHook("OnUserPermissionRevoked"); + EmitPermDrift("revoked", id, permName, null); + } + + private void OnUserGroupAdded(string id, string groupName) + { + MarkHook("OnUserGroupAdded"); + EmitPermDrift("group-added", id, null, groupName); + } + + private void OnUserGroupRemoved(string id, string groupName) + { + MarkHook("OnUserGroupRemoved"); + EmitPermDrift("group-removed", id, null, groupName); + } + + private void OnGroupPermissionGranted(string name, string permName) + { + MarkHook("OnGroupPermissionGranted"); + EmitPermDrift("group-permission-granted", null, permName, name); + } + + private void OnGroupPermissionRevoked(string name, string permName) + { + MarkHook("OnGroupPermissionRevoked"); + EmitPermDrift("group-permission-revoked", null, permName, name); + } + + // ---- the reload, watched ---- + // + // These two are why an automatic rollback is possible at all (R18): a reload either + // produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin + // that is DOWN because of an edit the website just made. + // + // They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from + // either: a busy server loads and unloads plugins for reasons that have nothing to do with + // us, and a frame per event would be noise on the wire and a trigger nobody asked for. + + private void OnPluginLoaded(Plugin plugin) + { + MarkHook("OnPluginLoaded"); + + // ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an + // event opened. They are put back from this plugin's own record (D92). + if (plugin != null && plugin != this && plugin.Name == "ZoneManager") + NextTick(() => RecreateZones("ZoneManager reloaded")); + + // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has + // forgotten our getter. Registering again is the only way back (§33.1). + if (plugin != null && plugin != this && plugin.Name == "BetterChat") + NextTick(() => RegisterTitles("BetterChat loaded")); + + ConfigWriteJob job = _configJob; + + if (job == null || plugin == null) + return; + + if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase)) + ConfigReloadArrived(); + } + + private void OnPluginUnloaded(Plugin plugin) + { + MarkHook("OnPluginUnloaded"); + + if (plugin != null && plugin.Name == "BetterChat") + _titlesRegistered = false; + + ConfigWriteJob job = _configJob; + + if (job == null || plugin == null) + return; + + // Only ever used to word the failure. "It unloaded and did not come back" is a + // compile error in the edited file; "it never reloaded" is a framework that did not + // act on the request at all, and those send an operator to different places. + if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase)) + job.Unloaded = true; + } + + /// + /// Main thread. Says that the store changed under us, and by how much it is worth + /// caring. + /// + /// + /// Staff class (§8.5): it names a Steam id beside a privilege, which is a fact about a + /// person's standing rather than about what happened on the server. + /// + /// + private void EmitPermDrift(string action, string steamId, string perm, string group) + { + // Our own writes raise these hooks too. Suppressing them here rather than filtering on + // the website is the only version that works: the site cannot tell its own grant from + // a human's by looking at one, which is the entire reason drift needs a desired set. + if (_applyingPermissions) + return; + + Dictionary frame = Frame("perm.drift", "event"); + frame["action"] = action; + + if (!string.IsNullOrEmpty(steamId)) + frame["steamId"] = steamId; + + if (!string.IsNullOrEmpty(perm)) + frame["permission"] = perm; + + if (!string.IsNullOrEmpty(group)) + frame["group"] = group; + + EmitFrame(frame); + } + + // ---- protocol 5: plugin configuration ---- + // + // R18. An admin edits a loaded plugin's settings on the website and the plugin reloads. + // Three request/reply verbs: `config.list` describes the tree, `config.read` returns one + // file, `config.write` replaces a set and reloads whatever owns it. + // + // Four properties live on this side because only the game host can hold them: + // + // * **The roots come from the framework, never from a literal.** `ConfigDirectory` is + // `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose + // operator moved it (`CARBON.md` §3). That amendment was proven the best way it could + // have been: this plugin's own config landed in both places, from this same file. + // * **`DataDirectory` is never walked.** It holds live state — kit cooldowns, zone + // definitions — and both frameworks' permission stores, which is R2's mirror one + // directory over. A settings editor that strayed there would be editing phase 7 + // underneath itself. + // * **A reload is observable.** `OnPluginLoaded` is a real hook, so "did that work" has + // an answer rather than an assumption — and an answer is the whole reason an automatic + // rollback is possible at all. + // * **The site composes the bytes; this plugin writes them.** It receives whole file + // text, not a key and a value, because JavaScript cannot tell `1` from `1.0` and Oxide + // deserializes into typed C# classes (D35/D36). Everything this end does to a document + // is check that it parses. + + /// How deep the walk goes before it stops trying to describe a tree. + private const int ConfigMaxDepth = 6; + + /// How many files the walk will report. + private const int ConfigMaxFiles = 500; + + /// + /// The largest file this plugin will read or write. + /// + /// + /// A file past it is reported and marked un-editable with the reason, never + /// omitted: an operator who cannot find a file they know exists goes looking for a bug + /// in the bridge, and one who can see why it was refused does not. + /// + /// + private const int ConfigMaxFileBytes = 256 * 1024; + + /// Files one write may touch. A plugin can own several; ten of them is not a tree. + private const int ConfigMaxWriteFiles = 10; + + /// + /// How long a reload has to produce OnPluginLoaded before the write is undone. + /// + /// + /// It is under half the sidecar's RPC timeout, and that is arithmetic rather than + /// taste. The worst path through one write is two of these windows — wait for the + /// edit's reload, give up, restore, wait for the restore's reload — with a caller + /// holding a socket for the whole of it (REPLY_TIMEOUT, 10s, rust-link + /// sidecar/src/rpc.rs). A window that does not leave room for both makes the + /// rollback report arrive after the only thing waiting to hear it has given up, which + /// is the one outcome worse than no rollback: the plugin is fine and nobody knows. + /// + /// + private const float ConfigReloadWindowSeconds = 4f; + + /// How much of the newest log file is read back when a reload does not arrive. + private const int ConfigLogTailBytes = 8 * 1024; + + /// One file, resolved and checked, on its way to disk. + private sealed class StagedConfigWrite + { + public string RelPath; + public string FullPath; + public string Text; + + /// What was there before, held for the rollback that may never be needed. + public string Original; + + public bool Written; + } + + /// + /// A write that has landed and is waiting to hear whether the plugin came back. + /// + /// + /// At most one exists at a time. A second write arriving mid-window is refused rather + /// than queued: two overlapping rollbacks over intersecting file sets can restore a + /// file to a state that was never on disk. + /// + /// + private sealed class ConfigWriteJob + { + public string ReqId; + public string Reload; + public List Staged; + public Timer Watch; + + /// Whether the target was seen leaving. It separates "never started" from "did not come back". + public bool Unloaded; + } + + private ConfigWriteJob _configJob; + + private string _lastConfigSummary = "none yet"; + + /// Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19). + private static bool? _carbon; + + private void HandleConfigList(string reqId) + { + string root = ConfigRoot(); + + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + EmitConfigError(reqId, "no-root", "this framework reports no configuration directory"); + return; + } + + var files = new List(); + bool truncated = false; + + try + { + WalkConfig(root, root, 0, files, ref truncated); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", ex.Message); + return; + } + + Dictionary reply = Frame("config.catalogue", "reply"); + reply["root"] = root; + reply["files"] = files; + reply["plugins"] = LoadedPlugins(); + reply["truncated"] = truncated; + + // Named rather than inferred, so the website's locked-key guard (D38) is written + // against what this plugin calls itself instead of against a filename somebody may + // rename. The file carrying the link is not a file the site may break from a form. + reply["self"] = Name; + + reply["limits"] = new Dictionary + { + { "depth", ConfigMaxDepth }, + { "files", ConfigMaxFiles }, + { "fileBytes", ConfigMaxFileBytes }, + { "writeFiles", ConfigMaxWriteFiles } + }; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread, and deliberately cheap: it reads directory entries, never file + /// contents. A tree of 500 files costs 500 stat calls here; hashing all of them would + /// cost up to 128 MB of reads in one frame, which is exactly the unbounded work §10.5 + /// forbids. A file's version is therefore produced by config.read, on the one + /// file somebody actually opened. + /// + private void WalkConfig(string root, string dir, int depth, List into, ref bool truncated) + { + if (depth > ConfigMaxDepth) + { + truncated = true; + return; + } + + string[] entries; + + try + { + entries = Directory.GetFiles(dir, "*.json"); + } + catch (Exception) + { + truncated = true; + return; + } + + Array.Sort(entries, StringComparer.OrdinalIgnoreCase); + + foreach (string full in entries) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + FileInfo info; + + try + { + info = new FileInfo(full); + } + catch (Exception) + { + continue; + } + + // A link is not followed even when it points somewhere legitimate. Resolving one + // is how a tree that looks bounded turns out not to be, and how a path that + // validates as "under the root" writes somewhere else. + if ((info.Attributes & FileAttributes.ReparsePoint) != 0) + continue; + + string rel = RelativeConfigPath(root, full); + + var row = new Dictionary + { + { "path", rel }, + { "bytes", info.Length }, + { "modified", ToUnixMs(info.LastWriteTimeUtc) }, + { "plugin", PluginGuess(rel) } + }; + + if (info.Length > ConfigMaxFileBytes) + { + row["editable"] = false; + row["reason"] = "larger than this bridge will carry"; + } + else + { + row["editable"] = true; + } + + into.Add(row); + } + + string[] dirs; + + try + { + dirs = Directory.GetDirectories(dir); + } + catch (Exception) + { + truncated = true; + return; + } + + Array.Sort(dirs, StringComparer.OrdinalIgnoreCase); + + foreach (string sub in dirs) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + try + { + if ((new DirectoryInfo(sub).Attributes & FileAttributes.ReparsePoint) != 0) + continue; + } + catch (Exception) + { + continue; + } + + WalkConfig(root, sub, depth + 1, into, ref truncated); + } + } + + private void HandleConfigRead(Dictionary cmd, string reqId) + { + string rel = StringField(cmd, "path"); + string full; + string refusal; + + if (!ResolveConfigPath(rel, out full, out refusal)) + { + EmitConfigError(reqId, "path", refusal); + return; + } + + if (!File.Exists(full)) + { + EmitConfigError(reqId, "missing", "no such configuration file"); + return; + } + + string text; + long bytes; + DateTime modified; + + try + { + var info = new FileInfo(full); + bytes = info.Length; + modified = info.LastWriteTimeUtc; + + if (bytes > ConfigMaxFileBytes) + { + EmitConfigError(reqId, "too-large", "larger than this bridge will carry"); + return; + } + + text = File.ReadAllText(full, Encoding.UTF8); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", ex.Message); + return; + } + + Dictionary reply = Frame("config.file", "reply"); + reply["path"] = rel; + reply["text"] = text; + reply["version"] = VersionOf(text); + reply["bytes"] = bytes; + reply["modified"] = ToUnixMs(modified); + reply["plugin"] = PluginGuess(rel); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Replaces a set of files and reloads what owns them. + /// + /// + /// Everything is resolved, version-checked and parsed before the first byte is + /// written — the same posture as perm.sync, and for the same reason: a + /// refusal that has touched nothing has nothing to unwind. What cannot be checked in + /// advance is whether the plugin will come back, which is what the window and the + /// rollback are for. + /// + /// + private void HandleConfigWrite(Dictionary cmd, string reqId) + { + if (_configJob != null) + { + EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload"); + return; + } + + var rows = new List>(Rows(cmd, "files")); + + if (rows.Count == 0) + { + EmitConfigError(reqId, "empty", "a write must name at least one file"); + return; + } + + if (rows.Count > ConfigMaxWriteFiles) + { + EmitConfigError(reqId, "too-many", $"a write may touch at most {ConfigMaxWriteFiles} files"); + return; + } + + string reload = StringField(cmd, "reload"); + + // The site may not reload the bridge. The reload would unload this plugin, close the + // link the answer travels on, and leave a rollback with nothing watching it — so the + // one failure this whole mechanism exists to prevent would be the one it could not + // report. Our own settings are still editable (D38); they take effect the next time + // somebody reloads this plugin deliberately. + if (!string.IsNullOrEmpty(reload) && reload.Equals(Name, StringComparison.OrdinalIgnoreCase)) + { + EmitConfigError(reqId, "reload-self", + "this bridge will not reload itself: the reload would close the link carrying the answer"); + return; + } + + var staged = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Dictionary row in rows) + { + string rel = StringField(row, "path"); + string version = StringField(row, "version"); + string text = StringField(row, "text"); + string full; + string refusal; + + if (!ResolveConfigPath(rel, out full, out refusal)) + { + EmitConfigError(reqId, "path", refusal); + return; + } + + if (!seen.Add(full)) + { + EmitConfigError(reqId, "duplicate", $"'{rel}' is named twice in one write"); + return; + } + + if (text == null) + { + EmitConfigError(reqId, "empty", $"'{rel}' carries no text"); + return; + } + + if (Encoding.UTF8.GetByteCount(text) > ConfigMaxFileBytes) + { + EmitConfigError(reqId, "too-large", $"'{rel}' is larger than this bridge will carry"); + return; + } + + // A config file is edited, never created. A plugin writes its own defaults, and a + // file this walk has never seen is not a settings file somebody forgot — it is a + // path we have no reason to believe in. + if (!File.Exists(full)) + { + EmitConfigError(reqId, "missing", $"'{rel}' does not exist"); + return; + } + + string current; + + try + { + current = File.ReadAllText(full, Encoding.UTF8); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", $"'{rel}': {ex.Message}"); + return; + } + + string actual = VersionOf(current); + + if (version != actual) + { + Dictionary conflict = Frame("config.error", "reply"); + conflict["reason"] = "conflict"; + conflict["detail"] = $"'{rel}' changed on disk since it was read"; + conflict["path"] = rel; + conflict["version"] = actual; + + if (reqId != null) + conflict["reqId"] = reqId; + + EmitFrame(conflict); + return; + } + + try + { + Newtonsoft.Json.Linq.JToken.Parse(text); + } + catch (Exception ex) + { + EmitConfigError(reqId, "invalid", $"'{rel}' is not valid JSON: {ex.Message}"); + return; + } + + staged.Add(new StagedConfigWrite + { + RelPath = rel, + FullPath = full, + Text = text, + Original = current + }); + } + + foreach (StagedConfigWrite file in staged) + { + try + { + BackupConfig(file); + File.WriteAllText(file.FullPath, file.Text, Utf8NoBom); + file.Written = true; + } + catch (Exception ex) + { + // Half a set on disk is the state nothing can reason about. Put back whatever + // did land before answering. + RestoreConfig(staged); + EmitConfigError(reqId, "unwritable", $"'{file.RelPath}': {ex.Message}"); + return; + } + } + + // This plugin never reloads itself (§21.3), so a change to its own settings would + // otherwise wait for somebody's next manual reload — and `EventsEnabled` is the first + // of them an operator is MEANT to change from the website (protocol 8, D76). It is + // re-read here instead. Host, Port and ServerId are locked on the website's side and + // still apply on the next reload, because changing them live would mean tearing down + // the link this answer is about to travel on. + bool ownApplied = ApplyOwnSettings(staged); + + if (string.IsNullOrEmpty(reload)) + { + SendConfigReport(reqId, staged, false, false, + ownApplied + ? "EventsEnabled took effect at once; the bridge's other settings apply on its next reload" + : "no reload was asked for", + null); + return; + } + + if (plugins.Find(reload) == null) + { + // The file is written and the plugin is not loaded, so there is nothing to reload + // and nothing to roll back from: the change applies when it is next loaded. Said + // plainly, because "saved" and "in effect" are different claims. + SendConfigReport(reqId, staged, false, false, $"'{reload}' is not loaded", null); + return; + } + + _configJob = new ConfigWriteJob + { + ReqId = reqId, + Reload = reload, + Staged = staged + }; + + _configJob.Watch = timer.Once(ConfigReloadWindowSeconds, ConfigReloadTimedOut); + + if (!ReloadPluginByName(reload)) + { + StopConfigWatch(); + _configJob = null; + RestoreConfig(staged); + EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'"); + } + } + + /// Main thread. The reload arrived: the plugin is back, so the write stands. + private void ConfigReloadArrived() + { + ConfigWriteJob job = _configJob; + + if (job == null) + return; + + StopConfigWatch(); + _configJob = null; + + SendConfigReport(job.ReqId, job.Staged, true, false, null, null); + } + + /// + /// Main thread. The window closed with no OnPluginLoaded, which means the plugin + /// is down and the edit is why. Put every file back, reload again, and say so. + /// + /// + /// This is the feature's real content. Without it, R18 is a web form that takes a + /// required plugin off a production server one typo at a time. + /// + /// + private void ConfigReloadTimedOut() + { + ConfigWriteJob job = _configJob; + + if (job == null) + return; + + _configJob = null; + StopConfigWatch(); + + RestoreConfig(job.Staged); + + string reason = job.Unloaded + ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadWindowSeconds:0}s" + : $"'{job.Reload}' did not reload within {ConfigReloadWindowSeconds:0}s"; + + // Fire and forget: the restore is on disk either way, and waiting for a second window + // would spend the caller's whole RPC budget to learn something the operator can see. + ReloadPluginByName(job.Reload); + + SendConfigReport(job.ReqId, job.Staged, false, true, reason, LogTail()); + } + + private void StopConfigWatch() + { + ConfigWriteJob job = _configJob; + + if (job == null || job.Watch == null) + return; + + job.Watch.Destroy(); + job.Watch = null; + } + + /// + /// Main thread. What happened, and what is on disk now. + /// + /// + /// The versions are re-read rather than assumed, because both frameworks merge missing + /// defaults into a config on load and save it back — so the file after a successful + /// reload is regularly not the file we wrote, and a site holding our version would + /// conflict with itself on the next save. + /// + /// + private void SendConfigReport( + string reqId, + List staged, + bool reloaded, + bool rolledBack, + string reason, + string log) + { + var files = new List(); + + foreach (StagedConfigWrite file in staged) + { + var row = new Dictionary { { "path", file.RelPath } }; + + try + { + string now = File.ReadAllText(file.FullPath, Encoding.UTF8); + row["version"] = VersionOf(now); + row["bytes"] = Encoding.UTF8.GetByteCount(now); + row["rewritten"] = !rolledBack && now != file.Text; + } + catch (Exception) + { + row["version"] = null; + } + + files.Add(row); + } + + Dictionary report = Frame("config.report", "reply"); + report["ok"] = !rolledBack; + report["reloaded"] = reloaded; + report["rolledBack"] = rolledBack; + report["files"] = files; + + if (!string.IsNullOrEmpty(reason)) + report["reason"] = reason; + + if (!string.IsNullOrEmpty(log)) + report["log"] = log; + + if (reqId != null) + report["reqId"] = reqId; + + _lastConfigSummary = + $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + + $"at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + + EmitFrame(report); + } + + private void EmitConfigError(string reqId, string reason, string detail) + { + Dictionary frame = Frame("config.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(detail)) + frame["detail"] = detail; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + /// Main thread. Puts every file that landed back the way it was. + private void RestoreConfig(List staged) + { + foreach (StagedConfigWrite file in staged) + { + if (!file.Written) + continue; + + try + { + File.WriteAllText(file.FullPath, file.Original, Utf8NoBom); + } + catch (Exception ex) + { + // Nothing else can be done from here, and it must be loud: the file on disk is + // now the edit that took a plugin down, and the operator is the only one who + // can fix it. + Puts($"could not restore '{file.RelPath}': {ex.Message}"); + } + } + } + + /// + /// Main thread. If a write touched this plugin's own config, take the one setting that + /// is safe to change live — EventsEnabled — from what was written. Returns + /// whether it did. + /// + private bool ApplyOwnSettings(List staged) + { + string own; + + try + { + own = Path.GetFullPath(Path.Combine(Interface.Oxide.ConfigDirectory, Name + ".json")); + } + catch (Exception) + { + return false; + } + + foreach (StagedConfigWrite file in staged) + { + string full; + + try + { + full = Path.GetFullPath(file.FullPath); + } + catch (Exception) + { + continue; + } + + if (!string.Equals(full, own, StringComparison.OrdinalIgnoreCase)) + continue; + + try + { + var root = Newtonsoft.Json.Linq.JObject.Parse(file.Text); + Newtonsoft.Json.Linq.JToken flag = root["EventsEnabled"]; + + if (flag == null || flag.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || _config == null) + return false; + + bool now = (bool)flag; + + if (_config.EventsEnabled != now) + Puts($"EventsEnabled is now {now} (set from the website)"); + + _config.EventsEnabled = now; + return true; + } + catch (Exception ex) + { + Puts($"could not re-read own settings: {Flatten(ex.Message)}"); + return false; + } + } + + return false; + } + + /// + /// Keeps a copy of a file about to be overwritten, and the nine before it. + /// + /// + /// Under DataDirectory, which is this plugin writing its own state — not the + /// editor reaching into the tree it must never walk. The rollback itself does not + /// depend on these files; it holds the original in memory. They are for the operator + /// who wants last Tuesday's config back. + /// + /// + private void BackupConfig(StagedConfigWrite file) + { + string dir = Path.Combine(Interface.Oxide.DataDirectory, Name, "config-backups"); + Directory.CreateDirectory(dir); + + string flat = file.RelPath.Replace('/', '_'); + string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + + File.WriteAllText(Path.Combine(dir, $"{flat}.{stamp}.bak"), file.Original, Utf8NoBom); + + string[] kept; + + try + { + kept = Directory.GetFiles(dir, $"{flat}.*.bak"); + } + catch (Exception) + { + return; + } + + if (kept.Length <= 10) + return; + + Array.Sort(kept, StringComparer.Ordinal); + + for (int i = 0; i < kept.Length - 10; i++) + { + try + { + File.Delete(kept[i]); + } + catch (Exception) + { + } + } + } + + /// + /// The tail of the newest log file, read only when a reload failed. + /// + /// + /// A rollback that says "it did not come back" and nothing else sends the operator to a + /// console to find the line that says why. This carries the line instead. Bounded, and + /// paid for only on the failure path. + /// + /// + private string LogTail() + { + try + { + string dir = Interface.Oxide.LogDirectory; + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return null; + + string[] logs = Directory.GetFiles(dir, "*.txt"); + + if (logs.Length == 0) + return null; + + string newest = null; + DateTime newestAt = DateTime.MinValue; + + foreach (string candidate in logs) + { + DateTime at = File.GetLastWriteTimeUtc(candidate); + + if (at <= newestAt) + continue; + + newest = candidate; + newestAt = at; + } + + if (newest == null) + return null; + + using (var stream = new FileStream(newest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + int take = (int)Math.Min(stream.Length, ConfigLogTailBytes); + stream.Seek(-take, SeekOrigin.End); + + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + + return Encoding.UTF8.GetString(buffer, 0, read); + } + } + catch (Exception) + { + return null; + } + } + + /// + /// Reloads a plugin through the framework rather than by composing a console command. + /// + /// + /// The console is the one place the two frameworks genuinely differ — Carbon's commands + /// are c.-prefixed and an alias for the Oxide names is opt-in — and a wrong + /// prefix on Carbon prints nothing at all, so it looks exactly like a command that + /// worked (`CARBON.md` §5). Reflection rather than #if CARBON, because one + /// byte-identical source file on both frameworks is the property R19 proved and is + /// worth keeping. + /// + /// + private bool ReloadPluginByName(string name) + { + try + { + System.Reflection.MethodInfo reload = Interface.Oxide.GetType() + .GetMethod("ReloadPlugin", new[] { typeof(string) }); + + if (reload != null) + { + reload.Invoke(Interface.Oxide, new object[] { name }); + return true; + } + } + catch (Exception ex) + { + Puts($"framework reload of '{name}' threw: {ex.Message}"); + } + + try + { + covalence.Server.Command(IsCarbon() ? "c.reload" : "oxide.reload", name); + return true; + } + catch (Exception ex) + { + Puts($"console reload of '{name}' threw: {ex.Message}"); + return false; + } + } + + private static bool IsCarbon() + { + if (_carbon.HasValue) + return _carbon.Value; + + bool found = false; + + try + { + foreach (System.Reflection.Assembly loaded in AppDomain.CurrentDomain.GetAssemblies()) + { + string assembly = loaded.GetName().Name; + + if (assembly == null) + continue; + + if (assembly == "Carbon" || assembly.StartsWith("Carbon.", StringComparison.Ordinal)) + { + found = true; + break; + } + } + } + catch (Exception) + { + } + + _carbon = found; + return found; + } + + private static string ConfigRoot() + { + try + { + return Interface.Oxide.ConfigDirectory; + } + catch (Exception) + { + return null; + } + } + + /// + /// Turns a path from a web form into a path on this host, or refuses it with a reason. + /// + /// + /// Phase 7 addressed things by name; this phase addresses them by path, which is + /// exactly the change that introduces this bug class. Four refusals, and the last is the + /// one that matters: a resolved path must genuinely sit under the config root, checked + /// after canonicalisation rather than before. + /// + /// + private static bool ResolveConfigPath(string rel, out string full, out string refusal) + { + full = null; + refusal = null; + + if (string.IsNullOrEmpty(rel)) + { + refusal = "no path"; + return false; + } + + if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0) + { + refusal = "a path may not step outside the configuration directory"; + return false; + } + + if (rel.StartsWith("/", StringComparison.Ordinal) + || rel.StartsWith("\\", StringComparison.Ordinal) + || Path.IsPathRooted(rel) + || rel.IndexOf(':') >= 0) + { + refusal = "a path must be relative to the configuration directory"; + return false; + } + + if (!rel.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + refusal = "only .json files are configuration"; + return false; + } + + string root = ConfigRoot(); + + if (string.IsNullOrEmpty(root)) + { + refusal = "this framework reports no configuration directory"; + return false; + } + + string rooted; + + try + { + rooted = Path.GetFullPath(root); + full = Path.GetFullPath(Path.Combine(rooted, rel.Replace('/', Path.DirectorySeparatorChar))); + } + catch (Exception ex) + { + refusal = ex.Message; + full = null; + return false; + } + + string prefix = rooted.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? rooted + : rooted + Path.DirectorySeparatorChar; + + if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + refusal = "that path is not inside the configuration directory"; + full = null; + return false; + } + + try + { + var info = new FileInfo(full); + + if (info.Exists && (info.Attributes & FileAttributes.ReparsePoint) != 0) + { + refusal = "that file is a link, and a link is not followed"; + full = null; + return false; + } + } + catch (Exception) + { + } + + return true; + } + + private static string RelativeConfigPath(string root, string full) + { + string prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? root + : root + Path.DirectorySeparatorChar; + + string rel = full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? full.Substring(prefix.Length) + : Path.GetFileName(full); + + return rel.Replace(Path.DirectorySeparatorChar, '/').Replace('\\', '/'); + } + + /// + /// Which plugin a file probably belongs to: the folder for a nested file, the file's own + /// name otherwise. + /// + /// + /// A guess, and it is labelled one all the way to the form. A folder name is + /// convention, not contract. Infer it silently and the failure is the nastiest available + /// here: we reload the wrong plugin, watch OnPluginLoaded fire for it, and + /// report success while the plugin that was actually edited never re-read a thing. + /// + /// + private static string PluginGuess(string rel) + { + int slash = rel.IndexOf('/'); + + return slash > 0 + ? rel.Substring(0, slash) + : Path.GetFileNameWithoutExtension(rel); + } + + private List LoadedPlugins() + { + var rows = new List(); + Plugin[] all; + + try + { + all = plugins.GetAll(); + } + catch (Exception) + { + return rows; + } + + if (all == null) + return rows; + + foreach (Plugin loaded in all) + { + if (loaded == null || string.IsNullOrEmpty(loaded.Name)) + continue; + + rows.Add(new Dictionary + { + { "name", loaded.Name }, + { "title", loaded.Title }, + { "version", loaded.Version.ToString() }, + { "author", loaded.Author } + }); + } + + return rows; + } + + /// + /// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its + /// text. + /// + /// + /// Deliberately not a cryptographic digest. Nothing here is a security claim — the site + /// never computes one of these, it only echoes back the one it was given — and + /// System.Security.Cryptography is a namespace whose availability under a plugin + /// compiler is one more thing that would have to be true on two frameworks. + /// + /// + private static string VersionOf(string text) + { + byte[] bytes = Encoding.UTF8.GetBytes(text ?? string.Empty); + ulong hash = 14695981039346656037UL; + + foreach (byte b in bytes) + { + hash ^= b; + hash *= 1099511628211UL; + } + + return $"{bytes.Length:x}-{hash:x16}"; + } + + /// UTF-8 with no byte-order mark, which is what both frameworks write. + private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false); + + private static long ToUnixMs(DateTime utc) + { + return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + } + + // ---- leases (protocol 8, PLAN.md §27) ---- + // + // An event does not edit the world. It borrows a value and gives it back. The website + // decides WHEN (core's `core.lease` reads the baseline, reserves the target and schedules + // the restore); this plugin decides WHETHER, holds the deadline, and does the writing. + // + // Four rules, each the answer to a way the obvious version goes wrong: + // + // • **The deadline lives here.** A hold arms a deadline and the plugin restores the + // baseline when it passes, whether or not the website is ever heard from again. Core's + // restore is the normal path; this is the fail-safe that makes an unattended change at + // 04:00 defensible. `holdMs` is authoritative and `untilMs` is display only — an + // absolute time computed on the website and honoured here is a deadline measured + // against two clocks. + // • **Release is compare-and-set, never a blind write.** A value that is no longer what + // the lease applied was moved by somebody on purpose: answer `lease.drifted` with what + // is there now and leave it alone. + // • **Values cross as text and compare parsed.** `1` and `1.0` are one number and two + // strings; a drift check on formatted numbers blames an innocent operator. + // • **A hold longer than the ceiling is refused, never clamped.** A clamp gives the + // website a shorter lease than it believes it has, and the website is the half that + // schedules the restore. + // + // And one that is Rust's own (§27.1): **a convar hold is memory-only and a restart is a + // free restore, but a group permission is persisted by the framework.** So holds are + // written to a file under this plugin's data directory with the boot they were taken + // under, and on load a convar hold from an earlier boot is dropped (the restart already + // gave it back) while a permission hold is re-armed — or restored at once, if its + // deadline passed while the server was down. + + /// + /// The longest a lease may be held: seven days (D77). The website declares the same + /// ceiling; this one is independent of it and exists for the case where the website + /// is wrong. + /// + private const long LeaseMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; + + /// How often held deadlines are checked. A lease ends within a second of its time. + private const float LeaseCheckSeconds = 1f; + + /// The wire key of the one lease that is not a convar. + private const string GroupPermissionKey = "group.permission"; + + /// + /// One convar the website may borrow, and the bounds this plugin holds it to. The + /// website's declaration carries the same bounds so a bad value is refused on a form; + /// these are what is true when the website is wrong. + /// + private sealed class LeaseKey + { + public string Key; + public string Family; + public double Min; + public double Max; + + public LeaseKey(string key, string family, double min, double max) + { + Key = key; + Family = family; + Min = min; + Max = max; + } + } + + /// + /// Every convar a lease may name (D74: the game's own convars, nothing Carbon-only). + /// + /// + /// An allowlist rather than "any convar", because the catalogue is a promise: a key + /// here has been set, observed changing something in a running game, and restored. A + /// value the server reads once at boot applies cleanly, reads back cleanly and does + /// nothing, and nothing but a live walk can tell the two apart (§9). + /// + /// + private static readonly LeaseKey[] LeaseKeys = + { + new LeaseKey("decay.scale", "decay", 0, 10), + + // Every population is per square kilometre — vehicles too, whatever the game's own + // help text says ("active on the server"): on a 9 km² map a value of 3 is a target of + // 27. Each was walked: set, seen moving `spawn.report`'s target, and given back. + new LeaseKey("bear.population", "population", 0, 50), + new LeaseKey("polarbear.population", "population", 0, 50), + new LeaseKey("boar.population", "population", 0, 50), + new LeaseKey("chicken.population", "population", 0, 50), + new LeaseKey("stag.population", "population", 0, 50), + new LeaseKey("wolf2.population", "population", 0, 50), + new LeaseKey("ridablehorse.population", "population", 0, 50), + new LeaseKey("crocodile.population", "population", 0, 50), + new LeaseKey("panther.population", "population", 0, 50), + new LeaseKey("tiger.population", "population", 0, 50), + new LeaseKey("snakehazard.population", "population", 0, 50), + + new LeaseKey("minicopter.population", "population", 0, 50), + new LeaseKey("scraptransporthelicopter.population", "population", 0, 50), + new LeaseKey("hotairballoon.population", "population", 0, 50), + new LeaseKey("modularcar.population", "population", 0, 50), + new LeaseKey("motorrowboat.population", "population", 0, 50), + new LeaseKey("rhib.rhibpopulation", "population", 0, 50), + new LeaseKey("traincar.population", "population", 0, 50), + + // The population system's own scalars. Only the two MINIMUMS: the game scales between + // min and max by how full the server is, so on a rig with nobody on it the effective + // rate and density ARE the minimums, and `spawn.scalars` showed both moving. The two + // maximums only matter with players online, which no walk has had — so they are not + // lent until one does (§9: a key that cannot be seen doing something is not advertised). + new LeaseKey("spawn.min_rate", "spawn", 0, 10), + new LeaseKey("spawn.min_density", "spawn", 0, 10) + }; + + /// + /// One value this plugin is holding. Persisted as it is, so every field is public and + /// plain. + /// + private sealed class LeaseHold + { + public string Key; + + /// Null for a convar; group/permission for a group permission. + public string Target; + + public string Baseline; + public string Applied; + + /// When the hold ends, on this host's clock: taken time plus holdMs. + public long DeadlineMs; + + /// The website's absolute deadline, for display. Never compared against. + public long UntilMs; + + public string RunId; + public string BootId; + public long TakenMs; + + /// + /// What the website's permission set said about this pair while the lease held it: + /// null for nothing, else whether the site wants it granted. A group permission + /// held by a lease belongs to the lease until it ends; a sync that arrives in the + /// meantime is recorded here and honoured at release instead of being applied + /// under the lease's feet (§27.3). + /// + public bool? SiteWants; + } + + /// Holds by . Main thread only. + private Dictionary _leases = new Dictionary(); + + /// Convars by lowered full name, resolved once. Null until first needed. + private Dictionary _convars; + + private Timer _leaseTimer; + + /// The last thing a lease did, for rg.leases. Diagnostics only. + private string _lastLeaseSummary = "none yet"; + + private static string LeaseSlot(string key, string target) + { + return string.IsNullOrEmpty(target) ? key : key + "#" + target; + } + + private static LeaseKey FindLeaseKey(string key) + { + foreach (LeaseKey k in LeaseKeys) + { + if (string.Equals(k.Key, key, StringComparison.OrdinalIgnoreCase)) + return k; + } + + return null; + } + + /// + /// Main thread. The game's convar, or null when this build has no such convar or it + /// is one the plugin refuses to hold. + /// + /// + /// A Saved convar is refused (§27.1). The game writes those to + /// serverauto.cfg, so a hold on one would survive the restart that is + /// supposed to end it — and a lease that outlives its own fail-safe is the one thing + /// a lease exists to prevent. None of the allowlist is Saved today; this is + /// what keeps a Facepunch change to that from becoming our bug silently. + /// + /// + private ConsoleSystem.Command LeaseConvar(string key, out string refusal) + { + refusal = null; + + if (_convars == null) + { + _convars = new Dictionary(StringComparer.OrdinalIgnoreCase); + ConsoleSystem.Command[] all = ConsoleSystem.Index.All; + + if (all != null) + { + foreach (ConsoleSystem.Command c in all) + { + if (c != null && c.Variable && !string.IsNullOrEmpty(c.FullName)) + _convars[c.FullName] = c; + } + } + } + + ConsoleSystem.Command command; + + if (!_convars.TryGetValue(key, out command) || command == null || command.SetOveride == null) + { + refusal = $"this server has no convar '{key}'"; + return null; + } + + if (command.Saved) + { + refusal = $"'{key}' is saved to serverauto.cfg, so a restart would not give it back"; + return null; + } + + return command; + } + + private static bool TryParseNumber(string text, out double value) + { + return double.TryParse( + text, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out value); + } + + private static bool TryParseFlag(string text, out bool value) + { + value = false; + + if (text == null) + return false; + + string t = text.Trim().ToLowerInvariant(); + + if (t == "true" || t == "1") + { + value = true; + return true; + } + + if (t == "false" || t == "0") + return true; + + return false; + } + + /// + /// Whether two values are the same value, parsed. A convar reads back through + /// float.ToString(), so the tolerance is a float's, not a double's. + /// + private static bool SameLeaseValue(string key, string a, string b) + { + if (key == GroupPermissionKey) + { + bool x, y; + return TryParseFlag(a, out x) && TryParseFlag(b, out y) && x == y; + } + + double p, q; + + if (!TryParseNumber(a, out p) || !TryParseNumber(b, out q)) + return string.Equals(a, b, StringComparison.Ordinal); + + return Math.Abs(p - q) <= 1e-4 * Math.Max(1.0, Math.Max(Math.Abs(p), Math.Abs(q))); + } + + /// + /// Splits a group-permission target at its LAST slash. A permission name never + /// contains one; a group name is a free-form string an operator typed, so it is the + /// half allowed to. + /// + private static bool SplitGroupTarget(string target, out string group, out string perm) + { + group = null; + perm = null; + + if (string.IsNullOrEmpty(target)) + return false; + + int slash = target.LastIndexOf('/'); + + if (slash <= 0 || slash >= target.Length - 1) + return false; + + group = Lower(target.Substring(0, slash)); + perm = Lower(target.Substring(slash + 1)); + return !string.IsNullOrEmpty(group) && !string.IsNullOrEmpty(perm); + } + + /// + /// Main thread. The value a key holds right now, as text — or null with a reason. + /// is set when the target itself no longer exists, which a + /// release treats as nothing left to give back rather than as a failure. + /// + private string ReadLeaseValue(string key, string target, out string refusal, out bool gone) + { + gone = false; + + if (key == GroupPermissionKey) + { + string group, perm; + + if (!SplitGroupTarget(target, out group, out perm)) + { + refusal = "a group permission is named as group/permission"; + return null; + } + + if (!permission.GroupExists(group)) + { + gone = true; + refusal = $"there is no group '{group}' on this server"; + return null; + } + + refusal = null; + return permission.GroupHasPermission(group, perm) ? "true" : "false"; + } + + if (FindLeaseKey(key) == null) + { + refusal = $"'{key}' is not a value this server lends"; + return null; + } + + ConsoleSystem.Command command = LeaseConvar(key, out refusal); + return command == null ? null : command.String; + } + + /// + /// Main thread. Writes a value, then reads it back — the read-back is the answer, not + /// the call. Returns null on success, or why not. + /// + private string WriteLeaseValue(string key, string target, string value) + { + if (key == GroupPermissionKey) + { + string group, perm; + + if (!SplitGroupTarget(target, out group, out perm)) + return "a group permission is named as group/permission"; + + bool grant; + + if (!TryParseFlag(value, out grant)) + return $"'{value}' is not true or false"; + + // Our own write raises OnGroupPermissionGranted/Revoked. Without this the site + // would be told a human had just edited the store, and it would reconcile against + // the lease's own change (§27.3). + _applyingPermissions = true; + + try + { + // **`null`, not `this`, as the owner.** With an owner, Oxide's grant first asks + // `PermissionExists(perm, owner)` — whether THAT plugin registered the name — + // and returns without a word when it did not. Every permission a lease borrows + // belongs to some other plugin, so `this` made every grant a silent no-op. The + // read-back below is what caught it. + if (grant) + permission.GrantGroupPermission(group, perm, null); + else + permission.RevokeGroupPermission(group, perm); + } + finally + { + _applyingPermissions = false; + } + + if (permission.GroupHasPermission(group, perm) != grant) + return $"the store did not {(grant ? "grant" : "revoke")} '{perm}' on '{group}'"; + + return null; + } + + string refusal; + ConsoleSystem.Command command = LeaseConvar(key, out refusal); + + if (command == null) + return refusal; + + command.Set(value); + + if (!SameLeaseValue(key, command.String, value)) + return $"'{key}' reads back as {command.String} after being set to {value}"; + + return null; + } + + /// + /// Main thread. Protocol 8's catalogue: every key this server lends and what it holds + /// now, and every hold in force. + /// + /// + /// Narrowed by key (and target) when asked, because a group permission + /// has no single current value — it is one per pair — and a caller asking about one + /// pair wants that pair's row, not the store. + /// + /// + private void HandleLeaseList(Dictionary cmd, string reqId) + { + string onlyKey = Lower(StringField(cmd, "key")); + string onlyTarget = StringField(cmd, "target"); + var rows = new List(); + + foreach (LeaseKey k in LeaseKeys) + { + if (!string.IsNullOrEmpty(onlyKey) && onlyKey != k.Key) + continue; + + rows.Add(LeaseRow(k.Key, k.Family, k.Min, k.Max, null)); + } + + if (onlyKey == GroupPermissionKey && !string.IsNullOrEmpty(onlyTarget)) + rows.Add(LeaseRow(GroupPermissionKey, "group", 0, 1, onlyTarget)); + + var holds = new List(); + + foreach (LeaseHold h in _leases.Values) + holds.Add(HoldBody(h)); + + Dictionary reply = Frame("lease.list", "reply"); + reply["eventsEnabled"] = _config != null && _config.EventsEnabled; + reply["maxHoldMs"] = LeaseMaxHoldMs; + reply["leases"] = rows; + reply["holds"] = holds; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private Dictionary LeaseRow(string key, string family, double min, double max, string target) + { + var row = new Dictionary + { + { "key", key }, + { "family", family } + }; + + if (key != GroupPermissionKey) + { + row["min"] = min; + row["max"] = max; + } + else + { + row["target"] = target; + } + + string refusal; + bool gone; + string current = ReadLeaseValue(key, target, out refusal, out gone); + + if (current != null) + row["current"] = current; + else + row["unreadable"] = refusal; + + if (gone) + row["targetGone"] = true; + + LeaseHold hold; + bool held = _leases.TryGetValue(LeaseSlot(key, target), out hold); + row["held"] = held; + + if (held) + { + row["baseline"] = hold.Baseline; + row["applied"] = hold.Applied; + row["untilMs"] = hold.UntilMs; + row["runId"] = hold.RunId; + } + + return row; + } + + private static Dictionary HoldBody(LeaseHold h) + { + var body = new Dictionary + { + { "key", h.Key }, + { "baseline", h.Baseline }, + { "applied", h.Applied }, + { "untilMs", h.UntilMs }, + { "remainingMs", Math.Max(0L, h.DeadlineMs - NowMs()) } + }; + + if (!string.IsNullOrEmpty(h.Target)) + body["target"] = h.Target; + + if (!string.IsNullOrEmpty(h.RunId)) + body["runId"] = h.RunId; + + return body; + } + + /// + /// Main thread. Take a value for a while. + /// + /// + /// A key this plugin already holds keeps its ORIGINAL baseline. Core reserves + /// a target before it applies, so a second holder is refused on the website's side; + /// a second apply arriving here means the first one's answer was lost, and core is + /// trying again. The value to give back at the end is still what was there before + /// anybody borrowed it, not the first attempt's value. + /// + /// + private void HandleLeaseApply(Dictionary cmd, string reqId) + { + string key = Lower(StringField(cmd, "key")); + string target = key == GroupPermissionKey ? StringField(cmd, "target") : null; + string family = Lower(StringField(cmd, "family")); + string value = StringField(cmd, "value"); + long holdMs = LongField(cmd, "holdMs"); + long untilMs = LongField(cmd, "untilMs"); + + if (_config == null || !_config.EventsEnabled) + { + EmitLeaseError(reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return; + } + + if (string.IsNullOrEmpty(key) || value == null) + { + EmitLeaseError(reqId, "malformed", "a lease names a key and a value"); + return; + } + + if (holdMs <= 0) + { + EmitLeaseError(reqId, "malformed", "a lease is held for a positive number of milliseconds"); + return; + } + + if (holdMs > LeaseMaxHoldMs) + { + EmitLeaseError(reqId, "too-long", + $"this server holds a lease for at most {LeaseMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}"); + return; + } + + if (key == GroupPermissionKey) + { + bool flag; + + if (!TryParseFlag(value, out flag)) + { + EmitLeaseError(reqId, "out-of-range", $"'{value}' is not true or false"); + return; + } + + string group, perm; + + if (!SplitGroupTarget(target, out group, out perm)) + { + EmitLeaseError(reqId, "malformed", "a group permission is named as group/permission"); + return; + } + + // The same pre-check the permission mirror makes (§12.2 rule 1): a grant of a + // name nothing registered returns void and does nothing, and the lease would then + // record a hold on a value that never changed. + if (!permission.PermissionExists(perm)) + { + EmitLeaseError(reqId, "unresolved", $"no loaded plugin on this server registers '{perm}'"); + return; + } + + target = group + "/" + perm; + value = flag ? "true" : "false"; + } + else + { + LeaseKey k = FindLeaseKey(key); + + if (k == null) + { + EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a value this server lends"); + return; + } + + if (!string.IsNullOrEmpty(family) && family != k.Family) + { + EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a {family} value"); + return; + } + + double number; + + if (!TryParseNumber(value, out number) || number < k.Min || number > k.Max) + { + EmitLeaseError(reqId, "out-of-range", $"{key} takes {k.Min} to {k.Max}, not {value}"); + return; + } + } + + string slot = LeaseSlot(key, target); + LeaseHold existing; + _leases.TryGetValue(slot, out existing); + + string refusal; + bool gone; + string before = ReadLeaseValue(key, target, out refusal, out gone); + + if (before == null) + { + EmitLeaseError(reqId, gone ? "target-gone" : "unreadable", refusal); + return; + } + + string failed = WriteLeaseValue(key, target, value); + + if (failed != null) + { + // Put back what was there; a half-applied lease is not a state to leave behind. + WriteLeaseValue(key, target, before); + EmitLeaseError(reqId, "refused", failed); + return; + } + + long now = NowMs(); + + var hold = new LeaseHold + { + Key = key, + Target = target, + Baseline = existing != null ? existing.Baseline : before, + Applied = value, + DeadlineMs = now + holdMs, + UntilMs = untilMs > 0 ? untilMs : now + holdMs, + RunId = StringField(cmd, "runId"), + BootId = _bootId, + TakenMs = now, + SiteWants = existing != null ? existing.SiteWants : null + }; + + _leases[slot] = hold; + SaveLeases(); + EnsureLeaseTimer(); + + _lastLeaseSummary = $"applied {slot}={value} (baseline {hold.Baseline}) for {holdMs / 1000L}s"; + Puts($"lease {_lastLeaseSummary}"); + + Dictionary reply = Frame("lease.ok", "reply"); + reply["key"] = key; + + if (!string.IsNullOrEmpty(target)) + reply["target"] = target; + + reply["baseline"] = hold.Baseline; + reply["applied"] = hold.Applied; + reply["untilMs"] = hold.UntilMs; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Give a value back — compare-and-set. + /// + /// + /// The comparison is against what the LEASE applied, from this plugin's own record + /// when it has one, else from the website's expected. The record is missing + /// when the deadline already fired, or when a restart dropped a convar hold; both of + /// those have already given the value back, and the compare then finds baseline there + /// and answers success without writing anything. + /// + /// + private void HandleLeaseRelease(Dictionary cmd, string reqId) + { + string key = Lower(StringField(cmd, "key")); + string target = StringField(cmd, "target"); + + if (key == GroupPermissionKey) + { + string group, perm; + + if (SplitGroupTarget(target, out group, out perm)) + target = group + "/" + perm; + } + else + { + target = null; + } + + if (string.IsNullOrEmpty(key)) + { + EmitLeaseError(reqId, "malformed", "a release names a key"); + return; + } + + LeaseHold hold; + _leases.TryGetValue(LeaseSlot(key, target), out hold); + + string expected = hold != null ? hold.Applied : StringField(cmd, "expected"); + string baseline = hold != null ? hold.Baseline : StringField(cmd, "baseline"); + + Dictionary outcome = Restore(key, target, expected, baseline, hold, "release"); + + if (reqId != null) + outcome["reqId"] = reqId; + + EmitFrame(outcome); + } + + /// + /// Main thread. The one place a value is given back, by a release or a deadline. The + /// hold is ended either way — a drifted value is left alone, but the lease is over. + /// + private Dictionary Restore( + string key, string target, string expected, string baseline, LeaseHold hold, string why) + { + string slot = LeaseSlot(key, target); + + // A group permission the site asked for during the hold is given to the site, not + // back to the baseline: the lease borrowed the pair, the site owns what it becomes. + string restoreTo = baseline; + + if (hold != null && hold.SiteWants.HasValue) + restoreTo = hold.SiteWants.Value ? "true" : "false"; + + string refusal; + bool gone; + string current = ReadLeaseValue(key, target, out refusal, out gone); + + if (current == null) + { + if (gone) + { + // The group was deleted mid-hold: nothing is owed and nothing is out there. + _leases.Remove(slot); + SaveLeases(); + _lastLeaseSummary = $"{why} {slot}: target gone"; + + Dictionary goneFrame = Frame("lease.ok", "reply"); + goneFrame["key"] = key; + goneFrame["targetGone"] = true; + + if (!string.IsNullOrEmpty(target)) + goneFrame["target"] = target; + + return goneFrame; + } + + // The value cannot be read, so it cannot be compared. The hold stays: the next + // release, or the next deadline check, tries again. + Dictionary error = Frame("lease.error", "reply"); + error["reason"] = "unreadable"; + error["message"] = refusal; + return error; + } + + if (restoreTo != null && SameLeaseValue(key, current, restoreTo)) + { + // Already given back — by a deadline, a restart, or somebody who restored it by + // hand. Nothing to write, and it is a success. + _leases.Remove(slot); + SaveLeases(); + _lastLeaseSummary = $"{why} {slot}: already {current}"; + return RestoredFrame(key, target, current); + } + + if (expected == null || !SameLeaseValue(key, current, expected)) + { + _leases.Remove(slot); + SaveLeases(); + _lastLeaseSummary = $"{why} {slot}: drifted to {current}, left alone"; + Puts($"lease {_lastLeaseSummary}"); + + Dictionary drifted = Frame("lease.drifted", "reply"); + drifted["key"] = key; + + if (!string.IsNullOrEmpty(target)) + drifted["target"] = target; + + drifted["current"] = current; + drifted["expected"] = expected; + return drifted; + } + + string failed = restoreTo == null ? "no baseline to restore" : WriteLeaseValue(key, target, restoreTo); + + if (failed != null) + { + Dictionary error = Frame("lease.error", "reply"); + error["reason"] = "refused"; + error["message"] = failed; + return error; + } + + _leases.Remove(slot); + SaveLeases(); + _lastLeaseSummary = $"{why} {slot}: restored {restoreTo}"; + Puts($"lease {_lastLeaseSummary}"); + return RestoredFrame(key, target, restoreTo); + } + + private Dictionary RestoredFrame(string key, string target, string value) + { + Dictionary ok = Frame("lease.ok", "reply"); + ok["key"] = key; + + if (!string.IsNullOrEmpty(target)) + ok["target"] = target; + + ok["restored"] = value; + return ok; + } + + /// + /// Main thread, every second while anything is held. The fail-safe: a deadline that + /// passes gives the value back without anybody asking, and says so. + /// + private void CheckLeaseDeadlines() + { + if (_leases.Count == 0) + { + StopLeaseTimer(); + return; + } + + long now = NowMs(); + List due = null; + + foreach (LeaseHold h in _leases.Values) + { + if (h.DeadlineMs <= now) + { + if (due == null) + due = new List(); + + due.Add(h); + } + } + + if (due == null) + return; + + foreach (LeaseHold h in due) + Expire(h); + } + + private void Expire(LeaseHold hold) + { + Dictionary outcome = Restore( + hold.Key, hold.Target, hold.Applied, hold.Baseline, hold, "expired"); + + string kind = StringField(outcome, "kind"); + + // An unreadable value keeps the hold; the next tick tries again rather than + // announcing an expiry that did not happen. + if (kind == "lease.error") + return; + + Dictionary frame = Frame("lease.expired", "event"); + frame["key"] = hold.Key; + + if (!string.IsNullOrEmpty(hold.Target)) + frame["target"] = hold.Target; + + if (!string.IsNullOrEmpty(hold.RunId)) + frame["runId"] = hold.RunId; + + frame["drifted"] = kind == "lease.drifted"; + + object current; + if (outcome.TryGetValue("current", out current)) + frame["current"] = current; + + EmitFrame(frame); + } + + private void EnsureLeaseTimer() + { + if (_leaseTimer == null && _leases.Count > 0) + _leaseTimer = timer.Every(LeaseCheckSeconds, CheckLeaseDeadlines); + } + + private void StopLeaseTimer() + { + if (_leaseTimer != null) + { + _leaseTimer.Destroy(); + _leaseTimer = null; + } + } + + private void EmitLeaseError(string reqId, string reason, string message) + { + Dictionary frame = Frame("lease.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastLeaseSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + private string LeasesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "leases.json"); + } + + /// + /// Main thread. Written on every change, because the whole point of the file is the + /// crash nobody saw coming. It is a handful of rows. + /// + private void SaveLeases() + { + try + { + string path = LeasesPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + var rows = new List(_leases.Values); + string tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom); + + if (File.Exists(path)) + File.Delete(path); + + File.Move(tmp, path); + } + catch (Exception ex) + { + Puts($"could not save leases: {Flatten(ex.Message)}"); + } + } + + /// + /// Main thread, once per load, with the world available. Re-arms what is still owed. + /// + /// + /// A convar hold taken under an earlier BOOT is dropped: the restart put the game's + /// own value back, and re-applying it would resurrect a lease the restart ended. A + /// convar hold from THIS boot survives a plugin reload — the value is still in the + /// game's memory, and a config save from the website must not end an event. A group + /// permission hold is re-armed whatever the boot, because the framework kept it. + /// + /// + private void LoadLeases() + { + _leases = new Dictionary(); + + List rows = null; + + try + { + string path = LeasesPath(); + + if (File.Exists(path)) + rows = JsonConvert.DeserializeObject>(File.ReadAllText(path)); + } + catch (Exception ex) + { + Puts($"could not read leases: {Flatten(ex.Message)}"); + } + + if (rows == null) + return; + + int dropped = 0; + + foreach (LeaseHold h in rows) + { + if (h == null || string.IsNullOrEmpty(h.Key)) + continue; + + if (h.Key != GroupPermissionKey && h.BootId != _bootId) + { + dropped++; + continue; + } + + _leases[LeaseSlot(h.Key, h.Target)] = h; + } + + if (dropped > 0) + SaveLeases(); + + Puts($"leases: {_leases.Count} held, {dropped} ended by a restart"); + + // Due now or later; the first tick restores anything whose deadline passed while the + // server was down. + EnsureLeaseTimer(); + } + + /// + /// Whether a lease holds this group permission, for the permission mirror. A pair a + /// lease has borrowed is the lease's until it ends: a sync neither applies to it nor + /// reports it foreign. + /// + private LeaseHold LeasedGroupPermission(string group, string perm) + { + LeaseHold hold; + return _leases.TryGetValue(LeaseSlot(GroupPermissionKey, Lower(group) + "/" + Lower(perm)), out hold) + ? hold + : null; + } + + /// What every hold is doing, from the server console. + [ConsoleCommand("rg.leases")] + private void CmdLeases(ConsoleSystem.Arg arg) + { + var lines = new List + { + $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + + $"held={_leases.Count} bootId={_bootId}" + }; + + foreach (LeaseHold h in _leases.Values) + { + lines.Add( + $" {LeaseSlot(h.Key, h.Target)} applied={h.Applied} baseline={h.Baseline} " + + $"remaining={Math.Max(0L, h.DeadlineMs - NowMs()) / 1000L}s run={h.RunId ?? "-"}"); + } + + lines.Add($"last: {_lastLeaseSummary}"); + arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); + } + + // ---- the world verbs (protocol 9, PLAN.md §28) ---- + // + // Two things an event makes that did not exist — a zone, and a crate or NPC placed in the + // world — and one registry that remembers which run made what. Four rules shape all of it, + // and each came from the rig rather than from a document (§28.1): + // + // * **A restart is not proof a placed thing is gone.** Crates are saved by the game and + // come back with the same net id; NPCs are not saved and do not. So nothing here is + // inferred from a boot id. `world.owned` LOOKS, by net id, every time. + // * **A wipe is proof everything is gone.** The registry is dropped whole on a new save + // and at load when it names another wipe. + // * **ZoneManager forgets.** It holds temporary zones in memory and erases every zone a + // plugin owns when that plugin unloads — so an `oxide.reload` of this bridge deletes + // every event zone mid-run. The registry is the record, and zones are put back from it + // on every load and every ZoneManager reload (D92). + // * **There is no at-most-once store on this bridge.** The registry is keyed by the + // website's idempotency key instead: a repeat answers with the ids the first call made + // and places nothing, which is the whole of what the key is for. + // + // What may be erased is decided by the registry and never by ZoneManager, whose + // `EraseTemporaryZone` deletes an operator's own hand-made zone and returns true (§12.4). + + /// The ceiling on crates per step. EventsMaxCrates may lower it, never raise it. + private const int WorldHardMaxCrates = 25; + + /// The ceiling on NPCs per step (D95). + private const int WorldHardMaxNpcs = 20; + + /// How far a group may be scattered around its point, in metres. + private const float WorldMaxSpread = 50f; + + /// How far from a monument's centre a step may put its point, in metres. + private const float WorldMaxOffset = 150f; + + private const float ZoneMinRadius = 5f; + private const float ZoneMaxRadius = 150f; + + /// + /// The longest a zone may stand: seven days, the lease ceiling (D96). A zone is priced + /// by its minutes, so it has a deadline, and the deadline is kept HERE — a zone never + /// outlives its event even when the website that opened it has gone. + /// + private const long ZoneMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; + + private const string WorldKindZone = "zone"; + private const string WorldKindCrate = "crate"; + private const string WorldKindNpc = "npc"; + + /// One thing this plugin will place, by the key the website names it with. + private sealed class WorldPrefab + { + public readonly string Key; + public readonly string Kind; + public readonly string Path; + public readonly string Label; + + public WorldPrefab(string key, string kind, string path, string label) + { + Key = key; + Kind = kind; + Path = path; + Label = label; + } + } + + /// + /// The allowlist (D88), and the only one: the sidecar forwards and the website offers + /// what this answers. Crates and NPCs, never vehicles — a player can take a vehicle, and + /// a revert would kill something a person may be sitting in. Every path here was + /// spawned on the rig and reported its type before it was listed (§28.1). + /// + private static readonly WorldPrefab[] WorldPrefabs = + { + new WorldPrefab("crate.basic", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_basic.prefab", "Basic crate"), + new WorldPrefab("crate.normal", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal.prefab", "Military crate"), + new WorldPrefab("crate.normal2", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal_2.prefab", "Crate"), + new WorldPrefab("crate.elite", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_elite.prefab", "Elite crate"), + new WorldPrefab("crate.tools", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_tools.prefab", "Tool box"), + new WorldPrefab("crate.hackable", WorldKindCrate, "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", "Locked crate (hackable)"), + new WorldPrefab("supply.drop", WorldKindCrate, "assets/prefabs/misc/supply drop/supply_drop.prefab", "Supply drop"), + new WorldPrefab("barrel.loot", WorldKindCrate, "assets/bundled/prefabs/radtown/loot_barrel_1.prefab", "Loot barrel"), + new WorldPrefab("npc.scientist", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab", "Scientist"), + new WorldPrefab("npc.scientist.heavy", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab", "Heavy scientist"), + new WorldPrefab("npc.scientist.tethered", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roamtethered.prefab", "Scientist (stays put)"), + new WorldPrefab("npc.bandit.guard", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/banditguard/npc_bandit_guard.prefab", "Bandit guard") + }; + + /// One thing an event made. Persisted as it is, so every field is public and plain. + private sealed class WorldEntry + { + /// The net id for an entity, as text; the zone id for a zone. + public string Id; + + public string Kind; + + /// The allowlist key, for a crate or an NPC. + public string Prefab; + + public string RunId; + + /// The website's idempotency key for the step that made it. + public string Key; + + public float X, Y, Z; + + /// A zone's radius and name, for re-creating it. + public float Radius; + public string Name; + + /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. + public long DeadlineMs; + + public string BootId; + public string WipeId; + public long TakenMs; + } + + /// Everything events have made on this map, by id. Main thread only. + private Dictionary _world = new Dictionary(); + + /// A counter for zone ids, so two zones one run opens in one tick never collide. + private int _zoneSerial; + + /// + /// Whether the world is loaded for THIS load of the plugin — set at + /// OnServerInitialized, which fires at the end of a real start and at once on a + /// hotload. Until then every world command is refused not-ready. + /// + /// + /// Found by the phase 13a walk, and it is the worst kind of wrong: the link starts in + /// Init, BEFORE the save loads, so a website that saw the new boot id asked + /// world.owned into an empty world — and the plugin, finding no entity behind + /// any net id, pruned three live crates from its own registry. They stayed in the + /// world owned by nothing, and a revert would have refused them. An empty world at + /// boot is not an answer; it is the question arriving too early. + /// + /// + private bool _worldReady; + + private string _lastWorldSummary = "none yet"; + + private Timer _worldTimer; + + private static WorldPrefab FindWorldPrefab(string key) + { + foreach (WorldPrefab p in WorldPrefabs) + { + if (string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase)) + return p; + } + + return null; + } + + private Plugin ZoneManagerPlugin() + { + return plugins.Find("ZoneManager"); + } + + /// + /// The map's monuments in the one order every caller agrees on: grouped by prefab short + /// name, then by position. #n in a value counts within its group, so it names the + /// same monument every time it is asked on this map (D93). + /// + private static List>> MonumentGroups() + { + var byKind = new SortedDictionary>(StringComparer.Ordinal); + List all = TerrainMeta.Path != null ? TerrainMeta.Path.Monuments : null; + + if (all != null) + { + foreach (MonumentInfo m in all) + { + if (m == null || string.IsNullOrEmpty(m.name)) + continue; + + string kind = Path.GetFileNameWithoutExtension(m.name).ToLowerInvariant(); + List list; + + if (!byKind.TryGetValue(kind, out list)) + { + list = new List(); + byKind[kind] = list; + } + + list.Add(m); + } + } + + var groups = new List>>(); + + foreach (KeyValuePair> kv in byKind) + { + kv.Value.Sort((a, b) => + { + int cx = a.transform.position.x.CompareTo(b.transform.position.x); + return cx != 0 ? cx : a.transform.position.z.CompareTo(b.transform.position.z); + }); + + groups.Add(kv); + } + + return groups; + } + + /// + /// Main thread. A monument value — kind or kind#n — to the monument it + /// names on this map, or null with a reason. A bare kind that repeats is its first + /// instance, so a step written against a map where it was unique still means something + /// on one where it is not. + /// + private static MonumentInfo ResolveMonument(string value, out string refusal) + { + refusal = null; + string text = Lower(value); + + if (string.IsNullOrEmpty(text)) + { + refusal = "no monument was named"; + return null; + } + + int instance = 1; + string kind = text; + int hash = text.LastIndexOf('#'); + + if (hash > 0) + { + kind = text.Substring(0, hash); + + if (!int.TryParse(text.Substring(hash + 1), out instance) || instance < 1) + { + refusal = $"'{value}' is not a monument, followed by an instance number"; + return null; + } + } + + foreach (KeyValuePair> kv in MonumentGroups()) + { + if (kv.Key != kind) + continue; + + if (instance > kv.Value.Count) + { + refusal = $"this map has {kv.Value.Count} {kind}, so there is no #{instance}"; + return null; + } + + return kv.Value[instance - 1]; + } + + refusal = $"this map has no {kind}"; + return null; + } + + private static double? DoubleField(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value) || value == null) + return null; + + double parsed; + return double.TryParse( + value.ToString(), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out parsed) && !double.IsNaN(parsed) && !double.IsInfinity(parsed) + ? parsed + : (double?)null; + } + + /// + /// Main thread. Where a step puts its thing: a monument (plus an offset), or raw world + /// coordinates — exactly one of the two (D87). Returns false with a reason and the + /// refusal code that goes with it. + /// + private bool ResolveWorldPoint(Dictionary cmd, out UnityEngine.Vector3 point, out string reason, out string refusal) + { + point = UnityEngine.Vector3.zero; + reason = null; + refusal = null; + + string monument = StringField(cmd, "monument"); + double? x = DoubleField(cmd, "x"); + double? z = DoubleField(cmd, "z"); + double? y = DoubleField(cmd, "y"); + bool byMonument = !string.IsNullOrEmpty(monument); + bool byCoords = x.HasValue || z.HasValue; + + if (byMonument == byCoords) + { + reason = "malformed"; + refusal = "a location is a monument or x and z, and exactly one of them"; + return false; + } + + if (byMonument) + { + MonumentInfo m = ResolveMonument(monument, out refusal); + + if (m == null) + { + reason = "no-monument"; + return false; + } + + double dx = DoubleField(cmd, "offsetX") ?? 0.0; + double dz = DoubleField(cmd, "offsetZ") ?? 0.0; + + if (Math.Sqrt(dx * dx + dz * dz) > WorldMaxOffset) + { + reason = "out-of-range"; + refusal = $"an offset from a monument is at most {WorldMaxOffset} m"; + return false; + } + + point = m.transform.position + new UnityEngine.Vector3((float)dx, 0f, (float)dz); + point.y = TerrainMeta.HeightMap.GetHeight(point); + return true; + } + + if (!x.HasValue || !z.HasValue) + { + reason = "malformed"; + refusal = "coordinates need both x and z"; + return false; + } + + float half = ConVar.Server.worldsize / 2f; + + if (Math.Abs(x.Value) > half || Math.Abs(z.Value) > half) + { + reason = "off-map"; + refusal = $"({x.Value:0}, {z.Value:0}) is off this map, which runs from -{half:0} to {half:0}"; + return false; + } + + point = new UnityEngine.Vector3((float)x.Value, 0f, (float)z.Value); + point.y = y.HasValue ? (float)y.Value : TerrainMeta.HeightMap.GetHeight(point); + return true; + } + + /// Main thread. Refuses a world command until the world is loaded (see _worldReady). + private bool WorldReady(string reqId) + { + if (_worldReady) + return true; + + EmitWorldError(reqId, "not-ready", + "the server is still loading its world; ask again once it has started"); + return false; + } + + private bool WorldWritesAllowed(string reqId) + { + if (!WorldReady(reqId)) + return false; + + if (_config != null && _config.EventsEnabled) + return true; + + // One consent for everything an event does to this world unattended (D94). Giving + // back, listing and asking what is still out there are never behind it. + EmitWorldError(reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return false; + } + + /// + /// Main thread. The ids a key already made, or null. A repeat of a key the registry has + /// seen is a retry whose first answer was lost, and it is answered with the first + /// call's ids rather than a second set of things (§28.1). + /// + private List EntriesForKey(string key) + { + if (string.IsNullOrEmpty(key)) + return null; + + List found = null; + + foreach (WorldEntry e in _world.Values) + { + if (e.Key != key) + continue; + + if (found == null) + found = new List(); + + found.Add(e); + } + + return found; + } + + private void HandleWorldMonuments(string reqId) + { + if (!WorldReady(reqId)) + return; + + var rows = new List(); + + foreach (KeyValuePair> kv in MonumentGroups()) + { + for (int i = 0; i < kv.Value.Count; i++) + { + MonumentInfo m = kv.Value[i]; + UnityEngine.Vector3 p = m.transform.position; + string phrase = m.displayPhrase != null ? m.displayPhrase.english : null; + + var row = new Dictionary + { + { "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key }, + { "kind", kv.Key }, + { "instance", i + 1 }, + { "of", kv.Value.Count }, + { "label", string.IsNullOrEmpty(phrase) ? kv.Key : phrase }, + { "x", Math.Round(p.x, 1) }, + { "z", Math.Round(p.z, 1) }, + { "grid", Grid(p) } + }; + + rows.Add(row); + } + } + + Dictionary reply = Frame("world.monuments", "reply"); + reply["worldSize"] = ConVar.Server.worldsize; + reply["monuments"] = rows; + reply["prefabs"] = WorldPrefabRows(); + reply["eventsEnabled"] = _config != null && _config.EventsEnabled; + reply["maxCrates"] = _config != null ? _config.EventsMaxCrates : WorldHardMaxCrates; + reply["maxNpcs"] = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs; + reply["zoneManager"] = ZoneManagerPlugin() != null; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static List WorldPrefabRows() + { + var rows = new List(); + + foreach (WorldPrefab p in WorldPrefabs) + { + rows.Add(new Dictionary + { + { "key", p.Key }, + { "kind", p.Kind }, + { "label", p.Label } + }); + } + + return rows; + } + + /// Main thread. Open a zone an event owns. + private void HandleWorldZone(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + string runId = StringField(cmd, "runId"); + List repeat = EntriesForKey(key); + + if (repeat != null) + { + EmitWorldPlaced(reqId, repeat, true); + return; + } + + if (string.IsNullOrEmpty(runId)) + { + EmitWorldError(reqId, "malformed", "a zone is opened for a run"); + return; + } + + Plugin zm = ZoneManagerPlugin(); + + if (zm == null) + { + EmitWorldError(reqId, "zonemanager-missing", "ZoneManager is not loaded on this server"); + return; + } + + double radius = DoubleField(cmd, "radius") ?? 0.0; + + if (radius < ZoneMinRadius || radius > ZoneMaxRadius) + { + EmitWorldError(reqId, "out-of-range", + $"a zone's radius is {ZoneMinRadius} to {ZoneMaxRadius} m, not {radius}"); + return; + } + + UnityEngine.Vector3 point; + string reason, refusal; + + if (!ResolveWorldPoint(cmd, out point, out reason, out refusal)) + { + EmitWorldError(reqId, reason, refusal); + return; + } + + long holdMs = LongField(cmd, "holdMs"); + + if (holdMs <= 0 || holdMs > ZoneMaxHoldMs) + { + EmitWorldError(reqId, "out-of-range", + $"a zone stands for 1 minute to {ZoneMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}"); + return; + } + + string name = StringField(cmd, "name"); + + if (string.IsNullOrEmpty(name)) + name = "Event zone"; + + if (name.Length > 64) + name = name.Substring(0, 64); + + var entry = new WorldEntry + { + Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}", + Kind = WorldKindZone, + RunId = runId, + Key = key, + X = point.x, + Y = point.y, + Z = point.z, + Radius = (float)radius, + Name = name, + DeadlineMs = NowMs() + holdMs, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }; + + if (!CreateZone(zm, entry)) + { + EmitWorldError(reqId, "refused", "ZoneManager did not create the zone"); + return; + } + + _world[entry.Id] = entry; + SaveWorld(); + EnsureWorldTimer(); + + _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, run {runId}"; + Puts($"world: {_lastWorldSummary}"); + EmitWorldPlaced(reqId, new List { entry }, false); + } + + private static string ShortRun(string runId) + { + var chars = new StringBuilder(); + + foreach (char c in runId ?? "") + { + if (char.IsLetterOrDigit(c)) + chars.Append(c); + + if (chars.Length >= 12) + break; + } + + return chars.Length > 0 ? chars.ToString() : "run"; + } + + /// + /// Main thread. Creates, or re-creates, one zone from its record, and says whether + /// ZoneManager now has it. Our plugin is the owner, so ZoneManager itself refuses to + /// let another plugin's zone be overwritten under the same id. + /// + private bool CreateZone(Plugin zm, WorldEntry entry) + { + string[] args = + { + "name", entry.Name ?? "Event zone", + "radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture) + }; + + object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args, new UnityEngine.Vector3(entry.X, entry.Y, entry.Z)); + + return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null; + } + + /// + /// Main thread. Puts back every zone the registry holds that ZoneManager does not (D92). + /// Called on this plugin's load, on ZoneManager's, and from world.owned. + /// + private void RecreateZones(string why) + { + // At a real start ZoneManager loads before the world does and initialises its own + // zones at OnTerrainInitialized; ours wait for OnServerInitialized, which calls here. + if (!_worldReady) + return; + + Plugin zm = ZoneManagerPlugin(); + + if (zm == null) + return; + + int restored = 0, failed = 0; + + foreach (WorldEntry e in _world.Values) + { + // A zone past its deadline is the timer's to end, not this loop's to revive. + if (e.Kind != WorldKindZone || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) != null) + continue; + + if (CreateZone(zm, e)) + restored++; + else + failed++; + } + + if (restored > 0 || failed > 0) + { + _lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed"; + Puts($"world: {_lastWorldSummary}"); + } + } + + /// Main thread. Place crates or NPCs an event owns, one registry entry per net id. + private void HandleWorldPlace(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + string runId = StringField(cmd, "runId"); + List repeat = EntriesForKey(key); + + if (repeat != null) + { + EmitWorldPlaced(reqId, repeat, true); + return; + } + + if (string.IsNullOrEmpty(runId)) + { + EmitWorldError(reqId, "malformed", "a placement is made for a run"); + return; + } + + WorldPrefab prefab = FindWorldPrefab(StringField(cmd, "prefab")); + + if (prefab == null) + { + EmitWorldError(reqId, "unknown-prefab", + $"'{StringField(cmd, "prefab")}' is not something this server places for events"); + return; + } + + int count = IntField(cmd, "count"); + int max = prefab.Kind == WorldKindNpc + ? (_config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs) + : (_config != null ? _config.EventsMaxCrates : WorldHardMaxCrates); + + if (count < 1 || count > max) + { + EmitWorldError(reqId, "out-of-range", + $"this server places 1 to {max} {(prefab.Kind == WorldKindNpc ? "NPCs" : "crates")} at a time, not {count}"); + return; + } + + double spread = DoubleField(cmd, "spread") ?? (count > 1 ? 10.0 : 0.0); + + if (spread < 0 || spread > WorldMaxSpread) + { + EmitWorldError(reqId, "out-of-range", $"a scatter is 0 to {WorldMaxSpread} m, not {spread}"); + return; + } + + UnityEngine.Vector3 center; + string reason, refusal; + + if (!ResolveWorldPoint(cmd, out center, out reason, out refusal)) + { + EmitWorldError(reqId, reason, refusal); + return; + } + + bool explicitY = DoubleField(cmd, "y").HasValue; + var made = new List(); + + for (int i = 0; i < count; i++) + { + UnityEngine.Vector3 at = center; + + if (count > 1) + { + UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread; + at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y); + + if (!explicitY) + at.y = TerrainMeta.HeightMap.GetHeight(at); + } + + BaseEntity entity = GameManager.server.CreateEntity(prefab.Path, at); + + if (entity == null) + { + // All or nothing: a group half of which exists is not what the step asked for, + // and the half that does exist has no record yet to be given back through. + foreach (WorldEntry e in made) + KillEntity(e.Id); + + EmitWorldError(reqId, "refused", $"the game could not create {prefab.Label}"); + return; + } + + entity.Spawn(); + + made.Add(new WorldEntry + { + Id = entity.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind = prefab.Kind, + Prefab = prefab.Key, + RunId = runId, + Key = key, + X = at.x, + Y = at.y, + Z = at.z, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }); + } + + foreach (WorldEntry e in made) + _world[e.Id] = e; + + SaveWorld(); + + _lastWorldSummary = $"placed {count} {prefab.Key} for run {runId}"; + Puts($"world: {_lastWorldSummary}"); + EmitWorldPlaced(reqId, made, false); + } + + private static BaseNetworkable FindEntity(string id) + { + ulong value; + + if (!ulong.TryParse(id, out value)) + return null; + + BaseNetworkable found = BaseNetworkable.serverEntities.Find(new NetworkableId(value)); + return found != null && !found.IsDestroyed ? found : null; + } + + /// Main thread. Whether the entity was there to kill; either way it is not now. + private static bool KillEntity(string id) + { + BaseNetworkable found = FindEntity(id); + + if (found == null) + return false; + + found.Kill(); + return true; + } + + /// + /// Main thread. Give back what a run owns. Never behind EventsEnabled: switching + /// events off must not strand anything already placed (D94). + /// + /// + /// Named ids first; else everything under the key (core lost the answer to a dispatch + /// and knows only its key); else everything the run owns. An id already gone is a + /// success. An id the registry does not hold for this run is refused — this + /// bridge erases only what it made, whatever ZoneManager would allow (§12.4). + /// + /// + private void HandleWorldRevert(Dictionary cmd, string reqId) + { + // Before the world loads, every entity looks gone, and a revert would forget things + // that are about to exist again. + if (!WorldReady(reqId)) + return; + + string runId = StringField(cmd, "runId"); + string key = StringField(cmd, "key"); + var ids = new List(Strings(cmd, "ids")); + + if (string.IsNullOrEmpty(runId)) + { + EmitWorldError(reqId, "malformed", "a revert names the run whose things to give back"); + return; + } + + var targets = new List(); + var refused = new List(); + var gone = new List(); + Plugin zm = ZoneManagerPlugin(); + + if (ids.Count > 0) + { + foreach (string id in ids) + { + WorldEntry e; + + if (_world.TryGetValue(id, out e)) + { + if (e.RunId == runId) + targets.Add(e); + else + refused.Add(id); + + continue; + } + + // Not on record. That is either something a wipe or a prune already took — + // gone, which is a success — or something this bridge never made. Only a + // thing that is actually THERE can be the second, and only that is refused. + bool exists = id.StartsWith("rg-", StringComparison.Ordinal) + ? zm != null && zm.Call("CheckZoneID", id) != null + : FindEntity(id) != null; + + (exists ? refused : gone).Add(id); + } + } + else + { + foreach (WorldEntry e in _world.Values) + { + if (e.RunId == runId && (string.IsNullOrEmpty(key) || e.Key == key)) + targets.Add(e); + } + } + + var removed = new List(); + + foreach (WorldEntry e in targets) + { + bool wasThere; + + if (e.Kind == WorldKindZone) + { + // A zone ZoneManager does not have (unloaded, or already erased) is gone — + // it lives in memory only — and its record is all there is left to remove. + wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null; + + if (wasThere) + zm.Call("EraseTemporaryZone", this, e.Id); + } + else + { + wasThere = KillEntity(e.Id); + } + + _world.Remove(e.Id); + (wasThere ? removed : gone).Add(e.Id); + } + + if (targets.Count > 0) + SaveWorld(); + + _lastWorldSummary = $"revert run {runId}: {removed.Count} removed, {gone.Count} already gone, {refused.Count} refused"; + Puts($"world: {_lastWorldSummary}"); + + Dictionary reply = Frame("world.ok", "reply"); + reply["removed"] = removed; + reply["gone"] = gone; + reply["refused"] = refused; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. What the world still holds of what events made — looked at, never + /// inferred (§28.1). An entity that is gone is pruned as the walk passes it. A zone is + /// put back if ZoneManager has lost it; while ZoneManager is not loaded a zone is + /// reported held, because "I cannot ask" is not "it is gone", and its load will put + /// the zone back. + /// + private void HandleWorldOwned(Dictionary cmd, string reqId) + { + if (!WorldReady(reqId)) + return; + + string runId = StringField(cmd, "runId"); + RecreateZones("owned"); + + Plugin zm = ZoneManagerPlugin(); + var rows = new List(); + List pruned = null; + + foreach (WorldEntry e in _world.Values) + { + if (!string.IsNullOrEmpty(runId) && e.RunId != runId) + continue; + + bool held; + + if (e.Kind == WorldKindZone) + held = zm == null || zm.Call("CheckZoneID", e.Id) != null; + else + held = FindEntity(e.Id) != null; + + if (!held) + { + if (pruned == null) + pruned = new List(); + + pruned.Add(e.Id); + continue; + } + + rows.Add(WorldRow(e)); + } + + if (pruned != null) + { + foreach (string id in pruned) + _world.Remove(id); + + SaveWorld(); + } + + Dictionary reply = Frame("world.owned", "reply"); + reply["owned"] = rows; + reply["pruned"] = pruned != null ? pruned.Count : 0; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static Dictionary WorldRow(WorldEntry e) + { + var row = new Dictionary + { + { "id", e.Id }, + { "kind", e.Kind }, + { "runId", e.RunId }, + { "x", Math.Round(e.X, 1) }, + { "y", Math.Round(e.Y, 1) }, + { "z", Math.Round(e.Z, 1) } + }; + + if (!string.IsNullOrEmpty(e.Prefab)) + row["prefab"] = e.Prefab; + + if (e.Kind == WorldKindZone) + { + row["radius"] = e.Radius; + row["name"] = e.Name; + row["remainingMs"] = Math.Max(0L, e.DeadlineMs - NowMs()); + } + + return row; + } + + private void EmitWorldPlaced(string reqId, List entries, bool repeat) + { + var placed = new List(); + + foreach (WorldEntry e in entries) + placed.Add(WorldRow(e)); + + Dictionary reply = Frame("world.ok", "reply"); + reply["placed"] = placed; + reply["repeat"] = repeat; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitWorldError(string reqId, string reason, string message) + { + Dictionary frame = Frame("world.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastWorldSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + private string WorldPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "world.json"); + } + + /// Main thread. Written on every change, like the lease holds and for the same reason. + private void SaveWorld() + { + try + { + string path = WorldPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + var rows = new List(_world.Values); + string tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom); + + if (File.Exists(path)) + File.Delete(path); + + File.Move(tmp, path); + } + catch (Exception ex) + { + Puts($"could not save the world registry: {Flatten(ex.Message)}"); + } + } + + /// + /// Main thread, once per load. Reads the registry and nothing more: whether each thing is + /// still out there is asked when somebody wants to know, because at a real server start + /// this runs before the save has loaded and every entity would look gone. + /// + private void LoadWorld() + { + _world = new Dictionary(); + List rows = null; + + try + { + string path = WorldPath(); + + if (File.Exists(path)) + rows = JsonConvert.DeserializeObject>(File.ReadAllText(path)); + } + catch (Exception ex) + { + Puts($"could not read the world registry: {Flatten(ex.Message)}"); + } + + if (rows == null) + return; + + foreach (WorldEntry e in rows) + { + if (e != null && !string.IsNullOrEmpty(e.Id)) + _world[e.Id] = e; + } + + Puts($"world: {_world.Count} things events made are on record"); + + // Due now or later; the first tick ends any zone whose deadline passed while the + // server was down, before anything could re-create it. + EnsureWorldTimer(); + } + + private static bool ZoneExpired(WorldEntry e) + { + return e.Kind == WorldKindZone && e.DeadlineMs > 0 && e.DeadlineMs <= NowMs(); + } + + private void EnsureWorldTimer() + { + if (_worldTimer != null) + return; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindZone) + { + _worldTimer = timer.Every(LeaseCheckSeconds, CheckZoneDeadlines); + return; + } + } + } + + private void StopWorldTimer() + { + if (_worldTimer != null) + { + _worldTimer.Destroy(); + _worldTimer = null; + } + } + + /// + /// Main thread, every second while a zone stands. The fail-safe D96 bought: a zone whose + /// time is up is erased without anybody asking, and says so — the same promise a lease + /// makes, for the same reason. + /// + private void CheckZoneDeadlines() + { + List due = null; + bool anyZone = false; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindZone) + continue; + + anyZone = true; + + if (!ZoneExpired(e)) + continue; + + if (due == null) + due = new List(); + + due.Add(e); + } + + if (!anyZone) + { + StopWorldTimer(); + return; + } + + if (due == null) + return; + + Plugin zm = ZoneManagerPlugin(); + + foreach (WorldEntry e in due) + { + if (zm != null && zm.Call("CheckZoneID", e.Id) != null) + zm.Call("EraseTemporaryZone", this, e.Id); + + _world.Remove(e.Id); + + Dictionary frame = Frame("world.expired", "event"); + frame["id"] = e.Id; + frame["kind"] = e.Kind; + + if (!string.IsNullOrEmpty(e.RunId)) + frame["runId"] = e.RunId; + + EmitFrame(frame); + } + + SaveWorld(); + _lastWorldSummary = $"{due.Count} zone(s) reached their deadline and were erased"; + Puts($"world: {_lastWorldSummary}"); + } + + /// + /// Main thread. Drops everything recorded against another wipe — that map, and all of + /// it, is gone. Only callable once the wipe is known, which a real server start does not + /// know until OnServerInitialized. + /// + private void DropWorldFromOtherWipes() + { + if (string.IsNullOrEmpty(_wipeId) || _world.Count == 0) + return; + + var stale = new List(); + + foreach (WorldEntry e in _world.Values) + { + if (!string.IsNullOrEmpty(e.WipeId) && e.WipeId != _wipeId) + stale.Add(e.Id); + } + + if (stale.Count == 0) + return; + + foreach (string id in stale) + _world.Remove(id); + + SaveWorld(); + _lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map"; + Puts($"world: {_lastWorldSummary}"); + } + + /// What events have made on this map, from the server console. + [ConsoleCommand("rg.world")] + private void CmdWorld(ConsoleSystem.Arg arg) + { + int zones = 0, crates = 0, npcs = 0; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindZone) + zones++; + else if (e.Kind == WorldKindNpc) + npcs++; + else + crates++; + } + + arg.ReplyWith( + $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + + $"wipeId={(_wipeId ?? "none")}" + Environment.NewLine + + $"last: {_lastWorldSummary}"); + } + + // ---- the rewards (protocol 10, PLAN.md §29) ---- + // + // Three things, and the website counts none of them: + // + // * **The tally** — who took part in a run. It is counted here because only the game + // sees a player stand in a zone or land the last hit (D81), and it is persisted, + // because a restart in the middle of an event must not forget who was there. + // * **The credits** — each reward is one more use of a kit (D103). The site authors how + // many a player has earned and pushes them with `perm.sync`; this plugin spends them + // LAST, from `OnKitRedeemed`, because Kits cannot be told in advance: its + // `SetPlayerKitUses` does nothing for a player with no usage record of the kit. + // * **The chat line** — `chat.say`, for a run's `rust.announce` and for the news leg. + // + // The zone a tally counts in is asked of ZoneManager on every tick (`GetPlayersInZone`, + // `IsPlayerInZone`) rather than mirrored from `OnEnterZone`/`OnExitZone`. ZoneManager + // already holds each zone's members, and a mirror would have to be reseeded every time a + // zone is re-created (D92) — the tick asks at most `EventsMaxTallies` questions every five + // seconds, and no chatty hook is subscribed at all (R17). + + /// The most tallies that may count at once. EventsMaxTallies may lower it. + private const int TallyHardMax = 8; + + /// + /// The most people one reward step may name. The plugin publishes it and the website + /// refuses a step over it (§29.5): a reward is priced before its tally is read, so this + /// is the price of every mode that is not a count. + /// + private const int RecipientsHardMax = 100; + + /// The longest chat line, in characters. EventsMaxChat may lower it. + private const int ChatHardMax = 256; + + private const float TallyTickSeconds = 5f; + + /// + /// The longest a tally counts, and the longest it is kept at all: seven days from + /// opening, the lease and zone ceiling (D108). A tally that stopped counting earlier is + /// kept for the steps that read it after — collect and the reward — until then, and a + /// teardown closes it sooner. One ceiling for everything, so a website that went away + /// never leaves a tally on disk for ever. + /// + private const long TallyMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; + + /// Core files at most 5 000 participants per step; a tally holds no more. + private const int TallyMaxPeople = 5000; + + /// + /// The most one tick may credit. A tick that arrives late accrues the time that really + /// passed, so a longer interval never rewrites history — but a clock that jumps is not + /// time anybody spent standing in a zone. + /// + private const long TallyMaxStepMs = 60000L; + + /// Every twelfth tick (a minute) writes the file, when anything changed. + private const int TallySaveEveryTicks = 12; + + private const long ChatKeyMemoryMs = 10L * 60L * 1000L; + + private const string ScoreSeconds = "seconds"; + private const string ScoreKills = "kills"; + private const string ScoreBoth = "both"; + + /// One person in one tally. Persisted as it is. + private sealed class Participant + { + public string SteamId; + public string Name; + public double Seconds; + public int Kills; + public long JoinedMs; + } + + /// One run's tally on this server. There is at most one per run (§29.5). + private sealed class RunTally + { + public string RunId; + + /// The idempotency key of the step that opened it; a repeat is answered, not refused. + public string Key; + + /// The zone step's name, as the step gave it, or null for the whole server. + public string Zone; + + /// What that name resolved to when the tally opened (D100). + public string ZoneId; + + public string Score; + public string KillsOf; + public double KillWeight; + + public long OpenedMs; + + /// When counting stops. What was counted is kept until seven days after opening (D108). + public long DeadlineMs; + + /// People who took part after the tally was full, counted so a result can say so. + public int Overflow; + + public Dictionary People = new Dictionary(); + + /// Not persisted: a load starts accruing from the load, never from before a restart. + [JsonIgnore] + public long LastAccrueMs; + } + + /// + /// One player's credits for one kit (D103). Earned is the site's; the other two + /// are this plugin's own record of what it did with them. + /// + private sealed class KitCredit + { + public string SteamId; + public string Kit; + + /// How many the site says this player has earned. Authored there, pushed here. + public int Earned; + + /// How many have been moved into Kits' counter — each one a use count lowered by one. + public int Applied; + + /// How many of those moved uses have since been redeemed. + public int Consumed; + } + + private Dictionary _runTallies = new Dictionary(); + private Dictionary _credits = new Dictionary(); + + /// Recently said chat keys, and when. Main thread only. + private readonly Dictionary _chatKeys = new Dictionary(); + + private Timer _tallyTimer; + private int _tallyTicks; + private bool _talliesDirty; + private string _lastRewardSummary = "none yet"; + + private int MaxTallies() + { + return _config != null ? _config.EventsMaxTallies : TallyHardMax; + } + + private int MaxRecipients() + { + return _config != null ? _config.EventsMaxRecipients : RecipientsHardMax; + } + + private int MaxChat() + { + return _config != null ? _config.EventsMaxChat : ChatHardMax; + } + + private static bool Counting(RunTally t, long now) + { + return now < t.DeadlineMs; + } + + /// Whether a player is somebody a tally counts: a real, connected, living, awake person. + private static bool CountsAsPresent(BasePlayer p) + { + return p != null && !p.IsNpc && p.IsConnected && !p.IsDead() && !p.IsSleeping(); + } + + private static Participant PersonIn(RunTally t, BasePlayer player, long now) + { + string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture); + Participant person; + + if (t.People.TryGetValue(id, out person)) + { + person.Name = player.displayName; + return person; + } + + if (t.People.Count >= TallyMaxPeople) + { + t.Overflow++; + return null; + } + + person = new Participant { SteamId = id, Name = player.displayName, JoinedMs = now }; + t.People[id] = person; + return person; + } + + /// The D99 score: seconds, kills, or minutes present plus the weight times kills. + private static double ScoreOf(RunTally t, Participant p) + { + switch (t.Score) + { + case ScoreSeconds: + return Math.Floor(p.Seconds); + case ScoreKills: + return p.Kills; + default: + return Math.Round(p.Seconds / 60.0 + t.KillWeight * p.Kills, 2); + } + } + + /// Main thread. Start counting who takes part in a run. + private void HandleTallyOpen(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(runId)) + { + EmitRewardError("tally.error", reqId, "malformed", "a tally is opened for a run"); + return; + } + + RunTally existing; + + if (_runTallies.TryGetValue(runId, out existing)) + { + // A retry whose first answer was lost repeats the key; anything else is a second + // tally for one run, which would split its people across two answers (§29.5). + if (!string.IsNullOrEmpty(key) && existing.Key == key) + EmitTallyOk(reqId, existing, true); + else + EmitRewardError("tally.error", reqId, "already-open", + "this run already has a tally on this server, and a run holds one"); + + return; + } + + // The same consent as the world verbs (D94): a tally is an event doing something on + // this server unattended. Reading and closing one are never behind it. + if (_config == null || !_config.EventsEnabled) + { + EmitRewardError("tally.error", reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return; + } + + string score = Lower(StringField(cmd, "score")); + + if (score != ScoreSeconds && score != ScoreKills && score != ScoreBoth) + { + EmitRewardError("tally.error", reqId, "malformed", "a tally scores seconds, kills or both"); + return; + } + + string killsOf = Lower(StringField(cmd, "killsOf")); + + if (score == ScoreSeconds) + { + killsOf = null; + } + else if (killsOf != "players" && killsOf != "npcs" && killsOf != "both") + { + EmitRewardError("tally.error", reqId, "malformed", "a tally that counts kills says whose: players, npcs or both"); + return; + } + + double weight = score == ScoreBoth ? (DoubleField(cmd, "killWeight") ?? 5.0) : 0.0; + + if (weight < 0 || weight > 1000) + { + EmitRewardError("tally.error", reqId, "out-of-range", $"a kill is worth 0 to 1000 minutes, not {weight}"); + return; + } + + long holdMs = LongField(cmd, "holdMs"); + + if (holdMs == 0) + holdMs = TallyMaxHoldMs; + + if (holdMs < 60000L || holdMs > TallyMaxHoldMs) + { + EmitRewardError("tally.error", reqId, "out-of-range", + $"a tally counts for 1 minute to {TallyMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}"); + return; + } + + long now = NowMs(); + int live = 0; + + foreach (RunTally t in _runTallies.Values) + { + if (Counting(t, now)) + live++; + } + + if (live >= MaxTallies()) + { + EmitRewardError("tally.error", reqId, "too-many", + $"this server counts at most {MaxTallies()} tallies at once, and {live} are counting"); + return; + } + + string zone = StringField(cmd, "zone"); + string zoneId = null; + + if (!string.IsNullOrEmpty(zone)) + { + // Only a zone THIS run opened (D100) — never an operator's arena, never another + // run's. Matched on the name its step gave it, the way the step's author wrote it. + var matches = new List(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindZone && e.RunId == runId && + string.Equals((e.Name ?? "").Trim(), zone.Trim(), StringComparison.OrdinalIgnoreCase)) + matches.Add(e); + } + + if (matches.Count == 0) + { + EmitRewardError("tally.error", reqId, "no-zone", + $"this run has opened no zone named '{zone}' on this server — open it in an earlier step"); + return; + } + + if (matches.Count > 1) + { + EmitRewardError("tally.error", reqId, "ambiguous-zone", + $"this run has opened {matches.Count} zones named '{zone}' — give each its own name"); + return; + } + + zoneId = matches[0].Id; + } + + var tally = new RunTally + { + RunId = runId, + Key = key, + Zone = zoneId != null ? zone.Trim() : null, + ZoneId = zoneId, + Score = score, + KillsOf = killsOf, + KillWeight = weight, + OpenedMs = now, + DeadlineMs = now + holdMs, + LastAccrueMs = now + }; + + _runTallies[runId] = tally; + SaveTallies(); + EnsureTallyTimer(); + + _lastRewardSummary = $"tally opened for run {runId} ({score}, {(zoneId ?? "whole server")}) for {holdMs / 60000L} min"; + Puts($"rewards: {_lastRewardSummary}"); + EmitTallyOk(reqId, tally, false); + } + + /// Main thread. Who has taken part in a run so far. Never behind EventsEnabled. + private void HandleTallySnapshot(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + RunTally t; + + if (string.IsNullOrEmpty(runId) || !_runTallies.TryGetValue(runId, out t)) + { + EmitRewardError("tally.error", reqId, "no-tally", "this run has no tally on this server"); + return; + } + + // Brought up to this moment first, so a reward read at the end of an event includes + // its last few seconds. + Accrue(t, NowMs(), null); + + var people = new List(); + + foreach (Participant p in t.People.Values) + { + people.Add(new Dictionary + { + { "steamId", p.SteamId }, + { "name", p.Name }, + { "seconds", (long)Math.Floor(p.Seconds) }, + { "kills", p.Kills }, + { "score", ScoreOf(t, p) }, + { "joinedAt", p.JoinedMs } + }); + } + + Dictionary reply = Frame("tally.snapshot", "reply"); + reply["runId"] = t.RunId; + reply["score"] = t.Score; + reply["killWeight"] = t.KillWeight; + reply["counting"] = Counting(t, NowMs()); + reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs()); + reply["overflow"] = t.Overflow; + reply["people"] = people; + reply["maxRecipients"] = MaxRecipients(); + + if (t.KillsOf != null) + reply["killsOf"] = t.KillsOf; + + if (t.Zone != null) + { + reply["zone"] = t.Zone; + Plugin zm = ZoneManagerPlugin(); + reply["zoneStanding"] = zm != null && zm.Call("CheckZoneID", t.ZoneId) != null; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// Main thread. Stop counting and forget. A tally already gone is a success. + private void HandleTallyClose(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + + if (string.IsNullOrEmpty(runId)) + { + EmitRewardError("tally.error", reqId, "malformed", "a close names the run whose tally to forget"); + return; + } + + bool closed = _runTallies.Remove(runId); + + if (closed) + SaveTallies(); + + _lastRewardSummary = closed ? $"tally for run {runId} closed" : $"tally for run {runId} was already gone"; + + Dictionary reply = Frame("tally.ok", "reply"); + reply["runId"] = runId; + reply["closed"] = closed; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitTallyOk(string reqId, RunTally t, bool repeat) + { + Dictionary reply = Frame("tally.ok", "reply"); + reply["runId"] = t.RunId; + reply["score"] = t.Score; + reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs()); + reply["repeat"] = repeat; + + if (t.Zone != null) + { + reply["zone"] = t.Zone; + reply["zoneId"] = t.ZoneId; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitRewardError(string kind, string reqId, string reason, string message) + { + Dictionary frame = Frame(kind, "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastRewardSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// + /// Main thread. Credits presence up to — and no further than the + /// tally's deadline. is looked up once per tick by the caller. + /// + private void Accrue(RunTally t, long now, Plugin zm) + { + long until = Math.Min(now, t.DeadlineMs); + long from = t.LastAccrueMs > 0 ? t.LastAccrueMs : until; + + if (until <= from) + { + t.LastAccrueMs = Math.Max(t.LastAccrueMs, until); + return; + } + + t.LastAccrueMs = until; + double seconds = Math.Min(until - from, TallyMaxStepMs) / 1000.0; + IEnumerable present; + + if (t.ZoneId != null) + { + zm = zm ?? ZoneManagerPlugin(); + + // A zone that is not standing — ZoneManager unloaded, or a wipe took it — gains + // nobody, and keeps what it has. + if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null) + return; + + present = zm.Call("GetPlayersInZone", t.ZoneId) as List; + } + else + { + present = BasePlayer.activePlayerList; + } + + if (present == null) + return; + + foreach (BasePlayer player in present) + { + if (!CountsAsPresent(player)) + continue; + + Participant person = PersonIn(t, player, now); + + if (person == null) + continue; + + person.Seconds += seconds; + _talliesDirty = true; + } + } + + /// Main thread, every five seconds while any tally is held. + private void TallyTick() + { + long now = NowMs(); + List forget = null; + Plugin zm = ZoneManagerPlugin(); + + foreach (RunTally t in _runTallies.Values) + { + // Forgotten seven days after it opened, whatever its minutes (D108): a website + // that went away must not leave a tally on disk for ever. + if (now >= t.OpenedMs + TallyMaxHoldMs) + { + if (forget == null) + forget = new List(); + + forget.Add(t.RunId); + continue; + } + + if (Counting(t, now) || t.LastAccrueMs < t.DeadlineMs) + Accrue(t, now, zm); + } + + if (forget != null) + { + foreach (string id in forget) + _runTallies.Remove(id); + + _talliesDirty = true; + _lastRewardSummary = $"{forget.Count} tally(s) seven days old were forgotten"; + Puts($"rewards: {_lastRewardSummary}"); + } + + if (_talliesDirty && (++_tallyTicks % TallySaveEveryTicks == 0 || forget != null)) + SaveTallies(); + + if (_runTallies.Count == 0) + StopTallyTimer(); + } + + /// + /// Main thread. The last hit's kill, for every tally counting kills of that kind whose area + /// the killer is standing in when the victim dies (D98). + /// + private void CreditKill(BasePlayer killer, bool victimIsPlayer) + { + if (_runTallies.Count == 0 || killer == null || killer.IsNpc) + return; + + long now = NowMs(); + Plugin zm = null; + bool zmLooked = false; + + foreach (RunTally t in _runTallies.Values) + { + if (!Counting(t, now) || t.KillsOf == null) + continue; + + if (victimIsPlayer ? t.KillsOf == "npcs" : t.KillsOf == "players") + continue; + + if (t.ZoneId != null) + { + if (!zmLooked) + { + zm = ZoneManagerPlugin(); + zmLooked = true; + } + + object inside = zm != null ? zm.Call("IsPlayerInZone", t.ZoneId, killer) : null; + + if (!(inside is bool) || !(bool)inside) + continue; + } + + Participant person = PersonIn(t, killer, now); + + if (person == null) + continue; + + person.Kills++; + _talliesDirty = true; + } + } + + private void EnsureTallyTimer() + { + if (_tallyTimer == null && _runTallies.Count > 0) + _tallyTimer = timer.Every(TallyTickSeconds, TallyTick); + } + + private void StopTallyTimer() + { + if (_tallyTimer != null) + { + _tallyTimer.Destroy(); + _tallyTimer = null; + } + } + + private string TalliesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "tallies.json"); + } + + private string CreditsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "credits.json"); + } + + /// Main thread. Written at most once a minute while counting, and on every open and close. + private void SaveTallies() + { + WriteDataFile(TalliesPath(), new List(_runTallies.Values), "tallies"); + _talliesDirty = false; + } + + private void SaveCredits() + { + WriteDataFile(CreditsPath(), new List(_credits.Values), "kit credits"); + } + + private void WriteDataFile(string path, object rows, string what) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + string tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom); + + if (File.Exists(path)) + File.Delete(path); + + File.Move(tmp, path); + } + catch (Exception ex) + { + Puts($"could not save the {what}: {Flatten(ex.Message)}"); + } + } + + private List ReadDataFile(string path, string what) + { + try + { + if (File.Exists(path)) + return JsonConvert.DeserializeObject>(File.ReadAllText(path)); + } + catch (Exception ex) + { + Puts($"could not read the {what}: {Flatten(ex.Message)}"); + } + + return null; + } + + /// + /// Main thread, once per load. A tally survives a restart, a reload and a wipe: it records + /// people, not the map. Presence accrues from now — the time the server was down is not + /// time anybody spent in a zone. + /// + private void LoadRewards() + { + _runTallies = new Dictionary(); + _credits = new Dictionary(); + long now = NowMs(); + + List tallies = ReadDataFile(TalliesPath(), "tallies"); + + if (tallies != null) + { + foreach (RunTally t in tallies) + { + if (t == null || string.IsNullOrEmpty(t.RunId)) + continue; + + if (t.People == null) + t.People = new Dictionary(); + + t.LastAccrueMs = Math.Min(now, t.DeadlineMs); + _runTallies[t.RunId] = t; + } + } + + List credits = ReadDataFile(CreditsPath(), "kit credits"); + + if (credits != null) + { + foreach (KitCredit c in credits) + { + if (c != null && !string.IsNullOrEmpty(c.SteamId) && !string.IsNullOrEmpty(c.Kit)) + _credits[CreditKey(c.SteamId, c.Kit)] = c; + } + } + + Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record"); + EnsureTallyTimer(); + } + + // ── Kits ────────────────────────────────────────────────────────────────── + + private Plugin KitsPlugin() + { + return plugins.Find("Kits"); + } + + /// Main thread. The kits a reward can name, and what gates each (the kit source). + private void HandleKitsList(string reqId) + { + Plugin kits = KitsPlugin(); + + if (kits == null) + { + EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server"); + return; + } + + var names = new List(); + kits.Call("GetKitNames", names); + + var rows = new List(); + + foreach (string name in names) + { + if (string.IsNullOrEmpty(name)) + continue; + + var info = kits.Call("GetKitInfo", name) as Newtonsoft.Json.Linq.JObject; + + if (info == null) + continue; + + rows.Add(new Dictionary + { + { "name", name }, + { "permission", (string)info["permission"] ?? "" }, + { "max", info["max"] != null ? (int)info["max"] : 0 }, + { "cooldown", info["cooldown"] != null ? (int)info["cooldown"] : 0 }, + { "hidden", info["hide"] != null && (bool)info["hide"] }, + { "description", Flatten((string)info["description"] ?? "") } + }); + } + + Dictionary reply = Frame("kits.list", "reply"); + reply["kits"] = rows; + reply["maxRecipients"] = MaxRecipients(); + reply["maxTallies"] = MaxTallies(); + reply["maxChat"] = MaxChat(); + reply["eventsEnabled"] = _config != null && _config.EventsEnabled; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static string CreditKey(string steamId, string kit) + { + return steamId + "|" + Lower(kit); + } + + private static int CallInt(Plugin plugin, string method, params object[] args) + { + object value = plugin.Call(method, args); + return value is int ? (int)value : 0; + } + + /// + /// Main thread. Brings one player's credits for one kit into line with what the site says + /// they earned and what Kits says they have used. Three steps, in this order: + /// + /// + /// + /// Notice a redemption. A moved credit lowered the use count to one below the + /// kit's limit; if the count is back at the limit, the player has redeemed it. This is + /// how OnKitRedeemed is read, and it also heals a redemption made while this + /// plugin was unloaded. + /// + /// + /// Withdraw what the site took back (a revert). Only a moved credit not yet + /// redeemed is put back; a redemption already made stands (R16). A use count is + /// never raised past the kit's limit. + /// + /// + /// Spend the next one, last. Only when the player has used the kit up, and one + /// at a time, so the credit is always the use after the kit's own. + /// + /// + /// + private void SettleCredit(Plugin kits, KitCredit c, ref int applied, ref int withdrawn) + { + if (kits == null) + return; + + int max = CallInt(kits, "GetKitMaxUses", c.Kit); + + // A kit with no use limit has no "one more use" to give; its permission is the reward. + if (max <= 0) + return; + + ulong id; + + if (!ulong.TryParse(c.SteamId, out id)) + return; + + int uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit); + + if (c.Applied > c.Consumed && uses >= max) + c.Consumed = c.Applied; + + if (c.Applied > c.Earned) + { + int withdraw = Math.Min(c.Applied - c.Earned, c.Applied - c.Consumed); + + if (withdraw > 0) + { + kits.Call("SetPlayerKitUses", id, c.Kit, Math.Min(uses + withdraw, max)); + uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit); + c.Applied -= withdraw; + withdrawn += withdraw; + } + + // What is left over was redeemed and stands. It is forgiven rather than held + // against the next credit this player earns: the reward it came from is gone. + c.Applied = Math.Min(c.Applied, c.Earned); + c.Consumed = Math.Min(c.Consumed, c.Applied); + } + + if (c.Applied < c.Earned && c.Applied == c.Consumed && uses >= max) + { + // `SetPlayerKitUses` is a no-op for a player with no usage record of this kit, so + // the read-back is what makes the count true — the same rule as the grants (D85). + kits.Call("SetPlayerKitUses", id, c.Kit, max - 1); + + if (CallInt(kits, "GetPlayerKitUses", id, c.Kit) == max - 1) + { + c.Applied++; + applied++; + } + } + } + + /// + /// Main thread, at the end of a perm.sync that carried credits. The field is + /// the site's whole set for this server: a player and kit it no longer names has earned + /// nothing, and their unredeemed credit is withdrawn. + /// + private void ReconcileCredits(PermSyncJob job) + { + var desired = new Dictionary(); + + foreach (KitCredit row in job.Credits) + { + string key = CreditKey(row.SteamId, row.Kit); + KitCredit have; + + if (desired.TryGetValue(key, out have)) + have.Earned += row.Earned; + else + desired[key] = row; + } + + foreach (KitCredit c in _credits.Values) + { + if (!desired.ContainsKey(CreditKey(c.SteamId, c.Kit))) + c.Earned = 0; + } + + foreach (KeyValuePair kv in desired) + { + KitCredit c; + + if (!_credits.TryGetValue(kv.Key, out c)) + { + c = new KitCredit { SteamId = kv.Value.SteamId, Kit = kv.Value.Kit }; + _credits[kv.Key] = c; + } + + c.Earned = kv.Value.Earned; + } + + Plugin kits = KitsPlugin(); + var unknown = new HashSet(); + + foreach (KitCredit c in _credits.Values) + { + if (kits == null) + continue; + + object isKit = kits.Call("IsKit", c.Kit); + + if (!(isKit is bool) || !(bool)isKit) + { + if (c.Earned > 0 && unknown.Add(c.Kit)) + job.CreditsUnresolved.Add(c.Kit); + + continue; + } + + SettleCredit(kits, c, ref job.CreditsApplied, ref job.CreditsWithdrawn); + } + + var empty = new List(); + + foreach (KeyValuePair kv in _credits) + { + if (kv.Value.Earned == 0 && kv.Value.Applied == 0) + empty.Add(kv.Key); + } + + foreach (string key in empty) + _credits.Remove(key); + + job.CreditsWaiting = kits == null; + SaveCredits(); + } + + /// + /// Void, like every hook here: Kits raises this after a redemption has been counted, which + /// is the only moment a player's usage record of the kit is certain to exist. + /// + private void OnKitRedeemed(BasePlayer player, string kitName) + { + MarkHook("OnKitRedeemed"); + + if (player == null || string.IsNullOrEmpty(kitName) || _credits.Count == 0) + return; + + KitCredit c; + + if (!_credits.TryGetValue(CreditKey(player.UserIDString, kitName), out c)) + return; + + int applied = 0, withdrawn = 0; + SettleCredit(KitsPlugin(), c, ref applied, ref withdrawn); + SaveCredits(); + + if (applied > 0) + _lastRewardSummary = $"{player.displayName} redeemed {kitName}; one more use applied from a reward"; + } + + /// + /// Whether Kits clears its usage data at a wipe (its own Wipe player data when the + /// server is wiped, off by default). Read from its config file, because the switch + /// is not in its API — and on Carbon that file is under carbon/configs, which + /// is why the directory comes from the framework and never from a literal (R19). + /// + private bool KitsWipesPlayerData() + { + try + { + string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Kits.json"); + + if (!File.Exists(path)) + return false; + + var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + Newtonsoft.Json.Linq.JToken wipe = config["Wipe player data when the server is wiped"]; + + return wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)wipe; + } + catch (Exception ex) + { + Puts($"could not read Kits' wipe setting: {Flatten(ex.Message)}"); + return false; + } + } + + /// + /// Main thread, from OnNewSave. When Kits has wiped every use count, a credit this + /// plugin moved into one is gone with it — so the record of moving it goes too, or the + /// next redemption would be read as spending a credit that no longer exists. What the + /// site says was earned stays: a wipe does not take a reward away. + /// + private void ResetCreditsForWipe() + { + if (_credits.Count == 0 || !KitsWipesPlayerData()) + return; + + foreach (KitCredit c in _credits.Values) + { + c.Applied = 0; + c.Consumed = 0; + } + + SaveCredits(); + Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over"); + } + + // ── Chat ────────────────────────────────────────────────────────────────── + + /// + /// Main thread. One line to everybody on the server. A key said in the last ten minutes + /// is answered ok and not said again, so a retried step never repeats itself. + /// A run's line is behind EventsEnabled; a news post is behind the site's own + /// switch instead (D104), because news is not an event. + /// + private void HandleChatSay(Dictionary cmd, string reqId) + { + string key = StringField(cmd, "key"); + bool fromEvent = cmd.ContainsKey("event") && string.Equals(StringField(cmd, "event"), "true", StringComparison.OrdinalIgnoreCase); + string message = Flatten(StringField(cmd, "message")); + long now = NowMs(); + + if (fromEvent && (_config == null || !_config.EventsEnabled)) + { + EmitRewardError("chat.error", reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return; + } + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(message)) + { + EmitRewardError("chat.error", reqId, "malformed", "a chat line has a key and a message"); + return; + } + + if (message.Length > MaxChat()) + { + EmitRewardError("chat.error", reqId, "too-long", + $"this server says at most {MaxChat()} characters in one line, not {message.Length}"); + return; + } + + // Protocol 12: where the line goes, and the voice it is said in (D140, D141). A caller + // from protocol 11 sends neither and is answered exactly as before. + string delivery = StringField(cmd, "delivery"); + + if (string.IsNullOrEmpty(delivery)) + delivery = DeliveryChat; + + if (delivery != DeliveryChat && delivery != DeliveryPopup) + { + EmitRewardError("chat.error", reqId, "malformed", + $"a line is delivered to chat or to a popup, not to '{delivery}'"); + return; + } + + string format = StringField(cmd, "format"); + + if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1)) + { + EmitRewardError("chat.error", reqId, "malformed", + $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters"); + return; + } + + Plugin popup = null; + + if (delivery == DeliveryPopup) + { + popup = PopupPlugin(); + + // R3: a refusal with a reason, never a line silently said somewhere else. + if (popup == null) + { + EmitRewardError("chat.error", reqId, "popup-unavailable", + "PopupNotifications is not loaded on this server, so a popup cannot be shown — install it, or deliver this line to chat"); + return; + } + } + + var stale = new List(); + + foreach (KeyValuePair kv in _chatKeys) + { + if (now - kv.Value > ChatKeyMemoryMs) + stale.Add(kv.Key); + } + + foreach (string s in stale) + _chatKeys.Remove(s); + + bool repeat = _chatKeys.ContainsKey(key); + + if (!repeat) + { + // A popup ignores the format: its panel is not a chat line. A null player is + // everybody connected, and a duration of 0 is the plugin's own default. Nothing + // queues for a player who is offline (§33.1). + string line = popup != null || format == null ? message : Voice(format, message); + + if (popup != null) + popup.Call("CreatePopupNotification", line, null, 0f); + else + Server.Broadcast(line); + + // Logged, because the game logs neither a broadcast nor a popup, and a line the + // website put in front of every player deserves a record on the host (§33.3). + Puts($"said ({delivery}): {line}"); + + _chatKeys[key] = now; + _lastRewardSummary = $"said a line ({message.Length} chars, {delivery}{(format != null ? ", voiced" : "")}) to {BasePlayer.activePlayerList.Count} players"; + } + + Dictionary reply = Frame("chat.ok", "reply"); + reply["said"] = !repeat; + reply["repeat"] = repeat; + reply["players"] = BasePlayer.activePlayerList.Count; + reply["delivery"] = delivery; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + // ── The optional mods (protocol 12) ─────────────────────────────────────── + // + // BetterChat and PopupNotifications are optional (PLAN.md §33, D141). Each is looked up at + // the moment it is needed, never held from our load, because an operator can load or + // unload either at any time; and every call into one that is missing answers with a + // reason instead of doing nothing quietly (R3). + + private const string DeliveryChat = "chat"; + private const string DeliveryPopup = "popup"; + + /// The most titles one set carries. The site has its own, lower, limit. + private const int TitlesMax = 500; + + /// + /// The longest one player's title string may be. The site caps a title's text at 24 + /// characters (§33.4); this bounds what a player with every title, in markup, can hold. + /// + private const int TitleTextMax = 1024; + + /// The longest voice format a chat line may carry (§33.2). + private const int ChatFormatMax = 512; + + /// + /// The chat titles players have earned on this server, by Steam id, already in + /// BetterChat's markup. The website composes them from the wipe's standings and pushes + /// the whole set (titles.set). + /// + /// + /// Kept on disk as well (D144). The site re-sends on a new boot id or wipe id, and + /// a hot reload of this plugin — an update, an oxide.reload — is neither: the boot + /// id names the server process. Without the file a reload empties every title until the + /// standings next move. A file from another wipe is not loaded, because titles rank the + /// wipe they were earned on. + /// + /// + /// + /// Swapped, never edited. BetterChat reads this through on + /// every chat line, and another plugin can make it do so from its own thread through + /// BetterChat's API. A dictionary written while it is read can throw or answer wrong; a + /// reference replaced whole is read either before the swap or after it. + /// + /// + private volatile Dictionary _titles = new Dictionary(); + + /// The getter BetterChat holds, kept so every registration hands it the same delegate. + private Func _titleGetter; + + /// + /// Whether the BetterChat loaded right now holds our getter. Cleared when BetterChat + /// unloads: its registrations live on the instance, so a reloaded BetterChat has + /// forgotten us until registers again (§33.1). + /// + private bool _titlesRegistered; + + /// The last title set, for rg.titles. Diagnostics only. + private string _lastTitlesSummary = "none yet"; + + /// + /// The twelve fields of a BetterChat group (D138), by the name its API takes, with + /// BetterChat 5.2.15's default for each. Title is absent: its default is the + /// group's own name in brackets (). + /// + private static readonly Dictionary ChatFieldDefaults = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Priority", "0" }, + { "Title", null }, + { "TitleColor", "#55aaff" }, + { "TitleSize", "15" }, + { "TitleHidden", "false" }, + { "TitleHiddenIfNotPrimary", "false" }, + { "UsernameColor", "#55aaff" }, + { "UsernameSize", "15" }, + { "MessageColor", "white" }, + { "MessageSize", "15" }, + { "ChatFormat", "{Title} {Username}: {Message}" }, + { "ConsoleFormat", "{Title} {Username}: {Message}" } + }; + + private Plugin BetterChatPlugin() + { + return plugins.Find("BetterChat"); + } + + private Plugin PopupPlugin() + { + return plugins.Find("PopupNotifications"); + } + + /// + /// BetterChat's third-party title getter. It runs on the chat path, once per line + /// and again whenever another plugin asks BetterChat to format one, so it does one + /// lookup and nothing else: no socket, no file, no allocation (R15). A player with no + /// title gets null, which BetterChat skips. + /// + private string TitleFor(Oxide.Core.Libraries.Covalence.IPlayer player) + { + if (player == null) + return null; + + ulong id; + string title; + + return ulong.TryParse(player.Id, out id) && _titles.TryGetValue(id, out title) ? title : null; + } + + /// Main thread. Hands BetterChat our title getter, if BetterChat is here. + private void RegisterTitles(string why) + { + Plugin betterChat = BetterChatPlugin(); + + if (betterChat == null) + { + _titlesRegistered = false; + return; + } + + if (_titleGetter == null) + _titleGetter = TitleFor; + + // Void, so the answer is null either way. `rg.titles ` is how a rig proves + // BetterChat renders what this handed it. + betterChat.Call("API_RegisterThirdPartyTitle", this, _titleGetter); + _titlesRegistered = true; + + if (why != "load") + Puts($"titles: registered with BetterChat again ({why}), holding {_titles.Count}"); + } + + /// + /// Main thread. Replaces the titles this server holds with the set the site sent. A + /// malformed entry refuses the whole set rather than being skipped: the set is whole by + /// contract, and half of one is a set nobody sent. + /// + private void HandleTitlesSet(Dictionary cmd, string reqId) + { + object raw; + var list = cmd.TryGetValue("titles", out raw) ? raw as Newtonsoft.Json.Linq.JArray : null; + + if (list == null) + { + EmitTitlesError(reqId, "malformed", "a title set carries a titles array"); + return; + } + + if (list.Count > TitlesMax) + { + EmitTitlesError(reqId, "too-large", $"this server holds at most {TitlesMax} titles, not {list.Count}"); + return; + } + + var next = new Dictionary(); + + foreach (Newtonsoft.Json.Linq.JToken item in list) + { + var row = item as Newtonsoft.Json.Linq.JObject; + string steamId = row != null ? JsonText(row["steamId"]) : null; + string text = row != null ? JsonText(row["text"]) : null; + ulong id; + + if (!ulong.TryParse(steamId, out id) || string.IsNullOrEmpty(text) || text.Length > TitleTextMax) + { + EmitTitlesError(reqId, "malformed", + $"every title names a Steam id and carries text of at most {TitleTextMax} characters"); + return; + } + + next[id] = text; + } + + _titles = next; + SaveTitles(); + + // A BetterChat that arrived without us hearing it load is picked up here, which also + // makes a restart of either plugin heal on the site's next push. + if (!_titlesRegistered && BetterChatPlugin() != null) + RegisterTitles("title set"); + + bool shown = _titlesRegistered && BetterChatPlugin() != null; + _lastTitlesSummary = $"{next.Count} titles, betterChat={shown}, at={DateTime.UtcNow:HH:mm:ss}Z"; + + Dictionary reply = Frame("titles.ok", "reply"); + reply["count"] = next.Count; + reply["betterChat"] = shown; + + string setId = StringField(cmd, "setId"); + + if (!string.IsNullOrEmpty(setId)) + reply["setId"] = setId; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// What titles.json holds: the set, and the wipe it was pushed on. + private sealed class TitleFile + { + public string WipeId; + public Dictionary Titles = new Dictionary(); + } + + private string TitlesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "titles.json"); + } + + /// Main thread. Written on every set the site pushes — a set arrives only when it changed. + private void SaveTitles() + { + var file = new TitleFile { WipeId = _wipeId }; + + foreach (KeyValuePair kv in _titles) + file.Titles[kv.Key.ToString()] = kv.Value; + + WriteDataFile(TitlesPath(), file, "titles"); + } + + /// + /// Main thread, from OnServerInitialized — the first moment the wipe id is real. + /// A file from another wipe, or with no wipe, is left unread: the site sends this wipe's. + /// + private void LoadTitles() + { + try + { + string path = TitlesPath(); + + if (!File.Exists(path)) + return; + + var file = JsonConvert.DeserializeObject(File.ReadAllText(path)); + + if (file == null || file.Titles == null || string.IsNullOrEmpty(_wipeId) || file.WipeId != _wipeId) + return; + + var loaded = new Dictionary(); + + foreach (KeyValuePair kv in file.Titles) + { + ulong id; + if (ulong.TryParse(kv.Key, out id) && !string.IsNullOrEmpty(kv.Value)) + loaded[id] = kv.Value; + } + + _titles = loaded; + _lastTitlesSummary = $"{loaded.Count} titles from disk, at={DateTime.UtcNow:HH:mm:ss}Z"; + } + catch (Exception ex) + { + Puts($"could not read the titles: {Flatten(ex.Message)}"); + } + } + + private void EmitTitlesError(string reqId, string reason, string message) + { + Dictionary frame = Frame("titles.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastTitlesSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// A JSON string or number as text, or null for anything else. + private static string JsonText(Newtonsoft.Json.Linq.JToken token) + { + if (token == null) + return null; + + switch (token.Type) + { + case Newtonsoft.Json.Linq.JTokenType.String: + return (string)token; + case Newtonsoft.Json.Linq.JTokenType.Integer: + return token.ToString(); + case Newtonsoft.Json.Linq.JTokenType.Boolean: + return (bool)token ? "true" : "false"; + default: + return null; + } + } + + /// + /// A chat line in a voice (D140): the site's format with the words put into it, turned + /// into the game's rich text. Said by us, with no player as its sender, so it works + /// whether or not BetterChat is loaded. + /// + private string Voice(string format, string message) + { + // BetterChat's own rule for a player's words (its GetOutput), for the same reason: the + // line is ours to style and the words inside it are not. + if (message.Contains("[#") || message.Contains("[+")) + message = message.Replace("[", string.Empty).Replace("]", string.Empty); + + return covalence.FormatText(format.Replace("{message}", message)); + } + + private static int Occurrences(string text, string needle) + { + int count = 0; + + for (int at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal)) + count++; + + return count; + } + + private Dictionary IntegrationsBody() + { + return new Dictionary + { + { "betterChat", IntegrationBody(BetterChatPlugin()) }, + { "popupNotifications", IntegrationBody(PopupPlugin()) } + }; + } + + /// A version only for a plugin that is loaded. Absent, not empty. + private static Dictionary IntegrationBody(Plugin plugin) + { + var body = new Dictionary { { "loaded", plugin != null } }; + + if (plugin != null) + body["version"] = plugin.Version.ToString(); + + return body; + } + + // ---- group styles (D138, D139, D143) ---- + + /// + /// The BetterChat fields a sync asks for on one group. A field this build does not + /// know, or a value that is not a string, number or boolean, is reported failed and + /// left out rather than guessed at. + /// + private List ChatStyle(PermSyncJob job, string group, Dictionary row) + { + var style = new List(); + object raw; + + if (!row.TryGetValue("chat", out raw)) + return style; + + var fields = raw as Newtonsoft.Json.Linq.JObject; + + if (fields == null) + return style; + + foreach (Newtonsoft.Json.Linq.JProperty field in fields.Properties()) + { + var want = field.Value as Newtonsoft.Json.Linq.JObject; + string value = want != null ? JsonText(want["value"]) : null; + + if (!ChatFieldDefaults.ContainsKey(field.Name) || value == null) + { + job.ChatFailed.Add(new Dictionary + { + { "group", group }, + { "field", field.Name }, + { "reason", "malformed" } + }); + continue; + } + + style.Add(new ChatFieldWant + { + Field = field.Name, + Value = value, + Expect = JsonText(want["expect"]) + }); + } + + return style; + } + + /// + /// Main thread. Puts the site's style on one BetterChat group, a field at a time, and + /// leaves alone any field somebody changed by hand. + /// + /// + /// A field is written when the game still holds what the site last pushed there, or + /// holds BetterChat's default and the site has never pushed it. Anything else is a hand + /// edit: it is not written, and it is reported with the game's value (D138, R2). A group + /// this sync had to create is written whole, because nobody can have edited it yet. + /// + /// + /// + /// The save is the part the API does not do. API_SetGroupField changes a + /// field in memory and never writes BetterChat's file, so on its next reload the style + /// would revert and read as drift. BetterChat's own chat group set does write + /// it, and writes every group, so it is run once per group touched with a value that + /// has not changed (D143). + /// + /// + private void ApplyChatStyle(PermSyncJob job, string group) + { + Plugin betterChat = BetterChatPlugin(); + + if (betterChat == null) + { + ChatFailure(job, group, null, "betterchat-unloaded"); + return; + } + + bool created = false; + + if (!ChatGroupExists(betterChat, group)) + { + // Saves on its own. + betterChat.Call("API_AddGroup", group); + + if (!ChatGroupExists(betterChat, group)) + { + ChatFailure(job, group, null, "not-created"); + return; + } + + created = true; + } + + Dictionary game = ChatGroupFields(betterChat, group); + bool touched = false; + + foreach (ChatFieldWant want in job.ChatStyles[group]) + { + string now = ChatFieldValue(game, want.Field); + + if (now == want.Value) + { + job.ChatSame++; + continue; + } + + bool ours = created || now == want.Expect || (want.Expect == null && now == ChatDefault(group, want.Field)); + + if (!ours) + { + job.ChatDrift.Add(new Dictionary + { + { "group", group }, + { "field", want.Field }, + { "game", now } + }); + continue; + } + + // The typed answer is why each field goes through the API (D143): `Success`, + // `InvalidField` or `InvalidValue`, as a BetterChat enum this plugin cannot name — + // so read by its name. Null is a group that is not there, or a value BetterChat's + // parser threw on without catching (a number too big for an int). + object result = betterChat.Call("API_SetGroupField", group, want.Field, want.Value); + string answer = result != null ? result.ToString() : null; + + if (answer != "Success") + { + ChatFailure(job, group, want.Field, answer ?? "no-answer"); + continue; + } + + job.ChatApplied++; + touched = true; + } + + if (!touched) + return; + + game = ChatGroupFields(betterChat, group); + string priority = ChatFieldValue(game, "Priority"); + + ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "set", group, "Priority", priority); + + // Read back rather than trusted: the command has no answer a plugin can read, and a + // console path that did something else would otherwise be counted as a save. + if (ChatFieldValue(ChatGroupFields(betterChat, group), "Priority") == priority) + job.ChatSaved++; + else + ChatFailure(job, group, "Priority", "not-saved"); + } + + /// + /// Main thread. Removes a BetterChat group the site no longer styles (D139). There is + /// no API for it, only BetterChat's own chat group remove, run as the server + /// console. A group already gone is removed: the retirement is idempotent. + /// + private void RemoveChatGroup(PermSyncJob job, string group) + { + Plugin betterChat = BetterChatPlugin(); + + if (betterChat == null) + { + ChatFailure(job, group, null, "betterchat-unloaded"); + return; + } + + if (ChatGroupExists(betterChat, group)) + ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "remove", group); + + if (ChatGroupExists(betterChat, group)) + ChatFailure(job, group, null, "still-present"); + else + job.ChatRemoved.Add(group); + } + + private static void ChatFailure(PermSyncJob job, string group, string field, string reason) + { + var row = new Dictionary { { "group", group }, { "reason", reason } }; + + if (field != null) + row["field"] = field; + + job.ChatFailed.Add(row); + } + + private static bool ChatGroupExists(Plugin betterChat, string group) + { + object exists = betterChat.Call("API_GroupExists", group); + return exists is bool && (bool)exists; + } + + private static Dictionary ChatGroupFields(Plugin betterChat, string group) + { + return betterChat.Call("API_GetGroupFields", group) as Dictionary + ?? new Dictionary(); + } + + /// + /// A field's value as the text the site compares: true/false for a + /// boolean, never C#'s True, and an invariant number. + /// + private static string ChatFieldValue(Dictionary fields, string field) + { + object value = null; + + foreach (KeyValuePair kv in fields) + { + if (string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase)) + { + value = kv.Value; + break; + } + } + + if (value == null) + return null; + + if (value is bool) + return (bool)value ? "true" : "false"; + + if (value is IFormattable) + return ((IFormattable)value).ToString(null, System.Globalization.CultureInfo.InvariantCulture); + + return value.ToString(); + } + + /// + /// BetterChat's default for a field on a new group. A group's title defaults to its own + /// name in brackets, except default's, which is [Player]. + /// + private static string ChatDefault(string group, string field) + { + if (string.Equals(field, "Title", StringComparison.OrdinalIgnoreCase)) + return group == "default" ? "[Player]" : "[" + group + "]"; + + string value; + return ChatFieldDefaults.TryGetValue(field, out value) ? value : null; + } + + /// + /// The titles this server holds, from the server console. Given a Steam id it also + /// prints what BetterChat would render for that player — which is how a rig proves a + /// title without a person typing in chat (§33.3). + /// + [ConsoleCommand("rg.titles")] + private void CmdTitles(ConsoleSystem.Arg arg) + { + Plugin betterChat = BetterChatPlugin(); + + var lines = new List + { + $"protocol={ProtocolVersion} titles={_titles.Count} " + + $"betterChat={(betterChat != null ? betterChat.Version.ToString() : "absent")} " + + $"registered={(_titlesRegistered && betterChat != null)}", + $"last set: {_lastTitlesSummary}" + }; + + // `GetString`, not `Args[0]`: current Rust hands a console command StringViews. + string steamId = arg.GetString(0, null); + + if (!string.IsNullOrEmpty(steamId)) + { + ulong id; + string held; + + lines.Add(ulong.TryParse(steamId, out id) && _titles.TryGetValue(id, out held) + ? $"held: {held}" + : "held: (none)"); + + Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId); + + if (betterChat == null) + lines.Add("rendered: (BetterChat is not loaded)"); + else if (known == null) + lines.Add("rendered: (this server has never seen that player)"); + else + lines.Add("rendered: " + (betterChat.Call("API_GetFormattedMessage", known, "(rg.titles)", false) as string ?? "(no answer)")); + } + + arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); + } + + /// What events are counting and what rewards are held, from the server console. + [ConsoleCommand("rg.rewards")] + private void CmdRewards(ConsoleSystem.Arg arg) + { + var lines = new List + { + $"protocol={ProtocolVersion} tallies={_runTallies.Count} credits={_credits.Count} " + + $"kits={(KitsPlugin() != null)} maxTallies={MaxTallies()} maxRecipients={MaxRecipients()}" + }; + + long now = NowMs(); + + foreach (RunTally t in _runTallies.Values) + { + lines.Add( + $" run {t.RunId}: {t.Score}{(t.KillsOf != null ? "/" + t.KillsOf : "")} " + + $"{(t.Zone != null ? "zone '" + t.Zone + "'" : "whole server")} people={t.People.Count} " + + $"{(Counting(t, now) ? "counting, " + (t.DeadlineMs - now) / 1000L + "s left" : "closed")}"); + } + + foreach (KitCredit c in _credits.Values) + lines.Add($" credit {c.SteamId} {c.Kit}: earned={c.Earned} applied={c.Applied} redeemed={c.Consumed}"); + + lines.Add($"last: {_lastRewardSummary}"); + arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); + } + + // ---- the map (protocol 11, PLAN.md §30) ---- + // + // The picture of this wipe's map and everything that moves on it, in four verbs. Five rules + // shape the section, and each came from the rig (§30.0): + // + // * **Nothing renders unasked.** The game's renderer holds the main thread for 8.5 s on a + // 3000 map and 21 s at full scale. The picture comes from Rust+'s cache, which the game + // fills at boot when `app.port` is set; only an admin's `map.render` ever draws one, and + // then on a later frame than the one that answered (D109). + // * **The picture is read, never copied, on the main thread.** Hashing it and cutting it + // into base64 slices happen on the thread pool. The bytes are never mutated once made, so + // handing the array to another thread is safe. + // * **No sweep per ask.** What moves is kept in sets by `OnEntitySpawned` / `OnEntityKill`, + // seeded by one sweep when the world is ready. An ask copies positions out of the sets. + // * **Filtering is the website's job** (§8.5). `map.live` answers every layer, and which of + // them a viewer may see is decided by the module, never here. + // * **Every map verb is outside `EventsEnabled`.** Nothing here changes the world. + + /// + /// One slice of the picture on the wire. Base64 grows it by a third, and the result must + /// fit the game link's 1 MiB line cap with its envelope (the asset bridge's phase 7 shape). + /// + private const int MapChunkBytes = 512 * 1024; + + /// + /// The render's geometry, which is the Rust+ cache's geometry exactly: half scale, and an + /// ocean margin of 500 PIXELS on each side, unscaled (§30.0). + /// + private const float MapRenderScale = 0.5f; + + private const int MapOceanMargin = 500; + + /// The default most sleepers, and the most bases, one live answer carries. + private const int MapDefaultMax = 2000; + + private const string MapSourceCompanion = "companion"; + private const string MapSourceRendered = "rendered"; + private const string MapSourceNone = "none"; + + /// What this plugin knows about the picture it can serve. Replaced whole, never edited. + private sealed class MapPicture + { + public string MapKey; + public string Source; + public byte[] Bytes; + public int Width; + public int Height; + public string Background; + + /// Null until the thread pool has hashed . + public volatile string Sha256; + } + + /// A rendered picture's companion file: which map it is a picture of. + private sealed class RenderedMapMeta + { + public string MapKey; + public string Background; + public long RenderedMs; + public long TookMs; + } + + /// The current picture, or null. Main thread writes; the pool only reads what it is handed. + private MapPicture _mapPicture; + + /// A picture this plugin rendered for the current map, read from disk at most once per map. + private MapPicture _mapRendered; + + private string _mapRenderedCheckedFor; + + /// Set between a `map.render` being accepted and the render finishing. + private bool _mapRendering; + + private readonly HashSet _mapWorld = new HashSet(); + private readonly HashSet _mapBases = new HashSet(); + + private string _lastMapSummary = "none yet"; + private long _mapLiveAsks, _mapLiveLastMs, _mapLiveMaxMs; + + private int MapMaxSleepers() + { + return _config != null ? _config.MapMaxSleepers : MapDefaultMax; + } + + private int MapMaxBases() + { + return _config != null ? _config.MapMaxBases : MapDefaultMax; + } + + /// + /// Which map this is. A procedural map is its size, seed and salt; a custom map is the + /// hash of the URL it was downloaded from. The key says WHICH map, and the picture's own + /// SHA-256 says which picture of it (§30.2). + /// + private static string MapKey() + { + if (World.Procedural) + { + return string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}.{1}.{2}", World.Size, World.Seed, World.Salt); + } + + string url = !string.IsNullOrEmpty(World.Url) ? World.Url : ConVar.Server.levelurl; + return "custom." + Sha256Hex(Encoding.UTF8.GetBytes(url ?? string.Empty)); + } + + private static string Sha256Hex(byte[] bytes) + { + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + byte[] hash = sha.ComputeHash(bytes); + var sb = new StringBuilder(hash.Length * 2); + + foreach (byte b in hash) + sb.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); + + return sb.ToString(); + } + } + + /// + /// A JPEG's own width and height, read from its frame header. The picture says what size + /// it is; a private field that says so as well is a second opinion that can drift. + /// + private static bool JpegSize(byte[] bytes, out int width, out int height) + { + width = 0; + height = 0; + + if (bytes == null || bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8) + return false; + + int i = 2; + + while (i + 9 < bytes.Length) + { + if (bytes[i] != 0xFF) + return false; + + byte marker = bytes[i + 1]; + int length = (bytes[i + 2] << 8) | bytes[i + 3]; + + // SOF0..SOF15 carry the frame's size — except DHT (C4), JPG (C8) and DAC (CC), + // which share the range and do not. + if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) + { + height = (bytes[i + 5] << 8) | bytes[i + 6]; + width = (bytes[i + 7] << 8) | bytes[i + 8]; + return width > 0 && height > 0; + } + + i += 2 + length; + } + + return false; + } + + private static System.Reflection.PropertyInfo _companionImage; + private static System.Reflection.FieldInfo _companionBackground; + private static bool _companionResolved; + + /// + /// Main thread. The Rust+ cache's picture, or null. Read by reflection, by NAME, and only + /// the two members this needs: CompanionServer types hold the server's Rust+ + /// secret, and a probe that enumerated one printed it into the log (§30.0). A Facepunch + /// rename makes this answer null, which is none — never an exception. + /// + private static byte[] CompanionImage(out string background) + { + background = null; + + try + { + if (!_companionResolved) + { + _companionResolved = true; + Type map = typeof(BasePlayer).Assembly.GetType("CompanionServer.Handlers.Map", false); + + if (map != null) + { + const System.Reflection.BindingFlags Static = + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Static; + + _companionImage = map.GetProperty("ImageData", Static); + _companionBackground = map.GetField("_background", Static); + } + } + + if (_companionImage == null) + return null; + + byte[] bytes = _companionImage.GetValue(null, null) as byte[]; + + if (bytes == null || bytes.Length == 0) + return null; + + if (_companionBackground != null) + background = _companionBackground.GetValue(null) as string; + + return bytes; + } + catch (Exception) + { + return null; + } + } + + private string MapDir() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name); + } + + /// + /// Main thread. A picture this plugin rendered earlier for THIS map, from disk. Read once + /// per map key, so a restart within the map does not ask for another render (§30.2). + /// + private MapPicture RenderedFromDisk(string key) + { + if (_mapRenderedCheckedFor == key) + return _mapRendered; + + _mapRenderedCheckedFor = key; + _mapRendered = null; + + try + { + string meta = Path.Combine(MapDir(), "map.json"); + string jpeg = Path.Combine(MapDir(), "map.jpg"); + + if (!File.Exists(meta) || !File.Exists(jpeg)) + return null; + + RenderedMapMeta m = JsonConvert.DeserializeObject(File.ReadAllText(meta)); + + if (m == null || m.MapKey != key) + return null; + + byte[] bytes = File.ReadAllBytes(jpeg); + int w, h; + + if (!JpegSize(bytes, out w, out h)) + return null; + + _mapRendered = new MapPicture + { + MapKey = key, + Source = MapSourceRendered, + Bytes = bytes, + Width = w, + Height = h, + Background = m.Background + }; + } + catch (Exception ex) + { + Puts($"map: could not read the rendered picture: {Flatten(ex.Message)}"); + } + + return _mapRendered; + } + + /// + /// Main thread. The picture to serve now: the Rust+ cache when it has one, else a render + /// of this map kept on disk, else null. Rebuilt only when the key or the cache's array + /// changes, and hashed on the pool the first time a picture is seen. + /// + private MapPicture CurrentPicture() + { + string key = MapKey(); + string background; + byte[] companion = CompanionImage(out background); + MapPicture current = _mapPicture; + + if (companion != null) + { + if (current != null && current.MapKey == key && ReferenceEquals(current.Bytes, companion)) + return current; + + int w, h; + + if (!JpegSize(companion, out w, out h)) + return null; + + current = new MapPicture + { + MapKey = key, + Source = MapSourceCompanion, + Bytes = companion, + Width = w, + Height = h, + Background = background + }; + } + else + { + current = RenderedFromDisk(key); + } + + _mapPicture = current; + HashLater(current); + return current; + } + + private static void HashLater(MapPicture picture) + { + if (picture == null || picture.Sha256 != null) + return; + + ThreadPool.QueueUserWorkItem(_ => + { + try + { + picture.Sha256 = Sha256Hex(picture.Bytes); + } + catch (Exception) + { + // Left null: `map.info` keeps answering `hashing`, and a later ask tries again. + } + }); + } + + /// + /// Main thread. The game's own grid, from its own function: how many cells a side has and + /// how wide one is. MapHelper is what the in-game map labels with, so the website + /// draws the game's grid by construction rather than from a constant somebody measured + /// once (§30.3). + /// + private static void GameGrid(out int cells, out float cellSize) + { + float size = ConVar.Server.worldsize; + float half = size / 2f; + + Vector2i last = MapHelper.PositionToGrid(new UnityEngine.Vector3(half - 0.5f, 0f, -half + 0.5f)); + cells = Math.Max(1, last.x + 1); + cellSize = size / cells; + } + + /// + /// Main thread. The monuments the in-game map shows: those with a display phrase, in the + /// same kind#n vocabulary as world.monuments, so a monument a step names and + /// a monument on the map are the same token. + /// + private static List MapMonuments() + { + var rows = new List(); + + foreach (KeyValuePair> kv in MonumentGroups()) + { + for (int i = 0; i < kv.Value.Count; i++) + { + MonumentInfo m = kv.Value[i]; + string phrase = m.displayPhrase != null ? m.displayPhrase.english : null; + + if (string.IsNullOrEmpty(phrase)) + continue; + + UnityEngine.Vector3 p = m.transform.position; + + rows.Add(new Dictionary + { + { "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key }, + { "kind", kv.Key }, + { "label", phrase }, + { "x", Math.Round(p.x, 1) }, + { "z", Math.Round(p.z, 1) }, + { "grid", MapHelper.PositionToString(p) } + }); + } + } + + return rows; + } + + /// + /// Stage one: what exists. Reads the cache, and hashes it on the pool once per map; no + /// other work. sha256 is absent while the hash is still being computed, and the + /// answer then says hashing so the caller asks again. + /// + private void HandleMapInfo(string reqId) + { + if (!MapReady(reqId)) + return; + + MapPicture picture = CurrentPicture(); + int cells; + float cellSize; + GameGrid(out cells, out cellSize); + + Dictionary reply = Frame("map.info", "reply"); + reply["mapKey"] = MapKey(); + reply["worldSize"] = ConVar.Server.worldsize; + reply["oceanMargin"] = MapOceanMargin; + reply["gridCells"] = cells; + reply["gridCellSize"] = Math.Round(cellSize, 3); + reply["monuments"] = MapMonuments(); + reply["rendering"] = _mapRendering; + + if (picture == null) + { + reply["source"] = MapSourceNone; + } + else + { + reply["source"] = picture.Source; + reply["bytes"] = picture.Bytes.Length; + reply["width"] = picture.Width; + reply["height"] = picture.Height; + reply["chunks"] = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes; + reply["chunkBytes"] = MapChunkBytes; + + if (!string.IsNullOrEmpty(picture.Background)) + reply["background"] = picture.Background; + + string hash = picture.Sha256; + + if (hash != null) + reply["sha256"] = hash; + else + reply["hashing"] = true; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Stage two: one slice of the picture. Refused stale when the map or the picture + /// moved since the caller asked stage one, so a fetch that straddles a wipe cannot splice + /// two maps. The slice is base64'd and serialized on the pool: at 512 KiB that is work the + /// main thread has no business doing. + /// + private void HandleMapFetch(Dictionary cmd, string reqId) + { + if (!MapReady(reqId)) + return; + + if (_mapRendering) + { + EmitMapError(reqId, "busy", "the server is drawing its map; ask again when map.info says it is done"); + return; + } + + string key = StringField(cmd, "mapKey"); + string sha = StringField(cmd, "sha256"); + int n = IntField(cmd, "chunk"); + MapPicture picture = CurrentPicture(); + + if (picture == null) + { + EmitMapError(reqId, "no-picture", "this server has no picture of its map"); + return; + } + + string hash = picture.Sha256; + + if (key != picture.MapKey || hash == null || !string.Equals(sha, hash, StringComparison.OrdinalIgnoreCase)) + { + EmitMapError(reqId, "stale", "the map or its picture changed since it was described; ask map.info again"); + return; + } + + int chunks = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes; + + if (n < 0 || n >= chunks) + { + EmitMapError(reqId, "malformed", $"this picture has {chunks} slices, numbered from 0"); + return; + } + + Dictionary reply = Frame("map.chunk", "reply"); + reply["mapKey"] = picture.MapKey; + reply["sha256"] = hash; + reply["chunk"] = n; + reply["chunks"] = chunks; + + if (reqId != null) + reply["reqId"] = reqId; + + byte[] bytes = picture.Bytes; + int offset = n * MapChunkBytes; + int length = Math.Min(MapChunkBytes, bytes.Length - offset); + + ThreadPool.QueueUserWorkItem(_ => + { + try + { + reply["data"] = Convert.ToBase64String(bytes, offset, length); + Emit(JsonConvert.SerializeObject(reply, Formatting.None)); + } + catch (Exception ex) + { + // Nothing is sent, and the caller's ask times out: a slice is re-asked, never trusted half-made. + Interface.Oxide.NextTick(() => Puts($"map: could not encode slice {n}: {Flatten(ex.Message)}")); + } + }); + } + + /// + /// The admin fallback (D109): draw the map with the game's own renderer, once. Refused + /// unless there is no picture at all, and while the world is loading. Answers + /// accepted AT ONCE and renders on a later frame, because the render outlasts the + /// sidecar's reply budget on any map larger than the rig's; the caller polls + /// map.info until its source is rendered. + /// + private void HandleMapRender(Dictionary cmd, string reqId) + { + if (!MapReady(reqId)) + return; + + if (_mapRendering) + { + EmitMapError(reqId, "busy", "the server is already drawing its map"); + return; + } + + MapPicture picture = CurrentPicture(); + + if (picture != null) + { + EmitMapError(reqId, "has-picture", + $"this server already has a picture of its map ({picture.Source}); nothing needs drawing"); + return; + } + + _mapRendering = true; + string actor = StringField(cmd, "actor"); + string key = MapKey(); + + Dictionary reply = Frame("map.render", "reply"); + reply["accepted"] = true; + reply["mapKey"] = key; + reply["worldSize"] = ConVar.Server.worldsize; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + + Puts($"map: drawing the map for {key} at an admin's request{(string.IsNullOrEmpty(actor) ? "" : " (" + actor + ")")}; " + + "the server will stall while it does"); + + // A timer rather than NextTick, so the reply above is on the wire before the stall. + timer.Once(0.5f, () => RenderMap(key)); + } + + /// Main thread, deliberately: the renderer reads the terrain, which is main-thread-only. + private void RenderMap(string key) + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + + try + { + if (MapKey() != key) + { + _lastMapSummary = "a render was abandoned: the map changed before it started"; + return; + } + + System.Reflection.MethodInfo render = null; + + foreach (System.Reflection.MethodInfo m in typeof(MapImageRenderer).GetMethods( + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)) + { + if (m.Name == "Render" && m.ReturnType == typeof(byte[])) + { + render = m; + break; + } + } + + if (render == null) + { + _lastMapSummary = "a render failed: the game has no MapImageRenderer.Render"; + Puts("map: " + _lastMapSummary); + return; + } + + // Called by parameter NAME, as the probe did: a reordered signature must not swap + // the scale for the margin. + System.Reflection.ParameterInfo[] ps = render.GetParameters(); + object[] args = new object[ps.Length]; + + for (int i = 0; i < ps.Length; i++) + { + if (ps[i].IsOut) + continue; + + if (ps[i].Name == "scale") + args[i] = MapRenderScale; + else if (ps[i].Name == "oceanMargin") + args[i] = MapOceanMargin; + else if (ps[i].Name == "lossy") + args[i] = true; + else if (ps[i].Name == "transparent") + args[i] = false; + else if (ps[i].HasDefaultValue) + args[i] = ps[i].DefaultValue; + } + + byte[] bytes = render.Invoke(null, args) as byte[]; + clock.Stop(); + + int w, h; + + if (bytes == null || !JpegSize(bytes, out w, out h)) + { + _lastMapSummary = $"a render produced no picture after {clock.ElapsedMilliseconds} ms"; + Puts("map: " + _lastMapSummary); + return; + } + + string background = null; + + for (int i = 0; i < ps.Length; i++) + { + if (ps[i].IsOut && args[i] is UnityEngine.Color) + background = "#" + UnityEngine.ColorUtility.ToHtmlStringRGB((UnityEngine.Color)args[i]); + } + + Directory.CreateDirectory(MapDir()); + File.WriteAllBytes(Path.Combine(MapDir(), "map.jpg"), bytes); + File.WriteAllText(Path.Combine(MapDir(), "map.json"), JsonConvert.SerializeObject(new RenderedMapMeta + { + MapKey = key, + Background = background, + RenderedMs = NowMs(), + TookMs = clock.ElapsedMilliseconds + }, Formatting.Indented)); + + // Read back through the path a restart would take, so what is served now is exactly + // what a restart would serve. + _mapRenderedCheckedFor = null; + _mapPicture = null; + CurrentPicture(); + + _lastMapSummary = $"drew the map for {key}: {w}x{h}, {bytes.Length} bytes, {clock.ElapsedMilliseconds} ms of stall"; + Puts("map: " + _lastMapSummary); + } + catch (Exception ex) + { + _lastMapSummary = $"a render threw after {clock.ElapsedMilliseconds} ms: {Flatten((ex.InnerException ?? ex).Message)}"; + Puts("map: " + _lastMapSummary); + } + finally + { + _mapRendering = false; + } + } + + /// Refuses a map command until the world is loaded, as a map.error. + private bool MapReady(string reqId) + { + if (_worldReady) + return true; + + EmitMapError(reqId, "not-ready", "the server is still loading its world; ask again once it has started"); + return false; + } + + // ── What moves ──────────────────────────────────────────────────────────── + + /// The world-event types the map follows (D112). A cheap type test: this runs on every spawn. + private static bool IsMapWorldThing(BaseNetworkable entity) + { + return entity is CargoShip || entity is PatrolHelicopter || entity is CH47Helicopter || + entity is BradleyAPC || entity is SupplyDrop || entity is HackableLockedCrate; + } + + /// Tool cupboards, and a player's vending machine — never a monument's (NPC) one. + private static bool IsMapBase(BaseNetworkable entity) + { + return entity is BuildingPrivlidge || (entity is VendingMachine && !(entity is NPCVendingMachine)); + } + + /// + /// Runs on EVERY entity spawn on the server, tens of thousands at boot: two type tests and, + /// rarely, a set insert. Void, so it can never veto a spawn. + /// + private void OnEntitySpawned(BaseNetworkable entity) + { + MarkHook("OnEntitySpawned"); + + if (entity == null) + return; + + if (IsMapWorldThing(entity)) + _mapWorld.Add((BaseEntity)entity); + else if (IsMapBase(entity)) + _mapBases.Add((BaseEntity)entity); + } + + private void OnEntityKill(BaseNetworkable entity) + { + MarkHook("OnEntityKill"); + + var e = entity as BaseEntity; + + if (e == null) + return; + + if (!_mapWorld.Remove(e)) + _mapBases.Remove(e); + } + + /// + /// Main thread, once per load when the world is there: the one sweep that seeds the sets. + /// The spawn hook keeps them after that; a hotload has no spawns to hear, which is why this + /// runs at all. + /// + private void SeedMapSets() + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + _mapWorld.Clear(); + _mapBases.Clear(); + + foreach (BaseNetworkable n in BaseNetworkable.serverEntities) + { + if (IsMapWorldThing(n)) + _mapWorld.Add((BaseEntity)n); + else if (IsMapBase(n)) + _mapBases.Add((BaseEntity)n); + } + + clock.Stop(); + Puts($"map: following {_mapWorld.Count} world things and {_mapBases.Count} bases ({clock.ElapsedMilliseconds} ms sweep)"); + + // Hashed now rather than at the first ask, so the first `map.info` usually has it. + CurrentPicture(); + } + + /// + /// A player's Steam id as text, from userID itself. UserIDString is a + /// cached string the game fills for a player it has seen connect, and the phase 14 walk + /// read it back empty from a sleeper made on the server: an id the map could not name + /// is an own dot and a clan mate the site can never find. + /// + private static string SteamIdOf(BasePlayer p) + { + ulong id = p.userID; + return id.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + private static bool Gone(BaseEntity e) + { + return e == null || e.IsDestroyed || e.transform == null; + } + + private static Dictionary At(UnityEngine.Vector3 p) + { + return new Dictionary + { + { "x", Math.Round(p.x, 1) }, + { "z", Math.Round(p.z, 1) } + }; + } + + private static string WorldKind(BaseEntity e) + { + if (e is CargoShip) return "cargo"; + if (e is PatrolHelicopter) return "heli"; + if (e is CH47Helicopter) return "chinook"; + if (e is BradleyAPC) return "bradley"; + if (e is SupplyDrop) return "supply"; + return "crate"; + } + + /// + /// What moves, every layer at once. Positions only, and nothing a player typed about a + /// base: no owner, no authorised list, no shop name (§30.5). The two unbounded layers — + /// sleepers and bases — stop at their bound and say so with truncated; a read has + /// nobody to refuse. + /// + private void HandleMapLive(string reqId) + { + if (!MapReady(reqId)) + return; + + var clock = System.Diagnostics.Stopwatch.StartNew(); + + var world = new List(); + var dead = new List(); + + foreach (BaseEntity e in _mapWorld) + { + if (Gone(e)) + { + dead.Add(e); + continue; + } + + Dictionary row = At(e.transform.position); + row["kind"] = WorldKind(e); + + var crate = e as HackableLockedCrate; + + if (crate != null) + { + if (crate.IsFullyHacked()) + row["hacked"] = true; + else if (crate.IsBeingHacked()) + row["hackLeftSec"] = (int)Math.Max(0f, HackableLockedCrate.requiredHackSeconds - crate.hackSeconds); + } + + world.Add(row); + } + + foreach (BaseEntity e in dead) + _mapWorld.Remove(e); + + var events = new List(); + + foreach (WorldEntry w in _world.Values) + { + Dictionary row; + + if (w.Kind == WorldKindZone) + { + row = At(new UnityEngine.Vector3(w.X, 0f, w.Z)); + row["radius"] = Math.Round(w.Radius, 1); + + if (!string.IsNullOrEmpty(w.Name)) + row["name"] = w.Name; + } + else + { + // Where it is NOW: an NPC walks, and a crate can be knocked about. + var found = FindEntity(w.Id) as BaseEntity; + + if (Gone(found)) + continue; + + row = At(found.transform.position); + row["prefab"] = w.Prefab; + } + + row["kind"] = w.Kind; + row["runId"] = w.RunId; + events.Add(row); + } + + var players = new List(); + + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (Gone(p) || p.IsDead()) + continue; + + Dictionary row = At(p.transform.position); + row["steamId"] = SteamIdOf(p); + row["name"] = p.displayName; + row["sleeping"] = p.IsSleeping(); + row["online"] = true; + players.Add(row); + } + + int maxSleepers = MapMaxSleepers(); + int sleepers = 0; + bool sleepersTruncated = false; + + foreach (BasePlayer p in BasePlayer.sleepingPlayerList) + { + if (Gone(p) || p.IsDead() || p.IsConnected) + continue; + + if (sleepers >= maxSleepers) + { + sleepersTruncated = true; + break; + } + + Dictionary row = At(p.transform.position); + row["steamId"] = SteamIdOf(p); + row["name"] = p.displayName; + row["sleeping"] = true; + row["online"] = false; + players.Add(row); + sleepers++; + } + + int maxBases = MapMaxBases(); + var bases = new List(); + bool basesTruncated = false; + dead.Clear(); + + foreach (BaseEntity e in _mapBases) + { + if (Gone(e)) + { + dead.Add(e); + continue; + } + + if (bases.Count >= maxBases) + { + basesTruncated = true; + continue; + } + + Dictionary row = At(e.transform.position); + row["kind"] = e is BuildingPrivlidge ? "tc" : "vending"; + bases.Add(row); + } + + foreach (BaseEntity e in dead) + _mapBases.Remove(e); + + clock.Stop(); + _mapLiveAsks++; + _mapLiveLastMs = clock.ElapsedMilliseconds; + _mapLiveMaxMs = Math.Max(_mapLiveMaxMs, _mapLiveLastMs); + + Dictionary reply = Frame("map.live", "reply"); + reply["mapKey"] = MapKey(); + reply["world"] = world; + reply["events"] = events; + reply["players"] = players; + reply["bases"] = bases; + reply["tookMs"] = clock.ElapsedMilliseconds; + + if (sleepersTruncated) + reply["playersTruncated"] = true; + + if (basesTruncated) + reply["basesTruncated"] = true; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// The map's one refusal frame. Every reason is a word the module can switch on. + private void EmitMapError(string reqId, string reason, string message) + { + Dictionary frame = Frame("map.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastMapSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What the map can serve and what asking it costs, from the server console. + [ConsoleCommand("rg.map")] + private void CmdMap(ConsoleSystem.Arg arg) + { + // Re-read rather than print the last answer: the cache can empty, or a render land, + // between asks, and a console that shows yesterday's source misleads the one person + // looking at it. + MapPicture picture = _worldReady ? CurrentPicture() : _mapPicture; + int cells; + float cellSize; + GameGrid(out cells, out cellSize); + string hash = picture != null ? picture.Sha256 : null; + + arg.ReplyWith( + $"protocol={ProtocolVersion} mapKey={MapKey()} source={(picture != null ? picture.Source : MapSourceNone)} " + + $"bytes={(picture != null ? picture.Bytes.Length : 0)} size={(picture != null ? picture.Width + "x" + picture.Height : "-")} " + + $"sha256={(hash != null ? hash.Substring(0, 12) : "-")} rendering={_mapRendering} " + + $"grid={cells}x{cellSize:0.##}m" + Environment.NewLine + + $"following world={_mapWorld.Count} bases={_mapBases.Count} " + + $"live asks={_mapLiveAsks} last={_mapLiveLastMs}ms max={_mapLiveMaxMs}ms" + Environment.NewLine + + $"last: {_lastMapSummary}"); + } + + // ---- reading a command's fields ---- + // + // `JsonConvert` hands back `object`, and every field below arrives as one. These are the + // three shapes this protocol uses, read defensively: a malformed field is a missing field, + // never an exception on the main thread. + + private static string StringField(Dictionary row, string key) + { + object value; + return row != null && row.TryGetValue(key, out value) && value != null + ? value.ToString() + : null; + } + + private static int IntField(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value) || value == null) + return 0; + + int parsed; + return int.TryParse(value.ToString(), out parsed) ? parsed : 0; + } + + private static long LongField(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value) || value == null) + return 0L; + + long parsed; + return long.TryParse(value.ToString(), out parsed) ? parsed : 0L; + } + + /// The objects in an array field, skipping anything that is not one. + private static IEnumerable> Rows(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value)) + yield break; + + var list = value as Newtonsoft.Json.Linq.JArray; + + if (list == null) + yield break; + + foreach (Newtonsoft.Json.Linq.JToken item in list) + { + var obj = item as Newtonsoft.Json.Linq.JObject; + + if (obj == null) + continue; + + yield return obj.ToObject>(); + } + } + + /// The strings in an array field. + private static IEnumerable Strings(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value)) + yield break; + + var list = value as Newtonsoft.Json.Linq.JArray; + + if (list == null) + yield break; + + foreach (Newtonsoft.Json.Linq.JToken item in list) + { + if (item == null || item.Type == Newtonsoft.Json.Linq.JTokenType.Null) + continue; + + yield return item.ToString(); + } + } + + /// + /// Permission and group names are compared case-insensitively because both frameworks + /// store them lowered. Comparing what the site sent against what the store holds + /// without this makes `Kits.VIP` and `kits.vip` two different privileges, one of which + /// is granted for ever and never found again. + /// + private static string Lower(string value) + { + return value == null ? null : value.Trim().ToLowerInvariant(); + } + + private static HashSet Bucket(Dictionary> map, string key) + { + HashSet bucket; + + if (!map.TryGetValue(key, out bucket)) + { + bucket = new HashSet(); + map[key] = bucket; + } + + return bucket; } // ---- diagnostics ---- @@ -1664,6 +10213,53 @@ namespace Oxide.Plugins $"wipeId={(_wipeId ?? "none")}"); } + /// + /// What the last permission sync did, from the server console. + /// + /// + /// It is the fastest way to tell the two silent failures apart on a rig: a sync that + /// reported `unresolved` names granted nothing because no loaded plugin registered + /// them, and a sync that reported `pending` members could not put somebody in a group + /// because the store has never seen them. Both look like "the website says they have + /// it and the game disagrees" from the other end. + /// + /// + [ConsoleCommand("rg.perms")] + private void CmdPerms(ConsoleSystem.Arg arg) + { + string[] groups = permission.GetGroups(); + string[] registered = permission.GetPermissions(); + + arg.ReplyWith( + $"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " + + $"groups={(groups != null ? groups.Length : 0)} " + + $"running={(_permJob != null)}" + Environment.NewLine + + $"last sync: {_lastPermSummary}"); + } + + /// + /// Where this framework keeps configuration, and what the last write from the website + /// did. + /// + /// + /// The root is the interesting half. It is oxide/config on one rig and + /// carbon/configs on the other, from this same source file, and an operator + /// wondering why the website is offering a tree they do not recognise is usually + /// looking at a server whose directories were moved. + /// + /// + [ConsoleCommand("rg.config")] + private void CmdConfig(ConsoleSystem.Arg arg) + { + string root = ConfigRoot(); + + arg.ReplyWith( + $"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " + + $"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine + + $"waiting={(_configJob != null ? _configJob.Reload : "no")}" + Environment.NewLine + + $"last write: {_lastConfigSummary}"); + } + /// /// Which of this plugin's hooks have actually fired, and how often. /// diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 56b04eb..d7ac002 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -62,6 +62,12 @@ const ANSWERS_DELIBERATELY = Object.create(null) /** Anything shaped like this is a game hook, by both frameworks' own convention. */ const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/ +/** + * The parameter list both frameworks bind a chat command on: the caller, the command word, and + * whatever followed it. Names are the author's; the types are not. + */ +const CHAT_SIGNATURE = /^BasePlayer\s+\w+,\s*string\s+\w+,\s*string\[\]\s+\w+$/ + /** * Method declarations, as this file cares about them: the return type and the * name. Deliberately narrow — it matches the plugin's own single style @@ -82,6 +88,31 @@ function readExpectedHooks(source) { .map((m) => m[1]) } +/** + * Every `[ChatCommand("x")]` and the signature of the method under it. + * + * **A chat command binds by reflection, exactly like a hook**, and fails the same silent way: a + * method with the wrong parameter list is never called and neither framework says a word. The + * player types `/link`, nothing happens, and there is no log line to find. So the signature is + * checked here rather than discovered on a rig. + */ +function readChatCommands(source) { + const found = [] + const attribute = /\[ChatCommand\("([^"]+)"\)\]\s*(?:\/\/[^\n]*\n\s*)*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(([^)]*)\)/g + + let match + while ((match = attribute.exec(source)) !== null) { + found.push({ + command: match[1], + returns: match[2].trim(), + name: match[3], + params: match[4].replace(/\s+/g, ' ').trim(), + }) + } + + return found +} + function readMethods(source) { const found = [] let m @@ -133,7 +164,39 @@ function check(source, toml) { } } - // 4. The two declaration sites this repository owns must agree. + // 4. Every chat command is bound by reflection and must have the signature the + // frameworks call. A wrong one is never invoked and says nothing. + const chat = readChatCommands(source) + + for (const cmd of chat) { + if (cmd.returns !== 'void') { + problems.push( + `/${cmd.command} (${cmd.name}) returns ${cmd.returns}, not void — a chat command's return ` + + 'value is not read, and a non-void signature is the shape that silently binds nothing.' + ) + } + + if (!CHAT_SIGNATURE.test(cmd.params)) { + problems.push( + `/${cmd.command} (${cmd.name}) takes (${cmd.params}), not (BasePlayer, string, string[]). ` + + 'Both frameworks bind a chat command by reflection on that exact signature, so this one ' + + 'would never be called and neither would log it — the player types the command and ' + + 'nothing happens at all.' + ) + } + } + + // Two commands answering to one name is one of them never running, and which one is + // whichever the framework happened to register second. + const seen = new Set() + for (const cmd of chat) { + if (seen.has(cmd.command)) { + problems.push(`/${cmd.command} is declared twice; only one of them can ever be bound`) + } + seen.add(cmd.command) + } + + // 5. The two declaration sites this repository owns must agree. const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source) const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml) @@ -171,6 +234,6 @@ function main() { ) } -module.exports = { check, readExpectedHooks, readMethods, HOOK_NAME } +module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } if (require.main === module) main() diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index 1e9cbbf..ebb8eff 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -1,4 +1,4 @@ -// A check is worth what it catches, so this breaks it seven ways. +// A check is worth what it catches, so this breaks it thirteen ways. // // The case that matters most is the last one: `checkPlugin.js` finds hooks with a // deliberately narrow regex, and a regex that silently matches NOTHING passes @@ -16,7 +16,7 @@ const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') -const { check, readExpectedHooks, readMethods, HOOK_NAME } = require('./checkPlugin') +const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin') /** A minimal plugin that passes, as the baseline every case below deviates from. */ function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) { @@ -128,3 +128,85 @@ test('the parser actually reads the real plugin, rather than quietly matching no const expected = readExpectedHooks(real) assert.ok(expected.length >= 15, `only found ${expected.length} entries in ExpectedHooks`) }) + +// ── Chat commands (protocol 3) ───────────────────────────────────────────── +// +// A chat command binds by reflection on an exact signature, and a wrong one is +// never invoked and never logged: the player types `/link` and nothing happens. +// That is the same failure mode the hook checks exist for, one surface along. + +/** A plugin whose only body is one chat command. */ +function withChat(attribute, signature, returns = 'void') { + return source({ + expected: [], + methods: ` [ChatCommand("${attribute}")] + private ${returns} CmdThing(${signature}) + { + }`, + }) +} + +const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args' + +test('a correctly shaped chat command passes', () => { + assert.deepEqual(check(withChat('link', GOOD_SIGNATURE), toml()), []) +}) + +test('a chat command taking the wrong player type is caught', () => { + // The plausible mistake: Covalence's `IPlayer` is what a Universal plugin + // takes, and this plugin is not one. It compiles and binds nothing. + const problems = check(withChat('link', 'IPlayer player, string command, string[] args'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/) +}) + +test('a chat command missing the trailing args is caught', () => { + const problems = check(withChat('link', 'BasePlayer player, string command'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /nothing happens at all/) +}) + +test('a chat command that returns something is caught', () => { + const problems = check(withChat('link', GOOD_SIGNATURE, 'object'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /returns object, not void/) +}) + +test('two chat commands answering to one name is caught', () => { + const both = source({ + expected: [], + methods: ` [ChatCommand("link")] + private void CmdOne(${GOOD_SIGNATURE}) + { + } + + [ChatCommand("link")] + private void CmdTwo(${GOOD_SIGNATURE}) + { + }`, + }) + + const problems = check(both, toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /declared twice/) +}) + +test('the chat-command parser reads the real plugin, rather than quietly matching nothing', () => { + // The same trap the method parser has, and the reason this file's last test + // exists: a regex that matches NOTHING passes every case above. + const real = fs.readFileSync( + path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'), + 'utf8' + ) + + const commands = readChatCommands(real) + const names = new Set(commands.map((c) => c.command)) + + assert.ok(commands.length >= 2, `only found ${commands.length} chat commands in the real plugin`) + assert.ok(names.has('link'), '/link was not found by the chat-command parser') + assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser') +})