fix: 修复实机运行才暴露的 8 个缺陷,新增控制台添加玩家与移动测试
首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后暴露的问题,单元测试全绿但机器人干不了活: 致命 - 寻路插件从未加载:项目从没调用 bot.loadPlugin(pathfinder),bot.pathfinder 恒为 undefined、isReady() 恒为 false,扩地任务连启动都启动不了,界面只显示含糊的 「机器人尚未就绪」。现在 createBot 后立刻加载,失败时明确报错。 登录门禁相关(三处死锁) - preLoginCommands 白名单:/login、/register、简幻通的 /auth 本身就是登录流程的一部分, 原先被门禁一并拦下,形成「要先登录才能发登录指令」 - 验证码常被要求直接当聊天内容发出(不是指令),原先被 sendChat 的门禁挡住; 现在服务器明确要求验证时自动放行,另有 server.allowChatBeforeLogin 兜底 - 配置了登录指令却没有密码时 loggedIn 永久为 false,控制台连手动指令都发不出; 现在走与其他「服务器不回提示」一致的超时兜底并给出说明 可用性 - 离线时指令被静默丢弃却回报「已发送」;现在要求已连接并进入世界,失败信息区分 「未连接」「登录被拒」「未通过验证」 - 「被服务器传送」日志洪水:实测 3 分钟 748 行,会冲光日志缓冲并打满 WebSocket; 现在按 30 秒窗口聚合,断开时刷出未汇总计数 - 识别外站账号绑定流程并给出可操作告警,新增 server.authCommand 模板 - 重连指数退避(server.reconnectMaxDelayMs) - 控制台瓦片 URL 与后端路由不一致 新增 - 控制台添加 / 移除玩家:不必改 .env 重启。密码只写入 data/bots.credentials.json (强制 0600),不进仓库、不出现在任何响应里;.env 与凭据文件同名时以 .env 为准, .env 里的账号不允许从控制台移除 - 手动移动测试接口与按钮:绕开寻路器直接按方向键,返回真实位移, 用于区分「算不出路径」与「根本走不动」 - 位置同步诊断:位置包收发计数、onGround、游戏模式、维度、脚下方块与六向实心 - grim.reportOnGroundFromWorld:按世界数据修正出站 onGround(默认开启) - scripts/check-protocol.js:核对包名与字段名同目标版本协议是否一致 文档 - README 新增「部署到服务器」与「机器人走不动的排查方法」两章 已知限制:实测本服上 mineflayer 客户端无法移动(按方向键 2 秒位移 0.00~0.02 格, 服务端每 tick 回弹一次),已排除区块数据、onGround、Grim 层、游戏模式、物理引擎、 寻路插件、teleport_confirm 协议字段等因素,彻底定位需与原生客户端逐字节对比移动包。 连接 / 登录 / 指令 / 聊天 / 公屏日志 / 大地图均正常。 测试从 263 增至 290,全部通过。
这个提交包含在:
+92
-2
@@ -259,7 +259,8 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'grim.blockSprintJump', label: '禁止疾跑跳跃', type: 'checkbox', hint: '疾跑跳本身是原版合法动作,仅在实测被标记时开启' },
|
||||
{ path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' },
|
||||
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
|
||||
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' }
|
||||
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
|
||||
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -272,6 +273,7 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' },
|
||||
{ path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' },
|
||||
{ path: 'server.preLoginCommands', label: '允许免登录发送的指令前缀', type: 'list', hint: '逗号分隔,默认 login,l,register,reg,auth;这些指令在登录验证前也放行,避免死锁' },
|
||||
{ path: 'server.allowChatBeforeLogin', label: '登录前允许发公屏消息', type: 'checkbox', hint: '服务器明确要求验证时会自动放行(验证码常直接当聊天内容发出);此项用于无提示但必须先说话的服务器' },
|
||||
{ path: 'server.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' },
|
||||
{ path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 },
|
||||
{ path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' },
|
||||
@@ -523,7 +525,7 @@ let botListSignature = '';
|
||||
|
||||
function botSignature(bots) {
|
||||
return bots.map((b) => [
|
||||
b.id, b.state, b.enabled, b.task ? b.task.state : '', b.task ? b.task.completedClaims : '',
|
||||
b.id, b.state, b.enabled, b.source, b.task ? b.task.state : '', b.task ? b.task.completedClaims : '',
|
||||
b.position ? Math.round(b.position.x) : '', b.position ? Math.round(b.position.z) : ''
|
||||
].join(':')).join('|');
|
||||
}
|
||||
@@ -569,6 +571,20 @@ function renderBotList(status) {
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = (bot.task ? bot.task.state : 'IDLE') + ' · ' + (bot.task ? bot.task.completedClaims : 0);
|
||||
if (bot.source !== 'env') {
|
||||
// 控制台新增的账号可以在这里直接移除;.env 里的必须改 .env
|
||||
const remove = document.createElement('button');
|
||||
remove.className = 'remove';
|
||||
remove.textContent = '×';
|
||||
remove.title = '移除该玩家';
|
||||
remove.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeBot(bot.id, bot.username);
|
||||
});
|
||||
meta.appendChild(remove);
|
||||
} else {
|
||||
meta.title = '该玩家在 .env 的 MC_BOTS 中配置,需修改 .env 后重启才能移除';
|
||||
}
|
||||
item.appendChild(meta);
|
||||
|
||||
item.addEventListener('click', () => selectBot(bot.id));
|
||||
@@ -1461,6 +1477,13 @@ function connectWs() {
|
||||
function bindEvents() {
|
||||
$('botSelect').addEventListener('change', () => selectBot($('botSelect').value));
|
||||
|
||||
$('btnAddBot').addEventListener('click', addBot);
|
||||
for (const id of ['newBotName', 'newBotPass', 'newBotLabel']) {
|
||||
$(id).addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') addBot();
|
||||
});
|
||||
}
|
||||
|
||||
$('btnStart').addEventListener('click', () => startTask(selectedBotId));
|
||||
$('btnStop').addEventListener('click', () => stopTask(selectedBotId));
|
||||
$('btnReconnect').addEventListener('click', () => reconnectBot(selectedBotId));
|
||||
@@ -1689,6 +1712,26 @@ function bindEvents() {
|
||||
});
|
||||
}
|
||||
|
||||
// 移动测试:绕开寻路器直接按方向键,用于区分「算不出路径」与「根本走不动」
|
||||
for (const btn of document.querySelectorAll('[data-move]')) {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!selectedBotId) return;
|
||||
btn.disabled = true;
|
||||
toast('移动测试中…');
|
||||
try {
|
||||
const res = await api(`/api/bots/${encodeURIComponent(selectedBotId)}/move`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ direction: btn.dataset.move, seconds: 2 })
|
||||
});
|
||||
toast('移动测试:' + res.message);
|
||||
} catch (err) {
|
||||
toast('移动测试:' + err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('btnLogin').addEventListener('click', doLogin);
|
||||
$('loginPassword').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
@@ -1769,6 +1812,53 @@ async function reconnectBot(botId) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 控制台新增一个玩家(机器人) */
|
||||
async function addBot() {
|
||||
const username = $('newBotName').value.trim();
|
||||
if (!username) {
|
||||
toast('请填写玩家名', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api('/api/bots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password: $('newBotPass').value,
|
||||
label: $('newBotLabel').value.trim()
|
||||
})
|
||||
});
|
||||
$('newBotName').value = '';
|
||||
$('newBotPass').value = '';
|
||||
$('newBotLabel').value = '';
|
||||
toast(res.message || '已添加');
|
||||
if (res.data && res.data.bots) {
|
||||
botListSignature = '';
|
||||
renderStatus(Object.assign({}, lastStatus, { bots: res.data.bots }));
|
||||
}
|
||||
if (res.data && res.data.id) selectBot(res.data.id);
|
||||
} catch (err) {
|
||||
toast('添加失败:' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除控制台新增的玩家 */
|
||||
async function removeBot(botId, username) {
|
||||
if (!botId) return;
|
||||
if (!window.confirm(`确定移除玩家「${username}」吗?该操作会断开它的连接并删除本地保存的凭据。`)) return;
|
||||
try {
|
||||
const res = await api('/api/bots/' + encodeURIComponent(botId), { method: 'DELETE' });
|
||||
toast(res.message || '已移除');
|
||||
if (selectedBotId === botId) selectedBotId = '';
|
||||
if (res.data && res.data.bots) {
|
||||
botListSignature = '';
|
||||
renderStatus(Object.assign({}, lastStatus, { bots: res.data.bots }));
|
||||
}
|
||||
} catch (err) {
|
||||
toast('移除失败:' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 启动 ====================
|
||||
|
||||
async function startApp() {
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="botList" class="bot-list"></div>
|
||||
<div class="add-bot">
|
||||
<div class="row">
|
||||
<input id="newBotName" class="input input-sm" type="text" placeholder="玩家名" maxlength="16">
|
||||
<input id="newBotPass" class="input input-sm" type="password" placeholder="服务器密码(可留空)">
|
||||
<input id="newBotLabel" class="input input-sm" type="text" placeholder="备注(可留空)">
|
||||
<button id="btnAddBot" class="btn btn-sm btn-primary">添加玩家</button>
|
||||
</div>
|
||||
<p class="hint">密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -247,6 +256,14 @@
|
||||
<input id="quickChatInput" class="input" type="text" placeholder="快捷公屏消息(如:大家好)">
|
||||
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="hint">移动测试:</span>
|
||||
<button class="btn btn-sm" data-move="forward">前</button>
|
||||
<button class="btn btn-sm" data-move="back">后</button>
|
||||
<button class="btn btn-sm" data-move="left">左</button>
|
||||
<button class="btn btn-sm" data-move="right">右</button>
|
||||
<button class="btn btn-sm" data-move="jump">跳</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -196,6 +196,23 @@ html, body {
|
||||
.bot-item .name { font-size: 13px; }
|
||||
.bot-item .sub { font-size: 11px; color: var(--muted); }
|
||||
.bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; }
|
||||
.bot-item .remove {
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.bot-item .remove:hover { color: var(--red); border-color: #7a2b34; }
|
||||
|
||||
.add-bot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
|
||||
.add-bot .row { margin-top: 0; }
|
||||
.add-bot .input-sm { flex: 1 1 60px; min-width: 0; }
|
||||
.add-bot .hint { font-size: 11px; color: #5d6672; margin: 6px 0 0; }
|
||||
|
||||
/* ==================== 公屏聊天 ==================== */
|
||||
.chat-line { display: block; }
|
||||
|
||||
+67
-2
@@ -247,6 +247,50 @@ class WebConsole {
|
||||
res.json({ ok: true, data: this.botsPayload() });
|
||||
});
|
||||
|
||||
// 控制台新增玩家:密码只落到服务器本地凭据文件(0600),不会出现在任何响应里
|
||||
app.post('/api/bots', (req, res) => {
|
||||
if (typeof this.pool.addBot !== 'function') {
|
||||
res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台新增玩家' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const entry = this.pool.addBot({
|
||||
username: body.username,
|
||||
password: body.password,
|
||||
label: body.label,
|
||||
auth: body.auth,
|
||||
host: body.host,
|
||||
port: body.port,
|
||||
version: body.version
|
||||
});
|
||||
res.json({
|
||||
ok: true,
|
||||
message: `已添加玩家 ${entry.connection.username}`,
|
||||
data: { id: entry.id, username: entry.connection.username, bots: this.botsPayload() }
|
||||
});
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/bots/:id', (req, res) => {
|
||||
if (typeof this.pool.removeBot !== 'function') {
|
||||
res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台移除玩家' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const entry = this.pool.removeBot(req.params.id);
|
||||
res.json({
|
||||
ok: true,
|
||||
message: `已移除玩家 ${entry.connection.username}`,
|
||||
data: { bots: this.botsPayload() }
|
||||
});
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/status', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => ({
|
||||
ok: true,
|
||||
@@ -302,6 +346,22 @@ class WebConsole {
|
||||
});
|
||||
});
|
||||
|
||||
// 手动移动测试:绕开寻路器直接按方向键,用于判断「算不出路径」还是「根本走不动」
|
||||
app.post('/api/bots/:id/move', async (req, res) => {
|
||||
const body = req.body || {};
|
||||
if (typeof this.pool.walkTest !== 'function') {
|
||||
res.status(400).json({ ok: false, message: '当前运行模式不支持移动测试' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const entry = this.pool.require(req.params.id);
|
||||
const result = await this.pool.walkTest(entry.id, body.direction, body.seconds);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
|
||||
app.post('/api/bots/:id/command', (req, res) => {
|
||||
const command = req.body && req.body.command;
|
||||
@@ -828,8 +888,13 @@ class SingleBotPool {
|
||||
/** @param {string} id @param {string} message */
|
||||
sendChat(id, message) {
|
||||
const entry = this.require(id);
|
||||
const ok = entry.manager.sendChat(String(message || '').trim());
|
||||
return { ok, message: ok ? '已发送' : '发送失败(未完成登录验证)' };
|
||||
const text = String(message || '').trim();
|
||||
if (!text) return { ok: false, message: '消息为空' };
|
||||
if (!entry.manager.canSendChat()) {
|
||||
return { ok: false, message: '未完成登录验证,已拒绝发送公屏消息' };
|
||||
}
|
||||
const ok = entry.manager.sendChat(text);
|
||||
return { ok, message: ok ? '已发送' : '发送失败' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户