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
All checks were successful
Release plugin / release (push) Successful in -1m42s
Reviewed-on: #2
This commit is contained in:
41
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
41
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report something that is broken or behaving unexpectedly
|
||||
title: "[bug] "
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- A clear, concise description of the bug. -->
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
<!-- What you expected to happen. -->
|
||||
|
||||
## Actual behavior
|
||||
|
||||
<!-- What actually happened. Include exact error messages and logs if you have them. -->
|
||||
|
||||
## Environment
|
||||
|
||||
- Component / repo:
|
||||
- Version or commit:
|
||||
- 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.
|
||||
-->
|
||||
5
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
5
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://gitea.whitlocktech.com/RunicGateway/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).
|
||||
23
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
23
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea, enhancement, or new capability
|
||||
title: "[feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
---
|
||||
|
||||
## Problem / motivation
|
||||
|
||||
<!-- What are you trying to do? What's missing or painful today? -->
|
||||
|
||||
## Proposed solution
|
||||
|
||||
<!-- What you'd like to see happen. -->
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
<!-- Other approaches you thought about, and why you prefer the one above. -->
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Mockups, links, related issues, affected component/repo, etc. -->
|
||||
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
Thanks for contributing to Runic Gateway!
|
||||
Please fill out the sections below and check every box before requesting review.
|
||||
-->
|
||||
|
||||
## What & why
|
||||
|
||||
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!-- Commands you ran, manual steps, screenshots. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- [ ] The change builds and existing tests/checks pass locally.
|
||||
- [ ] I have added or updated tests/docs where it makes sense.
|
||||
- [ ] My commits are reasonably scoped with clear messages.
|
||||
|
||||
## AI-assisted contributions (required)
|
||||
|
||||
This project **requires disclosure of AI tool usage**. Please pick one:
|
||||
|
||||
- [ ] No AI tools were used to produce this contribution.
|
||||
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
|
||||
every change, and take responsibility for it. AI-authored commits are
|
||||
marked with a `Co-Authored-By` / `Assisted-By` trailer.
|
||||
|
||||
## License
|
||||
|
||||
- [ ] I agree that my contribution is licensed under this project's license
|
||||
(**GNU GPL v3.0 or later**), and I have the right to contribute it.
|
||||
59
.gitea/workflows/pr-checks.yml
Normal file
59
.gitea/workflows/pr-checks.yml
Normal 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
|
||||
476
.gitea/workflows/release.yml
Normal file
476
.gitea/workflows/release.yml
Normal 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
8
.gitignore
vendored
Normal 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
133
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**whitlocktech@gmail.com**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
107
CONTRIBUTING.md
Normal file
107
CONTRIBUTING.md
Normal 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.
|
||||
81
README.md
81
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-<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
51
SECURITY.md
Normal 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
47
plugin.toml
Normal 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
123
plugin/RunicNPC.cs
Normal 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
336
scripts/checkPlugin.js
Normal 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
303
scripts/checkPlugin.test.js
Normal 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
60
tools/console.js
Normal 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
52
tools/panel.js
Normal 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
85
tools/rig.js
Normal 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
8
tools/rigs.example.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"panel": "http://panel.example.lan",
|
||||
"tokenFile": "/path/to/pterodactyl_api_token",
|
||||
"rigs": {
|
||||
"oxide": "00000000",
|
||||
"carbon": "00000000"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user