feat(bridge): protocol 3 — /link, /unlink, and the first command the site sends
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
R1's identity flow, in the shape the UO bridge already proved: the player asks in game, the plugin mints a one-time code and hands it to them, and the website confirms it back through the sidecar. The site is the author of record; this plugin holds no link at all. **`/link` is chat, not console, and the reply is private** (D21). Both frameworks consume a `/` command rather than broadcasting it and `SendReply` addresses one player, so neither the request nor the code reaches anybody else — a code read off a stream is a code somebody else can spend. **Pending codes live in memory and nowhere else** (D22, matching module-uo): six characters from an alphabet with no O/0/I/1, five-minute TTL, a thirty-second cooldown per player, one outstanding code each, and a purge timer because an unconfirmed code is never looked up. A plugin reload drops them — phase 7b's config editor will do that routinely — and the cost is typing `/link` again, which is cheaper than a credential living in a second process. **The code is deliberately NOT in `account.link.requested`.** That event exists so an operator can see linking being used; the code travels through the player, which is what makes typing it proof they are the one who asked. `/unlink` emits rather than deletes, because there is nothing here to delete. It is the escape hatch that makes the site's refusal to move an already-linked Steam id safe: a player who linked the wrong account walks to a computer they are already sitting at. `link.confirm` consumes a code on the FIRST lookup whether or not it turns out to be expired, so a code cannot be probed twice. `unknown` and `expired` stay separate in the reply — an operator wants to know whether codes are being guessed or going stale — and the website says the same sentence for both. `checkPlugin.js` gains a check for the new surface, because a chat command binds by reflection exactly like a hook and fails the same silent way. It asserts the signature, the void return and the absence of duplicates, and — like the hook parser before it — a test reads the real plugin so a regex that matches nothing cannot pass. Verified by breaking it five ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -62,6 +62,12 @@ 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
|
||||
@@ -82,6 +88,31 @@ function readExpectedHooks(source) {
|
||||
.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
|
||||
}
|
||||
|
||||
function readMethods(source) {
|
||||
const found = []
|
||||
let m
|
||||
@@ -133,7 +164,39 @@ function check(source, toml) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. The two declaration sites this repository owns must agree.
|
||||
// 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. 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)
|
||||
|
||||
@@ -171,6 +234,6 @@ function main() {
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { check, readExpectedHooks, readMethods, HOOK_NAME }
|
||||
module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME }
|
||||
|
||||
if (require.main === module) main()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// A check is worth what it catches, so this breaks it seven ways.
|
||||
// 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
|
||||
@@ -16,7 +16,7 @@ const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { check, readExpectedHooks, readMethods, HOOK_NAME } = require('./checkPlugin')
|
||||
const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin')
|
||||
|
||||
/** A minimal plugin that passes, as the baseline every case below deviates from. */
|
||||
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
|
||||
@@ -128,3 +128,85 @@ test('the parser actually reads the real plugin, rather than quietly matching no
|
||||
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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user