From 249f2e513f9e8ff2a09008c7ba5176a39747f765 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 21:01:49 -0500 Subject: [PATCH] feat: stage 0, an empty RunicNPC that releases through CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository RunicNPC is built in (docs/runicnpc/PLAN.md §9, stage 0): - plugin/RunicNPC.cs: `// Requires: Kits` (D217), `[Info]` with the 0.0.0 placeholder the release stamps, `RunicNpc_ApiVersion()` (API 1), and `rnpc.status`, which reports the version and which hooks have fired. It spawns nothing. - plugin.toml: the API version, the framework floors it was loaded on (Oxide 2.0.7726, Carbon 2.0.259) and requires_plugins = ["Kits"]. - scripts/checkPlugin.js, adapted from Rust-Plugins': every hook listed and void unless written down; chat-command signatures; every RunicNpc_ call reachable by Call (the HumanNPC trap, PLAN.md §1.2); ApiVersion, `// Requires:` and [Info] agreeing with plugin.toml. 23 self-tests, including the real plugin and a CRLF checkout. - PR Checks on PRs into main and edge; the release workflow on main, with Rust-Plugins' release engine unchanged and an adapter that ships runicnpc-.tar.gz (runicnpc/RunicNPC.cs + manifest.json) and SHA256SUMS. No bundle dispatch until stage 4. - tools/: the rig panel scripts, with the panel and server ids moved into a git-ignored tools/rigs.json. `con.js` became `console.js`: CON is a reserved device name on Windows, and git there cannot open the file. - README, CONTRIBUTING (edge-based flow, AI disclosure, borrow-not-copy), SECURITY, the code of conduct, issue and PR templates. `feat:` so the cutover to main cuts the first release, 0.1.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/ISSUE_TEMPLATE/bug_report.md | 41 ++ .gitea/ISSUE_TEMPLATE/config.yaml | 5 + .gitea/ISSUE_TEMPLATE/feature_request.md | 23 ++ .gitea/PULL_REQUEST_TEMPLATE.md | 33 ++ .gitea/workflows/pr-checks.yml | 59 +++ .gitea/workflows/release.yml | 476 +++++++++++++++++++++++ .gitignore | 8 + CODE_OF_CONDUCT.md | 133 +++++++ CONTRIBUTING.md | 107 +++++ README.md | 81 +++- SECURITY.md | 51 +++ plugin.toml | 47 +++ plugin/RunicNPC.cs | 123 ++++++ scripts/checkPlugin.js | 336 ++++++++++++++++ scripts/checkPlugin.test.js | 303 +++++++++++++++ tools/console.js | 60 +++ tools/panel.js | 52 +++ tools/rig.js | 85 ++++ tools/rigs.example.json | 8 + 19 files changed, 2027 insertions(+), 4 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitea/workflows/pr-checks.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 plugin.toml create mode 100644 plugin/RunicNPC.cs create mode 100644 scripts/checkPlugin.js create mode 100644 scripts/checkPlugin.test.js create mode 100644 tools/console.js create mode 100644 tools/panel.js create mode 100644 tools/rig.js create mode 100644 tools/rigs.example.json diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..30e0c3e --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Report something that is broken or behaving unexpectedly +title: "[bug] " +labels: + - bug +--- + +## Summary + + + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + + + +## Actual behavior + + + +## Environment + +- Component / repo: +- Version or commit: +- Rust server build, Oxide or Carbon build, RunicNPC version, Kits version: +- Deployment (Docker Compose, local dev, bare metal…): + +## Additional context + + + + diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..212f99d --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/src/branch/main/SECURITY.md + about: Please do not open a public issue for security problems — report them privately by email instead (see SECURITY.md). diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..077b82d --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea, enhancement, or new capability +title: "[feature] " +labels: + - enhancement +--- + +## Problem / motivation + + + +## Proposed solution + + + +## Alternatives considered + + + +## Additional context + + diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..977b984 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ + + +## What & why + + + +## How it was tested + + + +## Checklist + +- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md). +- [ ] The change builds and existing tests/checks pass locally. +- [ ] I have added or updated tests/docs where it makes sense. +- [ ] My commits are reasonably scoped with clear messages. + +## AI-assisted contributions (required) + +This project **requires disclosure of AI tool usage**. Please pick one: + +- [ ] No AI tools were used to produce this contribution. +- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand + every change, and take responsibility for it. AI-authored commits are + marked with a `Co-Authored-By` / `Assisted-By` trailer. + +## License + +- [ ] I agree that my contribution is licensed under this project's license + (**GNU GPL v3.0 or later**), and I have the right to contribute it. diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml new file mode 100644 index 0000000..8c264ae --- /dev/null +++ b/.gitea/workflows/pr-checks.yml @@ -0,0 +1,59 @@ +# Gate every pull request into `main` and `edge`. +# +# RunicNPC cannot be compiled by CI: it is deployed as SOURCE and built by Oxide +# or Carbon against game assemblies that exist only on a Rust server, so a build +# job is not available at any price. +# +# What is available is a reader, and the mistakes worth reading for are the ones +# both frameworks make silent. Hooks, chat commands and `Call` targets bind by +# name through reflection, with no compile-time check and no warning when a name +# matches nothing. `scripts/checkPlugin.js` asks, dependency-free: +# +# • every hook is listed in `ExpectedHooks`, so `rnpc.status` can report it; +# • every hook is void unless answering is written down with a reason; +# • every chat command has the signature the frameworks bind; +# • every `RunicNpc_*` API call is one Oxide's `Call` can reach; +# • `ApiVersion`, `// Requires:` and `[Info]` agree with plugin.toml, which is +# what the release copies into the manifest the installer reads. +# +# Its own test suite breaks it every way it claims to catch — including the +# failure that would make every other case meaningless, a parser that silently +# matches nothing. +# +# Enforcement (one-time, in the Gitea UI): +# Repository Settings → Branches → Branch Protection (rules for `main`, `edge`) +# • Enable Status Check +# • Status check patterns: PR Checks / * +# Gitea only lists a context after it has reported once; the glob matches +# without the dropdown and keeps matching as jobs are added. + +name: PR Checks + +on: + pull_request: + branches: [main, edge] + +concurrency: + group: pr-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + plugin-checks: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # No install step: the checks are dependency-free on purpose, which is also + # how a contributor runs them. + - name: Check the plugin's hooks, API and declarations + run: node scripts/checkPlugin.js + + # Named individually rather than `node --test scripts/`: directory mode is + # not portable across the Node versions this project runs on. + - name: Test the checker itself + run: node --test scripts/checkPlugin.test.js diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..7d32328 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,476 @@ +# Automated release for RunicNPC. +# +# Trigger: every push to `main` (i.e. every merged PR — in practice the +# edge→main cutover, D226), and by hand. +# +# Why this exists: the Runic Gateway installer and the Pterodactyl egg will +# deploy RunicNPC from a release tarball, pinned and checksummed in the Rust +# bundle (docs/runicnpc/PLAN.md D224, stage 4), never from git — a game host gets +# no git and no Gitea credentials. The Gitea release is also the source of record +# for anyone who downloads RunicNPC on its own (D221). +# +# Flow — the same two halves as Rust-Plugins' release.yml, whose release engine +# is copied 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: runicnpc-.tar.gz + SHA256SUMS │ +# └───────────────────────────────────────────────────────────────────┘ +# +# ── What differs from Rust-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, `runicnpc/`, +# with `RunicNPC.cs` and `manifest.json` at its top. The same file goes to +# `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each +# decide which. 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 MANIFEST CARRIES `api`, not `protocol`. RunicNPC never speaks to the +# sidecar (PLAN.md §4): the bridge calls it in-process. What a consumer pairs +# on is the API version other plugins call, from plugin.toml. +# +# 4. NO BUNDLE DISPATCH, YET. The installer does not know RunicNPC until stage 4 +# makes it a third artefact of the Rust bundle (D224). That stage adds the +# step Rust-Plugins ends with, which asks RunicGateway/installer to recompose. +# +# 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, `0.0.0`; the manifest records the 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. v1.0.0 is stage 9's +# release, the one module-rust then requires. +# +# Prerequisites (Settings → Actions → Secrets on RunicGateway/runicnpc-rust, or +# the organisation's): +# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the +# tag and create the release. +# 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/runicnpc-rust + ARTIFACT: runicnpc + PLUGIN: plugin/RunicNPC.cs + SEED_VERSION: "0.1.0" + +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. + # A cutover merge commit is a tree no PR check ran on as such, so it is + # worth asking again. The checker also holds exactly one [Info(...)] line, + # which the stamp below depends on. + - 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 plugin.toml ] || { echo "::error::plugin.toml is missing (API + framework declarations)"; exit 1; } + node scripts/checkPlugin.js + + # ── 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\("RunicNPC", "Runic Gateway", ")[^"]*("\)\])/\1'"${VERSION}"'\2/' \ + "$PLUGIN" > "${STAGE}/RunicNPC.cs" + grep -qF "[Info(\"RunicNPC\", \"Runic Gateway\", \"${VERSION}\")]" "${STAGE}/RunicNPC.cs" \ + || { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; } + + # `files` names every .cs staged, with its sha256 — the installer places + # exactly this set, and checks each file against it. + PAIRS=() + for f in "${STAGE}"/*.cs; do + PAIRS+=("$(basename "$f")" "$(sha256sum "$f" | cut -d' ' -f1)") + done + FILES="$(jq -n '[$ARGS.positional | _nwise(2) | {(.[0]): .[1]}] | add' --args "${PAIRS[@]}")" + + # Declarations from plugin.toml. Read, don't hardcode — the point of + # that file is that each of these lives in one place. + toml_str() { grep -m1 -E "^$1[[:space:]]*=" plugin.toml | sed -E 's/.*"([^"]+)".*/\1/'; } + API="$(grep -m1 -E '^api[[:space:]]*=' plugin.toml | sed -E 's/[^0-9]//g')" + MIN_OXIDE="$(toml_str min_oxide_version)" + MIN_CARBON="$(toml_str min_carbon_version)" + # requires_plugins = ["Kits"] is already a JSON array. One line, quoted + # strings only; anything else fails the check below. + REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' plugin.toml | sed -E 's/^[^=]*=[[:space:]]*//')" + [ -n "$API" ] || { echo "::error::could not read api from plugin.toml"; exit 1; } + [ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from plugin.toml"; exit 1; } + [ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from plugin.toml"; exit 1; } + jq -e 'type == "array" and all(type == "string")' <<<"$REQUIRES" >/dev/null \ + || { echo "::error::requires_plugins in plugin.toml is not a one-line array of strings"; exit 1; } + echo "==> api=${API} oxide>=${MIN_OXIDE} carbon>=${MIN_CARBON} requires=${REQUIRES}" + + jq -n \ + --arg component "runicnpc" \ + --arg version "${VERSION}" \ + --arg commit "${GITHUB_SHA}" \ + --arg repo "${REPO}" \ + --argjson api "${API}" \ + --arg min_oxide "${MIN_OXIDE}" \ + --arg min_carbon "${MIN_CARBON}" \ + --argjson requires "${REQUIRES}" \ + --argjson files "${FILES}" \ + '{ + component: $component, + version: $version, + commit: $commit, + repo: $repo, + api: $api, + min_oxide_version: $min_oxide, + min_carbon_version: $min_carbon, + requires_plugins: $requires, + files: $files + }' > "${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 "runicnpc-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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6eda175 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +dist/ +*.log +.vs/ +bin/ +obj/ + +# Local knowledge of where the test rigs are; see tools/rigs.example.json. +tools/rigs.json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6503afa --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**whitlocktech@gmail.com**. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c69eed4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,107 @@ +# Contributing to RunicNPC + +Thanks for your interest in contributing! This repository is **RunicNPC**, Runic Gateway's NPC +plugin for Rust on Oxide and Carbon. What it will become, and in what order, is planned in +[`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). +Read it before proposing a feature: most are already placed in a stage. + +By participating you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Ways to contribute + +- **Report a bug** or **request a feature** through the + [issue tracker](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/issues) + (issue templates are provided). +- **Improve the code or docs** by opening a pull request (see below). +- **Never** report a security vulnerability in a public issue — see [SECURITY.md](SECURITY.md). + +## Development setup + +The plugin is deployed as **source** and compiled by Oxide or Carbon at load. There is no +standalone build and no CI build, because compiling it needs Rust's own managed assemblies. + +While developing, the loop is one copy and a wait: + +```bash +cp plugin/RunicNPC.cs /path/to/rust/oxide/plugins/ # or carbon/plugins/ +# The framework notices the write, recompiles, and reloads. Watch its log. +``` + +That is a developer's loop, not how a server is set up: servers get RunicNPC from a release. A +change that only works when you copy it by hand is a change that does not ship. + +**Borrow behaviours, never code.** RunicNPC may do what other NPC plugins do, but it is written from +how Rust's own classes behave. NpcSpawn states no licence, so its source grants nothing and is read +only as a description of what can be done (PLAN.md D214). Do not paste code from another plugin. + +### Checks + +```bash +node scripts/checkPlugin.js +node --test scripts/checkPlugin.test.js +``` + +Dependency-free; any Node 20+ runs them. They are what CI runs on every pull request, and what the +release runs again on the commit it ships. `scripts/checkPlugin.js` explains each check in its +header. Two matter most when you add code: + +- **A new hook goes in `ExpectedHooks`** in the plugin, so `rnpc.status` can report whether it + fires. +- **A hook that returns a value** changes what the game does. Add it to `ANSWERS_DELIBERATELY` in + the checker with the reason, and make it answer for RunicNPC's own NPCs only, returning null for + everything else on the server. + +### Testing on a server + +A hook binds by reflection and fails silently, so a running server is the only proof that one +fires. Every stage is tested on an **Oxide** server and then a **Carbon** server before it is +merged. `tools/` holds the scaffolding used for that (see `tools/rigs.example.json`). + +### Declarations + +`plugin.toml` and the plugin state some facts twice, and the checks hold them equal: + +| What | In the plugin | In `plugin.toml` | +|---|---|---| +| The API version | `ApiVersion` | `api` | +| Required plugins | `// Requires:` lines | `requires_plugins` | + +Bump the API version in both, in the same change, when a `RunicNpc_*` call or a raised hook changes +shape. + +## Branch & PR workflow + +1. Branch from an up-to-date **`edge`** with a descriptive name (`feat/…`, `fix/…`, `docs/…`, + `chore/…`). +2. Keep changes focused; small PRs are easier to review. +3. Open a pull request against `edge`. Fill out the PR template, including the **AI-assisted + contributions** disclosure. +4. A maintainer reviews it. `edge` is cut over to `main` for releases. + +### Commit messages + +We use [Conventional Commits](https://www.conventionalcommits.org/) — `type(scope): summary` (e.g. +`feat(api): add RunicNpc_Spawn`). The release version is derived from them: `feat` is a minor +release, `fix` and `perf` a patch, `!` or `BREAKING CHANGE` a major; `docs`, `chore`, `ci` and +`test` release nothing. + +## AI-assisted contributions (disclosure required) + +This project is developed openly with AI assistance, and we ask the same transparency of everyone. +**If you used an AI tool** (Claude, Copilot, ChatGPT, Cursor, etc.) to help produce a contribution, +you must disclose it: + +- Tick the AI-usage box in the pull-request template and name the tool(s). +- Mark AI-authored commits with a trailer, e.g. `Co-Authored-By: Claude ` or + `Assisted-By: `. +- You remain responsible for every line you submit: review it, understand it, and make sure it is + correct and that you have the right to contribute it. + +Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are not, and may be +closed. + +## License + +RunicNPC is licensed under the **GNU General Public License v3.0 or later** (see +[LICENSE.md](LICENSE.md)). By submitting a contribution you agree that it is licensed under the same +terms (inbound = outbound) and that you have the right to contribute it. diff --git a/README.md b/README.md index a900ccb..6b505de 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,82 @@ # RunicNPC -Runic Gateway's own NPC plugin for [Rust](https://rust.facepunch.com/), on Oxide and Carbon. +Runic Gateway's own NPC plugin for [Rust](https://rust.facepunch.com/), on **Oxide and Carbon**. -The plan of record is +Other plugins drive it through an API, and admins use it directly in game through chat commands. It +works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicGateway) server the +website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). -This repository is being set up (stage 0); work lands on `edge` and is cut over to `main` for releases. -Licensed GPL-3.0-or-later — see [LICENSE.md](LICENSE.md). +> **Status: stage 0.** The plugin loads, answers its API version, and reports on itself. It spawns +> nothing yet. Stage 1 is a measuring spike; the NPC and its API arrive in stage 2. + +## Requirements + +- **[Kits](https://umod.org/plugins/kits)** — required. A profile names kits, and that is how every + RunicNPC NPC is equipped (D217). The plugin declares `// Requires: Kits`, so neither framework + loads it without Kits. +- Oxide **2.0.7726** or Carbon **2.0.259**, or newer: the builds it has been loaded on + (`plugin.toml`). + +## Installing it + +On a Runic Gateway server, the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) +and the Pterodactyl egg will install it from the Rust bundle, pinned and checksummed (D224, from +stage 4). Until then, and on any other server: + +1. Download `runicnpc-.tar.gz` and `SHA256SUMS` from this repository's + [releases](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases), and check the + tarball against it (`sha256sum -c SHA256SUMS`). +2. Copy `runicnpc/RunicNPC.cs` into `oxide/plugins/` or `carbon/plugins/`. The framework compiles + and loads it on the write. + +`runicnpc/manifest.json`, beside it, states the release's version, commit, API version, framework +floors, required plugins, and the sha256 of every file it ships. + +## Checking it + +``` +rnpc.status +``` + +Answers at the server console and over RCON: the version, the API version, and which of the +plugin's hooks have fired. A hook that never fires is the first sign a Rust or framework update has +renamed it, because neither framework reports a hook that matches nothing. + +## For other plugins + +Every call is prefixed `RunicNpc_` and reached through `Call`: + +```csharp +[PluginReference] private Plugin RunicNPC; + +int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; +``` + +Stage 0 has only `RunicNpc_ApiVersion()`. The full API is planned in PLAN.md §4 and will be +documented as `docs/runicnpc/API.md` in stage 2. The API version moves when a call or a raised hook +changes shape, not on every release. + +## Repository layout + +| Path | What | +|---|---| +| `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | +| `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | +| `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | +| `tools/` | Developer scaffolding for the test rigs, never shipped (see `tools/rigs.example.json`). | + +## Releases + +Work lands on `edge` and is cut over to `main`; every releasable push to `main` tags and publishes a +release (`.gitea/workflows/release.yml`). The version comes from Conventional Commits since the +last tag. `v1.0.0` is stage 9's release, the one `module-rust` then requires. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) — including the AI-disclosure rule — and report security +problems privately as [SECURITY.md](SECURITY.md) describes. + +## License + +GPL-3.0-or-later — see [LICENSE.md](LICENSE.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2f41972 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,51 @@ +# Security Policy + +Thank you for helping keep Runic Gateway and its users safe. + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public issues, pull +requests, or the wiki.** A public report tips off attackers before a fix is +available. + +Instead, report privately by email to: + +**whitlocktech@gmail.com** + +Please include as much of the following as you can: + +- The repository and component affected. +- The type of issue (e.g. authentication bypass, injection, secret exposure, + remote code execution, denial of service). +- Step-by-step instructions to reproduce, and a proof-of-concept if you have one. +- The impact — what an attacker could do with it. +- Any suggested remediation. + +You will receive an acknowledgement of your report, typically within a few days. +We will keep you informed as we investigate and work toward a fix, and we are +happy to credit you in the release notes once the issue is resolved (let us know +if you would prefer to remain anonymous). + +## Scope + +Runic Gateway is a self-hosted platform made up of several components: + +| Component | Repo | Network exposure | +|---|---|---| +| Website (site + admin + API) | `RunicGateway/website` | Internet-facing (behind a reverse proxy) | +| rust-link sidecar | `RunicGateway/Rust-Link` | The only network-facing part of the game bridge | +| Oxide bridge plugin | `RunicGateway/Rust-Plugins` | Loopback only — dials the sidecar on `127.0.0.1` | +| RunicNPC | `RunicGateway/runicnpc-rust` | None — an in-process plugin; talks to no network, and reaches the site only through the bridge | +| Documentation | `RunicGateway/docs` | Content only | + +Because instances are self-hosted, the security of any given deployment also +depends on how it is configured and operated — strong secrets (`JWT_SECRET`, +`SECRET_ENC_KEY`, database and admin passwords), a correctly configured reverse +proxy and `TRUST_PROXY`, and keeping the shard itself unreachable from the +internet (only the sidecar should be exposed). See each repo's README for the +security model. + +## Supported versions + +This project is developed continuously and does not maintain long-term release +branches. Security fixes land on `main`; please run a recent build. diff --git a/plugin.toml b/plugin.toml new file mode 100644 index 0000000..7d3515f --- /dev/null +++ b/plugin.toml @@ -0,0 +1,47 @@ +# Release metadata for RunicNPC. +# +# Consumed by the release workflow, which folds these values into the +# manifest.json shipped inside the release tarball. The Runic Gateway installer +# and the Pterodactyl egg read that manifest to decide what they are placing +# (docs/runicnpc/PLAN.md D224), and the bridge will read the same `api` at hello +# (stage 4). +# +# There is deliberately NO version key here. The release version is derived from +# git tags and conventional commits by the release workflow, so there is no bump +# commit to keep in sync and no way for this file to disagree with the tag. + +# ── The API version other plugins call ─────────────────────────────────────── +# +# The number a caller of `RunicNpc_*` relies on (PLAN.md §4). It MUST equal +# `ApiVersion` in plugin/RunicNPC.cs; `node scripts/checkPlugin.js` holds the two +# equal on every pull request. It moves when a call or a raised hook changes +# shape, not on every release. +# +# The plugin answers this at run time through `RunicNpc_ApiVersion()`, but only +# once a server has booted with it loaded — too late for an installer to refuse +# a RunicNPC too old for the bridge it is pairing with. Declaring it here is what +# lets a bundle check the pair BEFORE an operator installs it. +# +# Current: 1 — `RunicNpc_ApiVersion()` and nothing else (stage 0). +api = 1 + +# ── Framework floors ───────────────────────────────────────────────────────── +# +# The same file runs on Oxide and Carbon; the installer and the egg decide +# whether it lands in `oxide/plugins/` or `carbon/plugins/`. These are the builds +# it is known good on — the two rigs it was loaded on — not a measured minimum. +# (The bridge's Oxide floor is older, 2.0.7585; RunicNPC has never run on that.) +min_oxide_version = "2.0.7726" +min_carbon_version = "2.0.259" + +# ── Plugins it cannot load without ─────────────────────────────────────────── +# +# Kits is how every RunicNPC NPC is equipped, and it is required (D217). The +# plugin says so itself with `// Requires: Kits`, which stops either framework +# loading it without Kits; this list is the same fact for the installer's +# `doctor`, which reports a missing one rather than installing it. The PR check +# holds this list and the plugin's `// Requires:` lines equal. +# +# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6), and a +# profile that asks for one on a server without it is refused when it is saved. +requires_plugins = ["Kits"] diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs new file mode 100644 index 0000000..edfd207 --- /dev/null +++ b/plugin/RunicNPC.cs @@ -0,0 +1,123 @@ +// Requires: Kits + +using System; +using System.Collections.Generic; + +namespace Oxide.Plugins +{ + /// + /// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon. + /// + /// + /// This is the stage 0 plugin (docs/runicnpc/PLAN.md §9): it loads, answers its API version, + /// and reports on itself. It spawns nothing. Everything it will do is planned in that + /// document, stage by stage. + /// + /// + /// + /// Kits is required (D217): it is how every RunicNPC NPC is equipped, so the plugin + /// declares it on the first line and neither framework loads RunicNPC without it. That line + /// and requires_plugins in plugin.toml are two statements of one fact, and the + /// PR check holds them equal. + /// + /// + [Info("RunicNPC", "Runic Gateway", "0.0.0")] + [Description("Runic Gateway's NPCs: profiles, placements and an API for other plugins.")] + internal class RunicNPC : RustPlugin + { + /// + /// The version of the API other plugins call (RunicNpc_*, PLAN.md §4). Declared + /// twice, here and as api in plugin.toml, which the release copies into + /// the tarball's manifest so the installer and the bridge can refuse a RunicNPC too old + /// for them before it is loaded. The PR check holds the two equal. + /// + /// + /// It moves when a call or a raised hook changes shape, not on every release: the + /// release version says what was built, this says what a caller can rely on. + /// + /// + private const int ApiVersion = 1; + + /// + /// Every hook this plugin declares. Both frameworks bind a hook by name and arity + /// through reflection, and neither says a word when a name matches nothing, so the + /// plugin counts its own: rnpc.status lists the ones that have never fired. The + /// list is seeded at zero, because a hook that never fired is exactly the one a + /// dictionary that learns names as they arrive could never report. + /// + private static readonly string[] ExpectedHooks = + { + "OnServerInitialized" + }; + + private readonly Dictionary _hookCounts = new Dictionary(); + + // ---- lifecycle ---- + + private void Init() + { + foreach (string hook in ExpectedHooks) + _hookCounts[hook] = 0L; + } + + private void OnServerInitialized() + { + MarkHook("OnServerInitialized"); + Puts($"RunicNPC {Version} loaded, API {ApiVersion}. Stage 0: it spawns nothing yet."); + } + + private void MarkHook(string name) + { + long count; + _hookCounts.TryGetValue(name, out count); + _hookCounts[name] = count + 1; + } + + // ---- the API (PLAN.md §4) ---- + // + // Every call is private and prefixed `RunicNpc_`. Private because Oxide's `Call` reaches a + // non-public method by name, and a PUBLIC one only when it carries [HookMethod] — the trap + // HumanNPC's RefreshNPC and RemoveNPC fell into (PLAN.md §1.2). Prefixed because `Call` + // matches by name alone, and no hook of any other plugin may ever match one of ours. + + /// The API's version. A caller that needs more refuses this RunicNPC and says so. + private int RunicNpc_ApiVersion() => ApiVersion; + + // ---- console ---- + + /// + /// What this RunicNPC is and which of its hooks have fired. Answers over RCON as well + /// as at the console, and is the first thing to ask for when a server says it has no + /// RunicNPC. + /// + [ConsoleCommand("rnpc.status")] + private void CmdStatus(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + var fired = new List(); + var silent = new List(); + + foreach (string hook in ExpectedHooks) + { + long count; + _hookCounts.TryGetValue(hook, out count); + + if (count > 0L) + fired.Add($"{hook}={count}"); + else + silent.Add(hook); + } + + string firedText = fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)"; + string silentText = silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)"; + + arg.ReplyWith( + $"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} " + + $"fired={fired.Count} silent={silent.Count}" + Environment.NewLine + + $"fired: {firedText}" + Environment.NewLine + + $"silent: {silentText}"); + } + } +} diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js new file mode 100644 index 0000000..7d35559 --- /dev/null +++ b/scripts/checkPlugin.js @@ -0,0 +1,336 @@ +#!/usr/bin/env node +// +// Static checks on RunicNPC, run on every pull request and again on the commit +// being released. +// +// This plugin has no unit tests and cannot have any in the ordinary sense: it is +// deployed as SOURCE and compiled by Oxide or Carbon against game assemblies that +// exist only on a Rust server. There is no way to build it here, and the nearest +// thing to a compiler this repository owns is a reader. It is the bridge's +// checker (Rust-Plugins' scripts/checkPlugin.js) adapted to what RunicNPC can get +// silently wrong. +// +// Both frameworks bind hooks, chat commands and `Call` targets **by name and +// arity, through reflection**, with no compile-time check and no warning when a +// name matches nothing. So: +// +// 1. A hook the plugin implements but does not list in `ExpectedHooks` is +// invisible to `rnpc.status`, the instrument that answers "does this hook +// fire on this framework". A name listed and never implemented reports +// silent for ever, which reads exactly like a hook the framework dropped. +// +// 2. A hook that ANSWERS changes what the game does: it can cancel a death, +// stop a turret targeting, or replace a corpse's loot. RunicNPC WILL answer +// some hooks — that is how an NPC plugin works (PLAN.md §3) — but each one +// must be a decision written down, so the rule is inverted: EVERY hook is +// `void` unless it is listed in `ANSWERS_DELIBERATELY` below, with a reason. +// A list of "dangerous" hook names would have to be maintained against a +// catalogue in another repository, and the first one somebody forgot would +// be the one that passed. There is nothing to forget this way. +// +// 3. A chat command with the wrong signature is never called, and nobody says +// so: the admin types `/rnpc` and nothing happens. +// +// 4. An API method (`RunicNpc_*`) that is `public` without `[HookMethod]` is +// unreachable: Oxide's `Call` finds a non-public method by name, and a public +// one only when it carries that attribute. The spike found HumanNPC's +// `RefreshNPC` and `RemoveNPC` in exactly this state (PLAN.md §1.2). +// +// 5. The facts declared twice must agree: +// • `ApiVersion` in the plugin and `api` in plugin.toml. The release copies +// plugin.toml into the manifest the installer and the bridge read, so a +// disagreement ships a RunicNPC that answers one version and claims +// another. +// • the plugin's `// Requires:` lines and plugin.toml's `requires_plugins`. +// The first stops the framework loading RunicNPC without Kits; the +// second is what `doctor` reports. Kits must be in both (D217). +// • exactly one `[Info("RunicNPC", "Runic Gateway", "…")]`, because the +// release stamps its version into that one line. +// +// Dependency-free by design, like every check script in this project: it runs on +// a bare Node with no install step, which is also how a contributor runs it. +// +// node scripts/checkPlugin.js + +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..') +const PLUGIN = path.join(ROOT, 'plugin', 'RunicNPC.cs') +const PLUGIN_TOML = path.join(ROOT, 'plugin.toml') + +/** + * Hooks this plugin answers on purpose, and why. + * + * Empty in stage 0, which spawns nothing. From stage 2 an entry here is a + * deliberate decision to let RunicNPC change what the game does — and, because + * the plugin shares the server with everyone else's entities, every one of them + * must answer for RunicNPC's OWN NPCs only and return null for anything else. + * That half cannot be read statically; it is the reviewer's to check, which is + * why the reason goes here where the reviewer will see it. + */ +const ANSWERS_DELIBERATELY = Object.create(null) + +/** Plugins RunicNPC must require, whatever else it requires (D217). */ +const REQUIRED_PLUGINS = ['Kits'] + +/** Anything shaped like this is a game hook, by both frameworks' own convention. */ +const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/ + +/** Every call of the public API carries this prefix (PLAN.md §4). */ +const API_NAME = /^RunicNpc_\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: any attributes directly above, the access + * modifier, the return type and the name. Deliberately narrow — it matches the plugin's own + * single style (`private [static] (`) rather than trying to parse C#. A method written + * some other way is not matched, which would let a hook through, so the shape is asserted by the + * self-test against the real plugin rather than assumed. `\r?` because a Windows checkout has + * CRLF endings, and an attribute line that failed to match there would hide a [HookMethod]. + */ +const METHOD = + /((?:^[ \t]*\[[^\]\r\n]*\][ \t]*\r?\n)*)^[ \t]*(private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(/gm + +/** The attribute the release stamps a version into. */ +const INFO = /^[ \t]*\[Info\("RunicNPC", "Runic Gateway", "[^"]*"\)\]/gm + +function readExpectedHooks(source) { + const block = /ExpectedHooks\s*=\s*\{([\s\S]*?)\}\s*;/.exec(source) + if (!block) return null + + return block[1] + .split(',') + .map((entry) => /"([^"]+)"/.exec(entry)) + .filter(Boolean) + .map((m) => m[1]) +} + +function readMethods(source) { + const found = [] + let m + METHOD.lastIndex = 0 + while ((m = METHOD.exec(source)) !== null) { + found.push({ attributes: m[1], access: m[2], returns: m[3].trim(), name: m[4] }) + } + return found +} + +/** + * 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. + */ +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 +} + +/** The plugin names in `// Requires: A, B` lines at the top of the file, in order. */ +function readRequires(source) { + const names = [] + const line = /^\s*\/\/\s*Requires:\s*(.+)$/gm + let m + while ((m = line.exec(source)) !== null) { + for (const name of m[1].split(',')) { + if (name.trim()) names.push(name.trim()) + } + } + return names +} + +/** `requires_plugins = ["A", "B"]`, one line, quoted strings only — or null if it is not that. */ +function readTomlRequires(toml) { + const m = /^\s*requires_plugins\s*=\s*(\[.*\])\s*$/m.exec(toml) + if (!m) return null + try { + const list = JSON.parse(m[1]) + return Array.isArray(list) && list.every((x) => typeof x === 'string') ? list : null + } catch { + return null + } +} + +function check(source, toml) { + const problems = [] + + const expected = readExpectedHooks(source) + if (!expected) { + return ['could not find the ExpectedHooks array in the plugin source'] + } + + const methods = readMethods(source) + const hooks = methods.filter((x) => HOOK_NAME.test(x.name)) + const hookNames = new Set(hooks.map((x) => x.name)) + + // 1. Every hook the plugin implements is one `rnpc.status` can report on. + for (const hook of hooks) { + if (!expected.includes(hook.name)) { + problems.push( + `${hook.name} is implemented but missing from ExpectedHooks, so rnpc.status cannot report it` + ) + } + } + + // ... and no phantom entries. + for (const name of expected) { + if (!hookNames.has(name)) { + problems.push(`ExpectedHooks lists ${name}, but no method of that name is implemented`) + } + } + + // 2. Every hook is void, unless answering is a decision somebody wrote down. + for (const hook of hooks) { + if (hook.returns === 'void') continue + if (hook.name in ANSWERS_DELIBERATELY) continue + + problems.push( + `${hook.name} returns ${hook.returns}, not void — a hook that answers changes what the game ` + + 'does. If RunicNPC must answer it, add it to ANSWERS_DELIBERATELY in scripts/checkPlugin.js ' + + 'with the reason, and answer for RunicNPC\'s own NPCs only.' + ) + } + + // 3. Every chat command has the signature the frameworks bind, and one name binds once. + const chat = readChatCommands(source) + const seen = new Set() + + 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.' + ) + } + + if (seen.has(cmd.command)) { + problems.push(`/${cmd.command} is declared twice; only one of them can ever be bound`) + } + seen.add(cmd.command) + } + + // 4. Every API method is one `Call` can reach. + for (const method of methods.filter((x) => API_NAME.test(x.name))) { + if (method.access === 'public' && !/\[HookMethod\(/.test(method.attributes)) { + problems.push( + `${method.name} is public without [HookMethod], so Oxide's Call cannot reach it — make it ` + + 'private, as every RunicNpc_ call is (PLAN.md §4).' + ) + } + } + + // 5a. The API version, declared twice. + const inCode = /ApiVersion\s*=\s*(\d+)\s*;/.exec(source) + const inToml = /^\s*api\s*=\s*(\d+)\s*$/m.exec(toml) + + if (!inCode) problems.push('could not read ApiVersion from the plugin source') + if (!inToml) problems.push('could not read `api` from plugin.toml') + + if (inCode && inToml && inCode[1] !== inToml[1]) { + problems.push( + `the plugin answers API ${inCode[1]} and plugin.toml declares ${inToml[1]}. The release ` + + 'copies plugin.toml into the manifest the installer and the bridge read, so this would ship ' + + 'a RunicNPC that claims one version and answers another.' + ) + } + + // 5b. The required plugins, declared twice. + const requires = readRequires(source) + const tomlRequires = readTomlRequires(toml) + + if (!tomlRequires) { + problems.push('could not read `requires_plugins` from plugin.toml as a one-line array of strings') + } else { + const a = [...new Set(requires)].sort().join(', ') + const b = [...new Set(tomlRequires)].sort().join(', ') + if (a !== b) { + problems.push( + `the plugin's // Requires: lines name [${a}] and plugin.toml's requires_plugins names [${b}]. ` + + 'The first is what stops the framework loading RunicNPC; the second is what doctor reports.' + ) + } + } + + for (const name of REQUIRED_PLUGINS) { + if (!requires.includes(name)) { + problems.push( + `the plugin does not declare // Requires: ${name}. ${name} is required (D217), and without ` + + 'the line the framework loads RunicNPC on a server that cannot equip a single NPC.' + ) + } + } + + // 5c. The one line the release stamps. + const infoCount = (source.match(INFO) || []).length + if (infoCount !== 1) { + problems.push( + `expected exactly one [Info("RunicNPC", "Runic Gateway", "…")] attribute, found ${infoCount}. ` + + 'The release stamps its version into that line; zero or two would ship a plugin whose ' + + 'reported version is a lie.' + ) + } + + return problems +} + +function main() { + const source = fs.readFileSync(PLUGIN, 'utf8') + const toml = fs.readFileSync(PLUGIN_TOML, 'utf8') + + const problems = check(source, toml) + + if (problems.length > 0) { + console.error('RunicNPC failed its static checks:\n') + for (const p of problems) console.error(` • ${p}`) + console.error('') + process.exit(1) + } + + const expected = readExpectedHooks(source) + const api = /ApiVersion\s*=\s*(\d+)\s*;/.exec(source)[1] + const calls = readMethods(source).filter((x) => API_NAME.test(x.name)).length + console.log( + `plugin ok — API ${api}, ${calls} API call(s), ${expected.length} hook(s) declared, ` + + `requires ${readRequires(source).join(', ')}` + ) +} + +module.exports = { + check, + readExpectedHooks, + readMethods, + readChatCommands, + readRequires, + readTomlRequires, + HOOK_NAME, + API_NAME, +} + +if (require.main === module) main() diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js new file mode 100644 index 0000000..f86785f --- /dev/null +++ b/scripts/checkPlugin.test.js @@ -0,0 +1,303 @@ +// A check is worth what it catches, so this breaks it every way it claims to catch. +// +// The cases that matter most are the last ones: `checkPlugin.js` finds hooks and +// API calls with deliberately narrow regexes, and a regex that silently matches +// NOTHING passes every other check in this file and every check in CI while +// asserting nothing at all. So the real plugin source is read too, and the parse +// is asserted against names known to be in it. +// +// node --test scripts/checkPlugin.test.js +// +// Named individually rather than `node --test scripts/`: directory mode is not +// portable across the Node versions this project runs on. + +const test = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const path = require('node:path') + +const { + check, + readExpectedHooks, + readMethods, + readRequires, + readTomlRequires, + HOOK_NAME, + API_NAME, +} = require('./checkPlugin') + +const INFO_LINE = ' [Info("RunicNPC", "Runic Gateway", "0.0.0")]' + +/** A minimal plugin that passes, as the baseline every case below deviates from. */ +function source({ + expected = ['OnServerInitialized'], + methods, + api = 1, + requires = '// Requires: Kits', + info = INFO_LINE, +} = {}) { + const body = + methods ?? + ` private void OnServerInitialized() + { + } + + private int RunicNpc_ApiVersion() => ApiVersion;` + + return `${requires} + +namespace Oxide.Plugins +{ +${info} + internal class RunicNPC : RustPlugin + { + private const int ApiVersion = ${api}; + + private static readonly string[] ExpectedHooks = + { + ${expected.map((e) => `"${e}"`).join(', ')} + }; + +${body} + } +}` +} + +const toml = ({ api = 1, requires = '["Kits"]' } = {}) => + `api = ${api}\nmin_oxide_version = "2.0.7585"\nrequires_plugins = ${requires}\n` + +test('a plugin that follows the rules passes', () => { + assert.deepEqual(check(source(), toml()), []) +}) + +// ── Hooks ────────────────────────────────────────────────────────────────── + +test('a hook missing from ExpectedHooks is caught, because rnpc.status could not report it', () => { + const problems = check(source({ expected: [] }), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /OnServerInitialized is implemented but missing from ExpectedHooks/) +}) + +test('a name listed but never implemented is caught, because it reports silent for ever', () => { + const problems = check(source({ expected: ['OnServerInitialized', 'OnEntityDeath'] }), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /ExpectedHooks lists OnEntityDeath, but no method/) +}) + +test('a hook that answers is caught unless it is written down', () => { + const methods = ` private object OnNpcTarget(BaseEntity npc, BaseEntity target) + { + return null; + }` + + const problems = check(source({ expected: ['OnNpcTarget'], methods }), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /OnNpcTarget returns object, not void/) + assert.match(problems[0], /ANSWERS_DELIBERATELY/) +}) + +test('returning null is not good enough — the signature is the rule', () => { + // `return null` today is one edit away from `return true` tomorrow, and the + // edit that breaks it looks harmless in a diff. A void method cannot be turned + // into a veto without changing its signature, which is visible. + const methods = ` private bool CanBeTargeted(BaseCombatEntity entity, AutoTurret turret) + { + return true; + }` + + const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml()) + assert.match(problems[0], /CanBeTargeted returns bool, not void/) +}) + +test('a method that is not shaped like a hook is left alone', () => { + const methods = ` private Dictionary Describe(BasePlayer npc) + { + return null; + } + + private static string Column(int index) + { + return null; + }` + + assert.deepEqual(check(source({ expected: [], methods }), toml()), []) + assert.ok(!HOOK_NAME.test('Describe')) + assert.ok(HOOK_NAME.test('OnServerInitialized')) + assert.ok(HOOK_NAME.test('CanBeTargeted')) +}) + +// ── The API ──────────────────────────────────────────────────────────────── + +test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => { + // The state the spike found HumanNPC's RefreshNPC and RemoveNPC in (PLAN.md §1.2). + const methods = ` private void OnServerInitialized() + { + } + + public int RunicNpc_ApiVersion() => ApiVersion;` + + const problems = check(source({ methods }), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /RunicNpc_ApiVersion is public without \[HookMethod\]/) +}) + +test('a public API call WITH [HookMethod] is reachable and passes', () => { + const methods = ` private void OnServerInitialized() + { + } + + [HookMethod("RunicNpc_ApiVersion")] + public int RunicNpc_ApiVersion() => ApiVersion;` + + assert.deepEqual(check(source({ methods }), toml()), []) +}) + +test('[HookMethod] is still seen in a CRLF checkout', () => { + // A Windows clone has CRLF endings; an attribute regex that only knew `\n` + // stopped seeing [HookMethod] there and failed a reachable call. + const methods = ` private void OnServerInitialized() + { + } + + [HookMethod("RunicNpc_ApiVersion")] + public int RunicNpc_ApiVersion() => ApiVersion;` + + assert.deepEqual(check(source({ methods }).replace(/\n/g, '\r\n'), toml()), []) +}) + +test('an API version that disagrees with plugin.toml is caught', () => { + const problems = check(source({ api: 2 }), toml({ api: 1 })) + assert.equal(problems.length, 1) + assert.match(problems[0], /answers API 2 and plugin\.toml declares 1/) +}) + +test('a missing api key in plugin.toml is caught', () => { + const problems = check(source(), 'requires_plugins = ["Kits"]\n') + assert.equal(problems.length, 1) + assert.match(problems[0], /could not read `api` from plugin\.toml/) +}) + +// ── Required plugins (D217) ──────────────────────────────────────────────── + +test('a plugin without // Requires: Kits is caught, even when the toml agrees', () => { + const problems = check(source({ requires: '' }), toml({ requires: '[]' })) + assert.equal(problems.length, 1) + assert.match(problems[0], /does not declare \/\/ Requires: Kits/) +}) + +test('// Requires: and requires_plugins that disagree are caught', () => { + const problems = check( + source({ requires: '// Requires: Kits, ZoneManager' }), + toml({ requires: '["Kits"]' }) + ) + assert.equal(problems.length, 1) + assert.match(problems[0], /name \[Kits, ZoneManager\] and plugin\.toml's requires_plugins names \[Kits\]/) +}) + +test('a requires_plugins that is not a one-line array of strings is caught', () => { + const problems = check(source(), toml({ requires: 'Kits' })) + assert.equal(problems.length, 1) + assert.match(problems[0], /could not read `requires_plugins`/) + assert.equal(readTomlRequires('requires_plugins = [1]'), null) +}) + +test('several // Requires: lines and comma lists are all read', () => { + assert.deepEqual(readRequires('// Requires: Kits\n// Requires: ZoneManager, Economics\n'), [ + 'Kits', + 'ZoneManager', + 'Economics', + ]) +}) + +// ── The stamped line ─────────────────────────────────────────────────────── + +test('a missing [Info] line is caught, because the release has nothing to stamp', () => { + const problems = check(source({ info: ' [Info("RunicNpc", "Runic Gateway", "0.0.0")]' }), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /exactly one \[Info\("RunicNPC", "Runic Gateway", "…"\)\] attribute, found 0/) +}) + +test('two [Info] lines are caught', () => { + const problems = check(source({ info: `${INFO_LINE}\n${INFO_LINE}` }), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /found 2/) +}) + +// ── Chat commands ────────────────────────────────────────────────────────── +// +// None exist until stage 3 (`/rnpc`), so these run on fixtures only. Stage 3 +// adds the real-plugin assertion below, as the bridge's checker has. + +const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args' + +function withChat(name, signature, returns = 'void') { + return source({ + expected: [], + methods: ` [ChatCommand("${name}")] + private ${returns} CmdThing(${signature}) + { + }`, + }) +} + +test('a correctly shaped chat command passes', () => { + assert.deepEqual(check(withChat('rnpc', GOOD_SIGNATURE), toml()), []) +}) + +test('a chat command taking the wrong player type is caught', () => { + const problems = check(withChat('rnpc', 'IPlayer player, string command, string[] args'), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/) +}) + +test('a chat command that returns something is caught', () => { + const problems = check(withChat('rnpc', 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("rnpc")] + private void CmdOne(${GOOD_SIGNATURE}) + { + } + + [ChatCommand("rnpc")] + private void CmdTwo(${GOOD_SIGNATURE}) + { + }`, + }) + + const problems = check(both, toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /declared twice/) +}) + +// ── The real plugin ──────────────────────────────────────────────────────── + +const ROOT = path.resolve(__dirname, '..') +const real = fs.readFileSync(path.join(ROOT, 'plugin', 'RunicNPC.cs'), 'utf8') +const realToml = fs.readFileSync(path.join(ROOT, 'plugin.toml'), 'utf8') + +test('the parser actually reads the real plugin, rather than quietly matching nothing', () => { + const methods = readMethods(real) + const names = new Set(methods.map((m) => m.name)) + + // Raise these floors as the plugin grows; they are what stops a regex that + // matches nothing from passing every case above. + assert.ok(methods.length >= 5, `only found ${methods.length} methods in the real plugin`) + assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser') + assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser') + + const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name) + assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call') + + assert.ok(readExpectedHooks(real).length >= 1) + assert.deepEqual(readRequires(real), ['Kits']) +}) + +test('the real plugin and plugin.toml pass', () => { + assert.deepEqual(check(real, realToml), []) +}) diff --git a/tools/console.js b/tools/console.js new file mode 100644 index 0000000..0334e6e --- /dev/null +++ b/tools/console.js @@ -0,0 +1,60 @@ +// Runs one console command on a rig through the panel's websocket and prints the console lines +// that follow — the way to read a command's reply, which rig.js's `cmd` cannot. +// +// node tools/console.js "" [seconds=6] [filter] +// +// node tools/console.js oxide "oxide.plugins" 6 RunicNPC +// node tools/console.js carbon "rnpc.status" +// +// Needs Node 22+ for the global WebSocket. + +const { load, serverId, unmsys } = require('./panel') + +const config = load() +const [rig, command, secs = '6', filter] = process.argv.slice(2).map(unmsys) +const strip = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') + +async function main() { + if (!command) { + console.error('usage: node tools/console.js "" [seconds=6] [filter]') + process.exit(2) + } + + const r = await fetch(`${config.panel}/api/client/servers/${serverId(config, rig)}/websocket`, { + headers: { Authorization: 'Bearer ' + config.key, Accept: 'application/json' }, + }) + if (!r.ok) { + console.error(r.status, await r.text()) + process.exit(1) + } + const { data } = await r.json() + + const ws = new WebSocket(data.socket, { headers: { Origin: config.panel } }) + let sent = false + + ws.onopen = () => ws.send(JSON.stringify({ event: 'auth', args: [data.token] })) + ws.onmessage = (m) => { + const msg = JSON.parse(m.data) + if (msg.event === 'auth success' && !sent) { + sent = true + // The panel replays recent history on connect; let it pass, then send. + setTimeout(() => ws.send(JSON.stringify({ event: 'send command', args: [command] })), 800) + setTimeout(() => { + ws.close() + process.exit(0) + }, Number(secs) * 1000) + } else if (msg.event === 'console output' && sent) { + for (const line of strip(String(msg.args[0])).split('\n')) { + if (!filter || line.toLowerCase().includes(filter.toLowerCase())) console.log(line) + } + } else if (msg.event === 'jwt error' || msg.event === 'token expired') { + console.error('ws:', msg.event, msg.args) + } + } + ws.onerror = (e) => console.error('ws error', e.message || e) +} + +main().catch((e) => { + console.error(e.message || e) + process.exit(1) +}) diff --git a/tools/panel.js b/tools/panel.js new file mode 100644 index 0000000..3ff6304 --- /dev/null +++ b/tools/panel.js @@ -0,0 +1,52 @@ +// The Pterodactyl panel the test rigs run on, shared by rig.js and console.js. +// +// Nothing here is shipped: tools/ is developer scaffolding (docs/runicnpc/PLAN.md §9, stage 0). +// Where the panel is and which servers are the rigs is local knowledge, so it lives in +// tools/rigs.json (git-ignored; copy tools/rigs.example.json). The panel's client API key is read +// at call time from the file rigs.json names, and is never printed. + +const fs = require('fs') +const path = require('path') + +const CONFIG = path.join(__dirname, 'rigs.json') + +function load() { + if (!fs.existsSync(CONFIG)) { + console.error(`No ${CONFIG}. Copy tools/rigs.example.json to tools/rigs.json and fill it in.`) + process.exit(2) + } + const config = JSON.parse(fs.readFileSync(CONFIG, 'utf8')) + + // The key file holds `name: value` lines; the panel's client key is `user`. + const keys = Object.fromEntries( + fs + .readFileSync(config.tokenFile, 'utf8') + .split(/\r?\n/) + .filter((l) => l.includes(':')) + .map((l) => [l.slice(0, l.indexOf(':')).trim(), l.slice(l.indexOf(':') + 1).trim()]) + ) + if (!keys.user) { + console.error(`${config.tokenFile} has no "user:" line (the panel's client API key).`) + process.exit(2) + } + + return { panel: config.panel.replace(/\/+$/, ''), rigs: config.rigs, key: keys.user } +} + +/** The server id of a rig by name, or exit with the names there are. */ +function serverId(config, rig) { + const id = config.rigs[rig] + if (!id) { + console.error(`Unknown rig "${rig}". Known: ${Object.keys(config.rigs).join(', ')}`) + process.exit(2) + } + return id +} + +// Git Bash (MSYS) rewrites an argument starting with / into C:/Program Files/Git/...; undo it, so +// a mangled path never reaches the server. On 2026-09-29 every rig file call failed this way and +// looked like an outage of the panel's file API. +const unmsys = (x) => + typeof x === 'string' ? x.replace(/^[A-Za-z]:\/Program Files\/Git(?=\/|$)/, '') || '/' : x + +module.exports = { load, serverId, unmsys } diff --git a/tools/rig.js b/tools/rig.js new file mode 100644 index 0000000..a2553a6 --- /dev/null +++ b/tools/rig.js @@ -0,0 +1,85 @@ +// A rig's server through the panel's client API: state, power, a console command, and files. +// +// node tools/rig.js state +// node tools/rig.js power +// node tools/rig.js cmd "" (fire and forget; console.js reads the reply) +// node tools/rig.js ls +// node tools/rig.js read (TAIL=n prints the last n lines) +// node tools/rig.js write +// node tools/rig.js rm +// +// Paths are the server's, from its root: /oxide/plugins/RunicNPC.cs, /carbon/plugins/RunicNPC.cs. +// +// A directory the panel's file API creates is NOT writable by the game (docs/runicnpc/PLAN.md +// §1.5). Let the plugin create its own data directory, then write files into it. + +const fs = require('fs') +const { load, serverId, unmsys } = require('./panel') + +const config = load() +const [rig, op, a, b] = process.argv.slice(2).map(unmsys) +const base = `${config.panel}/api/client/servers/${serverId(config, rig)}` +const headers = (type = 'application/json') => ({ + Authorization: 'Bearer ' + config.key, + Accept: 'application/json', + 'Content-Type': type, +}) + +async function main() { + let r + switch (op) { + case 'state': + r = await fetch(`${base}/resources`, { headers: headers() }) + break + case 'power': + r = await fetch(`${base}/power`, { method: 'POST', headers: headers(), body: JSON.stringify({ signal: a }) }) + break + case 'cmd': + r = await fetch(`${base}/command`, { method: 'POST', headers: headers(), body: JSON.stringify({ command: a }) }) + break + case 'ls': + r = await fetch(`${base}/files/list?directory=${encodeURIComponent(a)}`, { headers: headers() }) + break + case 'read': + r = await fetch(`${base}/files/contents?file=${encodeURIComponent(a)}`, { headers: headers() }) + break + case 'write': + r = await fetch(`${base}/files/write?file=${encodeURIComponent(a)}`, { + method: 'POST', + headers: headers('text/plain'), + body: fs.readFileSync(b), + }) + break + case 'rm': + r = await fetch(`${base}/files/delete`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ root: a.slice(0, a.lastIndexOf('/')) || '/', files: [a.slice(a.lastIndexOf('/') + 1)] }), + }) + break + default: + console.error('usage: node tools/rig.js state|power|cmd|ls|read|write|rm ...') + process.exit(2) + } + + const text = await r.text() + if (!r.ok) { + console.error(r.status, text) + process.exit(1) + } + + if (op === 'state') return console.log(JSON.parse(text).attributes.current_state) + if (op === 'ls') { + const rows = (JSON.parse(text).data || []).map( + (f) => `${f.attributes.is_file ? 'f' : 'd'} ${f.attributes.size}\t${f.attributes.name}` + ) + return console.log(rows.join('\n')) + } + if (op === 'read' && process.env.TAIL) return console.log(text.split('\n').slice(-Number(process.env.TAIL)).join('\n')) + console.log(op === 'read' ? text : r.status) +} + +main().catch((e) => { + console.error(e.message || e) + process.exit(1) +}) diff --git a/tools/rigs.example.json b/tools/rigs.example.json new file mode 100644 index 0000000..5837e15 --- /dev/null +++ b/tools/rigs.example.json @@ -0,0 +1,8 @@ +{ + "panel": "http://panel.example.lan", + "tokenFile": "/path/to/pterodactyl_api_token", + "rigs": { + "oxide": "00000000", + "carbon": "00000000" + } +}