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
131 lines
4.8 KiB
JavaScript
131 lines
4.8 KiB
JavaScript
// 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`)
|
|
})
|