Compare commits
5 Commits
a6cfa3abfb
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| 47bd166d91 | |||
| 551359c08e | |||
| 0d9ec655f7 | |||
| ad141368c8 | |||
| b3711e6778 |
61
.gitea/workflows/pr-checks.yml
Normal file
61
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Gate every pull request into `main`.
|
||||||
|
#
|
||||||
|
# This repository had no workflows at all — the same hole phase 2 found in
|
||||||
|
# Module-Rust, in the repo that ships the half running inside somebody's game
|
||||||
|
# server. It is the one component here that cannot be compiled by CI: the plugin
|
||||||
|
# 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 bind by name and arity through reflection,
|
||||||
|
# with no compile-time check and no warning when a name matches nothing, so:
|
||||||
|
#
|
||||||
|
# • a hook that is not in `ExpectedHooks` is invisible to `rg.hooks`, which is
|
||||||
|
# the instrument this project relies on to answer "does this hook fire on
|
||||||
|
# this framework" (CARBON.md §6);
|
||||||
|
# • a hook that RETURNS something can cancel a death, swallow a player's
|
||||||
|
# gathered wood, or refuse a login (PROTOCOL.md §8.7);
|
||||||
|
# • a `ProtocolVersion` that disagrees with `overlay.toml` produces a bundle
|
||||||
|
# that will not compose, and the game link has no handshake to catch it.
|
||||||
|
#
|
||||||
|
# `scripts/checkPlugin.js` asks all three, dependency-free, and its own test
|
||||||
|
# suite breaks it seven ways — including the failure that would make every other
|
||||||
|
# case meaningless, a method parser that silently matches nothing.
|
||||||
|
#
|
||||||
|
# Enforcement (one-time, in the Gitea UI):
|
||||||
|
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||||
|
# • 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, void rule and protocol declaration
|
||||||
|
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
|
||||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Rust-Plugins
|
||||||
|
|
||||||
|
The **in-game half** of the Runic Gateway bridge for [Rust](https://rust.facepunch.com/): one Oxide
|
||||||
|
plugin that dials out to a [rust-link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link)
|
||||||
|
sidecar and speaks newline-delimited JSON over it.
|
||||||
|
|
||||||
|
It is the mirror of
|
||||||
|
[`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins), which
|
||||||
|
does the same job for Ultima Online — and it inherits that plugin's threading contract wholesale,
|
||||||
|
because the reason for it is the same on both games.
|
||||||
|
|
||||||
|
## The threading contract
|
||||||
|
|
||||||
|
Everything else in this repo depends on these three:
|
||||||
|
|
||||||
|
- **`Emit` is called from the main thread. It formats nothing, blocks on nothing, and touches no
|
||||||
|
socket.** It enqueues and returns. A slow, wedged, or absent sidecar cannot stall the game.
|
||||||
|
- **One link thread owns the socket.** It connects, drains the queue, and reconnects with backoff.
|
||||||
|
A single writer keeps event ordering intact.
|
||||||
|
- **A reader thread parses inbound lines and hands each to the main thread** via
|
||||||
|
`Interface.Oxide.NextTick`. The reader touches no Unity object, no `BasePlayer` and no `ConVar` —
|
||||||
|
every one of those is main-thread-only, and reading one from the reader is the kind of bug that
|
||||||
|
presents as a crash somewhere else entirely.
|
||||||
|
|
||||||
|
The outbound queue is **bounded, drop-oldest**. On overflow the oldest record goes and is counted,
|
||||||
|
because telemetry is worth less than the server's memory.
|
||||||
|
|
||||||
|
## Loopback is the trust boundary
|
||||||
|
|
||||||
|
There is no token on the game link. The plugin and the sidecar share a host, and the sidecar binds
|
||||||
|
`127.0.0.1` — that is the authentication. Pointing `Host` at anything routable puts an
|
||||||
|
unauthenticated command channel on the network.
|
||||||
|
|
||||||
|
## Installing it
|
||||||
|
|
||||||
|
```
|
||||||
|
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 7799,
|
||||||
|
"QueueCap": 5000,
|
||||||
|
"ServerId": "main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ServerId` is this server's stable identity across wipes and restarts, as the website knows it. It
|
||||||
|
is deliberately **not** derived from the hostname: an operator renames a server for a season, and
|
||||||
|
the site must not lose its history for it.
|
||||||
|
|
||||||
|
That is the developer's loop. An operator uses the
|
||||||
|
[installer](https://gitea.whitlocktech.com/RunicGateway/installer), which syncs the released overlay
|
||||||
|
tarball and installs the sidecar alongside it.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
The bridge itself needs nothing but Oxide. The features that follow it read four third-party plugins
|
||||||
|
an operator installs from uMod — `Clans`, `Kits`, `PopupNotifications` and `ZoneManager`. They are
|
||||||
|
listed in `overlay.toml` so the installer's `doctor` can report a missing one by name rather than
|
||||||
|
leaving the site quietly short of a feature.
|
||||||
|
|
||||||
|
## Diagnosing it
|
||||||
|
|
||||||
|
```
|
||||||
|
rg.link
|
||||||
|
```
|
||||||
|
|
||||||
|
from the server console or over RCON. It reports the link's own counters:
|
||||||
|
|
||||||
|
```
|
||||||
|
protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
|
||||||
|
connects=1 writeErrors=0 bootId=boot-20260915T194502Z
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the first thing to ask for when the website says a server is offline — it separates "the
|
||||||
|
plugin is not loaded", "the plugin cannot reach the sidecar" and "the website cannot reach the
|
||||||
|
sidecar", which look identical from the site.
|
||||||
|
|
||||||
|
**`bootId` identifies the server PROCESS, not the plugin load.** It is the process start time, so
|
||||||
|
`oxide.reload RunicGateway` does not change it. That matters more than it looks: the website watches
|
||||||
|
this value to tell a game restart (everything an event put in the world is gone) from a bridge
|
||||||
|
reconnect (nothing is lost), and a plugin reload is the second kind.
|
||||||
|
|
||||||
|
## The protocol is a contract
|
||||||
|
|
||||||
|
`ProtocolVersion` in the plugin and `protocol` in `overlay.toml` must agree with the sidecar's
|
||||||
|
`PROTOCOL_VERSION` and the module's own constant. The installer refuses to pair an overlay and a
|
||||||
|
sidecar that disagree, so a bump landing in one repo and not the others fails to compose rather than
|
||||||
|
half-deploying.
|
||||||
|
|
||||||
|
The canonical spec is
|
||||||
|
[`docs/rust-link/PROTOCOL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md).
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later. See [LICENSE.md](LICENSE.md).
|
||||||
49
overlay.toml
Normal file
49
overlay.toml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Release metadata for the deployable overlay.
|
||||||
|
#
|
||||||
|
# Consumed by the release workflow, which folds these values into the
|
||||||
|
# manifest.json shipped inside the overlay tarball. The Runic Gateway installer
|
||||||
|
# reads that manifest to decide what it is deploying and whether it is compatible
|
||||||
|
# with the sidecar it is about to install.
|
||||||
|
#
|
||||||
|
# There is deliberately NO version key here. The release version is derived from
|
||||||
|
# git tags and conventional commits by the release workflow, so there is no bump
|
||||||
|
# commit to keep in sync and no way for this file to disagree with the tag.
|
||||||
|
|
||||||
|
# ── The loopback wire-protocol version this overlay speaks ───────────────────
|
||||||
|
#
|
||||||
|
# The plugin half of the compatibility contract. It MUST equal the sidecar's
|
||||||
|
# PROTOCOL_VERSION (Rust-Link's sidecar/src/main.rs) for a deployment to work:
|
||||||
|
# the sidecar rejects a mismatched WEBSITE with 409, and a mismatched PLUGIN is
|
||||||
|
# worse, because the game link has no such check — it would simply mis-parse.
|
||||||
|
#
|
||||||
|
# That asymmetry is why this file exists. The plugin announces its protocol in
|
||||||
|
# `server.hello`, which is only readable after the game server has booted with it
|
||||||
|
# loaded — far too late for an installer to refuse a bad pairing. This
|
||||||
|
# declaration is what lets the bundle CI check the pair BEFORE an operator
|
||||||
|
# installs either half.
|
||||||
|
#
|
||||||
|
# Keeping it honest is a manual duty: when the protocol changes, bump it here in
|
||||||
|
# the same change that alters the emitters, exactly as the sidecar bumps
|
||||||
|
# PROTOCOL_VERSION and the module bumps its own constant.
|
||||||
|
#
|
||||||
|
# Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8).
|
||||||
|
protocol = 2
|
||||||
|
|
||||||
|
# ── Oxide compatibility ──────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches
|
||||||
|
# nothing, so it is expected to work on any reasonably current Oxide. This is the
|
||||||
|
# oldest build it is known good on.
|
||||||
|
#
|
||||||
|
# There is no `patches_verified_against` key, and there is no `patches/` tier:
|
||||||
|
# Rust's server is a binary and Oxide's hook API is the supported way in, so
|
||||||
|
# there is nothing to diff against. That is the whole reason the Rust payload is
|
||||||
|
# simpler than the ServUO one.
|
||||||
|
min_oxide_version = "2.0.7585"
|
||||||
|
|
||||||
|
# The `oxide/plugins/` files this overlay expects to find already installed. They
|
||||||
|
# are not shipped here — they are third-party plugins an operator installs from
|
||||||
|
# uMod — and the installer's `doctor` reports a missing one rather than
|
||||||
|
# installing it. Listing them is what turns "the site shows no clans" into a
|
||||||
|
# named prerequisite.
|
||||||
|
requires_plugins = ["Clans", "Kits", "PopupNotifications", "ZoneManager"]
|
||||||
1712
overlay/oxide/plugins/RunicGateway.cs
Normal file
1712
overlay/oxide/plugins/RunicGateway.cs
Normal file
File diff suppressed because it is too large
Load Diff
176
scripts/checkPlugin.js
Normal file
176
scripts/checkPlugin.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
//
|
||||||
|
// Static checks on the bridge plugin, run on every pull request.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// So these checks ask the questions a compiler would not answer anyway. Both
|
||||||
|
// frameworks bind hooks **by name and arity, through reflection**, with no
|
||||||
|
// compile-time check and no warning when a name matches nothing — which makes
|
||||||
|
// three mistakes silent, and each has a cost bigger than it looks:
|
||||||
|
//
|
||||||
|
// 1. A hook method the plugin declares but never lists in `ExpectedHooks`.
|
||||||
|
// `rg.hooks` is how we answer "does this hook fire on this framework" —
|
||||||
|
// the standing answer to Facepunch renaming one and to Carbon's catalogue
|
||||||
|
// omitting thirteen names (CARBON.md §6). A hook missing from that list is
|
||||||
|
// invisible to the one instrument built to see it.
|
||||||
|
//
|
||||||
|
// 2. A hook that ANSWERS. Four of the hooks in the read path are documented as
|
||||||
|
// "returning a non-null value overrides default behavior" — a bridge that
|
||||||
|
// returned something would cancel a death, swallow a player's gathered
|
||||||
|
// wood, or refuse a login, on somebody's production server at 3am
|
||||||
|
// (PROTOCOL.md §8.7).
|
||||||
|
//
|
||||||
|
// The rule is inverted on purpose: EVERY hook must be `void`, rather than
|
||||||
|
// every *vetoable* hook. A list of vetoable hook names would have to be
|
||||||
|
// maintained here, against a catalogue in another repository, and the first
|
||||||
|
// hook somebody forgot to add to it would be the one that passed. There is
|
||||||
|
// nothing to forget this way — a hook that must genuinely answer is added
|
||||||
|
// to `ANSWERS_DELIBERATELY` below, with a reason, as a visible exception.
|
||||||
|
//
|
||||||
|
// 3. A protocol version that disagrees with `overlay.toml`. The game link has
|
||||||
|
// no version handshake (PROTOCOL.md §2), so a half-bumped pair does not
|
||||||
|
// refuse — it mis-parses. `overlay.toml` exists precisely so the installer
|
||||||
|
// can refuse the pairing BEFORE an operator deploys it, and it is worth
|
||||||
|
// exactly as much as its agreement with the code.
|
||||||
|
//
|
||||||
|
// 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, 'overlay', 'oxide', 'plugins', 'RunicGateway.cs')
|
||||||
|
const OVERLAY_TOML = path.join(ROOT, 'overlay.toml')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks this plugin answers on purpose, and why.
|
||||||
|
*
|
||||||
|
* Empty, and it should stay empty for as long as the plugin is a read path. A
|
||||||
|
* name here is a deliberate decision to let the bridge change what the game
|
||||||
|
* does — reviewable because it is written down in one place rather than implied
|
||||||
|
* by a return type somewhere in 1,500 lines.
|
||||||
|
*/
|
||||||
|
const ANSWERS_DELIBERATELY = Object.create(null)
|
||||||
|
|
||||||
|
/** Anything shaped like this is a game hook, by both frameworks' own convention. */
|
||||||
|
const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method declarations, as this file cares about them: 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 rather than assumed.
|
||||||
|
*/
|
||||||
|
const METHOD = /^\s*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(/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({ returns: m[1].trim(), name: m[2] })
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `rg.hooks` can report on.
|
||||||
|
for (const hook of hooks) {
|
||||||
|
if (!expected.includes(hook.name)) {
|
||||||
|
problems.push(
|
||||||
|
`${hook.name} is implemented but missing from ExpectedHooks, so rg.hooks cannot report it`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 read-path hook must not be able to ` +
|
||||||
|
'veto what the game was going to do (PROTOCOL.md §8.7). If it must answer, add it to ' +
|
||||||
|
'ANSWERS_DELIBERATELY with a reason.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No phantom entries: a name listed but never implemented reports "silent"
|
||||||
|
// for ever, which reads exactly like a hook the framework does not fire.
|
||||||
|
for (const name of expected) {
|
||||||
|
if (!hookNames.has(name)) {
|
||||||
|
problems.push(`ExpectedHooks lists ${name}, but no method of that name is implemented`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The two declaration sites this repository owns must agree.
|
||||||
|
const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)
|
||||||
|
const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml)
|
||||||
|
|
||||||
|
if (!inCode) problems.push('could not read ProtocolVersion from the plugin source')
|
||||||
|
if (!inToml) problems.push('could not read `protocol` from overlay.toml')
|
||||||
|
|
||||||
|
if (inCode && inToml && inCode[1] !== inToml[1]) {
|
||||||
|
problems.push(
|
||||||
|
`the plugin speaks protocol ${inCode[1]} and overlay.toml declares ${inToml[1]}. ` +
|
||||||
|
'The installer refuses to pair a sidecar and an overlay that disagree, so a bundle built ' +
|
||||||
|
'from this would not compose — and the game link itself has no version check to catch it.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const source = fs.readFileSync(PLUGIN, 'utf8')
|
||||||
|
const toml = fs.readFileSync(OVERLAY_TOML, 'utf8')
|
||||||
|
|
||||||
|
const problems = check(source, toml)
|
||||||
|
|
||||||
|
if (problems.length > 0) {
|
||||||
|
console.error('The bridge plugin failed its static checks:\n')
|
||||||
|
for (const p of problems) console.error(` • ${p}`)
|
||||||
|
console.error('')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = readExpectedHooks(source)
|
||||||
|
const version = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)[1]
|
||||||
|
console.log(
|
||||||
|
`plugin ok — protocol ${version}, ${expected.length} hooks declared, every one void and listed`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { check, readExpectedHooks, readMethods, HOOK_NAME }
|
||||||
|
|
||||||
|
if (require.main === module) main()
|
||||||
130
scripts/checkPlugin.test.js
Normal file
130
scripts/checkPlugin.test.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// A check is worth what it catches, so this breaks it seven ways.
|
||||||
|
//
|
||||||
|
// The case that matters most is the last one: `checkPlugin.js` finds hooks with a
|
||||||
|
// deliberately narrow regex, and a regex that silently matches NOTHING passes
|
||||||
|
// every check in this file and every check in CI while asserting nothing at all.
|
||||||
|
// So the real plugin source is read here too, and the parse is asserted against
|
||||||
|
// hooks that are 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, HOOK_NAME } = require('./checkPlugin')
|
||||||
|
|
||||||
|
/** A minimal plugin that passes, as the baseline every case below deviates from. */
|
||||||
|
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
|
||||||
|
const body =
|
||||||
|
methods ??
|
||||||
|
` private void OnPlayerDeath(BasePlayer player, HitInfo info)
|
||||||
|
{
|
||||||
|
}`
|
||||||
|
|
||||||
|
return `namespace Oxide.Plugins
|
||||||
|
{
|
||||||
|
internal class RunicGateway : RustPlugin
|
||||||
|
{
|
||||||
|
private const int ProtocolVersion = ${version};
|
||||||
|
|
||||||
|
private static readonly string[] ExpectedHooks =
|
||||||
|
{
|
||||||
|
${expected.map((e) => `"${e}"`).join(', ')}
|
||||||
|
};
|
||||||
|
|
||||||
|
${body}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const toml = (version = 2) => `protocol = ${version}\n`
|
||||||
|
|
||||||
|
test('a plugin that follows the rules passes', () => {
|
||||||
|
assert.deepEqual(check(source(), toml()), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a hook missing from ExpectedHooks is caught, because rg.hooks could not report it', () => {
|
||||||
|
const problems = check(source({ expected: [] }), toml())
|
||||||
|
assert.equal(problems.length, 1)
|
||||||
|
assert.match(problems[0], /OnPlayerDeath is implemented but missing from ExpectedHooks/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a hook that can answer is caught — the rule the read path depends on', () => {
|
||||||
|
const methods = ` private object OnPlayerDeath(BasePlayer player, HitInfo info)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}`
|
||||||
|
|
||||||
|
const problems = check(source({ methods }), toml())
|
||||||
|
assert.equal(problems.length, 1)
|
||||||
|
assert.match(problems[0], /returns object, not void/)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
// changed into a veto without changing its signature, which is visible.
|
||||||
|
const methods = ` private bool CanUserLogin(string name, string id, string ip)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}`
|
||||||
|
|
||||||
|
const problems = check(source({ expected: ['CanUserLogin'], methods }), toml())
|
||||||
|
assert.match(problems[0], /CanUserLogin returns bool, not void/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a name listed but never implemented is caught, because it reports silent for ever', () => {
|
||||||
|
const problems = check(source({ expected: ['OnPlayerDeath', 'OnNewSave'] }), toml())
|
||||||
|
assert.equal(problems.length, 1)
|
||||||
|
assert.match(problems[0], /ExpectedHooks lists OnNewSave, but no method/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a protocol version that disagrees with overlay.toml is caught', () => {
|
||||||
|
const problems = check(source({ version: 3 }), toml(2))
|
||||||
|
assert.equal(problems.length, 1)
|
||||||
|
assert.match(problems[0], /speaks protocol 3 and overlay\.toml declares 2/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a method that is not shaped like a hook is left alone', () => {
|
||||||
|
// `Cadence`, `Frame`, `Flatten` and friends are ours, return real types, and
|
||||||
|
// must not be dragged into the void rule.
|
||||||
|
const methods = ` private Dictionary<string, object> Frame(string kind, string type)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Column(int index)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}`
|
||||||
|
|
||||||
|
assert.deepEqual(check(source({ expected: [], methods }), toml()), [])
|
||||||
|
assert.ok(!HOOK_NAME.test('Cadence'))
|
||||||
|
assert.ok(!HOOK_NAME.test('Frame'))
|
||||||
|
assert.ok(HOOK_NAME.test('OnPlayerDeath'))
|
||||||
|
assert.ok(HOOK_NAME.test('CanUserLogin'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the parser actually reads the real plugin, rather than quietly matching nothing', () => {
|
||||||
|
const real = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const methods = readMethods(real)
|
||||||
|
const names = new Set(methods.map((m) => m.name))
|
||||||
|
|
||||||
|
// A narrow regex that matches nothing passes every other test in this file.
|
||||||
|
assert.ok(methods.length > 20, `only found ${methods.length} methods in the real plugin`)
|
||||||
|
for (const hook of ['OnPlayerDeath', 'OnPlayerConnected', 'CanUserLogin', 'OnNewSave']) {
|
||||||
|
assert.ok(names.has(hook), `${hook} was not found by the method parser`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = readExpectedHooks(real)
|
||||||
|
assert.ok(expected.length >= 15, `only found ${expected.length} entries in ExpectedHooks`)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user