SquadPlugin 插件开发文档(JS / goja 版)· v2 校订版#
面向插件开发者:说明在 SquadPlugin 云端后台上传的 JS 插件可用的全部接口、事件、回调与约定,并给出可直接套用的完整范例与安全实践。 运行环境:客户端内置的 goja JavaScript 引擎(平台约定按 ES5.1 书写,见 §3)。 本文档只描述对插件公开的 JS SDK;不涉及客户端内部实现、连接与鉴权等与插件开发无关的细节。 v2 说明:本版已按客户端源码(Go v4.0.0)与云端后台(商城版 v2)逐条正向/反向校验,更正 8 处偏差、补充 18 处行为细节,差异清单见 §22。
如何把本文档喂给 AI 生成插件#
把本文档整篇作为上下文,然后用一句话描述需求即可,例如:
“按本文档的 SDK 写一个插件:玩家发『签到』每天领 50 积分,每天限一次,并在命令菜单登记。用 ES5.1,配置项走 schema。”
AI 生成后请对照 §3 语言约定(不要出现箭头函数 / let / const / 模板串 / Promise)、§5.2 配置块硬性约定(default 一律字符串等)与 §20 自检清单逐条过一遍再上传。
目录#
- 五分钟快速上手
- 运行模型(必读)
- 语言约定:按 ES5.1 书写
- 插件结构与生命周期
- 配置:
Config与配置块 schema - 事件:
Squad.on()与全部事件载荷 - 聊天命令:
Squad.command/Squad.commands - 动作类 RCON 接口
- 结果类 RCON(异步回调)
- 积分:
Squad.points - 存储:local / cloud / shared
- 玩家查询:
Squad.players - 定时器与冷却
- 工具函数
- 管理 / 健康 / 状态
- 常用插件配方(可直接改用)
- 安全与健壮性最佳实践
- 常见坑与调试
- 接口速查表
- 插件骨架模板 + 上传前自检清单
- 平台流程与运行环境补充
- v2 修订记录
1. 五分钟快速上手#
一个插件就是一段 JS 脚本。最小可用示例(玩家发「你好」回一句私信):
jsSquad.on("chat", function (e) { if (("" + e.message).trim() === "你好") { Squad.warn(e.steamId, "你好," + e.name + "!"); } }); function onLoad() { Squad.log("demo 插件已就绪"); }
写插件的思维顺序:
- 想清楚触发方式:固定命令词 → 用
Squad.command;监听游戏行为 → 用Squad.on(事件)。 - 把可调参数做成配置项(§5),代码里用
loadConfig()读,onConfig()里再读一遍。 - 需要跨重启保存的数据用
Squad.cloud;只是本轮临时状态用普通var。 - 面向玩家的输出用
Squad.broadcast(全服)/Squad.warn(私信),纯文字、不带 emoji。 - 任何“花钱/给奖/管理”类操作,先校验、再限流、后执行(§17)。
2. 运行模型(必读)#
- 每个插件 = 一个独立 goja 运行时(VM),跑在自己的单个 goroutine 上。 插件之间不共享 JS 作用域。
- 该 goroutine 串行执行:你的顶层代码 → 各事件回调 → 命令回调 → 定时器回调 → 生命周期钩子。同一插件内没有并发,无需加锁——普通 JS 全局变量即可安全保存状态(内存奖池、计数器、红包表等)。
- 因为单线程:任何阻塞操作只会卡住本插件自己(如
Squad.http.*、Squad.points.*、Squad.cloud.*都是同步阻塞),不影响其它插件与游戏监控;但仍应避免在高频回调里做长耗时操作。
#### ⚠️ 单次执行 5 秒看门狗(重要) 顶层代码加载、每一次事件/命令/定时器/onError回调,单次执行时间上限为 5 秒:超时会被引擎中断(报“执行超时(疑似死循环)”),本次回调剩余逻辑不再执行(计入插件状态的 runtimeError),但 VM 保留、插件不下线、后续事件照常处理。 由于Squad.points.* / Squad.cloud.*单次云端请求超时 10 秒、Squad.http.*超时 10 秒,一次回调里串 2 个以上网络调用就可能被看门狗打断在半路(例如“扣分成功但发奖没执行”)。→ 一次回调最多做 1~2 次网络调用;批量任务放进定时器分批处理。
- 队列有上限,满时丢弃(并限频告警,以防拖慢监控):每插件事件队列 1024 条、异步 RCON 结果回调队列 256 条;发出的动作命令进入所分配发送连接的队列(每连接 1000 条),拥塞时等待 2 秒后丢弃该条命令。因此不要把关键状态只依赖“事件计数”(比如“数到第 100 个 kill 就发奖”不可靠),广播也别刷屏。
- 状态存活与 VM 生命周期一致:
| 操作 | VM 是否重建 | 内存状态(var、冷却) | 生命周期钩子 |
|---|---|---|---|
| 服主在后台保存新配置(热更新) | 否 | 保留 | 触发 onConfig |
| 修改源码并保存 | 是(直接重建,不会调用 onUnload) | 清零 | 新 VM 执行顶层代码 → onLoad |
| 停用插件 / 客户端退出 | —(销毁) | 清零 | 触发 onUnload |
| 客户端进程重启 | 是 | 清零 | 顶层 → onLoad |
需要跨重启/跨重建持久的数据,用Squad.cloud(云端)或Squad.store(本地),见 §11。 网页保存代码/配置后,客户端每 10 秒同步一次插件清单——最多约 10 秒后生效。
- 战斗类事件依赖服务器日志:
kill / wound / damage / revive / connect / newGame / matchStart / matchEnd全部来自本地SquadGame.log解析。若后台该服未配置正确的日志路径,客户端启动会告警,且这些事件一个都不会触发;chat / disconnect / squadCreate走 RCON,不受影响。调试“事件不来”先查这一点。 - 中文一律 UTF-8。服务器若设 GBK,客户端在发送
Squad.broadcast / warn / kick / ban时会自动转码,你无需处理;但Squad.exec与Squad.rcon.raw是原样发送——GBK 服务器上用它们发含中文的命令会乱码(见 §8/§9)。 - 不要给 RCON 发 emoji / 颜文字:游戏聊天框无法渲染,会显示成乱码(�/◇)。广播、私信一律纯文字。
3. 语言约定:按 ES5.1 书写#
事实说明(v2 更正):当前客户端内置的 goja(2024-02 版)实际已能解析大部分 ES6+ 语法(箭头函数、let/const、模板串、解构、class、for...of等),“写了就报错”的说法不再成立。 但平台仍强约定:一律按 ES5.1 提交。 理由:① 官方内置插件与全部模板均为 ES5,便于评审与 AI 生成一致;② 本运行环境没有事件循环,SDK 全部是同步或回调式接口(没有任何返回 Promise 的接口),Promise / async / await与宿主回调组合极易写出永不恢复、或被 5 秒看门狗打断的逻辑;③ 兼容可能仍在运行旧版引擎的客户端。
| ❌ 约定禁止 | ✅ 改用 |
|---|---|
箭头函数 () => {} | function () {} |
let / const | var |
模板字符串 ` ${x} ` | 字符串拼接 "" + x |
解构 {a,b}=obj / 展开 ...arr | 手动取值 / 手动遍历 |
class | 构造函数 + 原型,或普通对象 |
async / await / Promise | 回调(见 §9),绝对禁止(环境无事件循环) |
for...of | for (var i=0;i<n;i++) 或 forEach |
默认参数 function f(a=1) | 函数体内 a = (a===undefined)?1:a |
可以放心使用:var、function、for/while、Array(map/filter/forEach/slice/indexOf/join/splice/sort…)、Object、Math、JSON、parseInt/parseFloat/isNaN、字符串方法(split/indexOf/slice/replace/toLowerCase…)、正则字面量、Array.isArray、Date(取当前时间推荐用 Squad.now() / Squad.date())。
常用等价写法:
js// 模板串 → 拼接 var msg = "玩家 " + name + " 得到 " + n + " 积分"; // 占位符替换(自造小工具) function fmt(tpl, map) { var out = "" + tpl; for (var k in map) out = out.split("{" + k + "}").join("" + map[k]); return out; } fmt("{who} +{n} 分", { who: name, n: 50 }); // 数组求和 var total = 0; list.forEach(function (x) { total += x.weight; });
4. 插件结构与生命周期#
顶层代码在加载时执行一次,通常在这里:注册事件、注册命令、起定时器、读配置初始化。
三个可选的全局生命周期函数(定义了才会被调用):
| 函数 | 触发时机 | VM 是否重建 | 典型用途 |
|---|---|---|---|
function onLoad() | 插件加载完成、顶层代码执行后 | — | 打印就绪日志、登记命令菜单、初始化首轮定时 |
function onConfig() | 服主在后台保存了新配置后(热更新) | 否,内存状态保留 | 重新读取 Config 刷新本地缓存变量 |
function onUnload() | 插件被停用 / 客户端退出 | — | 收尾(一般无需做什么) |
关键点 1:热更新时,客户端会先把新配置注入全局Config,再调用onConfig()。所以onConfig()里读Config["键"]一定是新值。 关键点 2(v2 更正):源码热重载不会调用 onUnload——旧 VM 直接被丢弃重建。不要把“必须执行的收尾动作”寄托在 onUnload 上;需要落地的数据请随做随存(cloud/store)。 关键点 3:全局Config对象在每次热更新时会被整体替换。顶层写var C = Config;缓存引用会永远拿到旧值——一律现取Config["键"]或Squad.config.get("键")。 关键点 4:onConfig()里不要调用Squad.commands.add(热更新不重建 VM、不清命令注册表,会导致 cd 菜单条目重复叠加)。commands.add只放顶层或onLoad。
推荐范式——把“读配置”抽成一个函数,加载时与 onConfig 都调用:
jsvar INTERVAL, MSGS; function loadConfig() { INTERVAL = parseInt(Config["间隔秒"], 10) || 30; MSGS = ("" + (Config["广播内容"] || "")).split(/\r?\n/).filter(Boolean); } loadConfig(); // 加载时 function onConfig() { loadConfig(); } // 热更新时(拿到的是新值) function onLoad() { Squad.log("插件已就绪"); }
5. 配置:Config 与配置块 schema#
5.1 在插件里读配置#
四种等价读法(值都会被自动类型化):
jsConfig["每日积分"] // 全局对象下标取值(推荐) Squad.config.get("每日积分") // 函数式,等价(读的永远是最新配置) Squad.config.raw("每日积分") // 取原始字符串(不类型化、不去首尾空格) Squad.config.has("每日积分") // 是否存在该键 → bool Squad.config.all() // 取全部(已类型化)对象
自动类型化规则(字符串 → JS 值;类型化前会去掉首尾空格):
| 配置原始值 | 得到的 JS 类型/值 |
|---|---|
是 / true / TRUE / True / yes / YES / on / 开 / 启用 / 开启 | true |
否 / false / FALSE / False / no / NO / off / 关 / 禁用 / 关闭 | false |
纯整数(可负),如 100 / -5 | 数字 100 / -5 |
标准小数,如 0.5(注意 .5、1e3 不算,保持字符串) | 数字 0.5 |
含英文逗号,如 qd,QD,签到 | 字符串数组 ["qd","QD","签到"](元素不再数字化) |
| 其它 | 原样字符串(已去首尾空格) |
重要:后台下发给客户端的配置值永远是字符串,上面的“类型化”是客户端读取时才做的。所以“列表只填一个值(不含逗号)会得到字符串而非数组”“数字项被填空会得到空串”这类情况都要防。推荐统一用下面这组小工具(不要直接假设 Config[k] 已经是数组/数字):
jsfunction cfgStr(k, d) { var v = Config[k]; return (v===undefined||v===null||v==="") ? d : ("" + v); } function cfgInt(k, d) { var n = parseInt(Config[k], 10); return isNaN(n) ? d : n; } function cfgFloat(k,d) { var n = parseFloat(Config[k]); return isNaN(n) ? d : n; } function cfgBool(k, d) { var v = Config[k]; if (v===undefined||v===null||v==="") return d; if (typeof v==="boolean") return v; v=("" + v).toLowerCase(); return v==="是"||v==="true"||v==="1"||v==="on"||v==="开"||v==="启用"||v==="开启"; } function cfgList(k, d) { var v = Config[k]; if (v===undefined||v===null||v==="") return d; if (Array.isArray(v)) return v; return ("" + v).split(",").map(function(s){return s.trim();}).filter(Boolean); }
5.2 配置块(上传时的「配置字段定义 JSON」)#
上传插件时的「配置字段定义」是一个 JSON 数组,后台据此渲染配置表单,并用每个字段的默认值生成初始配置。每个元素:
jsonc{ "key": "每日积分", // 必填,配置键(与 Config["每日积分"] 对应;可中文) "label": "每日签到积分", // 必填,表单里显示的名字 "type": "number", // 必填,见下表 "default": "100", // 默认值——一律写成【字符串】(数字也加引号),见下方硬性约定 "help": "每天签到送多少积分", // 可选,字段下方灰字说明 "min": 0, "max": 9999, // 可选,写【数字】;number=取值范围,text/textarea/list=字符长度 "options": ["每局", "每小时"], // select / multiselect 专用,字符串数组 "required": true, // 可选 "pattern": "^#[0-9a-fA-F]{6}$", // 可选,正则(整串匹配,作用于 text/textarea/list) "patternMsg": "请输入十六进制颜色" // 可选,校验失败提示 }
#### ⚠️ 配置块硬性约定(照做,生成的 JSON 才和官方一致、不出歧义) 1.default一律写成字符串。 数字也要加引号:写"default": "100",不要写"default": 100。 2.bool的default写"是"或"否"(字符串),不要写true/false。 3.min/max写成数字(不加引号):"min": 0。对number表示取值范围,对text/textarea/list表示字符长度上下限。 4.options是字符串数组,仅select/multiselect用。 5. 整份内容必须是一个能被JSON.parse解析成数组的 JSON(后台保存插件时只校验这一条——不是数组就报“配置字段 JSON 格式有误”)。允许用jsonc风格写注释给自己看,但真正粘贴进后台的内容不能带注释/尾逗号。 6.required / min / max / pattern是双重校验:配置表单即时提示 + 服主保存时服务端强校验(number 卡范围、text/textarea/list 卡长度与 pattern、select/multiselect 卡选项合法性)。所以给数值项设好 min/max 是真能兜住服主误填的。 (原理:后台会把default统一强转成字符串保存——布尔true/false会被转成是/否、数字会被转成"100",客户端再按 §5.1 规则类型化。所以即便你写了裸数字/布尔也不会立刻报错;但全部官方内置插件与默认模板都用字符串写法,为保持一致、避免不同工具解析差异,请严格按上面来。)
type 取值:
| type | 表单控件 | 在 Config 里得到(客户端类型化后) |
|---|---|---|
text | 单行输入 | 字符串 |
textarea | 多行输入 | 字符串(自己按换行 split) |
number | 数字输入 | 数字 |
bool | 开关 | true / false(后台恒保存 是/否) |
select | 下拉单选(配 options) | 字符串 |
multiselect | 多选(配 options) | 逗号串 → 数组 |
list | 标签/逗号列表 | 逗号串 → 数组 |
color | 取色器 | #RRGGBB 字符串 |
-list/multiselect在Config里只有含逗号(多值)时才是数组;单值时是字符串——用cfgList()兜底最稳。 - 结构化数据(如抽奖奖品表)建议用textarea,约定“每行一条、列用|分隔”,自己在 JS 里解析(示例见 §16 抽奖)。 - 保留配置键发送连接数:若你的 schema 里包含发送连接数(number),客户端首次加载该插件时会据此为其分配相应条数的 RCON 发送连接(默认 1;发送池全体插件共享、按占用最少分配,总连接数有硬上限)。高吞吐私聊/广播类插件可加此项;改动仅在插件首次加载/客户端重启时生效,热重载不变。详见 §21.4。
一个符合硬性约定的完整 schema 例子(可直接照抄格式):
json[ {"key":"每日积分","label":"每日签到积分","type":"number","default":"100","min":0,"max":9999,"help":"每天签到送多少积分"}, {"key":"启用广播","label":"是否全服广播","type":"bool","default":"是"}, {"key":"播报方式","label":"播报方式","type":"select","default":"单人","options":["单人","全体"],"help":"单人=私信本人,全体=全服黄字"}, {"key":"触发词","label":"签到触发词","type":"list","default":"qd,QD,签到","help":"多个用英文逗号分隔"} ]
6. 事件:Squad.on() 与全部事件载荷#
jsSquad.on("kill", function (e) { /* e 是事件对象 */ });
- 同一事件可注册多个处理器,按注册顺序调用。
- 处理器抛错不影响其它插件与后续事件;可用
Squad.onError(fn)兜底(§14;只保留最后一次注册的回调)。 - 同一条聊天消息:先跑本插件全部
on("chat")处理器,再跑Squad.command命令路由(§7)。
6.1 “玩家对象”的两种形状 ⚠️ 重点#
不同来源给到的玩家对象字段并不一样,这是最容易踩的坑:
A) 事件玩家对象(kill / wound / damage / revive 里的 killer/victim/attacker/reviver…):
txt{ name, eosId, steamId(字符串,未知为""), teamId(数字), squadId(数字,无队为0), role }
六个键总是存在,但查不到在线玩家时其余字段会是空串/0(damage 事件最常见,见 6.2)。注意事件玩家对象没有 isLeader。
B) 受限玩家字段(chat 事件、以及 Squad.command 的 ctx.player):
txt{ name, steamId(字符串), eosId, teamId } // 注意:没有 squadId!没有 role!没有 isLeader!
也就是说:从聊天/命令拿不到发言人的小队号。若需要发言人的squadId/isLeader,用Squad.players.find(steamId)现查(§12,返回的 PlayerInfo 带 isLeader)。
6.2 全部事件类型与精确载荷#
事件 type | 触发 | 载荷字段 |
|---|---|---|
chat | 玩家发聊天 | { channel, eosId, steamId, name, message, teamId }(无 squadId;查不到在线玩家时 teamId 为 0) |
kill | 击杀(死亡结算) | { killer:玩家对象, victim:玩家对象, weapon, damage, suicide } |
wound | 击倒 | { attacker:玩家对象, victim:玩家对象, weapon, damage } |
damage | 造成伤害 | { attacker:玩家对象, victim:玩家对象, damage }(字段齐但常为空值,见下) |
revive | 救援 | { reviver:玩家对象, revived:玩家对象 } |
connect | 进服 | { ip, eosId, steamId }(来自日志,没有名字——稍后用 players.find 补,见 §16.1) |
disconnect | 退服 | { eosId, steamId, name, teamId }(由 3 秒轮询差分产生,延迟约 3–6 秒;无 squadId) |
squadCreate | 建队 | { name, eosId, steamId(字符串), squadId(数字), squadName, faction } |
newGame | 新对局 | { matchId }(与 matchStart 同时触发) |
matchStart | 对局开始 | { matchId } |
matchEnd | 对局结束(结算上一局) | { matchId(上一局编号), stats };仅当上一局有战绩时才触发,stats 字段名见下方专栏 ⚠️ |
#### ⚠️ matchEnd 的stats元素字段名是大写(v2 重要补充)e.stats的每个元素来自客户端内部结构体,字段名为首字母大写的 Go 字段名(不是本文其余地方的小写风格):
txt> { EosID, SteamID(数字!), Name, Kills, Wounds, Deaths, Revives } >
两个额外注意:①SteamID在这里是数字(其余所有地方 steamId 都是字符串)——要当键用请立刻"" + s.SteamID转成字符串;② 遍历用普通for循环最稳妥,不要依赖Array.isArray/ 数组原型方法:
js> Squad.on("matchEnd", function (e) { > var best = null; > for (var i = 0; i < e.stats.length; i++) { > var s = e.stats[i]; > if (!best || s.Kills > best.Kills) best = s; > } > if (best && best.Kills > 0) { > Squad.broadcast("上局击杀王:" + best.Name + "(" + best.Kills + " 杀)"); > Squad.points.add("" + best.SteamID, 20); > } > }); >
其余要点:
chat的channel是原始频道串:ChatAll/ChatTeam/ChatSquad/ChatAdmin。kill的自杀判定是启发式:weapon 含 "nullptr" 或 damage 恰为 100.0会被判为自杀;自杀事件里 killer 等于 victim,且weapon === "Suicide"(字面量)。做“击杀奖励”记得if (e.suicide) return;,且不要依赖 weapon 原文做精确匹配。damage事件:attacker/victim 六字段齐全但可能为空值——attacker 至少有 steamId、victim 至少有 name(若对应玩家在线则完整);受害者名为空、攻击者 steam 为 0 或伤害为 0 时该事件不发。伤害事件量极大,回调里别做重活。- 事件可能延迟:
kill/wound/revive若当时玩家索引尚未就绪会进入重试队列(每 3 秒重试,最多 10 次或 60 秒后放弃),因此事件可能比实际发生晚数秒到达,极端情况会丢。 newGame与matchStart同时各触发一次:若两个都监听同一个重置函数,该函数要写成幂等(连调两次结果一致)。触发顺序:matchEnd(上一局)→ 编号+1 →newGame→matchStart。matchId是整数字符串:客户端启动时用云端下发的「该服历史最大对局号 +1」做种子,每局自增,重启后编号连续。steamId除 matchEnd.stats 外处处是字符串;未知/机器人可能是""或"0",用前先判if (!sid || sid === "0") return;。- 在线玩家每 3 秒、 小队每 5 秒轮询刷新一次,个别时刻
squadId / isLeader / role可能尚未补全,代码要容忍squadId为0、role为空。
示例:
jsSquad.on("kill", function (e) { if (e.suicide) return; var k = e.killer || {}, v = e.victim || {}; if (!k.steamId || k.steamId === "0") return; Squad.warn(k.steamId, "击杀 " + (v.name || "敌人") + "(" + e.weapon + ")"); });
7. 聊天命令:Squad.command / Squad.commands#
比手动监听 chat 更省心的命令路由:
jsSquad.command("!sign", function (ctx) { /* ... */ }); // 触发词 + 处理器 Squad.command("!sign", "每日签到领积分", function (ctx) { ... }); // 触发词 + 描述 + 处理器
触发规则:玩家消息整体等于触发词,或以「触发词 + 空格」开头时命中;命中后触发词之后的文本被切分。 (因此触发词 悬赏 不会误命中 悬赏小队 …,因为后者“悬赏”之后不是空格。)
四条 v2 补充的重要细节:
- 大小写敏感:
"qd"不会命中玩家发的"QD"。要么把qd/QD都注册一遍,要么改用Squad.on("chat")自行toLowerCase匹配(官方内置签到插件就是后一种写法,且用的是“等于或包含”而非“前缀+空格”——两种范式按需选用)。 - 命令对所有频道生效(包括 ChatAdmin);要限制频道就在处理器里检查
ctx.channel。 - 同一条消息先跑本插件全部
Squad.on("chat")处理器,再跑命令路由;多个触发词同时命中会各自触发(按注册顺序)——别给同一句话注册互相包含的多个触发词。 - 保留触发词:客户端内置菜单插件占用
cd / CD / 菜单(玩家发它可收到全部已登记指令清单的私信),内置状态插件占用!状态 / !status(管理员查看插件运行状态)。你的插件不要占用这些词。
ctx 字段:
| 字段 | 说明 |
|---|---|
ctx.trigger | 命中的触发词 |
ctx.arg | 触发词之后的剩余文本(去首尾空格) |
ctx.args | 把 arg 按空白切成的字符串数组(连续空格合并) |
ctx.message | 完整消息 |
ctx.channel | 频道串 |
ctx.player | { name, steamId, eosId, teamId } ⚠️ 无 squadId / 无 role |
命令菜单(仅用于“列出有哪些命令”,不注册触发逻辑):
jsSquad.commands.add("!sign", "每日签到"); // 只登记菜单文案(进入 cd 菜单) Squad.commands.list(); // 返回 [{plugin, trigger, desc}, ...]
Squad.command(...)既注册触发逻辑、也顺带登记菜单;Squad.commands.add(...)只登记菜单文案——适合“自己用Squad.on("chat")做复杂匹配、但仍想在 cd 菜单展示”的插件。 ⚠️commands.add只放顶层或 onLoad,不要放onConfig(热更新不清注册表,会在菜单里叠加重复条目)。
何时用哪种(§18 也会强调):
- 固定单触发词、参数简单 →
Squad.command最省事。 - 多个触发词 / 中文别名多 / 需要大小写不敏感或自定义解析 → 顶层
Squad.on("chat")自己判断,再用Squad.commands.add登记菜单。
示例:
jsSquad.command("!give", "给自己加分:!give <积分>", function (ctx) { var n = parseInt(ctx.args[0], 10); if (isNaN(n)) { Squad.warn(ctx.player.steamId, "用法:!give 100"); return; } Squad.points.add(ctx.player.steamId, n); Squad.warn(ctx.player.steamId, "已加 " + n + " 积分"); }); // 需要发言人的小队号时,现查: Squad.command("我的队", function (ctx) { var p = Squad.players.find(ctx.player.steamId); // ctx.player 没有 squadId,这里补 if (!p) { Squad.warn(ctx.player.steamId, "查询失败"); return; } Squad.warn(ctx.player.steamId, "你在 " + p.teamId + " 队 " + (p.squadId || "无") + " 小队"); });
8. 动作类 RCON 接口#
“发了就完事”的命令(无返回值):
| 接口 | 实际命令 | 说明 |
|---|---|---|
Squad.broadcast(text) | AdminBroadcast | 全服黄字广播(GBK 服自动转码) |
Squad.warn(steamId, text) | AdminWarn | 给某玩家私信提示(GBK 服自动转码) |
Squad.exec(cmd) | 原文 | 执行任意 RCON 命令原文(高级;不自动转码,见下方注意) |
Squad.kick(steamId, reason) | AdminKick | 踢出玩家(原因自动转码) |
Squad.ban(steamId, duration, reason) | AdminBan | 封禁(duration 如 "1d";"0"=永久;传空串按 "0";原因自动转码) |
Squad.move(steamId) | AdminForceTeamChange | 把玩家换边 |
Squad.setNextLayer(layer) | AdminSetNextLayer | 设定下一张图 |
Squad.changeLayer(layer) | AdminChangeLayer | 立即换图 |
Squad.restartMatch() | AdminRestartMatch | 重开本局 |
Squad.endMatch() | AdminEndMatch | 结束本局 |
jsSquad.broadcast("服务器将在 5 分钟后换图"); Squad.warn("76561198000000000", "请遵守服务器规则");
- 这些命令异步入队,由客户端发送线程统一发出(每插件默认独享 1 条发送连接,可用保留配置键
发送连接数调整,见 §21.4)。队列每连接 1000 条,拥塞时等待 2 秒后丢弃该条命令——不要瞬时刷大量广播/私聊。文本用纯文字、勿带 emoji。 - ⚠️ 编码:自动 GBK 转码只覆盖
broadcast / warn / kick / ban。Squad.exec(以及 §9 的Squad.rcon.raw)原样发送——GBK 服务器上发含中文的命令会乱码;发中文请改用封装接口,exec只发 ASCII 命令。 - ⚠️
Squad.exec、kick/ban/换图/重开这类高影响力操作,务必用Squad.isAdmin做权限门禁,并对来自聊天的参数严格校验(§17)。不要把玩家可控的文本直接拼进Squad.exec。
9. 结果类 RCON(异步回调)#
需要“拿到执行结果”的命令是异步的:传回调,结果会在本插件 goroutine 内回传(回调里访问你的全局变量仍安全)。
#### ⚠️ 回调统一是function (result, err)两个参数(v2 更正) 成功时err === undefined;失败时result === undefined、err为错误字符串。先判 err 再用 result。
js// 任意命令的原始合并响应字符串 Squad.rcon.raw("ListPlayers", function (out, err) { if (err) { Squad.log("执行失败:" + err); return; } Squad.log("返回:" + out); }); // 当前图 / 下一张图:回调 { level, layer, raw } Squad.currentLayer(function (info, err) { if (err) { Squad.log("查询失败:" + err); return; } Squad.log("当前图:" + info.layer); }); Squad.nextLayer(function (info, err) { if (!err) Squad.log("下一图:" + info.layer); }); // 可选图层列表:回调字符串数组 Squad.layers(function (list, err) { if (!err) Squad.log("共有 " + list.length + " 张图"); });
- 没有Promise/await,一律用回调。Squad.rcon.raw的回调可省略(只想发命令、不关心结果时)。 - 这类命令经客户端的「玩家查询连接组」执行,内部合并响应整体上限约 5 秒(首包 3 秒);聊天/战斗日志刷屏时可能回调err = "读取耗时过长…"。别高频轮询 raw(会与 ListPlayers 轮询争抢连接),周期性信息用 §12/§13 的现成能力。 - GBK 服务器上rcon.raw同样不转码(同 §8 exec 注意事项)。 - 回调经异步结果队列(容量 256)回到本插件 goroutine,插件长期卡顿时可能丢结果。
10. 积分:Squad.points#
积分存云端,按「服务器 + SteamID」记账,与后台「玩家与积分」一致;同服的多个插件共享同一份(这是插件间共享数据的正规通道之一)。
| 接口 | 返回 | 说明 |
|---|---|---|
Squad.points.get(steamId) | 数字 | 当前积分;查询失败返回 0(会记录告警) |
Squad.points.add(steamId, delta) | 数字 | 增减后余额(delta 为负=扣分) |
Squad.points.set(steamId, value) | 数字 | 直接设为某值后的余额 |
Squad.points.rank(steamId) | 对象 | { points, rank, total }:积分、名次(=分数高于我的人数+1)、总人数(有积分记录的人数) |
jsvar bal = Squad.points.get(sid); if (bal < COST) { Squad.warn(sid, "积分不足,需要 " + COST); return; } Squad.points.add(sid, -COST); // 先扣 // ... 发奖
- 每次调用都是一次云端请求(同步阻塞本插件直到返回,单次超时 10 秒)。叠加 §2 的 5 秒看门狗:别在一次回调里连续多次调用,更别在循环里对大量玩家逐个调用——会明显卡顿甚至被看门狗打断在半路(“扣了没发”);必要时合并逻辑、放到定时器分批。 - 失败返回0:扣分前先确认get结果合理(例如if (bal <= 0) return;视业务而定),避免把“查询失败”误判成“没钱”或触发负数逻辑。
11. 存储:local / cloud / shared#
| API | 作用域 | 是否持久 | 用途 |
|---|---|---|---|
Squad.store | 本服 + 本插件(客户端本地文件) | 持久(本机) | 本插件自己的少量持久数据(如签到日期键) |
Squad.cloud | 本服 + 本插件(云端 KV,⚠️ 插件之间互不可见,跨服也不共享) | 持久(重启/换机不丢,网页可查看) | 需跨重启/可迁移;排行、累计值 |
Squad.shared | 同一客户端内跨插件 | 不持久(进程内存) | 插件间临时协作传值(只能存字符串) |
v2 更正:Squad.cloud不是“同服共享键空间”——云端按(服务器, 插件, 键)唯一存储,别的插件读不到你的键,同一插件装到另一台服也各存各的。真正“同服多插件共享”的是Squad.points(积分)与Squad.shared(进程内临时值)。
js// 本地 KV(字符串) Squad.store.set("lastResetDay", "2026-06-18"); var d = Squad.store.get("lastResetDay"); // 云端 KV(功能最全) Squad.cloud.set("notice", "今晚 8 点活动"); Squad.cloud.get("notice"); // 字符串(不存在/已删/出错都返回 "") Squad.cloud.del("notice"); // 实现为写空串 Squad.cloud.incr("total_draws", 1); // 自增,返回新值(delta 可省,默认 +1) Squad.cloud.setJSON("cfgX", { a: 1 }); // 存对象 Squad.cloud.getJSON("cfgX"); // 取回对象(不存在/解析失败返回 null) Squad.cloud.push("winners", "玩家A"); // 往列表追加,返回新长度 Squad.cloud.list("winners"); // 取整个列表(数组) // 跨插件共享(进程级,重启清空;值是字符串) Squad.shared.set("eventMode", "on"); Squad.shared.get("eventMode");
限制与性能(v2 补充):
Squad.cloud每次调用=一次云端请求(同步阻塞,超时 10 秒)——同样受 §2 看门狗约束。- 键最长 128 字符;值为 TEXT,约 64KB 上限。
incr/push是「读→改→写」两次请求:单客户端内串行是安全的,但若网页或其他途径并发改同一键会丢更新——同一键只让一个写入方负责。push/list每次全量读写整个 JSON 数组:列表越长越慢、且逼近 64KB 上限——中奖记录之类要自行截断(比如只留最近 100 条)或按日期分键。Squad.store只有get / set(没有 del、没有列举键);数据在客户端本地data/<服务器ID>/kv/<插件ID>.json,每次set全量同步写盘——高频写(如每次伤害)不合适;换机器/清目录即丢,需要可迁移就用cloud。
选型口诀:要跨重启/可迁移/要网页可见 →cloud;只是本插件在本机的小状态 →store;插件之间临时传个标志 →shared;插件之间要共享“余额型”数据 → 直接用points。内存里的临时态(如本期奖池)用普通var即可,但要记得它在重建/重启时清零。 键命名建议:带上业务前缀与维度便于自己整理与网页查看,例如signin:<steamId>:<日期>、bounty:<steamId>、rank:daily:<日期>(插件间不会撞键,这只是本插件内部的整理习惯)。
12. 玩家查询:Squad.players#
返回的 PlayerInfo 形如:
txt{ steamId(字符串), eosId, name, teamId, squadId(无队为0), isLeader, role }
| 接口 | 返回 |
|---|---|
Squad.players.online() | 当前在线玩家数组 |
Squad.players.count() | 在线人数(数字) |
Squad.players.find(steamId) | 命中的玩家或 null(参数须为数字串,否则必 null) |
Squad.players.findByName(name) | 见下方匹配语义;未命中或有歧义返回 null |
Squad.players.byEos(eosId) | 按 EOS ID 找或 null |
Squad.players.team(teamId) | 该阵营的玩家数组 |
Squad.players.squad(teamId, squadId) | 该小队的玩家数组 |
findByName 匹配语义(v2 更正):① 先按昵称精确等于;② 不中则把输入按空格拆词,从整串到右侧后缀逐级做“包含”匹配,仅当全服唯一命中才返回——有 2 个以上玩家名字包含该串时返回 null(宁缺毋滥,正适合处罚/发奖前核对)。它能容忍战队前缀/日志截断,但不是“模糊搜索取第一个”。
js// 给满 3 人小队的队长发奖 var players = Squad.players.online(); var sizes = {}; players.forEach(function (p) { if (p.squadId) { var k = p.teamId + ":" + p.squadId; sizes[k] = (sizes[k] || 0) + 1; } }); players.forEach(function (p) { if (p.isLeader && (sizes[p.teamId + ":" + p.squadId] || 0) >= 3) { Squad.points.add(p.steamId, 5); Squad.warn(p.steamId, "小队长奖励 +5 积分"); } });
-find/findByName/byEos未命中返回null,用前判空。 - 玩家数据由客户端轮询维护:在线玩家每 3 秒、小队信息每 5 秒刷新一次——squadId / isLeader / role可能滞后数秒、个别时刻为空/为 0,需容错。 - 名字匹配虽已做唯一性保护,涉及处罚/发奖仍建议回显给操作者确认(§17)。 - 这些查询读的是客户端内存快照,不发网络请求、开销极小,可放心在回调里用。
13. 定时器与冷却#
13.1 定时器#
| 接口 | 说明 |
|---|---|
Squad.setInterval(fn, seconds) | 每 seconds 秒重复执行(最小 1 秒,传更小会被抬到 1 秒) |
Squad.setTimeout(fn, seconds) | seconds 秒后执行一次(触发后自动丢弃;传 0 则下个 tick 内触发) |
Squad.dailyAt("HH:MM", fn) | 每天到点执行一次(按运行插件机器的本地时间;引擎以 1 秒 tick 检查,进入该分钟即触发、同一天只触发一次) |
- 定时器回调同样跑在本插件 goroutine 上、串行执行;判定以 1 秒 tick 为粒度,实际触发点有 ±1 秒抖动。单次回调同样受 5 秒看门狗约束。
- ⚠️ 没有
clearInterval/clearTimeout:定时器一旦注册就无法取消(在回调里再注册虽然会生效,但同样删不掉、只会越堆越多)。因此: - 不要在事件回调里反复setInterval。定时器都在顶层注册一次。 - 需要“可开关/可到期”的逻辑,用标志位或时间戳在回调内部判断,而不是去取消定时器。
想让“间隔”也能热更新而不重建定时器——两种常用写法:
js// (1) 1 秒心跳累计 var secs = 0, INTERVAL = 30; function onConfig(){ INTERVAL = parseInt(Config["间隔秒"],10)||30; } Squad.setInterval(function () { if (++secs >= INTERVAL) { secs = 0; tick(); } }, 1); // (2) 记“下次执行时间”,回调里比对(interval 改了立即生效) var nextAt = Squad.now() + 30; Squad.setInterval(function () { if (Squad.now() >= nextAt) { tick(); nextAt = Squad.now() + (parseInt(Config["间隔秒"],10)||30); } }, 5);
“收割器(reaper)”模式——做限时状态(如限时 buff、临时禁言到点解除)的标准做法:用一张 到期时间表 + 一个固定周期的扫描定时器,到点的项统一处理:
jsvar expireAt = {}; // sid -> 到期 unix 秒 function grant(sid, ttlSec){ buff[sid]=true; expireAt[sid]=Squad.now()+ttlSec; } Squad.setInterval(function () { // 每 5 秒收割一次 var now = Squad.now(), due = []; for (var sid in expireAt) if (expireAt.hasOwnProperty(sid) && now >= expireAt[sid]) due.push(sid); for (var i=0;i<due.length;i++){ var s=due[i]; buff[s]=false; delete expireAt[s]; Squad.warn(s,"限时已到,已关闭"); } }, 5);
收割周期用固定值(如 5 秒),这样即使热更新了 TTL,也无需改定时器;先把到期项收集进数组再处理,避免边遍历边删。
13.2 冷却 / 限流#
jsSquad.cooldown(key, seconds) // 未冷却→记下并返回 true(放行);仍在冷却→返回 false(且不重置) Squad.cooldownLeft(key) // 剩余冷却秒数;0 表示不在冷却
- 冷却是进程内存态、按
key区分(重建/重启清零);秒数可带小数。 - “每玩家”冷却要把 steamId 拼进 key,不带就是“全服共用”冷却:
jsvar key = "draw:" + sid; if (!Squad.cooldown(key, 600)) { Squad.warn(sid, "冷却中,还需 " + Squad.cooldownLeft(key) + " 秒"); return; } // 放行,执行抽奖...
14. 工具函数#
jsSquad.log(msg) // 写客户端日志(调试用,玩家看不到) Squad.now() // 当前 Unix 时间(秒,整数)——取时间优先用它 Squad.date() // "2006-01-02 15:04:05" 格式的当前时间 Squad.date("15:04:05") // 传 Go 时间布局串自定义格式 Squad.uptime() // 客户端进程运行秒数 Squad.uptimeText() // 人类可读运行时长 // 文本格式化 Squad.fmt.comma(1234567) // "1,234,567" 千分位(数字或数字串) Squad.fmt.pad("abc", 8) // 右侧补空格到显示宽度 8(中文按 2 宽计) Squad.fmt.padLeft("abc", 8) // 左侧补空格 Squad.fmt.repeat("=", 10) // 重复字符 Squad.fmt.truncate(s, 20) // 截断到显示宽度 20,超出加 … Squad.fmt.nl // 换行符常量 "\n" // 简单 HTTP(谨慎:同步阻塞本插件,单次超时 10 秒) Squad.http.get(url) // → { ok, status, body, error? }(ok = 状态码 <400;body 最多 1MB,超出截断) Squad.http.postForm(url, { a: 1 }) // 表单 POST,同上;参数值仅支持 字符串/数字/布尔(其他会变空串) // 错误兜底(只保留最后一次注册;回调自身报错/超时会被吞掉以防递归) Squad.onError(function (err) { Squad.log("插件异常:" + err); });
Go 时间布局:参考时间是2006-01-02 15:04:05(年-月-日 时:分:秒 = 1 2 3 4 5 6)。要“月-日 时:分”写Squad.date("01-02 15:04")。Squad.http.*会阻塞本插件直到返回(叠加 §2 的 5 秒看门狗,慢站点可能把整个回调打断),别在高频事件回调里调用;只在低频/定时场景用,且注意ok/error判断。
15. 管理 / 健康 / 状态#
jsSquad.isAdmin(steamId) // 是否管理员(由服务器管理员名单判定)→ bool Squad.admins() // 管理员 steamId 列表 // 自定义健康指标(随 10 秒心跳上报,显示在后台「插件状态」卡片,便于服主观察你的插件) Squad.health.set("activePackets", 3); // 值会被转成字符串(支持 字符串/数字/布尔) Squad.health.get("activePackets"); Squad.health.clear(); Squad.pluginStatus() // 返回“所有云端插件”状态数组(不含内置插件), // 每项 {id,name,state,error,runtimeError,reloads},state 取值 ok / error / loading
⚠️ 管理员名单只在客户端启动时从云端拉取一次(来源:网页「服务器设置 → 管理员 SteamID」,每行/逗号分隔、≥8 位纯数字)。网页改完名单后必须重启客户端,Squad.isAdmin / Squad.admins 才会更新。这是轻量名单,不是完整权限系统。
管理员门禁是所有“高影响力命令”的第一道防线:
jsSquad.command("!ban", function (ctx) { if (!Squad.isAdmin(ctx.player.steamId)) { Squad.warn(ctx.player.steamId, "无权限"); return; } // ... 管理员专属逻辑(务必再校验参数) });
16. 常用插件配方(可直接改用)#
以下每个都是完整可上传的最小实现,附带对应配置块(配置块均已按 §5.2 硬性约定书写:default 全字符串、bool 写 是/否、min/max 写数字)。按需改词、改数值即可。
16.1 进服欢迎#
jsfunction cfgStr(k,d){var v=Config[k];return(v===undefined||v===null||v==="")?d:(""+v);} var TEXT; function loadConfig(){ TEXT = cfgStr("欢迎语", "欢迎 {name} 进入服务器!"); } loadConfig(); function onConfig(){ loadConfig(); } Squad.on("connect", function (e) { if (!e.steamId) return; // connect 事件来自日志、没有名字;稍等玩家信息补全后再私信 Squad.setTimeout(function () { var p = Squad.players.find(e.steamId); var name = p ? p.name : "玩家"; Squad.warn(e.steamId, ("" + TEXT).split("{name}").join(name)); }, 5); });
json[ {"key":"欢迎语","label":"进服欢迎语","type":"text","default":"欢迎 {name} 进入服务器!","help":"{name}=玩家名"} ]
16.2 每日签到(积分 + 每日一次 + 命令菜单)#
jsfunction cfgInt(k,d){var n=parseInt(Config[k],10);return isNaN(n)?d:n;} var REWARD; function loadConfig(){ REWARD = cfgInt("签到积分", 50); } loadConfig(); function onConfig(){ loadConfig(); } function onLoad(){ Squad.commands.add("签到 / qd / QD", "每日签到领 " + REWARD + " 积分"); } function todayKey(sid){ return "signin:" + sid + ":" + Squad.date("2006-01-02"); } function doSign(p){ var key = todayKey(p.steamId); if (Squad.store.get(key) === "1") { Squad.warn(p.steamId, "你今天已经签到过了"); return; } Squad.store.set(key, "1"); var bal = Squad.points.add(p.steamId, REWARD); Squad.broadcast(p.name + " 签到成功,+" + REWARD + " 积分(当前 " + bal + ")"); } // 触发词大小写敏感:qd 与 QD 要各注册一次(或改用 on("chat") 自行 toLowerCase) Squad.command("签到", "每日签到", function (ctx) { doSign(ctx.player); }); Squad.command("qd", "每日签到", function (ctx) { doSign(ctx.player); }); Squad.command("QD", "每日签到", function (ctx) { doSign(ctx.player); });
json[ {"key":"签到积分","label":"每日签到积分","type":"number","default":"50","min":0} ]
16.3 定时广播(间隔可热更新)#
jsfunction cfgInt(k,d){var n=parseInt(Config[k],10);return isNaN(n)?d:n;} var INTERVAL, MSGS, idx=0; function loadConfig(){ INTERVAL = Math.max(5, cfgInt("间隔秒", 300)); MSGS = ("" + (Config["广播内容"] || "")).split(/\r?\n/).filter(Boolean); } loadConfig(); function onConfig(){ loadConfig(); } var nextAt = Squad.now() + 5; Squad.setInterval(function () { if (!MSGS.length) return; if (Squad.now() < nextAt) return; Squad.broadcast(MSGS[idx % MSGS.length]); idx++; nextAt = Squad.now() + INTERVAL; }, 5);
json[ {"key":"间隔秒","label":"广播间隔(秒)","type":"number","default":"300","min":5}, {"key":"广播内容","label":"广播内容(每行一条,轮播)","type":"textarea","default":"欢迎游玩\n加群交流:xxxx"} ]
16.4 击杀提示(事件 + 私信)#
jsSquad.on("kill", function (e) { if (e.suicide) return; // 注意:weapon 含 nullptr 或伤害恰为 100 会被判为自杀 var k = e.killer || {}, v = e.victim || {}; if (!k.steamId || k.steamId === "0") return; Squad.warn(k.steamId, "击杀 " + (v.name || "敌人") + "(" + (e.weapon || "?") + ")"); });
(此例无配置项,故无 schema。)
16.5 抽奖(消耗积分 + 权重 + 结构化奖品 + 冷却)#
jsfunction parsePrizes(raw){ // 每行:名称 | points|txt | 数值 | 权重 var out=[]; ("" + (raw||"")).split(/\r?\n/).forEach(function(line){ line=line.trim(); if(!line) return; var p=line.split("|").map(function(s){return s.trim();}); out.push({ name:p[0]||"奖品", type:(p[1]||"txt").toLowerCase()==="points"?"points":"txt", value:parseInt(p[2],10)||0, weight:parseInt(p[3],10)||1 }); }); return out; } var COST, CD_MIN, PRIZES, TOTAL; function loadConfig(){ COST=parseInt(Config["消耗积分"],10)||5; CD_MIN=parseInt(Config["冷却分钟"],10)||10; PRIZES=parsePrizes(Config["奖品"]); TOTAL=0; PRIZES.forEach(function(x){TOTAL+=x.weight;}); } loadConfig(); function onConfig(){ loadConfig(); } Squad.command("抽奖", "花积分抽一次", function (ctx) { var sid=ctx.player.steamId; if(!TOTAL){ Squad.warn(sid,"暂无奖品"); return; } if(!Squad.cooldown("cj:"+sid, CD_MIN*60)){ Squad.warn(sid,"冷却中,请 "+Math.ceil(Squad.cooldownLeft("cj:"+sid)/60)+" 分钟后再试"); return; } var bal=Squad.points.get(sid); if(bal<COST){ Squad.warn(sid,"积分不足,需要 "+COST); return; } Squad.points.add(sid,-COST); var r=Math.floor(Math.random()*TOTAL), prize=PRIZES[PRIZES.length-1]; for(var i=0;i<PRIZES.length;i++){ if(r<PRIZES[i].weight){ prize=PRIZES[i]; break; } r-=PRIZES[i].weight; } if(prize.type==="points"){ Squad.points.add(sid,prize.value); Squad.broadcast(ctx.player.name+" 抽中 "+prize.name+",+"+prize.value+" 积分"); } else { Squad.broadcast(ctx.player.name+" 抽中 "+prize.name+",请截图找管理领取"); } });
注意:这一次抽奖回调里最多有 3 次云端请求(get / 扣分 / 加分),已接近 §2 看门狗的合理上限——不要再往里加别的网络调用。
json[ {"key":"消耗积分","label":"每次消耗积分","type":"number","default":"5","min":0}, {"key":"冷却分钟","label":"冷却(分钟)","type":"number","default":"10","min":0}, {"key":"奖品","label":"奖品(每行:名称|类型|数值|权重)","type":"textarea", "default":"50积分 | points | 50 | 30\n谢谢参与 | txt | 0 | 50\n100积分 | points | 100 | 20", "help":"类型 points=加积分(数值为分数),txt=文字奖(数值填0);权重越大越易中"} ]
16.6 积分排行播报(定时 + 分批查询)#
jsfunction cfgInt(k,d){var n=parseInt(Config[k],10);return isNaN(n)?d:n;} var INTERVAL; function loadConfig(){ INTERVAL=Math.max(60,cfgInt("播报间隔秒",1800)); } loadConfig(); function onConfig(){ loadConfig(); } // 分批扫描在线玩家的积分,避免一次回调里串太多云端请求触发 5 秒看门狗。 var scanQueue = [], best = null, bp = -1, nextAt = Squad.now() + 60; Squad.setInterval(function () { // 阶段一:到点则生成本轮扫描队列 if (!scanQueue.length && Squad.now() >= nextAt) { nextAt = Squad.now() + INTERVAL; scanQueue = Squad.players.online(); best = null; bp = -1; if (!scanQueue.length) return; } // 阶段二:每个 tick 只查 2 人 var n = 0; while (scanQueue.length && n < 2) { var p = scanQueue.shift(); n++; if (!p.steamId) continue; var v = Squad.points.get(p.steamId); if (v > bp) { bp = v; best = p; } } // 阶段三:扫完播报 if (!scanQueue.length && best) { Squad.broadcast("当前在线积分王:" + best.name + "(" + bp + " 分)"); best = null; } }, 3);
json[ {"key":"播报间隔秒","label":"排行播报间隔(秒)","type":"number","default":"1800","min":60} ]
上面刻意“分批只查在线玩家”,因为 points.get 是逐个云端请求(每次约几十毫秒到数百毫秒);一次回调里串几十次既卡顿又会触发看门狗。切勿对全服历史玩家逐个循环查询。
17. 安全与健壮性最佳实践#
面向插件作者:写出既好用又不被玩家钻空子的插件。
输入与权限
- 一切来自聊天的参数都要校验:
parseInt后判isNaN,数值卡min/max,空串直接拒绝。 - 高影响力命令(踢/封/换图/重开/加分/发奖)必须
Squad.isAdmin门禁;Squad.exec尤其危险,不要把玩家可控文本拼进去。管理员名单改动需重启客户端才生效(§15)。 - 关键词/名字不唯一:
findByName已做“唯一命中才返回”,但涉及处罚或发奖仍建议把命中的玩家回显给操作者确认后再动手。
积分经济
- 扣分前
get一次并判断合理(失败返回0,别把“查询失败”当“没钱”或触发负逻辑)。 - 给配置项设
min/max(如单次消耗、单笔奖励、悬赏金额)——服务端保存时会强校验,能真正兜住服主误填。 - 先扣后发:先
add(sid, -cost)成功路径再发奖,避免“发了奖没扣成”。 - 一次回调控制在 1~3 次云端请求内(§2 看门狗),关键的“扣分→发奖”两步之间不要再插别的网络调用,降低被打断在中间的概率。
- 奖励类要防自领 / 队友刷分:例如“击杀某目标得赏金”应排除自杀(
e.suicide)、排除“凶手与目标同队”等可被 TK 利用的情形。
限流与防刷
- 面向玩家的可重复动作都套每玩家冷却(key 拼 steamId)。
- 广播类别刷屏:合并/节流,别在高频事件里逐条广播(发送队列拥塞会丢命令,§8)。
状态与持久
- 冷却、内存
var重建/重启即清零:不要把“唯一性/额度”这类需要长期生效的约束只放内存;需要跨重启就落Squad.cloud(如“每日一次”用store/cloud记日期键)。 - 幂等:
newGame与matchStart会同时触发,重置逻辑要能重复执行不出错。 - 不要指望
onUnload做收尾(源码热重载不会调用它,§4)——需要落地的数据随做随存。
输出与信息
- 不发 emoji(乱码)。
- 广播/私信不要泄露敏感信息(管理决策细节、他人隐私、内部状态)。面向玩家只说该说的。
- 别把玩家原始输入不加处理地回显进广播(避免冒充式误导、刷屏)。
性能
Squad.points.* / Squad.cloud.* / Squad.http.*同步阻塞本插件且单次超时 10 秒:别在循环里批量调用、别在高频回调里做网络请求;批量任务用定时器分批(§16.6 范式)。Squad.players.*读内存快照、无网络开销,可放心高频使用。- 定时器只在顶层注册、不可取消——用标志位/时间戳控制开关与到期(§13)。
18. 常见坑与调试#
- 用了 ES6 / Promise:平台强约定 ES5.1(§3);尤其
Promise/async/await在无事件循环的环境里极易写出永不恢复的逻辑。上传前全局搜=>、反引号、let、const、async、Promise。 - JSON 配置块写法(§5.2):
default必须是字符串(数字加引号)、bool用"是"/"否"、min/max用数字、整体是数组、不能有注释/尾逗号——否则要么后台报“配置字段 JSON 格式有误”,要么和官方写法不一致。 - 拿不到 squadId:
chat事件与Squad.command的ctx.player都没有 squadId;需要就Squad.players.find(steamId)现查(§6.1)。kill/wound的玩家对象才带 squadId(但没有 isLeader)。 - 事件字段可能为空值:尤其
damage(attacker 常只有 steamId、victim 只有 name)、在线玩家的小队信息(轮询有滞后);先判空再用。 steamId是字符串:判空用if (!sid || sid === "0");别当数字比较。唯一例外是matchEnd的stats——那里SteamID是数字且字段名大写(§6.2 专栏),这是最容易白屏的坑。- 配置热更新:把读配置写进
loadConfig(),加载与onConfig()都调用;onConfig时Config已是新值;不要在顶层缓存var C = Config(热更新会整体替换对象);onConfig里不要commands.add(菜单会重复)。保存后最多约 10 秒生效。 - 时间布局是 Go 风格:
2006-01-02 15:04:05,别写成YYYY-MM-DD(§14)。 - 定时器不可取消:别在回调里反复
setInterval;开关/到期用标志位与时间戳(§13)。触发精度 ±1 秒。 - 冷却/内存会清零:改源码/重启后冷却与
var全部重置——需要持久就用cloud/store。 commandvson("chat"):单触发词简单参数用command(注意大小写敏感、所有频道生效);多别名/复杂匹配/大小写不敏感用on("chat")自己判断,并commands.add登记菜单。别占用内置触发词cd / CD / 菜单 / !状态 / !status。- “事件不来”排查顺序:① 战斗类事件(kill 等)→ 先确认后台该服的 SquadGame.log 路径配置正确(配错则这些事件全无,客户端启动有告警);② 看后台「插件状态」是否 error/runtimeError;③ 加
Squad.log与Squad.onError观察;④ 记住 kill/wound/revive 可能延迟数秒(重试队列)。 - 回调执行超 5 秒被打断:日志出现“执行超时(疑似死循环)”多半不是死循环,而是一次回调里串了太多同步网络调用(points/cloud/http)——拆分、分批(§2/§16.6)。
- 结果类 RCON 忘了判 err:回调是
(result, err),失败时 result 是 undefined,直接取result.layer会抛错进 runtimeError(§9)。 - GBK 服上 exec/raw 发中文乱码:自动转码只覆盖 broadcast/warn/kick/ban(§8/§9)。
- 调试手段:多打
Squad.log(...),在后台「插件管理 → 使用日志 / 插件状态」观察;用Squad.health.set(...)把关键指标暴露到插件卡片;异常用Squad.onError(...)兜底记录;管理员在游戏里发!状态也能看各插件状态。
19. 接口速查表#
text生命周期 function onLoad() / onConfig() / onUnload() // 顶层代码加载时执行一次 // onUnload 仅在 停用插件/客户端退出 时触发;源码热重载不会调用 // 单次 JS 执行(加载/回调/定时器)看门狗 5 秒,超时中断本次剩余逻辑 配置 Config["键"] Squad.config.get/raw/has/all // Config 全局对象热更新时整体替换,勿缓存引用 // 配置块 schema:JSON 数组;default 一律字符串;bool 默认写 "是"/"否";min/max 写数字 // 保留键 "发送连接数"(number):首次加载时为本插件分配 N 条发送连接 事件 Squad.on(type, fn) Squad.onError(fn) // onError 只保留最后一次注册 type: chat kill wound damage revive connect disconnect squadCreate newGame matchStart matchEnd 事件玩家对象(kill/wound/damage/revive): {name,eosId,steamId,teamId,squadId,role} // 无 isLeader 受限(chat / ctx.player): {name,eosId,steamId,teamId} // 无 squadId matchEnd: {matchId(上一局), stats:[{EosID,SteamID(数字),Name,Kills,Wounds,Deaths,Revives}]} // 字段大写! // kill/wound/damage/revive/connect/newGame/matchStart/matchEnd 依赖后台配置的日志路径 命令 Squad.command(trigger[, desc], fn) // ctx:{trigger,arg,args,message,channel,player} // 大小写敏感;全频道生效;同插件内 先 on("chat") 后命令路由 Squad.commands.add(trigger, desc) Squad.commands.list() → [{plugin,trigger,desc}] // 保留触发词:cd / CD / 菜单(内置菜单)、!状态 / !status(内置状态) 动作RCON broadcast(t) warn(id,t) exec(cmd) kick(id,r) ban(id,dur,r) // dur 空串按 "0" move(id) setNextLayer(l) changeLayer(l) restartMatch() endMatch() // GBK 自动转码仅 broadcast/warn/kick/ban;exec 原样发送 // 异步入队;发送队列每连接 1000 条,拥塞 2 秒后丢弃 结果RCON Squad.rcon.raw(cmd, cb) // cb 均为 function(result, err);失败 result=undefined Squad.currentLayer(cb)→{level,layer,raw} Squad.nextLayer(cb) Squad.layers(cb)→[] // 内部整体超时约 5 秒;GBK 不自动转码;勿高频轮询 积分 Squad.points.get/add/set(id[,n]) Squad.points.rank(id)→{points,rank,total} // 按 服务器+SteamID 记账,同服多插件共享;查询失败返回 0;同步阻塞(超时10s);勿批量循环 存储 Squad.store.get/set(k[,v]) // 本地持久,本服+本插件;仅 get/set;每次 set 写盘 Squad.cloud.get/set/del/getJSON/setJSON // 云端持久,本服+本插件私有(插件间不共享!) /incr(k[,d])/push(k,v)/list(k) // 键≤128字符,值≈64KB;incr/push=读改写两次请求 Squad.shared.get/set(k[,v]) // 跨插件内存字符串(不持久) 玩家 Squad.players.online()/count()/find(id)/findByName(n)/byEos(e) /team(t)/squad(t,s) // 读内存快照,无网络开销 PlayerInfo: {steamId,eosId,name,teamId,squadId,isLeader,role} // 无 id 字段 // findByName: 精确优先→后缀渐进"唯一"子串匹配,歧义返回 null // 在线每 3 秒 / 小队每 5 秒刷新 定时 Squad.setInterval(fn,s) // 最小1秒, 不可取消, 精度±1秒 Squad.setTimeout(fn,s) // 一次性 Squad.dailyAt("HH:MM",fn)// 本地时间, 每秒检查, 同一天只触发一次 冷却 Squad.cooldown(key,s)→bool Squad.cooldownLeft(key)→秒 // 内存态,重启清零 工具 Squad.log(m) now() date([goLayout]) uptime()/uptimeText() Squad.fmt.comma/pad/padLeft/repeat/truncate/nl Squad.http.get(u)/postForm(u,obj)→{ok,status,body,error?} // 同步阻塞,超时10s,body≤1MB 管理/状态 Squad.isAdmin(id) Squad.admins() // 名单启动时拉取,网页改后需重启客户端 Squad.health.set/get/clear // 随 10 秒心跳上报到网页插件卡片 Squad.pluginStatus() → [{id,name,state(ok/error/loading),error,runtimeError,reloads}]
20. 插件骨架模板 + 上传前自检清单#
通用骨架(复制改用)#
js// ===== 配置读取(防御式) ===== function cfgStr(k,d){var v=Config[k];return(v===undefined||v===null||v==="")?d:(""+v);} function cfgInt(k,d){var n=parseInt(Config[k],10);return isNaN(n)?d:n;} function cfgBool(k,d){var v=Config[k];if(v===undefined||v===null||v==="")return d; if(typeof v==="boolean")return v;v=(""+v).toLowerCase(); return v==="是"||v==="true"||v==="1"||v==="on"||v==="开"||v==="启用"||v==="开启";} function cfgList(k,d){var v=Config[k];if(v===undefined||v===null||v==="")return d; if(Array.isArray(v))return v;return(""+v).split(",").map(function(s){return s.trim();}).filter(Boolean);} var ENABLED, TRIGGERS; function loadConfig(){ ENABLED = cfgBool("启用", true); TRIGGERS = cfgList("触发词", ["示例"]); } loadConfig(); function onConfig(){ loadConfig(); Squad.log("配置已热重载"); } // 这里不要 commands.add // ===== 加载:登记命令菜单、起定时器(定时器只在顶层注册一次) ===== function onLoad(){ var i; for(i=0;i<TRIGGERS.length;i++) Squad.commands.add(TRIGGERS[i], "示例命令"); Squad.log("插件已就绪"); } // ===== 命中判断(多触发词/大小写不敏感用 on(chat) 自己匹配) ===== function hit(msg){ var m=(""+(msg||"")).trim().toLowerCase(); for(var i=0;i<TRIGGERS.length;i++){ var t=(""+TRIGGERS[i]).toLowerCase(); if(m===t || m.indexOf(t+" ")===0) return true; } return false; } // ===== 事件 ===== Squad.on("chat", function (e) { if(!ENABLED || !e) return; var sid=e.steamId; if(!sid||sid==="0") return; if(!hit(e.message)) return; // TODO: 你的逻辑(先校验→再限流→后执行;一次回调 ≤2 次云端请求) Squad.warn(sid, "收到," + e.name); }); Squad.onError(function (err) { Squad.log("插件异常:" + err); });
json[ {"key":"启用","label":"启用插件","type":"bool","default":"是"}, {"key":"触发词","label":"触发词","type":"list","default":"示例","help":"多个用英文逗号分隔"} ]
上传前自检清单#
- [ ] 全局搜过
=>、反引号 ``、let、const、...、for...of、async、Promise`:均无(ES5.1 约定)。 - [ ] 配置块 JSON:是数组、无注释/尾逗号;
default全字符串(数字加引号);bool默认写"是"/"否";min/max写数字;select/multiselect配options(字符串数组)。 - [ ] 所有面向玩家文本无 emoji。
- [ ]
steamId判空用!sid || sid==="0";事件字段先判空(尤其damage/ 小队信息)。 - [ ] 用到
matchEnd时,stats字段按大写(Kills/SteamID…)访问,SteamID先转字符串。 - [ ] 需要发言人小队号的地方,用
Squad.players.find现查(chat/ctx.player没有 squadId)。 - [ ] 读配置写在
loadConfig(),加载与onConfig()都调用;顶层没有缓存Config引用;onConfig里没有commands.add。 - [ ] 花积分/发奖:先
get判断 → 先扣后发;配置项有min/max;一次回调 ≤3 次云端请求(看门狗 5 秒)。 - [ ] 结果类 RCON 回调写成
(result, err)并先判 err。 - [ ] 高影响力命令加
Squad.isAdmin门禁;不把玩家文本拼进Squad.exec;GBK 服不用 exec/raw 发中文。 - [ ] 可重复动作有每玩家冷却(key 拼 steamId)。
- [ ] 需要跨重启的数据用
cloud/store,没依赖内存态做“唯一性/额度”;没指望onUnload做收尾。 - [ ] 定时器只在顶层注册;开关/到期用标志位/时间戳(不试图取消定时器)。
- [ ]
newGame/matchStart的重置逻辑幂等(重复触发不出错)。 - [ ] 触发词没有占用
cd / CD / 菜单 / !状态 / !status;需要大小写不敏感已按 §7 处理。
21. 平台流程与运行环境补充#
本节是 v2 新增:把与“写代码”相邻、但影响开发体验的平台事实集中说明。
21.1 上传 → 审核 → 发布 → 安装(云端)#
- 上传:后台「我的插件 → 新建」,必填插件名称与标识 slug(唯一),粘贴 JS 源码与「配置字段定义 JSON」(§5.2),可选:简介、分类、标签、前置插件(按 slug)。新建即为草稿。
- 自测:草稿状态下,作者可直接把它安装到自己(或被授权)的服务器上调试,无需审核。
- 发布:「提交审核」→ 管理员通过后为已发布,进入公共商城;被驳回可看驳回理由、改后重提。已发布的插件作者不能再直接修改(需管理员先下架)。
- 安装:服主在商城一键安装到某服——会自动连带安装所有前置插件(含传递依赖),并写入你的默认配置(config_version=1)。
- 版本:每次源码变化
js_version+1,所有已装服的客户端在 ~10 秒内热重载;后台有版本历史,可一键回滚(回滚同样以新版本号下发,立即生效)。仅改配置则config_version+1,只触发onConfig。
21.2 生效时延与热更新链路#
客户端每 10 秒拉取一次插件清单:新增→加载;js_version 变化→热重载(VM 重建,内存清零);config_version 变化→注入新 Config 后调 onConfig(VM 保留);从启用列表消失→热关闭(调 onUnload)。
21.3 内置插件(随客户端携带,始终在线)#
| 内置插件 | 触发词 | 行为 |
|---|---|---|
| 菜单 | cd / CD / 菜单 | 私信发送者:所有插件经 Squad.commands.add / Squad.command 登记的指令清单(按插件分组) |
| 插件状态 | !状态 / !status(仅管理员) | 私信各云端插件运行状态(正常/异常、热重载次数、最近错误) |
它们不出现在 Squad.pluginStatus() 结果里,也不能被停用。不要占用这些触发词。
21.4 发送连接与保留配置键「发送连接数」#
- 每个插件的
broadcast/warn/exec/...走一条从共享发送池分配的 RCON 连接(默认 1 条,命令保序);连接池总数有硬上限,插件多时会与其它插件共用连接。 - 在插件配置里加保留键
发送连接数(number,≥1)可在首次加载时为本插件多分配几条发送连接(轮转发送,吞吐更高、顺序不再严格保证)。仅首次加载/客户端重启时生效,热重载不变。绝大多数插件保持默认即可。
21.5 状态可观测性#
- 插件运行状态(ok/error、加载错误、最近回调错误、热重载次数)与
Squad.health.set的自报指标,随客户端 10 秒心跳上报,在后台「服务器详情 → 插件卡片」展示。 - 游戏内:管理员发
!状态可即时查看;日志用Squad.log,在后台使用日志页查看。
21.6 与插件无关但相邻的能力#
后台「服务器管理」另有网页计划任务(定时/每日定点执行一条 RCON 命令,可加“在线人数 ≥ N”条件),由客户端独立执行,与插件系统无关——纯广播/定点命令类需求可以不写插件直接用它。
22. v2 修订记录#
更正(与 v1 不同的行为描述)
- §2/§4:源码热重载不会调用
onUnload(onUnload 仅在停用/客户端退出时触发)。 - §11:
Squad.cloud是「本服 + 本插件」私有,不是同服共享键空间;插件间互不可见、跨服不共享。 - §9:结果类 RCON 回调为
function (result, err)两参;补充内部 ~5 秒超时与不转码说明。 - §6.2:补全
matchEnd.stats元素字段(大写EosID/SteamID/Name/Kills/Wounds/Deaths/Revives,SteamID为数字)。 - §2/§8/§9:自动 GBK 转码仅覆盖
broadcast/warn/kick/ban;exec/rcon.raw原样发送。 - §12:
findByName语义修正为“精确优先 → 后缀渐进的唯一子串匹配,歧义返回 null”。 - §3:由“ES6 会直接报错”改为“平台强约定 ES5.1”(当前引擎实际支持大部分 ES6 语法;禁止 Promise/async 的原因是无事件循环)。
- §13:
dailyAt为每秒检查、同一天只触发一次;定时器判定精度 ±1 秒。 - §5.2:
required/min/max/pattern为前端 + 服务端双重校验。 - §6.2:
damage载荷精确化(六键齐全但可能空值;三种情况不发事件)。
补充(v1 未覆盖的行为)
单次执行 5 秒看门狗(§2);事件/回调/发送三级队列容量与丢弃策略(§2/§8/§9);保留配置键「发送连接数」(§5.2/§21.4);热更新 ~10 秒时延(§2/§21.2);管理员名单启动时一次性拉取(§15);内置插件与保留触发词 cd/CD/菜单/!状态/!status(§7/§21.3);命令路由大小写敏感、全频道生效、与 on("chat") 的执行顺序、多触发词重复命中(§7);onConfig 勿 commands.add、勿缓存 Config 引用(§4);战斗类事件依赖日志路径(§2/§18);玩家 3 秒/小队 5 秒轮询、disconnect 延迟、kill/wound/revive 重试延迟(§6/§12);自杀启发式与 weapon==="Suicide"(§6.2);cloud KV 键长/值长/读改写/全量列表等限制(§11);store 本地文件路径与写盘方式(§11);http 超时/1MB/参数值类型(§14);commands.list() 返回形状与 onError 只留最后一个(§7/§14);matchId 连号机制(§6.2);上传→审核→发布→安装→前置插件→回滚全流程(§21.1);§16.6 改为分批查询范式。
本文档面向插件开发,仅覆盖对插件公开的 JS SDK;若接口行为与文档不符,以客户端实际实现为准,并欢迎反馈。(v2 校订:已对照客户端 Go v4.0.0 源码与云端后台 v2 逐条核验。)