All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m55s
Stage 1 of the §14.3 integration plan: the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea, with no plugin (D349). - `world.event`, one frame kind whose `event` names which of 24 types (PROTOCOL.md §20.5). The type is in `event`, never `type`: `type` is the envelope's, and the rig's sidecar dropped every world event as untyped when the plan's example put it there. Nothing is said before the world loads or while the server quits. Positions are x/z; the site works out the grid. - Credit (D372, D379): a damage table for the patrol helicopter, the Chinook and Bradley; `heli.downed`, `chinook.downed` and `bradley.destroyed` carry every candidate (blow, last, most, damagers) and the plugin credits nobody. D174's `CreditVehicle` and the tally's apcKills/heliKills are gone. - Locked crates told positively (oil rigs, cargo, ghost ship, a Chinook drop joined at the crate's landing); anything else says nothing. A Bradley's `where` comes from the game's spawners, not `RoadSpawned` (which defaults to true). `airdrop.called` comes from the cargo plane's spawn: `supply.call` raised neither OnAirdrop nor OnCargoPlaneSignaled. - The tally gains cratesHacked and dropsLooted (Safecracker, Scavenger, D356). - `world.call` (D354, D367, D378): one of a schedule's TriggeredEvents at random, without Trigger (which would reset its clock); satellite.random for the satellite. A position for the airdrop (InitDropPosition) and the patrol helicopter (SetInitialDestination) only, both set before the spawn, as the stage-1 spike measured (0-1 m and 10-54 m). Kept; the key is remembered seven days in world-calls.json; EventsMaxWorldCalls (default and ceiling 10) per run. - Hello/status `console.plugins` (D363): the bridge runs its own rg.probe through ConsoleSystem.Run from a timer. Carbon 2.0.262 refuses plugin commands only inside a console command (or a NextTick one queued), which is how §14.2's spike tested; from a timer, and from the link reader's NextTick, it finds them. Reads true on both rigs. - checkPlugin.js: the probe must name a [ConsoleCommand] the plugin declares. Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262); heli.arrived, cargo.arrived and airdrop.called read back from each rig's sidecar store. Rigs restored. 18 check tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
265 lines
10 KiB
JavaScript
265 lines
10 KiB
JavaScript
// A check is worth what it catches, so this breaks it thirteen 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, readChatCommands, readConsoleCommands, readOwnConsoleRuns, 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`)
|
|
})
|
|
|
|
// ── Chat commands (protocol 3) ─────────────────────────────────────────────
|
|
//
|
|
// A chat command binds by reflection on an exact signature, and a wrong one is
|
|
// never invoked and never logged: the player types `/link` and nothing happens.
|
|
// That is the same failure mode the hook checks exist for, one surface along.
|
|
|
|
/** A plugin whose only body is one chat command. */
|
|
function withChat(attribute, signature, returns = 'void') {
|
|
return source({
|
|
expected: [],
|
|
methods: ` [ChatCommand("${attribute}")]
|
|
private ${returns} CmdThing(${signature})
|
|
{
|
|
}`,
|
|
})
|
|
}
|
|
|
|
const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args'
|
|
|
|
test('a correctly shaped chat command passes', () => {
|
|
assert.deepEqual(check(withChat('link', GOOD_SIGNATURE), toml()), [])
|
|
})
|
|
|
|
test('a chat command taking the wrong player type is caught', () => {
|
|
// The plausible mistake: Covalence's `IPlayer` is what a Universal plugin
|
|
// takes, and this plugin is not one. It compiles and binds nothing.
|
|
const problems = check(withChat('link', 'IPlayer player, string command, string[] args'), toml())
|
|
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/)
|
|
})
|
|
|
|
test('a chat command missing the trailing args is caught', () => {
|
|
const problems = check(withChat('link', 'BasePlayer player, string command'), toml())
|
|
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /nothing happens at all/)
|
|
})
|
|
|
|
test('a chat command that returns something is caught', () => {
|
|
const problems = check(withChat('link', 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("link")]
|
|
private void CmdOne(${GOOD_SIGNATURE})
|
|
{
|
|
}
|
|
|
|
[ChatCommand("link")]
|
|
private void CmdTwo(${GOOD_SIGNATURE})
|
|
{
|
|
}`,
|
|
})
|
|
|
|
const problems = check(both, toml())
|
|
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /declared twice/)
|
|
})
|
|
|
|
test('the chat-command parser reads the real plugin, rather than quietly matching nothing', () => {
|
|
// The same trap the method parser has, and the reason this file's last test
|
|
// exists: a regex that matches NOTHING passes every case above.
|
|
const real = fs.readFileSync(
|
|
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
|
|
'utf8'
|
|
)
|
|
|
|
const commands = readChatCommands(real)
|
|
const names = new Set(commands.map((c) => c.command))
|
|
|
|
assert.ok(commands.length >= 2, `only found ${commands.length} chat commands in the real plugin`)
|
|
assert.ok(names.has('link'), '/link was not found by the chat-command parser')
|
|
assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser')
|
|
})
|
|
|
|
// ── The console probe (protocol 14, D363) ──────────────────────────────────
|
|
//
|
|
// The plugin runs its own `rg.probe` through `ConsoleSystem.Run` to learn whether
|
|
// the framework runs a plugin's command from code. A probe that names a command
|
|
// the plugin does not declare answers false everywhere, and false is exactly what
|
|
// Carbon's bug looks like, so nobody would ever notice.
|
|
|
|
/** A plugin that runs `ran` on itself and declares `declared`. */
|
|
function withProbe(ran, declared) {
|
|
return source({
|
|
expected: [],
|
|
methods: ` private void StartWorldEvents()
|
|
{
|
|
ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "${ran}");
|
|
}
|
|
|
|
[ConsoleCommand("${declared}")]
|
|
private void CmdProbe(ConsoleSystem.Arg arg)
|
|
{
|
|
}`,
|
|
})
|
|
}
|
|
|
|
test('a probe that runs a command the plugin declares passes', () => {
|
|
assert.deepEqual(check(withProbe('rg.probe', 'rg.probe'), toml()), [])
|
|
})
|
|
|
|
test('a probe that runs a command the plugin does not declare is caught', () => {
|
|
const problems = check(withProbe('rg.probe', 'rg.prob'), toml())
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /ConsoleSystem\.Run runs "rg\.probe", but the plugin declares no \[ConsoleCommand\("rg\.probe"\)\]/)
|
|
})
|
|
|
|
test('a native command run through the console is not the probe, and is left alone', () => {
|
|
const methods = ` private void Call()
|
|
{
|
|
ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "satellite.random");
|
|
}`
|
|
assert.deepEqual(check(source({ expected: [], methods }), toml()), [])
|
|
})
|
|
|
|
test('the probe parsers read the real plugin, rather than quietly matching nothing', () => {
|
|
const real = fs.readFileSync(
|
|
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
|
|
'utf8'
|
|
)
|
|
|
|
assert.ok(readOwnConsoleRuns(real).includes('rg.probe'), 'the console probe was not found by the parser')
|
|
assert.ok(readConsoleCommands(real).includes('rg.probe'), 'rg.probe was not found among the console commands')
|
|
assert.ok(readConsoleCommands(real).includes('rg.hooks'), 'rg.hooks was not found among the console commands')
|
|
})
|