Compare commits

...

5 Commits

Author SHA1 Message Date
47bd166d91 Merge pull request 'feat(bridge): protocol 2 — the read path, and the first CI this repo has had' (#2) from feat/phase-3-read-path into main
Reviewed-on: #2
2026-09-16 16:36:57 +00:00
551359c08e fix(bridge): resolve the wipe at OnServerInitialized, and bound the connect
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
Two defects the Carbon rig found, neither visible on a development loopback.

**The wipe id was null for every real session.** `Init` runs before the save is
loaded, so `SaveRestore.SaveCreatedTime` is not yet meaningful there and the id
resolved at load time stayed null for the life of the process — every frame
shipped without the field R12 splits history on. It was invisible because a
hot-reloaded plugin reads an already-loaded world and gets the right answer every
time; it took a server that BOOTED with the plugin installed, which is every real
one, to show `wipeId=none` beside a save sitting on disk. Resolved again at
`OnServerInitialized`, and lazily while still unknown.

**Unload blocked the main thread for two seconds against an unreachable sidecar.**
Observed as `hook 'Unload' took longer than 100ms [2002ms]` next to `link thread
did not stop cleanly` — phase 1's stall arriving by a different road. The link
thread sits in a blocking `TcpClient.Connect`, which has no timeout of its own
and cannot be woken; a host that REFUSES answers instantly, which is every
loopback test, and a host that DROPS does not answer at all. The connect is now
bounded, and waits on a stop handle of its own so a reload abandons it at once.
It cannot share `Wake`, which also means 'the queue has something in it' and is
signalled by every hook.

After: the same reload logs no slow-hook warning and no stranded thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:29:24 -05:00
0d9ec655f7 feat(bridge): protocol 2 — the read path, and the first CI this repo has had
Fifteen hooks: presence, deaths, chat, gathering, raided structures, bans,
reports, login attempts and the wipe. Every frame now carries an envelope —
`type`, `serverId` and `wipeId` — built in one place so nothing can emit a frame
without one.

Three rules the code enforces structurally rather than by intention:

  • A read-path hook never vetoes. Four of these are documented as "returning a
    non-null value overrides default behavior", so every hook is declared `void`
    and cannot answer. `CanUserLogin` is in the wave for what it observes.

  • A hook that can fire more than once a second per player is a counter.
    `OnDispenserGather` fires on every swing at a tree; it accumulates into a
    per-player tally flushed once a minute as one `player.tally` frame, as a
    delta rather than a running total.

  • `wipeId` is derived here, from the save's creation time, because this is the
    only component that can read it. PROTOCOL.md §8.2 reverses protocol 1 on
    that point deliberately.

`server.hello` becomes a board rather than a greeting, and `players.online`
joins it; both are re-sent on connect and every 60 seconds, which is what makes
a restarted sidecar repopulate itself without asking.

`rg.hooks` reports which hooks have actually fired. Hooks bind by name and arity
through reflection on both frameworks, so a rename by Facepunch and a name
Carbon's catalogue omits present identically — as silence. This is the standing
answer to both, and it outranks either catalogue because it is a measurement.

The repository had no `.gitea/workflows/` at all. `scripts/checkPlugin.js` asks
the three questions a compiler here cannot: every hook is in `ExpectedHooks`, so
`rg.hooks` can see it; every hook is `void`, unless answering is a decision
written down in `ANSWERS_DELIBERATELY`; and `ProtocolVersion` agrees with
`overlay.toml`, which is what stops a bundle that will not compose. The void
rule is inverted on purpose — a list of *vetoable* hooks would have to be
maintained against a catalogue in another repository, and the first one somebody
forgot to add is the one that would pass. Its own suite breaks it seven ways,
including the failure that would make the other six meaningless: a method parser
that silently matches nothing.

Proven on a live Oxide server: compiled, loaded, the envelope correct, both ban
hooks firing, and the boards repopulating a sidecar whose database had been
deleted 0.3 seconds earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:18:42 -05:00
ad141368c8 Merge pull request 'feat(bridge): the Oxide plugin — protocol 1' (#1) from feat/phase-1-bridge into main
Reviewed-on: #1
2026-09-16 01:30:59 +00:00
b3711e6778 feat(bridge): the Oxide plugin — protocol 1
RunicGateway.cs dials out to a rust-link sidecar on loopback and speaks
newline-delimited JSON over it: server.hello on every connect, a pong to the
sidecar's heartbeat, and one correlated server.status.

The threading contract is the ServUO bridge's, unchanged, because the reason for
it is the same on both games:

* Emit is called from the main thread. It formats nothing, blocks on nothing and
  touches no socket -- it enqueues and returns, so a wedged or absent sidecar
  cannot stall the game. The queue is bounded, drop-oldest.
* One link thread owns the socket, which keeps event ordering intact.
* A reader thread marshals every inbound line to the main thread through
  Interface.Oxide.NextTick, and touches no Unity object, BasePlayer or ConVar.

Settings come from Oxide's own config (oxide/config/RunicGateway.json), so an
operator retunes the bridge the way they retune any other plugin -- and so it
lands inside the site-side config editor a later phase adds.

Four things the live rig corrected, none of which a unit test could have:

* A disconnect was silent in the game console. The teardown log sat in the
  catch, and a connection ending because the READER saw EOF leaves the writer to
  exit cleanly -- nothing throws, so nothing was logged. A log in a catch only
  covers the failures that throw, and an orderly peer shutdown is not one.
* Unload blocked the main thread for 1.9s (Oxide says so out loud), because the
  reconnect backoff was Thread.Sleep and Unload joins the link thread. Waiting on
  the AutoResetEvent that Unload already signals makes it immediate. The ServUO
  plugin has the same sleep and gets away with it only because ServUO does not
  hot-reload.
* Mono's SocketException.Message is NUL-padded on Windows -- around 200 \0 bytes
  in the middle of the sentence, from a fixed-size OS buffer. \0 is not
  whitespace, so Trim does not touch it and neither does a whitespace-only
  collapse; the flattener has to treat control characters as separators. It took
  od -c on the log to see at all.
* bootId regenerated on every PLUGIN load rather than every SERVER start. A
  fresh Guid at Init meant oxide.reload announced a brand new boot, and the
  website's reconcile design hangs off that value -- so every reload would have
  asked core to sweep its whole resource ledger for a world that never moved. It
  is now Process.StartTime: exact, identical on every read, and it changes when
  and only when the thing it names changes.

rg.link reports the link's own counters from the console or over RCON, which is
what separates 'the plugin is not loaded' from 'the plugin cannot reach the
sidecar' from 'the website cannot reach the sidecar'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 19:53:33 -05:00
6 changed files with 2228 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

176
scripts/checkPlugin.js Normal file
View 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
View 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`)
})