Merge pull request 'release: cut edge over to main — RunicNPC v0.1.0 (stage 0)' (#2) from edge into main
All checks were successful
Release plugin / release (push) Successful in -1m42s

Reviewed-on: #2
This commit is contained in:
2026-09-30 02:05:52 +00:00
19 changed files with 2027 additions and 4 deletions

View File

@@ -0,0 +1,41 @@
---
name: Bug report
about: Report something that is broken or behaving unexpectedly
title: "[bug] "
labels:
- bug
---
## Summary
<!-- A clear, concise description of the bug. -->
## Steps to reproduce
1.
2.
3.
## Expected behavior
<!-- What you expected to happen. -->
## Actual behavior
<!-- What actually happened. Include exact error messages and logs if you have them. -->
## Environment
- Component / repo:
- Version or commit:
- Rust server build, Oxide or Carbon build, RunicNPC version, Kits version:
- Deployment (Docker Compose, local dev, bare metal…):
## Additional context
<!-- Screenshots, config (with secrets redacted), anything else that helps. -->
<!--
Security issue? Do NOT file it here. See SECURITY.md and email
whitlocktech@gmail.com instead.
-->

View File

@@ -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).

View File

@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest an idea, enhancement, or new capability
title: "[feature] "
labels:
- enhancement
---
## Problem / motivation
<!-- What are you trying to do? What's missing or painful today? -->
## Proposed solution
<!-- What you'd like to see happen. -->
## Alternatives considered
<!-- Other approaches you thought about, and why you prefer the one above. -->
## Additional context
<!-- Mockups, links, related issues, affected component/repo, etc. -->

View File

@@ -0,0 +1,33 @@
<!--
Thanks for contributing to Runic Gateway!
Please fill out the sections below and check every box before requesting review.
-->
## What & why
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
## How it was tested
<!-- Commands you ran, manual steps, screenshots. -->
## Checklist
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
- [ ] The change builds and existing tests/checks pass locally.
- [ ] I have added or updated tests/docs where it makes sense.
- [ ] My commits are reasonably scoped with clear messages.
## AI-assisted contributions (required)
This project **requires disclosure of AI tool usage**. Please pick one:
- [ ] No AI tools were used to produce this contribution.
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
every change, and take responsibility for it. AI-authored commits are
marked with a `Co-Authored-By` / `Assisted-By` trailer.
## License
- [ ] I agree that my contribution is licensed under this project's license
(**GNU GPL v3.0 or later**), and I have the right to contribute it.

View File

@@ -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

View File

@@ -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-<ver>.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() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
RELEASE=true
if [ -z "$LAST_TAG" ]; then
VERSION="$SEED_VERSION" # first release: seed
elif [ "$BUMP" = none ]; then
RELEASE=false # no feat/fix/breaking since last tag
VERSION="${LAST_TAG#v}"
else
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing — which is exactly what happened on 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:-<none>}"
# ── Credential preflight ─────────────────────────────────────────────
# Runs BEFORE anything is built or pushed, and only when this run intends
# to publish, so a docs:/chore:-only merge stays green on a repo that has
# no secrets.
#
# This exists because of how servuo-plugins' first run failed. REGISTRY_USER and
# REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway:
# actions/checkout leaves an `http.<host>.extraheader` credential in the
# local git config, so `git remote set-url` to a URL with empty
# credentials still authenticated through that leftover header. The
# release API call had no such fallback and returned 401 — so the run
# tagged the repo and then failed, which is the worst of both outcomes.
# Checking the secrets up front turns that into an immediate, legible
# failure instead of a half-published release.
- name: Verify release credentials are configured
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
MISSING=""
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
if [ -n "$MISSING" ]; then
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs the write:repository scope to push the tag and create the release."
exit 1
fi
echo "Release credentials present."
- name: Install jq
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
command -v jq >/dev/null 2>&1 && exit 0
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq
# ── 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

8
.gitignore vendored Normal file
View File

@@ -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

133
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**whitlocktech@gmail.com**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

107
CONTRIBUTING.md Normal file
View File

@@ -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 <noreply@anthropic.com>` or
`Assisted-By: <tool>`.
- You remain responsible for every line you submit: review it, understand it, and make sure it is
correct and that you have the right to contribute it.
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are not, and may be
closed.
## License
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.

View File

@@ -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-<version>.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<int>("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).

51
SECURITY.md Normal file
View File

@@ -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.

47
plugin.toml Normal file
View File

@@ -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"]

123
plugin/RunicNPC.cs Normal file
View File

@@ -0,0 +1,123 @@
// Requires: Kits
using System;
using System.Collections.Generic;
namespace Oxide.Plugins
{
/// <summary>
/// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon.
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// <b>Kits is required</b> (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 <c>requires_plugins</c> in <c>plugin.toml</c> are two statements of one fact, and the
/// PR check holds them equal.
/// </para>
/// </summary>
[Info("RunicNPC", "Runic Gateway", "0.0.0")]
[Description("Runic Gateway's NPCs: profiles, placements and an API for other plugins.")]
internal class RunicNPC : RustPlugin
{
/// <summary>
/// The version of the API other plugins call (<c>RunicNpc_*</c>, PLAN.md §4). Declared
/// twice, here and as <c>api</c> in <c>plugin.toml</c>, 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.
///
/// <para>
/// 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.
/// </para>
/// </summary>
private const int ApiVersion = 1;
/// <summary>
/// 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: <c>rnpc.status</c> 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.
/// </summary>
private static readonly string[] ExpectedHooks =
{
"OnServerInitialized"
};
private readonly Dictionary<string, long> _hookCounts = new Dictionary<string, long>();
// ---- 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.
/// <summary>The API's version. A caller that needs more refuses this RunicNPC and says so.</summary>
private int RunicNpc_ApiVersion() => ApiVersion;
// ---- console ----
/// <summary>
/// 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.
/// </summary>
[ConsoleCommand("rnpc.status")]
private void CmdStatus(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin)
return;
var fired = new List<string>();
var silent = new List<string>();
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}");
}
}
}

336
scripts/checkPlugin.js Normal file
View File

@@ -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] <type> <Name>(`) 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()

303
scripts/checkPlugin.test.js Normal file
View File

@@ -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<string, object> 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), [])
})

60
tools/console.js Normal file
View File

@@ -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 <rig> "<command>" [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 <rig> "<command>" [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)
})

52
tools/panel.js Normal file
View File

@@ -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 }

85
tools/rig.js Normal file
View File

@@ -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 <rig> state
// node tools/rig.js <rig> power <start|stop|restart|kill>
// node tools/rig.js <rig> cmd "<console command>" (fire and forget; console.js reads the reply)
// node tools/rig.js <rig> ls <dir>
// node tools/rig.js <rig> read <path> (TAIL=n prints the last n lines)
// node tools/rig.js <rig> write <path> <local file>
// node tools/rig.js <rig> rm <path>
//
// 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 <rig> 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)
})

8
tools/rigs.example.json Normal file
View File

@@ -0,0 +1,8 @@
{
"panel": "http://panel.example.lan",
"tokenFile": "/path/to/pterodactyl_api_token",
"rigs": {
"oxide": "00000000",
"carbon": "00000000"
}
}