diff --git a/overlay.toml b/overlay.toml
index d3a3ca7..97adc01 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -27,7 +27,7 @@
# 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
+protocol = 3
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index dd1fb84..520dcc2 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -56,7 +56,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 2;
+ private const int ProtocolVersion = 3;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -243,6 +243,10 @@ namespace Oxide.Plugins
_wipeId = ResolveWipeId();
_running = true;
+ // Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is
+ // never looked up, so nothing else would ever remove one (protocol 3).
+ timer.Every(60f, PurgeExpiredCodes);
+
_link = new Thread(LinkLoop)
{
Name = "RunicGateway Link",
@@ -743,6 +747,13 @@ namespace Oxide.Plugins
EmitFrame(status);
break;
+ case "link.confirm":
+ // The first command the WEBSITE originates (protocol 3). Everything before it
+ // was the game talking, or the sidecar asking the game to say something it
+ // already knew.
+ HandleLinkConfirm(cmd, reqId);
+ break;
+
default:
Puts($"unknown command '{verb}'");
break;
@@ -1645,6 +1656,346 @@ namespace Oxide.Plugins
.TotalMilliseconds;
}
+ // ---- account linking ----
+
+ ///
+ /// The alphabet a link code is drawn from.
+ ///
+ ///
+ /// No O, 0, I or 1. A player reads this code off their screen and types it into
+ /// a browser, often on a phone, sometimes reading it aloud to somebody else. Those four
+ /// glyphs are the ones that get transcribed wrong, and a code that fails for a reason
+ /// the player cannot see is worse than a longer one.
+ ///
+ ///
+ private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+
+ private const int CodeLength = 6;
+
+ ///
+ /// How long a code is good for. Short on purpose: R1 made this an authorization path,
+ /// not a convenience — the site becomes the author of who may do what in game, so a
+ /// code that lingers is a privilege waiting to be picked up.
+ ///
+ private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
+
+ /// How long a player must wait between /link requests.
+ private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
+
+ ///
+ /// An unconfirmed code, and the Steam identity it will bind.
+ ///
+ ///
+ /// These live in memory and nowhere else, which is the same call the UO bridge
+ /// made and it is the right one for the same reason: the secret exists only on the
+ /// server that minted it, for five minutes, and nothing writes it to disk. A plugin
+ /// reload drops every pending code — and phase 7b's config editor reloads plugins
+ /// routinely — but the cost of that is a player typing /link again, which is
+ /// cheaper than a credential in a second process.
+ ///
+ ///
+ private sealed class PendingLink
+ {
+ public string SteamId;
+ public string Name;
+ public DateTime Expires;
+ }
+
+ /// code -> pending link. Main thread only, like every world read here.
+ private readonly Dictionary _codes =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ /// steam id -> when they last asked, so /link cannot be spammed.
+ private readonly Dictionary _lastLinkRequest =
+ new Dictionary(StringComparer.Ordinal);
+
+ private readonly System.Random _codeRandom = new System.Random();
+
+ ///
+ /// /link — mint a one-time code for the caller and tell the sidecar about it.
+ ///
+ ///
+ /// Chat, not console, and the reply is private. Both frameworks consume a
+ /// / command rather than broadcasting it, and SendReply addresses one
+ /// player — so neither the request nor the code reaches anybody else's chat. That is
+ /// load-bearing rather than polish: a code read off a stream is a code somebody else
+ /// can spend.
+ ///
+ ///
+ ///
+ /// The plugin does not record who is linked. The website is the author of
+ /// record for that (R2), and a second copy here would be a second thing to keep in
+ /// step across a wipe. So there is no "you are already linked" check on this path —
+ /// the site refuses a Steam id it already holds, where it can say which account holds
+ /// it.
+ ///
+ ///
+ [ChatCommand("link")]
+ private void CmdLinkChat(BasePlayer player, string command, string[] args)
+ {
+ if (player == null)
+ return;
+
+ string steamId = player.UserIDString;
+ if (string.IsNullOrEmpty(steamId))
+ return;
+
+ DateTime last;
+ if (_lastLinkRequest.TryGetValue(steamId, out last) && DateTime.UtcNow - last < RequestCooldown)
+ {
+ SendReply(player, "Please wait a moment before asking for another link code.");
+ return;
+ }
+
+ // One outstanding code per player: drop any earlier one so only the newest works.
+ // Without this a player who ran /link three times leaves three live codes behind.
+ DropCodesFor(steamId);
+
+ string code = MintCode();
+ _codes[code] = new PendingLink
+ {
+ SteamId = steamId,
+ Name = player.displayName,
+ Expires = DateTime.UtcNow + CodeTtl
+ };
+ _lastLinkRequest[steamId] = DateTime.UtcNow;
+
+ Dictionary frame = Frame("account.link.requested", "event");
+ frame["steamId"] = steamId;
+ frame["name"] = player.displayName;
+ frame["ttlSec"] = (long)CodeTtl.TotalSeconds;
+
+ // **The code is NOT in this frame, and that is the point.** The event exists so an
+ // operator can see that linking is being used and so the site can rate-limit a player
+ // who is fishing; it is not how the code travels. The code travels through the player,
+ // which is what makes typing it proof that they are the one who asked.
+ EmitFrame(frame);
+
+ SendReply(player, "Your link code is " + code + ".");
+ SendReply(
+ player,
+ "Enter it on the website within " + (int)CodeTtl.TotalMinutes +
+ " minutes to link this Steam account. It works once.");
+ }
+
+ ///
+ /// /unlink — ask the website to sever this Steam id's link.
+ ///
+ ///
+ /// It emits rather than deletes, because the plugin holds no link to delete. The
+ /// site owns the record and applies this like any other event off the feed.
+ ///
+ ///
+ ///
+ /// It exists because the site refuses to move a Steam id that another account
+ /// already holds. Without a way out, a player who linked the wrong account while logged
+ /// in as it would need staff; with this they walk to a computer they are already sitting
+ /// at. The authority is the Steam id itself — whoever is connected as it is who it is.
+ ///
+ ///
+ [ChatCommand("unlink")]
+ private void CmdUnlinkChat(BasePlayer player, string command, string[] args)
+ {
+ if (player == null)
+ return;
+
+ string steamId = player.UserIDString;
+ if (string.IsNullOrEmpty(steamId))
+ return;
+
+ DropCodesFor(steamId);
+
+ Dictionary frame = Frame("account.unlinked", "event");
+ frame["steamId"] = steamId;
+ frame["name"] = player.displayName;
+ frame["origin"] = "in-game";
+ EmitFrame(frame);
+
+ SendReply(
+ player,
+ "Asked the website to unlink this Steam account. If it was linked, it is not now.");
+ }
+
+ ///
+ /// Inbound link.confirm — the website has been given a code and wants to know
+ /// whose it is.
+ ///
+ ///
+ /// This is the first command the website originates in this bridge; everything
+ /// before protocol 3 was the game talking. The reply carries the Steam id and nothing
+ /// the caller did not already prove — there is no session here, no account creation,
+ /// and no write to the game.
+ ///
+ ///
+ ///
+ /// A code is consumed on the first lookup whether or not it turns out to be
+ /// expired, so a code cannot be probed twice. That is why the removal happens before
+ /// the expiry check rather than after it.
+ ///
+ ///
+ private void HandleLinkConfirm(Dictionary cmd, string reqId)
+ {
+ object codeObj;
+ string code = cmd.TryGetValue("code", out codeObj) && codeObj != null
+ ? codeObj.ToString().Trim()
+ : null;
+
+ if (string.IsNullOrEmpty(code))
+ {
+ EmitLinkError(reqId, "malformed");
+ return;
+ }
+
+ PendingLink pending;
+ if (!_codes.TryGetValue(code, out pending))
+ {
+ EmitLinkError(reqId, "unknown");
+ return;
+ }
+
+ _codes.Remove(code);
+
+ if (DateTime.UtcNow > pending.Expires)
+ {
+ EmitLinkError(reqId, "expired");
+ return;
+ }
+
+ Dictionary ok = Frame("link.ok", "reply");
+ ok["steamId"] = pending.SteamId;
+ ok["name"] = pending.Name;
+ if (reqId != null)
+ ok["reqId"] = reqId;
+
+ EmitFrame(ok);
+ }
+
+ ///
+ /// A refusal, with a reason the website can turn into a sentence.
+ ///
+ ///
+ /// unknown and expired are deliberately separate here and
+ /// deliberately the same to the player: an operator reading a log wants to know
+ /// whether codes are being guessed or merely going stale, and a stranger typing codes
+ /// must not learn which of the two they hit.
+ ///
+ ///
+ private void EmitLinkError(string reqId, string reason)
+ {
+ Dictionary frame = Frame("link.error", "reply");
+ frame["reason"] = reason;
+ if (reqId != null)
+ frame["reqId"] = reqId;
+
+ EmitFrame(frame);
+ }
+
+ /// Drops every outstanding code belonging to one Steam id.
+ private void DropCodesFor(string steamId)
+ {
+ List stale = null;
+
+ foreach (KeyValuePair entry in _codes)
+ {
+ if (entry.Value != null && entry.Value.SteamId == steamId)
+ {
+ if (stale == null)
+ stale = new List();
+
+ stale.Add(entry.Key);
+ }
+ }
+
+ if (stale == null)
+ return;
+
+ foreach (string key in stale)
+ _codes.Remove(key);
+ }
+
+ ///
+ /// Drops expired codes and stale cooldowns.
+ ///
+ ///
+ /// Run on a timer rather than only on lookup, because an unconfirmed code is never
+ /// looked up — a player who runs /link and wanders off leaves an entry that
+ /// nothing else would ever remove.
+ ///
+ ///
+ private void PurgeExpiredCodes()
+ {
+ DateTime now = DateTime.UtcNow;
+ List dead = null;
+
+ foreach (KeyValuePair entry in _codes)
+ {
+ if (entry.Value == null || now > entry.Value.Expires)
+ {
+ if (dead == null)
+ dead = new List();
+
+ dead.Add(entry.Key);
+ }
+ }
+
+ if (dead != null)
+ {
+ foreach (string key in dead)
+ _codes.Remove(key);
+ }
+
+ List cold = null;
+
+ foreach (KeyValuePair entry in _lastLinkRequest)
+ {
+ if (now - entry.Value > RequestCooldown)
+ {
+ if (cold == null)
+ cold = new List();
+
+ cold.Add(entry.Key);
+ }
+ }
+
+ if (cold == null)
+ return;
+
+ foreach (string key in cold)
+ _lastLinkRequest.Remove(key);
+ }
+
+ ///
+ /// A code from , retried until it does not collide.
+ ///
+ ///
+ /// System.Random rather than a cryptographic source, and the reason it is
+ /// defensible is the shape of the guess rather than the shape of the generator: a code
+ /// lives five minutes, is consumed on first lookup, and the website rate-limits the
+ /// confirm endpoint. An attacker who could predict the sequence would still have to
+ /// reach a code before the player does.
+ ///
+ ///
+ private string MintCode()
+ {
+ for (int attempt = 0; attempt < 16; attempt++)
+ {
+ var chars = new char[CodeLength];
+
+ for (int i = 0; i < CodeLength; i++)
+ chars[i] = CodeAlphabet[_codeRandom.Next(CodeAlphabet.Length)];
+
+ string candidate = new string(chars);
+
+ if (!_codes.ContainsKey(candidate))
+ return candidate;
+ }
+
+ // Sixteen collisions in a row against a table this small is not bit-space exhaustion,
+ // it is something being wrong. A code that cannot collide is better than a loop that
+ // might not end.
+ return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant();
+ }
+
// ---- diagnostics ----
///
diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js
index 56b04eb..d7ac002 100644
--- a/scripts/checkPlugin.js
+++ b/scripts/checkPlugin.js
@@ -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()
diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js
index 1e9cbbf..ebb8eff 100644
--- a/scripts/checkPlugin.test.js
+++ b/scripts/checkPlugin.test.js
@@ -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')
+})