feat(bridge): protocol 3 — /link, /unlink, and the first command the site sends #3
@@ -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 ──────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
@@ -56,7 +56,7 @@ namespace Oxide.Plugins
|
||||
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
|
||||
/// to pair a sidecar and an overlay that disagree.
|
||||
/// </summary>
|
||||
private const int ProtocolVersion = 2;
|
||||
private const int ProtocolVersion = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 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 ----
|
||||
|
||||
/// <summary>
|
||||
/// The alphabet a link code is drawn from.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>No O, 0, I or 1.</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
private const int CodeLength = 6;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>How long a player must wait between <c>/link</c> requests.</summary>
|
||||
private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// An unconfirmed code, and the Steam identity it will bind.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>These live in memory and nowhere else</b>, 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 <c>/link</c> again, which is
|
||||
/// cheaper than a credential in a second process.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private sealed class PendingLink
|
||||
{
|
||||
public string SteamId;
|
||||
public string Name;
|
||||
public DateTime Expires;
|
||||
}
|
||||
|
||||
/// <summary>code -> pending link. Main thread only, like every world read here.</summary>
|
||||
private readonly Dictionary<string, PendingLink> _codes =
|
||||
new Dictionary<string, PendingLink>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>steam id -> when they last asked, so <c>/link</c> cannot be spammed.</summary>
|
||||
private readonly Dictionary<string, DateTime> _lastLinkRequest =
|
||||
new Dictionary<string, DateTime>(StringComparer.Ordinal);
|
||||
|
||||
private readonly System.Random _codeRandom = new System.Random();
|
||||
|
||||
/// <summary>
|
||||
/// <c>/link</c> — mint a one-time code for the caller and tell the sidecar about it.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Chat, not console, and the reply is private.</b> Both frameworks consume a
|
||||
/// <c>/</c> command rather than broadcasting it, and <c>SendReply</c> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The plugin does <b>not</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<string, object> 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 <color=#e0b34d>" + code + "</color>.");
|
||||
SendReply(
|
||||
player,
|
||||
"Enter it on the website within " + (int)CodeTtl.TotalMinutes +
|
||||
" minutes to link this Steam account. It works once.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>/unlink</c> — ask the website to sever this Steam id's link.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>It emits rather than deletes</b>, because the plugin holds no link to delete. The
|
||||
/// site owns the record and applies this like any other event off the feed.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// It exists because the site <b>refuses</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<string, object> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>link.confirm</c> — the website has been given a code and wants to know
|
||||
/// whose it is.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>This is the first command the website originates</b> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A code is consumed on the <b>first</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void HandleLinkConfirm(Dictionary<string, object> 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<string, object> ok = Frame("link.ok", "reply");
|
||||
ok["steamId"] = pending.SteamId;
|
||||
ok["name"] = pending.Name;
|
||||
if (reqId != null)
|
||||
ok["reqId"] = reqId;
|
||||
|
||||
EmitFrame(ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A refusal, with a reason the website can turn into a sentence.
|
||||
///
|
||||
/// <para>
|
||||
/// <c>unknown</c> and <c>expired</c> are deliberately <b>separate</b> here and
|
||||
/// deliberately <b>the same</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void EmitLinkError(string reqId, string reason)
|
||||
{
|
||||
Dictionary<string, object> frame = Frame("link.error", "reply");
|
||||
frame["reason"] = reason;
|
||||
if (reqId != null)
|
||||
frame["reqId"] = reqId;
|
||||
|
||||
EmitFrame(frame);
|
||||
}
|
||||
|
||||
/// <summary>Drops every outstanding code belonging to one Steam id.</summary>
|
||||
private void DropCodesFor(string steamId)
|
||||
{
|
||||
List<string> stale = null;
|
||||
|
||||
foreach (KeyValuePair<string, PendingLink> entry in _codes)
|
||||
{
|
||||
if (entry.Value != null && entry.Value.SteamId == steamId)
|
||||
{
|
||||
if (stale == null)
|
||||
stale = new List<string>();
|
||||
|
||||
stale.Add(entry.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (stale == null)
|
||||
return;
|
||||
|
||||
foreach (string key in stale)
|
||||
_codes.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops expired codes and stale cooldowns.
|
||||
///
|
||||
/// <para>
|
||||
/// Run on a timer rather than only on lookup, because an unconfirmed code is never
|
||||
/// looked up — a player who runs <c>/link</c> and wanders off leaves an entry that
|
||||
/// nothing else would ever remove.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void PurgeExpiredCodes()
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
List<string> dead = null;
|
||||
|
||||
foreach (KeyValuePair<string, PendingLink> entry in _codes)
|
||||
{
|
||||
if (entry.Value == null || now > entry.Value.Expires)
|
||||
{
|
||||
if (dead == null)
|
||||
dead = new List<string>();
|
||||
|
||||
dead.Add(entry.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (dead != null)
|
||||
{
|
||||
foreach (string key in dead)
|
||||
_codes.Remove(key);
|
||||
}
|
||||
|
||||
List<string> cold = null;
|
||||
|
||||
foreach (KeyValuePair<string, DateTime> entry in _lastLinkRequest)
|
||||
{
|
||||
if (now - entry.Value > RequestCooldown)
|
||||
{
|
||||
if (cold == null)
|
||||
cold = new List<string>();
|
||||
|
||||
cold.Add(entry.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (cold == null)
|
||||
return;
|
||||
|
||||
foreach (string key in cold)
|
||||
_lastLinkRequest.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A code from <see cref="CodeAlphabet"/>, retried until it does not collide.
|
||||
///
|
||||
/// <para>
|
||||
/// <c>System.Random</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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 ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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