Files
Rust-Plugins/scripts/checkPlugin.js
wtclaude adb9df6a2e
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m55s
feat(bridge): Rust's own world events on protocol 14 (PLAN_REDESIGNS §14.3.1)
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
2026-10-15 00:39:00 -05:00

271 lines
11 KiB
JavaScript

#!/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*$/
/**
* 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: 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])
}
/**
* 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. The
* player types `/link`, nothing happens, and there is no log line to find. So the signature is
* checked here rather than discovered on a rig.
*/
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
}
/** Every `[ConsoleCommand("x")]` the plugin declares. */
function readConsoleCommands(source) {
return [...source.matchAll(/\[ConsoleCommand\("([^"]+)"\)\]/g)].map((m) => m[1])
}
/** The `rg.*` commands the plugin runs on itself through `ConsoleSystem.Run`. */
function readOwnConsoleRuns(source) {
return [...source.matchAll(/ConsoleSystem\.Run\([^,]+,\s*"(rg\.[\w.]+)"/g)].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. Every chat command is bound by reflection and must have the signature the
// frameworks call. A wrong one is never invoked and says nothing.
const chat = readChatCommands(source)
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 — the player types the command and ' +
'nothing happens at all.'
)
}
}
// Two commands answering to one name is one of them never running, and which one is
// whichever the framework happened to register second.
const seen = new Set()
for (const cmd of chat) {
if (seen.has(cmd.command)) {
problems.push(`/${cmd.command} is declared twice; only one of them can ever be bound`)
}
seen.add(cmd.command)
}
// 5. Protocol 14 (D363): the console probe runs one of this plugin's own commands
// through `ConsoleSystem.Run` to learn whether the framework runs a plugin's
// command from code. A probe naming a command the plugin does not declare
// answers "no" on every framework, which reads exactly like Carbon's bug.
for (const name of readOwnConsoleRuns(source)) {
if (!readConsoleCommands(source).includes(name)) {
problems.push(
`ConsoleSystem.Run runs "${name}", but the plugin declares no [ConsoleCommand("${name}")]: ` +
'the console probe would report false on every framework (D363).'
)
}
}
// 6. The two declaration sites this repository owns must agree.
const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)
// runicnpc stage 4: the RunicNPC API the bridge needs, declared twice like the protocol.
const npcToml = /^\s*runicnpc_api\s*=\s*(\d+)\s*$/m.exec(toml)
const npcCode = /const\s+int\s+RunicNpcApiNeeded\s*=\s*(\d+)\s*;/.exec(source)
if (npcCode && !npcToml) problems.push('the plugin needs a RunicNPC API (RunicNpcApiNeeded) and overlay.toml declares no runicnpc_api')
if (npcCode && npcToml && npcCode[1] !== npcToml[1]) {
problems.push(`the plugin needs RunicNPC API ${npcCode[1]} and overlay.toml declares runicnpc_api = ${npcToml[1]}. The bundle picks a RunicNPC by the second, so they must agree.`)
}
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, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME }
if (require.main === module) main()